From 919c8f43ad4821c0e73eba2698d6262a4a2dcf91 Mon Sep 17 00:00:00 2001 From: Tim Wagner Date: Sat, 21 Feb 2026 11:38:01 +0100 Subject: [PATCH 1/3] feat(SOSO-365): add SelectorBuilder with DI support, fix selector wrapping and default URL - Add SelectorBuilder class with SelectorBuilderInterface for API-compliant selector wrapping (Filter/Select/OrderBy/Top) and injection-safe value escaping - Fix AppSheetClient.find() to automatically wrap raw selectors in Filter() - Fix default base URL from deprecated api.appsheet.com to www.appsheet.com - Add DI-compatible injection via optional constructor parameters in AppSheetClient and AppSheetClientFactory (Option B - no breaking change) - Bump version to 3.1.0 --- AGENTS.md | 1 + CLAUDE.md | 86 +++-- docs/SOSO-365/BUGFIX_CONCEPT.md | 434 +++++++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- src/client/AppSheetClient.ts | 23 +- src/client/AppSheetClientFactory.ts | 33 +- src/types/config.ts | 2 +- src/types/index.ts | 3 + src/types/schema.ts | 2 +- src/types/selector.ts | 88 +++++ src/utils/SelectorBuilder.ts | 192 +++++++++++ src/utils/index.ts | 1 + tests/client/AppSheetClient.test.ts | 4 +- tests/client/AppSheetClient.v3.test.ts | 17 + tests/utils/SelectorBuilder.test.ts | 293 +++++++++++++++++ 16 files changed, 1143 insertions(+), 42 deletions(-) create mode 120000 AGENTS.md create mode 100644 docs/SOSO-365/BUGFIX_CONCEPT.md create mode 100644 src/types/selector.ts create mode 100644 src/utils/SelectorBuilder.ts create mode 100644 tests/utils/SelectorBuilder.test.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 938a675..cd17df8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,6 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview This is a generic TypeScript library for AppSheet CRUD operations, designed for building MCP servers and internal tools. The library provides two usage patterns: + 1. **Direct client usage** - Simple AppSheetClient for basic operations 2. **Schema-based usage** - Runtime schema loading from YAML/JSON with type-safe table clients and validation @@ -20,11 +21,11 @@ main → Production Deployment ### Branch Rules -| Branch | CI | Deploy | Purpose | -|-----------|-----|--------|---------| +| Branch | CI | Deploy | Purpose | +| --------- | --- | ------ | ----------------------------- | | `develop` | ✅ | ❌ | Feature integration (CI only) | -| `staging` | ✅ | ✅ | Pre-production testing | -| `main` | ✅ | ✅ | Production releases | +| `staging` | ✅ | ✅ | Pre-production testing | +| `main` | ✅ | ✅ | Production releases | ### Feature Development Flow @@ -79,9 +80,12 @@ npx appsheet inspect --help # After npm install (uses bin entry) ### Core Components **AppSheetClient** (`src/client/AppSheetClient.ts`) + - Main API client with CRUD methods (add, find, update, delete) - Handles authentication, retries with exponential backoff, and error conversion -- Base URL: `https://api.appsheet.com/api/v2` +- Base URL: `https://www.appsheet.com/api/v2` (regional endpoints via `baseUrl` override) +- **Selector wrapping**: `find()` automatically wraps raw boolean expressions in `Filter(tableName, expr)` via `SelectorBuilder` for API compliance +- Uses `SelectorBuilderInterface` internally (injected as `SelectorBuilder` instance) - **v3.0.0 Constructor**: `new AppSheetClient(connectionDef, runAsUserEmail)` - `connectionDef`: Full ConnectionDefinition with appId, applicationAccessKey, and tables - `runAsUserEmail`: Email of user to execute all operations as (required) @@ -91,11 +95,13 @@ npx appsheet inspect --help # After npm install (uses bin entry) - Direct array format: `[...]` (automatically converted to standard format) **AppSheetClientInterface** (`src/types/client.ts`) + - Interface defining the contract for all client implementations - Implemented by both AppSheetClient and MockAppSheetClient - Ensures type safety and allows swapping implementations in tests **DynamicTable** (`src/client/DynamicTable.ts`) + - Schema-aware table client with comprehensive AppSheet field type validation - Validates all 27 AppSheet field types (Email, URL, Phone, Enum, EnumList, etc.) - Format validation (email addresses, URLs, phone numbers, dates, percentages) @@ -103,17 +109,20 @@ npx appsheet inspect --help # After npm install (uses bin entry) - Created by SchemaManager, not instantiated directly **Validators** (`src/utils/validators/`) + - **BaseTypeValidator**: JavaScript primitive type validation (string, number, boolean, array) - **FormatValidator**: Format-specific validation (Email, URL, Phone, Date, DateTime, Percent) - **AppSheetTypeValidator**: Main orchestrator for AppSheet field type validation - Modular, reusable validation logic across the codebase **SchemaLoader** (`src/utils/SchemaLoader.ts`) + - Loads schema from YAML/JSON files - Resolves environment variables with `${VAR_NAME}` syntax - Validates schema structure before use **SchemaManager** (`src/utils/SchemaManager.ts`) + - Central management class using factory injection (v3.0.0) - **v3.0.0 Constructor**: `new SchemaManager(clientFactory, schema)` - `clientFactory`: AppSheetClientFactoryInterface (use AppSheetClientFactory or MockAppSheetClientFactory) @@ -127,6 +136,7 @@ npx appsheet inspect --help # After npm install (uses bin entry) - Entry point for schema-based usage pattern **ConnectionManager** (`src/utils/ConnectionManager.ts`) + - Simplified in v3.0.0 to use factory injection - **v3.0.0 Constructor**: `new ConnectionManager(clientFactory, schema)` - `clientFactory`: AppSheetClientFactoryInterface @@ -137,6 +147,7 @@ npx appsheet inspect --help # After npm install (uses bin entry) - **`has(connectionName)`**: Checks if connection exists **Factory Classes** (v3.0.0) + - **AppSheetClientFactory**: Creates real AppSheetClient instances - **MockAppSheetClientFactory**: Creates MockAppSheetClient instances for testing - **DynamicTableFactory**: Creates DynamicTable instances from schema @@ -144,15 +155,17 @@ npx appsheet inspect --help # After npm install (uses bin entry) ### CLI Tool **SchemaInspector** (`src/cli/SchemaInspector.ts`) + - Introspects AppSheet tables by analyzing up to 100 rows - Automatically detects all 27 AppSheet field types from actual data - Smart Enum detection: Identifies enum fields based on unique value ratio - Extracts `allowedValues` for Enum/EnumList fields automatically - Pattern detection for Email, URL, Phone, Date, DateTime, Percent -- Guesses key fields (looks for: id, key, ID, Key, _RowNumber) +- Guesses key fields (looks for: id, key, ID, Key, \_RowNumber) - Converts table names to schema names (e.g., "extract_user" → "users") **CLI Commands** (`src/cli/commands.ts`) + - `init` - Create empty schema file - `inspect` - Generate schema from AppSheet app - With `--tables` flag: Generate schema for specific tables @@ -165,25 +178,27 @@ CLI binary name: `appsheet` (defined in package.json bin field) ## Key Design Patterns ### Schema Structure (v2.0.0) + ```yaml connections: : appId: ${ENV_VAR} applicationAccessKey: ${ENV_VAR} - runAsUserEmail: user@example.com # Optional: global user for all operations + runAsUserEmail: user@example.com # Optional: global user for all operations tables: : tableName: keyField: fields: : - type: # Required: Text, Email, Number, Enum, etc. - required: # Optional: default false - allowedValues: [...] # Optional: for Enum/EnumList - description: # Optional + type: # Required: Text, Email, Number, Enum, etc. + required: # Optional: default false + allowedValues: [...] # Optional: for Enum/EnumList + description: # Optional ``` **AppSheet Field Types (27 total):** + - **Core**: Text, Number, Date, DateTime, Time, Duration, YesNo - **Specialized Text**: Name, Email, URL, Phone, Address - **Specialized Numbers**: Decimal, Percent, Price @@ -196,6 +211,7 @@ connections: ### Two Usage Patterns (v3.0.0) **Pattern 1: Direct Client** + ```typescript const connectionDef: ConnectionDefinition = { appId: 'app-id', @@ -209,12 +225,9 @@ await client.findAll('extract_user'); ``` **Pattern 2: Schema-Based with Factory Injection** (Recommended) + ```typescript -import { - SchemaLoader, - SchemaManager, - AppSheetClientFactory -} from '@techdivision/appsheet'; +import { SchemaLoader, SchemaManager, AppSheetClientFactory } from '@techdivision/appsheet'; // Production setup const clientFactory = new AppSheetClientFactory(); @@ -223,10 +236,11 @@ const db = new SchemaManager(clientFactory, schema); // Get table for user (runAsUserEmail is required in v3.0.0) const table = db.table('connection', 'tableName', 'user@example.com'); -await table.findAll(); // Executes as user@example.com +await table.findAll(); // Executes as user@example.com ``` **Pattern 3: Testing with Mock Factory** + ```typescript import { MockAppSheetClientFactory, @@ -249,6 +263,7 @@ const users = await table.findAll(); // Returns seeded test data ``` **Pattern 4: Multi-Tenant MCP Server** + ```typescript // Single SchemaManager instance for entire server const clientFactory = new AppSheetClientFactory(); @@ -291,15 +306,16 @@ if (values) { } // Use case: Populate UI dropdown -const options = db.getAllowedValues('default', 'users', 'status')?.map(v => ({ +const options = db.getAllowedValues('default', 'users', 'status')?.map((v) => ({ label: v, - value: v + value: v, })); ``` ### Validation Examples **Schema Definition with AppSheet Types:** + ```yaml fields: email: @@ -308,10 +324,10 @@ fields: status: type: Enum required: true - allowedValues: ["Active", "Inactive", "Pending"] + allowedValues: ['Active', 'Inactive', 'Pending'] tags: type: EnumList - allowedValues: ["JavaScript", "TypeScript", "React"] + allowedValues: ['JavaScript', 'TypeScript', 'React'] discount: type: Percent required: false @@ -320,6 +336,7 @@ fields: ``` **Validation Errors:** + ```typescript // Invalid email format await table.add([{ email: 'invalid' }]); @@ -339,10 +356,12 @@ await table.add([{ discount: 1.5 }]); **Feature**: Dependency injection via factory interfaces enables easy testing and flexible instantiation. **Key Interfaces**: + - `AppSheetClientFactoryInterface`: Creates client instances - `DynamicTableFactoryInterface`: Creates table instances **Production vs Test**: + ```typescript // Production: Use AppSheetClientFactory const prodFactory = new AppSheetClientFactory(); @@ -354,6 +373,7 @@ const testDb = new SchemaManager(testFactory, schema); ``` **Benefits**: + - Easy unit testing without mocking complex dependencies - No need to mock axios or network calls - Test data can be pre-seeded via MockDataProvider @@ -362,6 +382,7 @@ const testDb = new SchemaManager(testFactory, schema); ### Error Handling All errors extend `AppSheetError` with specific subtypes: + - `AuthenticationError` (401/403) - `ValidationError` (400) - Now includes field-level validation errors - `NotFoundError` (404) @@ -374,11 +395,15 @@ Retry logic applies to network errors and 5xx server errors (max 3 attempts by d **Action Types**: Add, Find, Edit (not Update), Delete -**Selectors**: AppSheet filter expressions use bracket notation: -- `[FieldName] = "value"` -- `[Status] = "Active" AND [Date] > "2025-01-01"` +**Selectors**: AppSheet API requires selector expressions to use function wrappers. The `AppSheetClient` automatically wraps raw boolean expressions via `SelectorBuilder.ensureFunction()`: + +- Raw input: `[FieldName] = "value"` is sent as `Filter(tableName, [FieldName] = "value")` +- Already wrapped expressions (Filter, Select, OrderBy, Top) pass through unchanged (idempotent) +- Use `SelectorBuilder.buildFilter()` for injection-safe selectors from user input +- Use `SelectorBuilder.escapeValue()` to escape values in manually built expressions **Request Structure**: + ```json { "Action": "Find", @@ -388,6 +413,7 @@ Retry logic applies to network errors and 5xx server errors (max 3 attempts by d ``` **Response Structure** (handled automatically by client): + ```json // Standard format { @@ -408,27 +434,32 @@ Retry logic applies to network errors and 5xx server errors (max 3 attempts by d ### v3.0.0 Breaking Changes **AppSheetClient**: + - ❌ Old: `new AppSheetClient({ appId, applicationAccessKey, runAsUserEmail? })` - ✅ New: `new AppSheetClient(connectionDef, runAsUserEmail)` - ❌ `getConfig()` removed - use `getTable()` instead **ConnectionManager**: + - ❌ Old: `new ConnectionManager()` + `register()` + `get(name, userEmail?)` - ✅ New: `new ConnectionManager(clientFactory, schema)` + `get(name, userEmail)` - ❌ `register()`, `remove()`, `clear()`, `ping()`, `healthCheck()` removed - ✅ `list()` and `has()` added for introspection **SchemaManager**: + - ❌ Old: `new SchemaManager(schema)` + `table(conn, table, userEmail?)` - ✅ New: `new SchemaManager(clientFactory, schema)` + `table(conn, table, userEmail)` - ❌ `getConnectionManager()` and `reload()` removed - ✅ `hasConnection()` and `hasTable()` added **MockAppSheetClient**: + - ❌ Old: `new MockAppSheetClient({ appId, applicationAccessKey })` - ✅ New: `new MockAppSheetClient(connectionDef, runAsUserEmail, dataProvider?)` ### v3.0.0 Migration Example + ```typescript // ❌ Old (v2.x) const client = new AppSheetClient({ @@ -451,6 +482,7 @@ const table = db.table('conn', 'tableName', 'user@example.com'); // required us ## Breaking Changes (v2.0.0) ### v2.0.0 Schema Changes + - ❌ Old generic types (`'string'`, `'number'`, etc.) no longer supported - ❌ Shorthand string format (`"email": "string"`) no longer supported - ❌ `enum` property renamed to `allowedValues` @@ -460,6 +492,7 @@ const table = db.table('conn', 'tableName', 'user@example.com'); // required us ## Documentation All public APIs use TSDoc comments with: + - `@param` for parameters - `@returns` for return values - `@throws` for error conditions @@ -537,12 +570,14 @@ const users = await table.findAll(); // Returns pre-seeded data ``` ### MockAppSheetClient + - In-memory mock implementation of `AppSheetClientInterface` - **v3.0.0 Constructor**: `new MockAppSheetClient(connectionDef, runAsUserEmail, dataProvider?)` - Stores data in memory without making API calls - Fully tested with comprehensive test suite ### Test Configuration + - Tests use Jest with ts-jest preset - Test files located in `tests/` directory (separate from `src/`) - Test structure mirrors `src/` directory structure @@ -552,6 +587,7 @@ const users = await table.findAll(); // Returns pre-seeded data - Import paths from tests: `import { X } from '../../src/module/X'` ### Test Files + - `tests/client/AppSheetClient.test.ts` - Tests for real AppSheet client - `tests/client/MockAppSheetClient.test.ts` - Tests for mock client implementation @@ -561,7 +597,7 @@ const users = await table.findAll(); // Returns pre-seeded data - CLI binary entry point: `dist/cli/index.js` (automatically made executable by npm) - CLI binary command: `appsheet` (can be run via `npx appsheet` after installation) - Schema files support environment variable substitution with `${VAR_NAME}` syntax -- SchemaInspector's `toSchemaName()` method removes "extract_" prefix and adds "s" suffix +- SchemaInspector's `toSchemaName()` method removes "extract\_" prefix and adds "s" suffix - Multi-instance support allows one MCP server to access multiple AppSheet apps - Runtime validation in DynamicTable checks types but doesn't prevent API calls for performance - The library is designed to be installed from GitHub via npm (not published to npm registry yet) diff --git a/docs/SOSO-365/BUGFIX_CONCEPT.md b/docs/SOSO-365/BUGFIX_CONCEPT.md new file mode 100644 index 0000000..0f6938b --- /dev/null +++ b/docs/SOSO-365/BUGFIX_CONCEPT.md @@ -0,0 +1,434 @@ +# SOSO-365: AppSheet API Selector-Fix + Default-URL Korrektur + +## Status: IMPLEMENTIERT + +## Overview + +Zwei Bugs in der `@techdivision/appsheet` Library: + +1. **Selector ohne `Filter()`-Wrapper** -- Die `find()`-Methode setzte einfache Boolean-Expressions direkt als `Properties.Selector`, obwohl die AppSheet API laut Spezifikation Funktionsausdruecke wie `Filter(tableName, expression)` erwartet. +2. **Veralteter Default-Endpoint** -- Die Library verwendete `https://api.appsheet.com/api/v2` als Default, dieser Endpoint ist laut offizieller Doku deprecated. + +Zusaetzlich wurde das `AppSheetFilterEscape`-Utility aus dem `service_portfolio_mcp`-Projekt in die Library integriert, da Value-Escaping gegen Injection-Angriffe ein generisches AppSheet-Problem ist. + +## Referenzen + +- Jira: [SOSO-365](https://techdivision.atlassian.net/browse/SOSO-365) +- AppSheet API Doku: [Read records from a table](https://support.google.com/appsheet/answer/10105770) +- AppSheet API Doku: [Invoke the API](https://support.google.com/appsheet/answer/10105398) +- AppSheet API Doku: [Data Residency / Regionale Endpoints](https://support.google.com/appsheet/answer/13788479) + +--- + +## Fix 1: SelectorBuilder-Klasse mit Interface + +### Problem + +`AppSheetClient.find()` setzte den Selector direkt ohne `Filter()`-Wrapper: + +```typescript +// VORHER (Bug): +if (options.selector) { + properties.Selector = options.selector; +} +``` + +Laut AppSheet API-Spezifikation muss der `Selector` eine AppSheet-Funktion sein: + +``` +Filter(People, [Age] >= 21) +Select(People[_ComputedKey], [Status] = "Active", true) +OrderBy(Filter(People, [Age] >= 21), [LastName], true) +Top(OrderBy(Filter(People, true), [LastName], true), 10) +``` + +### Design-Entscheidung + +Statt einzelner Utility-Funktionen wurde eine saubere Klasse mit Interface implementiert, die sowohl API-Compliance (Selector-Wrapping) als auch Security (Value-Escaping) abdeckt. + +**Wrapping nur im `AppSheetClient`** (realer HTTP-Client): + +- Die `FindOptions.selector`-API bleibt unveraendert -- Consumer uebergeben weiterhin einfache Expressions +- `MockAppSheetClient` braucht keine Aenderung -- parst rohe Expression in-memory +- `DynamicTable` braucht keine Aenderung -- delegiert an Client +- **Kein Breaking Change** + +### Loesung: `SelectorBuilderInterface` + `SelectorBuilder` + +#### Interface (`src/types/selector.ts`) + +```typescript +export interface SelectorBuilderInterface { + ensureFunction(selector: string, tableName: string): string; + escapeValue(value: string): string; + buildFilter(tableName: string, fieldName: string, value: string): string; + isSafeIdentifier(name: string): boolean; +} +``` + +| Methode | Zweck | Herkunft | +| -------------------- | -------------------------------------------------- | ----------------------------- | +| `ensureFunction()` | Wrappet raw Expressions in `Filter()` (Compliance) | Neu fuer SOSO-365 | +| `escapeValue()` | Escaped `"` und `\` gegen Injection-Angriffe | Aus `AppSheetFilterEscape.ts` | +| `buildFilter()` | Kombiniert Escaping + Filter()-Wrapper | Aus `AppSheetFilterEscape.ts` | +| `isSafeIdentifier()` | Prueft Tabellen-/Feldnamen auf sichere Zeichen | Aus `AppSheetFilterEscape.ts` | + +#### Implementierung (`src/utils/SelectorBuilder.ts`) + +```typescript +export class SelectorBuilder implements SelectorBuilderInterface { + ensureFunction(selector: string, tableName: string): string { + const trimmed = selector.trim(); + const alreadyWrapped = SELECTOR_FUNCTIONS.some((fn) => trimmed.startsWith(fn)); + if (alreadyWrapped) return trimmed; // Idempotent + return `Filter(${tableName}, ${trimmed})`; + } + + escapeValue(value: string): string { + return value + .replace(/\\/g, '\\\\') // Backslashes zuerst + .replace(/"/g, '\\"'); // Dann Quotes + } + + buildFilter(tableName: string, fieldName: string, value: string): string { + return `Filter(${tableName}, ${fieldName} = "${this.escapeValue(value)}")`; + } + + isSafeIdentifier(name: string): boolean { + return /^[a-zA-Z0-9_]+$/.test(name); + } +} +``` + +#### Integration in `AppSheetClient` (DI-kompatibel) + +Der `SelectorBuilder` wird ueber den Constructor injiziert (optionaler 3. Parameter). +Der Default ist `new SelectorBuilder()` -- bestehender Code bleibt kompatibel. + +```typescript +export class AppSheetClient implements AppSheetClientInterface { + private readonly selectorBuilder: SelectorBuilderInterface; + + constructor( + connectionDef: ConnectionDefinition, + runAsUserEmail: string, + selectorBuilder?: SelectorBuilderInterface // Optionaler Injection-Point + ) { + this.selectorBuilder = selectorBuilder ?? new SelectorBuilder(); + // ... + } + + async find(options: FindOptions): Promise> { + const properties = this.mergeProperties(options.properties); + if (options.selector) { + // NACHHER (Fix): Automatisches Wrapping in Filter() + properties.Selector = this.selectorBuilder.ensureFunction( + options.selector, + options.tableName + ); + } + // ... + } +} +``` + +#### Integration in `AppSheetClientFactory` (DI-Durchreichung) + +Die Factory nimmt optional einen `SelectorBuilderInterface` entgegen und reicht ihn +an alle erzeugten `AppSheetClient`-Instanzen weiter. + +**Wichtig:** `AppSheetClientFactoryInterface.create()` aendert sich NICHT -- kein Breaking Change. + +```typescript +export class AppSheetClientFactory implements AppSheetClientFactoryInterface { + private readonly selectorBuilder: SelectorBuilderInterface; + + constructor(selectorBuilder?: SelectorBuilderInterface) { + this.selectorBuilder = selectorBuilder ?? new SelectorBuilder(); + } + + create(connectionDef: ConnectionDefinition, runAsUserEmail: string): AppSheetClientInterface { + return new AppSheetClient(connectionDef, runAsUserEmail, this.selectorBuilder); + } +} +``` + +### DI-Kompatibilitaet (tsyringe) + +#### Problem: Hardcoded SelectorBuilder bricht mit v3.0.0 Factory-Pattern + +Die v3.0.0-Architektur setzt konsequent auf Factory-Injection fuer Austauschbarkeit: + +- `AppSheetClientFactoryInterface` -- Real/Mock-Client austauschbar +- `DynamicTableFactoryInterface` -- Real/Mock-Table austauschbar +- **`SelectorBuilderInterface`** -- bisher NICHT austauschbar (hardcoded `new SelectorBuilder()`) + +Ein hardcoded `new SelectorBuilder()` im Constructor wuerde dieses Pattern brechen. +Consumer-Projekte wie `service_portfolio_mcp` registrieren alle Dependencies ueber +tsyringe-Tokens und erwarten, dass Implementierungen austauschbar sind. + +#### Loesung: Injection ueber Factory-Constructor (Option B) + +Der `SelectorBuilder` wird ueber den `AppSheetClientFactory`-Constructor injiziert. +Die Injection-Kette ist: + +``` +DI Container (tsyringe) + | + +---> AppSheetClientFactory(selectorBuilder?) <-- hier Builder injecten + | + +---> .create(connectionDef, userEmail) + | + +---> new AppSheetClient(def, email, selectorBuilder) <-- durchgereicht + | + +---> this.selectorBuilder.ensureFunction(...) <-- genutzt +``` + +**Warum Option B (Factory-Constructor) statt anderer Optionen:** + +| Option | Ansatz | Factory-Interface-Aenderung | Bewertung | +| ------ | -------------------------------------------- | --------------------------- | ----------------- | +| A | Optional im AppSheetClient-Constructor | Nein | DI nur direkt | +| **B** | **Ueber AppSheetClientFactory durchreichen** | **Nein** | **DI-kompatibel** | +| C | Hardcoded lassen, dokumentieren | Nein | Bricht DI-Pattern | +| D | Eigenes Factory-Interface fuer Builder | Ja (neues Token) | Over-Engineering | + +Option B wurde gewaehlt, weil: + +1. `AppSheetClientFactoryInterface.create()` bleibt unveraendert -- **kein Breaking Change** +2. Consumer-Projekte koennen ueber den Factory-Constructor einen eigenen Builder injecten +3. Default-Verhalten bleibt identisch (kein Argument = Standard-SelectorBuilder) +4. Bestehende DI-Registrierungen in Consumer-Projekten bleiben kompatibel + +#### Konkrete DI-Szenarien in Consumer-Projekten + +**Szenario 1: Default (kein eigener Builder)** + +Keine Aenderung noetig. Bestehende Registrierung funktioniert weiter: + +```typescript +// container.base.ts -- bleibt identisch +container.register(TOKENS.AppSheetClientFactory, { + useFactory: () => new AppSheetClientFactory(), // Default-SelectorBuilder intern +}); +``` + +**Szenario 2: Custom SelectorBuilder injecten** + +```typescript +container.register(TOKENS.AppSheetClientFactory, { + useFactory: () => new AppSheetClientFactory(new CustomSelectorBuilder()), +}); +``` + +**Szenario 3: SelectorBuilder als eigenes DI-Token** + +```typescript +container.register(TOKENS.SelectorBuilder, { + useClass: CustomSelectorBuilder, +}); + +container.register(TOKENS.AppSheetClientFactory, { + useFactory: (c) => { + const builder = c.resolve(TOKENS.SelectorBuilder); + return new AppSheetClientFactory(builder); + }, +}); +``` + +### AOP-Erweiterbarkeit + +#### Kontext: AOP-basiertes Logging in Consumer-Projekten + +Das `service_portfolio_mcp`-Projekt verwendet TypeScript Method Decorators fuer +aspektorientiertes Logging (`@LogExecution`, `@LogPerformance`, `@HandleError`). +Manuelle `logger.info()`-Aufrufe sind dort verboten (Story 8.12). + +Die AOP-Decorators werden auf **Klassen-Methoden** angewandt -- sie funktionieren +nicht mit losen Funktionen. Das ist ein wichtiger Grund, warum der SelectorBuilder +als **Klasse mit Interface** implementiert wird statt als einzelne Utility-Funktionen. + +#### Warum die Klassen-Architektur AOP ermoeglicht + +Die `SelectorBuilder`-Klasse selbst braucht kein AOP-Logging -- sie ist ein +stateless, synchrones Utility ohne I/O. Aber die Klassen-Architektur mit +`SelectorBuilderInterface` ermoeglicht Consumern, bei Bedarf eine eigene +Subklasse mit AOP-Decorators zu erstellen und ueber DI (Option B) zu injecten: + +```typescript +// Im Consumer-Projekt (z.B. service_portfolio_mcp): +class LoggedSelectorBuilder extends SelectorBuilder { + @LogExecution({ level: 'debug' }) + ensureFunction(selector: string, tableName: string): string { + return super.ensureFunction(selector, tableName); + } + + @LogExecution({ level: 'debug' }) + buildFilter(tableName: string, fieldName: string, value: string): string { + return super.buildFilter(tableName, fieldName, value); + } +} + +// DI-Registration: +container.register(TOKENS.AppSheetClientFactory, { + useFactory: () => new AppSheetClientFactory(new LoggedSelectorBuilder()), +}); +``` + +**Ohne** Option B waere das nicht moeglich -- der hardcoded `new SelectorBuilder()` +im AppSheetClient-Constructor liesse sich nicht durch eine AOP-erweiterte +Subklasse austauschen. + +#### Abgrenzung: Library vs. Consumer + +| Aspekt | appsheet Library | Consumer-Projekte (z.B. MCP) | +| ------------------------ | -------------------------------- | ------------------------------- | +| Logging-Framework | Keins (bewusst) | Pino via AOP-Decorators | +| `experimentalDecorators` | Nein | Ja | +| SelectorBuilder-Logging | Nicht noetig (stateless Utility) | Optional via Subklasse + DI | +| Injection-Point | Factory-Constructor (Option B) | tsyringe `container.register()` | + +Die Library stellt die **Erweiterungspunkte** bereit (Interface + optionaler +Constructor-Parameter). Die Entscheidung, ob und wie geloggt wird, liegt +beim Consumer-Projekt. + +### Datenfluss nach Fix + +``` +Consumer-Code (z.B. MCP Service) + | + | selector = '[service_portfolio_id]="abc-123"' + | + +---> DynamicTable.findOne(selector) + | | + | | FindOptions { tableName: 'extract_sp', selector: '[sp_id]="abc-123"' } + | | + | +---> AppSheetClient.find(options) <-- Realer Client + | | | + | | | selectorBuilder.ensureFunction(...) + | | | Properties.Selector = 'Filter(extract_sp, [sp_id]="abc-123")' + | | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | | | Automatisch gewrappet! + | | | + | | +---> HTTP POST an AppSheet API + | | + | +---> MockAppSheetClient.find(options) <-- Mock Client (Tests) + | | + | | applySelector(rows, '[sp_id]="abc-123"') + | | Rohe Expression, direkt geparst (kein Wrapping) + | | + | +---> In-Memory Filterung +``` + +### Consumer-Nutzung (fuer MCP-Server / Services) + +Consumer koennen den `SelectorBuilder` auch direkt importieren und nutzen: + +```typescript +import { SelectorBuilder } from '@techdivision/appsheet'; + +const selector = new SelectorBuilder(); + +// Sicher: Escaped User-Input und wrappet in Filter() +const filter = selector.buildFilter('users', '[user_id]', userInput); +// => 'Filter(users, [user_id] = "escaped-value")' + +// Oder einzeln: +const escaped = selector.escapeValue(userInput); +const expr = `[name] = "${escaped}" AND [status] = "Active"`; +``` + +--- + +## Fix 2: Default Base URL + +### Problem + +Default URL `https://api.appsheet.com/api/v2` ist deprecated. + +### Loesung + +```typescript +// NACHHER: +const baseUrl = connectionDef.baseUrl || 'https://www.appsheet.com/api/v2'; +``` + +Regionale Endpunkte (EU, Asia Pacific) koennen weiterhin via `baseUrl` in der ConnectionDefinition konfiguriert werden. + +--- + +## Vollstaendige Aenderungsliste + +### Neue Dateien (3) + +| Datei | Beschreibung | +| ------------------------------------- | -------------------------------------------------------- | +| `src/types/selector.ts` | `SelectorBuilderInterface` Definition | +| `src/utils/SelectorBuilder.ts` | `SelectorBuilder` Klasse (Implementierung) | +| `tests/utils/SelectorBuilder.test.ts` | Unit-Tests inkl. Injection-Attack-Prevention (40+ Tests) | + +### Zu aendernde Dateien (8) + +| Datei | Aenderung | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `src/client/AppSheetClient.ts` | 3. optionaler Constructor-Parameter `selectorBuilder?`, `ensureFunction()` in `find()`, Default URL | +| `src/client/AppSheetClientFactory.ts` | Constructor mit optionalem `selectorBuilder?`, Durchreichung an `AppSheetClient` | +| `src/types/index.ts` | Export `selector` Types | +| `src/utils/index.ts` | Export `SelectorBuilder` | +| `src/types/config.ts` | Kommentar: Default URL aktualisiert | +| `src/types/schema.ts` | Kommentar: Default URL dokumentiert | +| `tests/client/AppSheetClient.test.ts` | Expected Selectors auf `Filter(tableName, ...)` angepasst | +| `tests/client/AppSheetClient.v3.test.ts` | Test fuer Default-URL `www.appsheet.com` hinzugefuegt | + +### Unveraenderte Dateien + +| Datei | Grund | +| ----------------------------------------- | ------------------------------------------------------------- | +| `src/client/MockAppSheetClient.ts` | Empfaengt rohe `FindOptions.selector`, kein HTTP | +| `src/client/DynamicTable.ts` | Delegiert nur an Client-Interface | +| `src/types/operations.ts` | `FindOptions.selector` bleibt `string` | +| `src/types/factories.ts` | `AppSheetClientFactoryInterface.create()` bleibt unveraendert | +| `tests/client/DynamicTable.test.ts` | Nutzt MockAppSheetClient, kein Filter()-Wrapping | +| `tests/client/MockAppSheetClient.test.ts` | In-Memory-Tests, kein HTTP involviert | + +--- + +## Test-Ergebnis + +``` +PASS tests/utils/SelectorBuilder.test.ts +PASS tests/client/MockAppSheetClient.test.ts +PASS tests/client/DynamicTable.test.ts +PASS tests/client/AppSheetClient.test.ts +PASS tests/client/AppSheetClient.v3.test.ts +PASS tests/utils/ConnectionManager.test.ts +PASS tests/client/factories.test.ts +PASS tests/utils/SchemaManager.test.ts +PASS tests/cli/SchemaInspector.test.ts + +Test Suites: 9 passed, 9 total +Tests: 265 passed, 265 total + +Build: clean (0 errors) +Lint: 0 errors (108 pre-existing no-explicit-any warnings) +``` + +--- + +## Risikobewertung + +| Risiko | Einstufung | Mitigation | +| ----------------------------------------------------------- | ------------ | ---------------------------------------------------- | +| AppSheet API akzeptiert `Filter()` nicht | Sehr niedrig | Ist die offizielle, dokumentierte Syntax | +| Double-Wrapping bei Consumern die bereits `Filter()` nutzen | Niedrig | Idempotenz-Check in `ensureFunction()` | +| `api.appsheet.com` wird abgeschaltet | Mittel | Fix auf `www.appsheet.com` loest das | +| MockAppSheetClient Verhalten divergiert vom realen Client | Niedrig | Mock empfaengt weiterhin rohe Expression | +| Consumer verwenden `baseUrl` Override mit altem Endpoint | Kein Risiko | Consumer-Konfiguration, nicht Library-Verantwortung | +| Bestehende DI-Registrierungen in Consumer-Projekten brechen | Kein Risiko | Factory-Interface unveraendert, Constructor optional | + +--- + +## Follow-Up Tickets + +- **Region-Feld in `ConnectionDefinition`** -- `region?: 'global' | 'eu' | 'asia-southeast'` mit automatischem URL-Mapping +- **`AppSheetFilterEscape` Cleanup im `service_portfolio_mcp`** -- Lokale Kopie durch Import aus `@techdivision/appsheet` ersetzen, eigenes `escapeSelector()` in `ServicePortfolioService` durch `SelectorBuilder.buildFilter()` ersetzen diff --git a/package-lock.json b/package-lock.json index bf4ac35..aa5418c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@techdivision/appsheet", - "version": "3.0.0", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@techdivision/appsheet", - "version": "3.0.0", + "version": "3.1.0", "license": "MIT", "dependencies": { "@types/uuid": "^10.0.0", diff --git a/package.json b/package.json index 37cb85a..7ffa97a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@techdivision/appsheet", - "version": "3.0.0", + "version": "3.1.0", "description": "Generic TypeScript library for AppSheet CRUD operations", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/client/AppSheetClient.ts b/src/client/AppSheetClient.ts index 2181c7d..4348d58 100644 --- a/src/client/AppSheetClient.ts +++ b/src/client/AppSheetClient.ts @@ -25,7 +25,9 @@ import { NotFoundError, RateLimitError, NetworkError, + SelectorBuilderInterface, } from '../types'; +import { SelectorBuilder } from '../utils/SelectorBuilder'; /** * AppSheet API client for performing CRUD operations on AppSheet tables. @@ -68,12 +70,14 @@ export class AppSheetClient implements AppSheetClientInterface { private readonly connectionDef: ConnectionDefinition; private readonly runAsUserEmail: string; private readonly retryAttempts: number; + private readonly selectorBuilder: SelectorBuilderInterface; /** * Creates a new AppSheet API client instance. * * @param connectionDef - Full connection definition including app credentials and table schemas * @param runAsUserEmail - Email of the user to execute all operations as (required) + * @param selectorBuilder - Optional custom SelectorBuilder for DI/AOP extensibility (defaults to SelectorBuilder) * * @example * ```typescript @@ -87,13 +91,18 @@ export class AppSheetClient implements AppSheetClientInterface { * const client = new AppSheetClient(connectionDef, 'user@example.com'); * ``` */ - constructor(connectionDef: ConnectionDefinition, runAsUserEmail: string) { + constructor( + connectionDef: ConnectionDefinition, + runAsUserEmail: string, + selectorBuilder?: SelectorBuilderInterface + ) { this.connectionDef = connectionDef; this.runAsUserEmail = runAsUserEmail; this.retryAttempts = 3; // Default retry attempts + this.selectorBuilder = selectorBuilder ?? new SelectorBuilder(); // Apply defaults - const baseUrl = connectionDef.baseUrl || 'https://api.appsheet.com/api/v2'; + const baseUrl = connectionDef.baseUrl || 'https://www.appsheet.com/api/v2'; const timeout = connectionDef.timeout || 30000; // Create axios instance @@ -173,7 +182,10 @@ export class AppSheetClient implements AppSheetClientInterface { const properties = this.mergeProperties(options.properties); if (options.selector) { - properties.Selector = options.selector; + properties.Selector = this.selectorBuilder.ensureFunction( + options.selector, + options.tableName + ); } const payload = { @@ -311,10 +323,7 @@ export class AppSheetClient implements AppSheetClientInterface { * } * ``` */ - async findOne>( - tableName: string, - selector: string - ): Promise { + async findOne>(tableName: string, selector: string): Promise { const response = await this.find({ tableName, selector }); return response.rows[0] || null; } diff --git a/src/client/AppSheetClientFactory.ts b/src/client/AppSheetClientFactory.ts index 69c1694..4e90ee3 100644 --- a/src/client/AppSheetClientFactory.ts +++ b/src/client/AppSheetClientFactory.ts @@ -8,8 +8,14 @@ * @category Client */ -import { AppSheetClientFactoryInterface, AppSheetClientInterface, ConnectionDefinition } from '../types'; +import { + AppSheetClientFactoryInterface, + AppSheetClientInterface, + ConnectionDefinition, + SelectorBuilderInterface, +} from '../types'; import { AppSheetClient } from './AppSheetClient'; +import { SelectorBuilder } from '../utils/SelectorBuilder'; /** * Factory for creating real AppSheetClient instances. @@ -17,13 +23,20 @@ import { AppSheetClient } from './AppSheetClient'; * This factory creates actual AppSheetClient instances that make real * HTTP requests to the AppSheet API. Use this factory in production. * + * Accepts an optional {@link SelectorBuilderInterface} in the constructor + * for DI/AOP extensibility. If not provided, the default {@link SelectorBuilder} + * is used. The builder is passed to all created AppSheetClient instances. + * * @category Client * * @example * ```typescript - * // Create factory + * // Create factory with default SelectorBuilder * const factory = new AppSheetClientFactory(); * + * // Create factory with custom SelectorBuilder (e.g. for AOP logging) + * const factory = new AppSheetClientFactory(new LoggedSelectorBuilder()); + * * // Use factory to create clients * const connectionDef: ConnectionDefinition = { * appId: 'your-app-id', @@ -39,14 +52,28 @@ import { AppSheetClient } from './AppSheetClient'; * ``` */ export class AppSheetClientFactory implements AppSheetClientFactoryInterface { + private readonly selectorBuilder: SelectorBuilderInterface; + + /** + * Creates a new AppSheetClientFactory. + * + * @param selectorBuilder - Optional custom SelectorBuilder for DI/AOP extensibility. + * If not provided, the default SelectorBuilder is used. + */ + constructor(selectorBuilder?: SelectorBuilderInterface) { + this.selectorBuilder = selectorBuilder ?? new SelectorBuilder(); + } + /** * Create a new AppSheetClient instance. * + * The factory's SelectorBuilder is passed to the created client instance. + * * @param connectionDef - Full connection definition including app credentials and table schemas * @param runAsUserEmail - Email of the user to execute all operations as * @returns A new AppSheetClient instance */ create(connectionDef: ConnectionDefinition, runAsUserEmail: string): AppSheetClientInterface { - return new AppSheetClient(connectionDef, runAsUserEmail); + return new AppSheetClient(connectionDef, runAsUserEmail, this.selectorBuilder); } } diff --git a/src/types/config.ts b/src/types/config.ts index 0ae9d5a..20ae520 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -19,7 +19,7 @@ export interface AppSheetConfig { /** Application Access Key for authentication */ applicationAccessKey: string; - /** Optional custom API base URL (default: https://api.appsheet.com/api/v2) */ + /** Optional custom API base URL (default: https://www.appsheet.com/api/v2) */ baseUrl?: string; /** Request timeout in milliseconds (default: 30000) */ diff --git a/src/types/index.ts b/src/types/index.ts index 96a617b..dfa1603 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -25,3 +25,6 @@ export * from './mock'; // Factory interfaces (v3.0.0) export * from './factories'; + +// Selector builder interface +export * from './selector'; diff --git a/src/types/schema.ts b/src/types/schema.ts index b914c0b..3e8f83c 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -149,7 +149,7 @@ export interface ConnectionDefinition { /** Application Access Key */ applicationAccessKey: string; - /** Optional custom base URL */ + /** Optional custom base URL (default: https://www.appsheet.com/api/v2) */ baseUrl?: string; /** Optional request timeout */ diff --git a/src/types/selector.ts b/src/types/selector.ts new file mode 100644 index 0000000..06ad63d --- /dev/null +++ b/src/types/selector.ts @@ -0,0 +1,88 @@ +/** + * Interface and types for AppSheet Selector building and escaping. + * + * AppSheet API requires selector expressions to use function wrappers + * like Filter(), Select(), OrderBy(), Top() instead of raw boolean expressions. + * + * @see https://support.google.com/appsheet/answer/10105770 + * @module types + * @category Types + */ + +/** + * Interface for building and processing AppSheet selector expressions. + * + * Implementations handle: + * - Wrapping raw boolean expressions in Filter() for API compliance + * - Escaping values to prevent injection attacks + * - Building complete, safe selector expressions + * + * @category Types + * + * @example + * ```typescript + * // Use the default implementation + * const builder: SelectorBuilderInterface = new SelectorBuilder(); + * + * // Wrap a raw expression for API compliance + * const selector = builder.ensureFunction('[Status] = "Active"', 'People'); + * // => 'Filter(People, [Status] = "Active")' + * + * // Build a safe filter with escaped values + * const filter = builder.buildFilter('People', '[name]', 'O"Brien'); + * // => 'Filter(People, [name] = "O\"Brien")' + * ``` + */ +export interface SelectorBuilderInterface { + /** + * Ensure a selector expression is wrapped in a valid AppSheet function. + * + * If the selector already starts with a recognized function (Filter, Select, + * OrderBy, Top), it is returned as-is (idempotent). + * + * If the selector is a raw boolean expression (e.g. `[Field] = "value"`), + * it is wrapped in `Filter(tableName, expression)`. + * + * @param selector - The selector expression to process + * @param tableName - The AppSheet table name for the Filter() wrapper + * @returns A valid selector expression wrapped in a function + */ + ensureFunction(selector: string, tableName: string): string; + + /** + * Escape a string value for safe use in AppSheet filter expressions. + * + * Prevents injection attacks by escaping special characters: + * - Backslash (`\`) is escaped to `\\` + * - Double quote (`"`) is escaped to `\"` + * + * @param value - Raw string value (e.g. user input) + * @returns Escaped string safe for use in filter expressions + */ + escapeValue(value: string): string; + + /** + * Build a complete, safe Filter() expression for an exact field match. + * + * Combines value escaping with Filter() wrapping. Use this when constructing + * selectors from user input to prevent injection attacks. + * + * @param tableName - AppSheet table name + * @param fieldName - Field name in bracket notation (e.g. `[user_id]`) + * @param value - Raw value to match (will be escaped) + * @returns Complete Filter() expression with escaped value + */ + buildFilter(tableName: string, fieldName: string, value: string): string; + + /** + * Validate that a table or field name contains only safe characters. + * + * Safe identifiers contain only alphanumeric characters and underscores. + * Names with special characters should be wrapped in [brackets] when used + * in AppSheet expressions. + * + * @param name - Table or field name to validate + * @returns True if the name is safe (alphanumeric + underscore only) + */ + isSafeIdentifier(name: string): boolean; +} diff --git a/src/utils/SelectorBuilder.ts b/src/utils/SelectorBuilder.ts new file mode 100644 index 0000000..16aa63a --- /dev/null +++ b/src/utils/SelectorBuilder.ts @@ -0,0 +1,192 @@ +/** + * AppSheet Selector Builder + * + * Builds and processes AppSheet selector expressions for API compliance + * and injection safety. + * + * The AppSheet API requires that the `Properties.Selector` field contains + * a function expression like `Filter()`, `Select()`, `OrderBy()`, or `Top()`. + * Raw boolean expressions like `[Field] = "value"` are not part of the + * documented API contract, even though the API currently tolerates them. + * + * This class ensures selectors are always spec-compliant and values are + * properly escaped against injection attacks. + * + * @see https://support.google.com/appsheet/answer/10105770 + * @module utils + * @category Utilities + */ + +import { SelectorBuilderInterface } from '../types'; + +/** + * Recognized AppSheet selector functions. + * + * These are the only function prefixes that the AppSheet API documents + * as valid Selector values for Find operations. + * + * @see https://support.google.com/appsheet/answer/10105770 + */ +const SELECTOR_FUNCTIONS = ['Filter(', 'Select(', 'OrderBy(', 'Top(']; + +/** + * Default implementation of the SelectorBuilder. + * + * Provides methods for: + * - Wrapping raw boolean expressions in `Filter()` (API compliance) + * - Escaping string values against injection attacks (security) + * - Building complete, safe filter expressions (convenience) + * - Validating identifier names (safety check) + * + * @category Utilities + * + * @example + * ```typescript + * const selector = new SelectorBuilder(); + * + * // Ensure API compliance + * selector.ensureFunction('[Status] = "Active"', 'People'); + * // => 'Filter(People, [Status] = "Active")' + * + * // Build safe filter from user input + * selector.buildFilter('People', '[name]', userInput); + * // => 'Filter(People, [name] = "escaped-value")' + * + * // Escape a value for manual expression building + * const safe = selector.escapeValue('O"Brien'); + * // => 'O\\"Brien' + * ``` + */ +export class SelectorBuilder implements SelectorBuilderInterface { + /** + * Ensure a selector expression is wrapped in a valid AppSheet function. + * + * If the selector already starts with a recognized function (Filter, Select, + * OrderBy, Top), it is returned as-is (idempotent). + * + * If the selector is a raw boolean expression (e.g. `[Field] = "value"`), + * it is wrapped in `Filter(tableName, expression)`. + * + * @param selector - The selector expression to process + * @param tableName - The AppSheet table name for the Filter() wrapper + * @returns A valid selector expression wrapped in a function + * + * @example + * ```typescript + * const builder = new SelectorBuilder(); + * + * // Raw expression gets wrapped + * builder.ensureFunction('[Status] = "Active"', 'People'); + * // => 'Filter(People, [Status] = "Active")' + * + * // Already wrapped expression is returned as-is + * builder.ensureFunction('Filter(People, [Status] = "Active")', 'People'); + * // => 'Filter(People, [Status] = "Active")' + * + * // Other functions are also recognized + * builder.ensureFunction('OrderBy(Filter(People, true), [Name], true)', 'People'); + * // => 'OrderBy(Filter(People, true), [Name], true)' + * ``` + */ + ensureFunction(selector: string, tableName: string): string { + const trimmed = selector.trim(); + + const alreadyWrapped = SELECTOR_FUNCTIONS.some((fn) => trimmed.startsWith(fn)); + + if (alreadyWrapped) { + return trimmed; + } + + return `Filter(${tableName}, ${trimmed})`; + } + + /** + * Escape a string value for safe use in AppSheet filter expressions. + * + * Prevents injection attacks by escaping special characters: + * - Backslash (`\`) is escaped to `\\` (must be escaped first) + * - Double quote (`"`) is escaped to `\"` + * + * @param value - Raw string value (e.g. user input) + * @returns Escaped string safe for use in filter expressions + * @throws {TypeError} If value is not a string + * + * @example + * ```typescript + * const builder = new SelectorBuilder(); + * + * builder.escapeValue('normal'); // => 'normal' + * builder.escapeValue('O"Brien'); // => 'O\\"Brien' + * builder.escapeValue('C:\\path'); // => 'C:\\\\path' + * + * // Prevents injection + * builder.escapeValue('123" OR "1"="1'); + * // => '123\\" OR \\"1\\"=\\"1' + * ``` + */ + escapeValue(value: string): string { + if (typeof value !== 'string') { + throw new TypeError('escapeValue: value must be a string'); + } + + return ( + value + // Step 1: Escape backslashes first (before escaping quotes) + .replace(/\\/g, '\\\\') + // Step 2: Escape double quotes + .replace(/"/g, '\\"') + ); + } + + /** + * Build a complete, safe Filter() expression for an exact field match. + * + * Combines value escaping with Filter() wrapping. Use this when constructing + * selectors from user input to prevent injection attacks. + * + * @param tableName - AppSheet table name + * @param fieldName - Field name in bracket notation (e.g. `[user_id]`) + * @param value - Raw value to match (will be escaped) + * @returns Complete Filter() expression with escaped value + * + * @example + * ```typescript + * const builder = new SelectorBuilder(); + * + * builder.buildFilter('users', '[user_id]', '123'); + * // => 'Filter(users, [user_id] = "123")' + * + * // Injection-safe with user input + * builder.buildFilter('users', '[user_id]', '123" OR "1"="1'); + * // => 'Filter(users, [user_id] = "123\\" OR \\"1\\"=\\"1")' + * ``` + */ + buildFilter(tableName: string, fieldName: string, value: string): string { + const escapedValue = this.escapeValue(value); + return `Filter(${tableName}, ${fieldName} = "${escapedValue}")`; + } + + /** + * Validate that a table or field name contains only safe characters. + * + * Safe identifiers contain only alphanumeric characters and underscores. + * Names with special characters should be wrapped in [brackets] when used + * in AppSheet expressions. + * + * @param name - Table or field name to validate + * @returns True if the name is safe (alphanumeric + underscore only) + * + * @example + * ```typescript + * const builder = new SelectorBuilder(); + * + * builder.isSafeIdentifier('users'); // => true + * builder.isSafeIdentifier('user_id'); // => true + * builder.isSafeIdentifier('user-id'); // => false + * builder.isSafeIdentifier('user id'); // => false + * ``` + */ + isSafeIdentifier(name: string): boolean { + return /^[a-zA-Z0-9_]+$/.test(name); + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index a9b83e7..8525af2 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -6,3 +6,4 @@ export * from './ConnectionManager'; export * from './SchemaLoader'; export * from './SchemaManager'; export * from './validators'; +export * from './SelectorBuilder'; diff --git a/tests/client/AppSheetClient.test.ts b/tests/client/AppSheetClient.test.ts index dfbbf9f..5f292b5 100644 --- a/tests/client/AppSheetClient.test.ts +++ b/tests/client/AppSheetClient.test.ts @@ -426,7 +426,7 @@ describe('AppSheetClient - runAsUserEmail', () => { expect.objectContaining({ Properties: expect.objectContaining({ RunAsUserEmail: 'finder@example.com', - Selector: '[Status] = "Active"', + Selector: 'Filter(Users, [Status] = "Active")', }), }) ); @@ -535,7 +535,7 @@ describe('AppSheetClient - runAsUserEmail', () => { expect.objectContaining({ Properties: expect.objectContaining({ RunAsUserEmail: 'reader@example.com', - Selector: '[Email] = "john@example.com"', + Selector: 'Filter(Users, [Email] = "john@example.com")', }), }) ); diff --git a/tests/client/AppSheetClient.v3.test.ts b/tests/client/AppSheetClient.v3.test.ts index 97e4f5f..5aa9025 100644 --- a/tests/client/AppSheetClient.v3.test.ts +++ b/tests/client/AppSheetClient.v3.test.ts @@ -112,6 +112,23 @@ describe('AppSheetClient v3.0.0', () => { ); }); + /** + * Test: Constructor uses www.appsheet.com as default base URL + * + * The deprecated api.appsheet.com domain was replaced with www.appsheet.com. + * @see SOSO-365 + * @see https://support.google.com/appsheet/answer/10105398 + */ + it('should use www.appsheet.com as default baseUrl', () => { + new AppSheetClient(mockConnectionDef, mockRunAsUserEmail); + + expect(mockedAxios.create).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: 'https://www.appsheet.com/api/v2', + }) + ); + }); + /** * Test: Constructor applies optional settings from ConnectionDefinition */ diff --git a/tests/utils/SelectorBuilder.test.ts b/tests/utils/SelectorBuilder.test.ts new file mode 100644 index 0000000..09ca2cb --- /dev/null +++ b/tests/utils/SelectorBuilder.test.ts @@ -0,0 +1,293 @@ +/** + * SelectorBuilder Tests + * + * Tests for AppSheet selector building, wrapping, and injection prevention. + * + * @see SOSO-365: AppSheet API Selector-Fix + Default-URL Korrektur + * @see https://support.google.com/appsheet/answer/10105770 + */ + +import { SelectorBuilder } from '../../src/utils/SelectorBuilder'; +import { SelectorBuilderInterface } from '../../src/types'; + +describe('SelectorBuilder', () => { + let builder: SelectorBuilderInterface; + + beforeEach(() => { + builder = new SelectorBuilder(); + }); + + it('should implement SelectorBuilderInterface', () => { + expect(builder).toBeDefined(); + expect(builder.ensureFunction).toBeDefined(); + expect(builder.escapeValue).toBeDefined(); + expect(builder.buildFilter).toBeDefined(); + expect(builder.isSafeIdentifier).toBeDefined(); + }); + + // ──────────────────────────────────────────────────────────────────── + // ensureFunction() + // ──────────────────────────────────────────────────────────────────── + + describe('ensureFunction', () => { + const tableName = 'People'; + + describe('wrapping raw boolean expressions', () => { + it('should wrap a simple equality expression', () => { + expect(builder.ensureFunction('[Status] = "Active"', tableName)).toBe( + 'Filter(People, [Status] = "Active")' + ); + }); + + it('should wrap a compound AND expression', () => { + expect(builder.ensureFunction('[Age] >= 21 AND [State] = "CA"', tableName)).toBe( + 'Filter(People, [Age] >= 21 AND [State] = "CA")' + ); + }); + + it('should wrap an IN expression', () => { + expect(builder.ensureFunction('[Status] IN ("Active", "Pending")', tableName)).toBe( + 'Filter(People, [Status] IN ("Active", "Pending"))' + ); + }); + + it('should wrap a NOT expression', () => { + expect(builder.ensureFunction('NOT([Status] = "Deleted")', tableName)).toBe( + 'Filter(People, NOT([Status] = "Deleted"))' + ); + }); + + it('should trim leading and trailing whitespace', () => { + expect(builder.ensureFunction(' [Status] = "Active" ', tableName)).toBe( + 'Filter(People, [Status] = "Active")' + ); + }); + + it('should use the provided table name in the wrapper', () => { + expect(builder.ensureFunction('[id] = "1"', 'extract_user')).toBe( + 'Filter(extract_user, [id] = "1")' + ); + }); + }); + + describe('idempotency - already wrapped expressions', () => { + it('should not double-wrap Filter()', () => { + const selector = 'Filter(People, [Status] = "Active")'; + expect(builder.ensureFunction(selector, tableName)).toBe(selector); + }); + + it('should not wrap Select()', () => { + const selector = 'Select(People[_ComputedKey], [Age] >= 21, true)'; + expect(builder.ensureFunction(selector, tableName)).toBe(selector); + }); + + it('should not wrap OrderBy()', () => { + const selector = 'OrderBy(Filter(People, [Age] >= 21), [LastName], true)'; + expect(builder.ensureFunction(selector, tableName)).toBe(selector); + }); + + it('should not wrap Top()', () => { + const selector = 'Top(OrderBy(Filter(People, true), [Name], true), 10)'; + expect(builder.ensureFunction(selector, tableName)).toBe(selector); + }); + + it('should handle Filter() with leading whitespace (trimmed)', () => { + const selector = ' Filter(People, [Status] = "Active") '; + expect(builder.ensureFunction(selector, tableName)).toBe( + 'Filter(People, [Status] = "Active")' + ); + }); + }); + }); + + // ──────────────────────────────────────────────────────────────────── + // escapeValue() + // ──────────────────────────────────────────────────────────────────── + + describe('escapeValue', () => { + describe('normal values (no escaping needed)', () => { + it('should pass through simple alphanumeric strings', () => { + expect(builder.escapeValue('abc123')).toBe('abc123'); + expect(builder.escapeValue('test')).toBe('test'); + expect(builder.escapeValue('550e8400-e29b-41d4-a716-446655440000')).toBe( + '550e8400-e29b-41d4-a716-446655440000' + ); + }); + + it('should pass through strings with spaces', () => { + expect(builder.escapeValue('hello world')).toBe('hello world'); + expect(builder.escapeValue('Service Portfolio')).toBe('Service Portfolio'); + }); + + it('should pass through strings with common punctuation', () => { + expect(builder.escapeValue('hello, world!')).toBe('hello, world!'); + expect(builder.escapeValue('test@example.com')).toBe('test@example.com'); + }); + }); + + describe('special character escaping', () => { + it('should escape double quotes', () => { + expect(builder.escapeValue('hello"world')).toBe('hello\\"world'); + expect(builder.escapeValue('"quoted"')).toBe('\\"quoted\\"'); + expect(builder.escapeValue('say "hello"')).toBe('say \\"hello\\"'); + }); + + it('should escape backslashes', () => { + expect(builder.escapeValue('C:\\path\\to\\file')).toBe('C:\\\\path\\\\to\\\\file'); + expect(builder.escapeValue('test\\value')).toBe('test\\\\value'); + }); + + it('should escape backslashes before quotes (correct order)', () => { + expect(builder.escapeValue('test\\"value')).toBe('test\\\\\\"value'); + expect(builder.escapeValue('path\\"file"')).toBe('path\\\\\\"file\\"'); + }); + }); + + describe('edge cases', () => { + it('should handle empty string', () => { + expect(builder.escapeValue('')).toBe(''); + }); + + it('should handle string with only quotes', () => { + expect(builder.escapeValue('"')).toBe('\\"'); + }); + + it('should handle string with only backslashes', () => { + expect(builder.escapeValue('\\')).toBe('\\\\'); + }); + + it('should throw TypeError for non-string values', () => { + // @ts-expect-error Testing runtime error handling + expect(() => builder.escapeValue(123)).toThrow(TypeError); + // @ts-expect-error Testing runtime error handling + expect(() => builder.escapeValue(null)).toThrow(TypeError); + // @ts-expect-error Testing runtime error handling + expect(() => builder.escapeValue(undefined)).toThrow(TypeError); + }); + }); + + describe('injection attack prevention', () => { + it('should prevent OR condition injection', () => { + const maliciousId = '123" OR "1"="1'; + const escaped = builder.escapeValue(maliciousId); + expect(escaped).toBe('123\\" OR \\"1\\"=\\"1'); + }); + + it('should prevent function call injection', () => { + const maliciousId = '123") OR ISBLANK([field])=FALSE OR ([id]="'; + const escaped = builder.escapeValue(maliciousId); + expect(escaped).toBe('123\\") OR ISBLANK([field])=FALSE OR ([id]=\\"'); + }); + + it('should prevent comment injection', () => { + const maliciousId = '123" -- ignore rest'; + const escaped = builder.escapeValue(maliciousId); + expect(escaped).toBe('123\\" -- ignore rest'); + }); + + it('should prevent backslash encoding bypass', () => { + const maliciousId = '1\\"; OR "1"="1'; + const escaped = builder.escapeValue(maliciousId); + // Backslash escaped first, then quote escaped + expect(escaped).toContain('\\\\'); + expect(escaped).toContain('\\"'); + }); + + it('should prevent complex nested injection', () => { + const maliciousId = '123") OR (SELECT([field], [table]) = "malicious'; + const escaped = builder.escapeValue(maliciousId); + expect(escaped).toBe('123\\") OR (SELECT([field], [table]) = \\"malicious'); + }); + }); + + describe('comprehensive injection payloads', () => { + const injectionPayloads = [ + { input: '" OR "1"="1', description: 'Double quote OR injection' }, + { input: '1" UNION SELECT * FROM users WHERE "1"="1', description: 'UNION injection' }, + { + input: '1") OR ISBLANK([password])=FALSE OR ("1"="1', + description: 'ISBLANK function injection', + }, + { + input: '1") OR COUNT(SELECT([id], [users])) > 0 OR ("1"="1', + description: 'COUNT/SELECT injection', + }, + { input: '1" -- ignore rest', description: 'SQL comment injection' }, + { input: '1" /* comment */ OR "1"="1', description: 'Block comment injection' }, + { input: '1\\"; OR "1"="1', description: 'Backslash encoding bypass' }, + { input: '1\\\"; OR "1"="1', description: 'Double backslash bypass' }, + ]; + + injectionPayloads.forEach(({ input, description }) => { + it(`should neutralize: ${description}`, () => { + const escaped = builder.escapeValue(input); + const filter = `Filter(table, [id] = "${escaped}")`; + + // No unescaped quotes should remain inside the filter value + const innerContent = filter.match(/Filter\(table, \[id\] = "(.*?)"\)/)?.[1] || ''; + expect(innerContent).not.toMatch(/[^\\]"/); + + expect(filter).toContain(escaped); + }); + }); + }); + }); + + // ──────────────────────────────────────────────────────────────────── + // buildFilter() + // ──────────────────────────────────────────────────────────────────── + + describe('buildFilter', () => { + it('should build correct filter expression for simple values', () => { + expect(builder.buildFilter('users', '[user_id]', '123')).toBe( + 'Filter(users, [user_id] = "123")' + ); + }); + + it('should escape values in filter expression', () => { + expect(builder.buildFilter('users', '[user_id]', '123" OR "1"="1')).toBe( + 'Filter(users, [user_id] = "123\\" OR \\"1\\"=\\"1")' + ); + }); + + it('should handle field names with brackets', () => { + expect(builder.buildFilter('service_portfolio', '[service_portfolio_id]', 'abc123')).toBe( + 'Filter(service_portfolio, [service_portfolio_id] = "abc123")' + ); + }); + + it('should work with UUIDs', () => { + const uuid = '550e8400-e29b-41d4-a716-446655440000'; + expect(builder.buildFilter('users', '[id]', uuid)).toBe(`Filter(users, [id] = "${uuid}")`); + }); + + it('should handle empty string value', () => { + expect(builder.buildFilter('users', '[name]', '')).toBe('Filter(users, [name] = "")'); + }); + }); + + // ──────────────────────────────────────────────────────────────────── + // isSafeIdentifier() + // ──────────────────────────────────────────────────────────────────── + + describe('isSafeIdentifier', () => { + it('should accept alphanumeric identifiers', () => { + expect(builder.isSafeIdentifier('users')).toBe(true); + expect(builder.isSafeIdentifier('user_id')).toBe(true); + expect(builder.isSafeIdentifier('table123')).toBe(true); + expect(builder.isSafeIdentifier('TABLE_NAME')).toBe(true); + }); + + it('should reject identifiers with special characters', () => { + expect(builder.isSafeIdentifier('user-id')).toBe(false); + expect(builder.isSafeIdentifier('user.id')).toBe(false); + expect(builder.isSafeIdentifier('user id')).toBe(false); + expect(builder.isSafeIdentifier('user[id]')).toBe(false); + expect(builder.isSafeIdentifier('user;id')).toBe(false); + }); + + it('should reject empty string', () => { + expect(builder.isSafeIdentifier('')).toBe(false); + }); + }); +}); From 90e7a4a8a4be0de241ac320da6dc3e052539e54a Mon Sep 17 00:00:00 2001 From: Tim Wagner Date: Sat, 21 Feb 2026 11:40:18 +0100 Subject: [PATCH 2/3] docs(SOSO-365): add v3.1.0 changelog entry --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f3a752..c810cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.1.0] - 2026-02-21 + +### Added + +- **SelectorBuilder Class with Interface** (SOSO-365) + - `SelectorBuilderInterface`: Contract for selector building and value escaping + - `SelectorBuilder`: Default implementation with 4 methods: + - `ensureFunction()`: Wraps raw boolean expressions in `Filter()` for API compliance + - `escapeValue()`: Escapes `"` and `\` to prevent injection attacks + - `buildFilter()`: Combines escaping with `Filter()` wrapping (convenience) + - `isSafeIdentifier()`: Validates table/field names for safe characters + - Comprehensive test suite with 40+ tests including injection attack prevention + - Integrated from `AppSheetFilterEscape` utility in `service_portfolio_mcp` + +- **DI-Compatible SelectorBuilder Injection (Option B)** + - `AppSheetClientFactory` accepts optional `SelectorBuilderInterface` in constructor + - `AppSheetClient` accepts optional `SelectorBuilderInterface` as 3rd constructor parameter + - Enables custom builder injection via tsyringe or other DI frameworks + - Enables AOP extensibility (e.g. `@LogExecution` via subclass) + - Default behavior unchanged (falls back to `new SelectorBuilder()`) + - `AppSheetClientFactoryInterface.create()` signature unchanged — no breaking change + +- **Bugfix Concept Document** + - `docs/SOSO-365/BUGFIX_CONCEPT.md` with design decisions, DI compatibility, and AOP extensibility + +### Fixed + +- **Selector Wrapping** (SOSO-365): `AppSheetClient.find()` now automatically wraps raw + boolean expressions in `Filter(tableName, expression)` as required by AppSheet API spec. + Previously raw expressions like `[Field] = "value"` were sent without wrapper. + Already-wrapped expressions (`Filter()`, `Select()`, `OrderBy()`, `Top()`) pass through + unchanged (idempotent). + +- **Default Base URL** (SOSO-365): Changed from deprecated `https://api.appsheet.com/api/v2` + to `https://www.appsheet.com/api/v2` per current AppSheet API documentation. + Regional endpoints (EU, Asia Pacific) continue to work via `baseUrl` override. + +### Technical Details + +- **Breaking Changes**: None +- **SemVer Level**: MINOR (new features + bug fixes, fully backward compatible) +- **Test Coverage**: 265 tests across 9 test suites +- **Jira**: [SOSO-365](https://techdivision.atlassian.net/browse/SOSO-365) + ## [3.0.0] - 2024-11-30 ### Added @@ -81,6 +125,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 See [CLAUDE.md](./CLAUDE.md) Breaking Changes section for detailed migration examples. **Quick Migration:** + ```typescript // Old (v2.x) const client = new AppSheetClient({ appId, applicationAccessKey, runAsUserEmail }); From b2d2d50286e7494963f8050bc18e713aaad7b891 Mon Sep 17 00:00:00 2001 From: Tim Wagner Date: Sat, 21 Feb 2026 11:47:24 +0100 Subject: [PATCH 3/3] docs(SOSO-365): update examples and docs for v3.1.0, archive v1.0 technical conception - Archive TECHNICAL_CONCEPTION.md to docs/archive/ (v1.0 planning document, not current) - Update examples/basic-usage.ts to v3.1.0 constructor signature (ConnectionDefinition) - Update examples/schema-based-usage.ts to v3.1.0 factory injection pattern - Update examples/config/example-schema.yaml to v2.0.0 AppSheet field type format - Add examples/selector-builder-usage.ts (escaping, safe filters, DI, AOP) - Update docs/README.md with all examples and concept document references --- docs/README.md | 29 ++- .../TECHNICAL_CONCEPTION_v1.md} | 193 ++++++++++-------- examples/basic-usage.ts | 66 ++++-- examples/config/example-schema.yaml | 102 +++++++-- examples/schema-based-usage.ts | 97 +++++++-- examples/selector-builder-usage.ts | 152 ++++++++++++++ 6 files changed, 489 insertions(+), 150 deletions(-) rename docs/{TECHNICAL_CONCEPTION.md => archive/TECHNICAL_CONCEPTION_v1.md} (93%) create mode 100644 examples/selector-builder-usage.ts diff --git a/docs/README.md b/docs/README.md index c515797..ec7d392 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,17 +23,32 @@ This will generate the documentation and serve it locally in your browser. ### Online Documentation Once published, the documentation will be available at: + - NPM: https://www.npmjs.com/package/@techdivision/appsheet - GitHub: https://github.com/techdivision/appsheet - GitHub Pages: (if configured) -## Technical Conception - -See [TECHNICAL_CONCEPTION.md](./TECHNICAL_CONCEPTION.md) for the complete technical design document. - ## Examples See the `examples/` directory for usage examples: -- `basic-usage.ts` - Direct client usage -- `schema-based-usage.ts` - Schema-based usage with runtime loading -- `config/example-schema.yaml` - Example schema configuration file + +- **`basic-usage.ts`** - Direct AppSheetClient usage with ConnectionDefinition (v3.1.0) +- **`schema-based-usage.ts`** - Schema-based usage with factory injection, multi-tenant pattern, and schema introspection (v3.1.0) +- **`selector-builder-usage.ts`** - SelectorBuilder for injection-safe filters, automatic wrapping, DI injection, and AOP extensibility (v3.1.0) +- **`config/example-schema.yaml`** - Example schema configuration with AppSheet field types (v2.0.0+ format) + +## Concept Documents + +Design decisions and implementation concepts for specific features: + +- **`SOSO-365/BUGFIX_CONCEPT.md`** - SelectorBuilder, selector wrapping fix, default URL correction, DI compatibility (v3.1.0) +- **`SOSO-249/INTEGRATION_CONCEPT.md`** - Factory injection pattern for DI and testing (v3.0.0) +- **`SOSO-248/INTEGRATION_CONCEPT.md`** - Per-request user context (v2.1.0) +- **`SOSO-247/INTEGRATION_CONCEPT.md`** - AppSheet field type system and validation (v2.0.0) +- **`SOSO-246/`** - Initial DI/testing concept research (v3.0.0 planning) + +## Archive + +Historical planning documents that are no longer current: + +- **`archive/TECHNICAL_CONCEPTION_v1.md`** - Original v1.0 design document. Contains planned features (FilterBuilder, BatchOperations, CachedAppSheetClient, Plugin-System, QueryBuilder) that were not implemented or replaced by other patterns. **Not representative of current implementation.** diff --git a/docs/TECHNICAL_CONCEPTION.md b/docs/archive/TECHNICAL_CONCEPTION_v1.md similarity index 93% rename from docs/TECHNICAL_CONCEPTION.md rename to docs/archive/TECHNICAL_CONCEPTION_v1.md index 21abe1b..32ada9c 100644 --- a/docs/TECHNICAL_CONCEPTION.md +++ b/docs/archive/TECHNICAL_CONCEPTION_v1.md @@ -1,6 +1,15 @@ -# AppSheet TypeScript Library - API Konzept +# AppSheet TypeScript Library - API Konzept (v1.0 - ARCHIVIERT) -## Übersicht +> **ARCHIVIERT**: Dieses Dokument war das urspruengliche Planungs- und Designkonzept fuer v1.0. +> Es spiegelt den Stand der Designphase wider und ist **nicht** repraesentativ fuer die +> aktuelle Implementierung (v3.1.0). Viele hier beschriebene Features (FilterBuilder, +> BatchOperations, CachedAppSheetClient, Plugin-System, Event-System, QueryBuilder) +> wurden bewusst nicht implementiert oder durch andere Patterns ersetzt. +> +> **Aktuelle Dokumentation:** Siehe [CLAUDE.md](../../CLAUDE.md) und die Konzeptdokumente +> unter `docs/SOSO-*/`. + +## Uebersicht Diese Library bietet eine generische, typsichere Schnittstelle für CRUD-Operationen auf beliebige AppSheet-Tabellen. Sie ist speziell für die Verwendung in MCP-Servern und internen Projekten optimiert. @@ -30,12 +39,12 @@ src/ ```typescript interface AppSheetConfig { - appId: string; // AppSheet App-ID - applicationAccessKey: string; // API Access Key - runAsUserEmail?: string; // Optional: Global User-Email für alle Operationen - baseUrl?: string; // Optional: Custom API URL - timeout?: number; // Optional: Request Timeout (default: 30000ms) - retryAttempts?: number; // Optional: Anzahl Wiederholungsversuche (default: 3) + appId: string; // AppSheet App-ID + applicationAccessKey: string; // API Access Key + runAsUserEmail?: string; // Optional: Global User-Email für alle Operationen + baseUrl?: string; // Optional: Custom API URL + timeout?: number; // Optional: Request Timeout (default: 30000ms) + retryAttempts?: number; // Optional: Anzahl Wiederholungsversuche (default: 3) } ``` @@ -44,10 +53,11 @@ interface AppSheetConfig { ### 2. CRUD-Operationen #### Add (Create) + ```typescript interface AddOptions> { tableName: string; - rows: T[]; // Ein oder mehrere Datensätze + rows: T[]; // Ein oder mehrere Datensätze properties?: { Locale?: string; Location?: string; @@ -58,22 +68,23 @@ interface AddOptions> { } interface AddResponse> { - rows: T[]; // Erstellte Datensätze mit Server-generierten Feldern + rows: T[]; // Erstellte Datensätze mit Server-generierten Feldern warnings?: string[]; } ``` #### Find (Read) + ```typescript interface FindOptions { tableName: string; - selector?: string; // SQL-ähnlicher Filter (z.B. "_RowNumber > 10") + selector?: string; // SQL-ähnlicher Filter (z.B. "_RowNumber > 10") properties?: { Locale?: string; Location?: string; Timezone?: string; UserId?: string; - Selector?: string; // Alternative zu top-level selector + Selector?: string; // Alternative zu top-level selector }; } @@ -84,10 +95,11 @@ interface FindResponse> { ``` #### Update + ```typescript interface UpdateOptions> { tableName: string; - rows: T[]; // Datensätze mit Key-Feldern zum Update + rows: T[]; // Datensätze mit Key-Feldern zum Update properties?: { Locale?: string; Location?: string; @@ -98,16 +110,17 @@ interface UpdateOptions> { } interface UpdateResponse> { - rows: T[]; // Aktualisierte Datensätze + rows: T[]; // Aktualisierte Datensätze warnings?: string[]; } ``` #### Delete + ```typescript interface DeleteOptions> { tableName: string; - rows: T[]; // Datensätze mit Key-Feldern zum Löschen + rows: T[]; // Datensätze mit Key-Feldern zum Löschen properties?: { Locale?: string; Location?: string; @@ -155,13 +168,13 @@ import { AppSheetClient } from '@techdivision/appsheet'; const client = new AppSheetClient({ appId: 'your-app-id', applicationAccessKey: 'your-access-key', - runAsUserEmail: 'user@example.com' // Optional: Default User für alle Operationen + runAsUserEmail: 'user@example.com', // Optional: Default User für alle Operationen }); // Create const newUser = await client.addOne('Users', { name: 'John Doe', - email: 'john@example.com' + email: 'john@example.com', }); // Read @@ -171,7 +184,7 @@ const john = await client.findOne('Users', '[Email] = "john@example.com"') // Update await client.updateOne('Users', { id: john.id, - name: 'John Smith' + name: 'John Smith', }); // Delete @@ -201,9 +214,7 @@ class FilterBuilder { // Verwendung const filter = new FilterBuilder() .equals('Status', 'Active') - .and( - new FilterBuilder().greaterThan('CreatedDate', new Date('2025-01-01')) - ) + .and(new FilterBuilder().greaterThan('CreatedDate', new Date('2025-01-01'))) .build(); // Ergebnis: "[Status] = 'Active' AND [CreatedDate] > '2025-01-01'" ``` @@ -289,8 +300,8 @@ class RateLimitError extends AppSheetError {} ```typescript interface CacheOptions { - ttl?: number; // Time to live in ms - maxSize?: number; // Max cache entries + ttl?: number; // Time to live in ms + maxSize?: number; // Max cache entries } class CachedAppSheetClient extends AppSheetClient { @@ -308,17 +319,20 @@ class CachedAppSheetClient extends AppSheetClient { ## API-Endpoint-Struktur ### Base URL + ``` https://api.appsheet.com/api/v2/apps/{appId}/tables/{tableName}/Action ``` ### HTTP Headers + ``` ApplicationAccessKey: Content-Type: application/json ``` ### Request Body (generisch) + ```json { "Action": "Add" | "Find" | "Edit" | "Delete", @@ -341,20 +355,28 @@ Content-Type: application/json Die AppSheet API kann Antworten in zwei verschiedenen Formaten zurückgeben: **Standard-Format** (empfohlen): + ```json { "Rows": [ - { /* returned row data */ } + { + /* returned row data */ + } ], "Warnings": ["optional warnings"] } ``` **Alternatives Format** (direkte Array-Antwort): + ```json [ - { /* row data */ }, - { /* row data */ } + { + /* row data */ + }, + { + /* row data */ + } ] ``` @@ -363,6 +385,7 @@ Die AppSheet API kann Antworten in zwei verschiedenen Formaten zurückgeben: ## Erweiterbarkeit ### Plugin-System (Optional) + ```typescript interface Plugin { name: string; @@ -377,6 +400,7 @@ class AppSheetClient { ``` ### Event-System + ```typescript class AppSheetClient extends EventEmitter { // Events: 'request', 'response', 'error', 'retry' @@ -399,7 +423,7 @@ connections: worklog: appId: ${APPSHEET_WORKLOG_APP_ID} applicationAccessKey: ${APPSHEET_WORKLOG_ACCESS_KEY} - runAsUserEmail: ${APPSHEET_USER_EMAIL} # Optional: Default User für alle Operationen + runAsUserEmail: ${APPSHEET_USER_EMAIL} # Optional: Default User für alle Operationen tables: worklogs: tableName: worklog @@ -425,7 +449,7 @@ connections: hr: appId: ${APPSHEET_HR_APP_ID} applicationAccessKey: ${APPSHEET_HR_ACCESS_KEY} - runAsUserEmail: ${APPSHEET_HR_USER_EMAIL} # Optional: Unterschiedlicher User pro Connection + runAsUserEmail: ${APPSHEET_HR_USER_EMAIL} # Optional: Unterschiedlicher User pro Connection tables: employees: tableName: employees @@ -507,7 +531,7 @@ export interface TableDefinition { export interface ConnectionDefinition { appId: string; applicationAccessKey: string; - runAsUserEmail?: string; // Optional: Default User für alle Operationen dieser Connection + runAsUserEmail?: string; // Optional: Default User für alle Operationen dieser Connection baseUrl?: string; timeout?: number; tables: Record; @@ -558,7 +582,7 @@ export class SchemaLoader { } if (Array.isArray(obj)) { - return obj.map(item => this.resolveEnvVars(item)); + return obj.map((item) => this.resolveEnvVars(item)); } if (obj && typeof obj === 'object') { @@ -643,7 +667,7 @@ export class SchemaManager { name: connName, appId: connDef.appId, applicationAccessKey: connDef.applicationAccessKey, - runAsUserEmail: connDef.runAsUserEmail, // Optional: User-Email für diese Connection + runAsUserEmail: connDef.runAsUserEmail, // Optional: User-Email für diese Connection baseUrl: connDef.baseUrl, timeout: connDef.timeout, }); @@ -671,7 +695,7 @@ export class SchemaManager { if (!table) { throw new Error( `Table "${tableName}" not found in connection "${connectionName}". ` + - `Available tables: ${[...connection.keys()].join(', ')}` + `Available tables: ${[...connection.keys()].join(', ')}` ); } @@ -812,14 +836,10 @@ export class DynamicTable> { if (value !== undefined && value !== null) { const actualType = typeof value; if (fieldType === 'number' && actualType !== 'number') { - throw new Error( - `Field "${fieldName}" must be a number, got ${actualType}` - ); + throw new Error(`Field "${fieldName}" must be a number, got ${actualType}`); } if (fieldType === 'boolean' && actualType !== 'boolean') { - throw new Error( - `Field "${fieldName}" must be a boolean, got ${actualType}` - ); + throw new Error(`Field "${fieldName}" must be a boolean, got ${actualType}`); } // string, array, object validations... } @@ -827,9 +847,7 @@ export class DynamicTable> { // Enum validation if (typeof fieldDef === 'object' && fieldDef.enum && value) { if (!fieldDef.enum.includes(value)) { - throw new Error( - `Field "${fieldName}" must be one of: ${fieldDef.enum.join(', ')}` - ); + throw new Error(`Field "${fieldName}" must be one of: ${fieldDef.enum.join(', ')}`); } } } @@ -1089,10 +1107,12 @@ export class SchemaInspector { private toSchemaName(tableName: string): string { // "extract_user" -> "users" // "worklog" -> "worklogs" - return tableName - .replace(/^extract_/, '') - .replace(/_/g, '') - .toLowerCase() + 's'; + return ( + tableName + .replace(/^extract_/, '') + .replace(/_/g, '') + .toLowerCase() + 's' + ); } } @@ -1115,10 +1135,7 @@ import * as fs from 'fs'; export function createCLI() { const program = new Command(); - program - .name('appsheet') - .description('AppSheet Schema Management CLI') - .version('1.0.0'); + program.name('appsheet').description('AppSheet Schema Management CLI').version('1.0.0'); // Command: init program @@ -1137,9 +1154,8 @@ export function createCLI() { }, }; - const output = options.format === 'json' - ? JSON.stringify(schema, null, 2) - : yaml.stringify(schema); + const output = + options.format === 'json' ? JSON.stringify(schema, null, 2) : yaml.stringify(schema); fs.writeFileSync(options.output, output, 'utf-8'); console.log(`✓ Schema file created: ${options.output}`); @@ -1166,10 +1182,7 @@ export function createCLI() { const tableNames = options.tables.split(',').map((t: string) => t.trim()); console.log(`Inspecting ${tableNames.length} tables...`); - const connection = await inspector.generateSchema( - options.connectionName, - tableNames - ); + const connection = await inspector.generateSchema(options.connectionName, tableNames); const schema: SchemaConfig = { connections: { @@ -1177,9 +1190,8 @@ export function createCLI() { }, }; - const output = options.format === 'json' - ? JSON.stringify(schema, null, 2) - : yaml.stringify(schema); + const output = + options.format === 'json' ? JSON.stringify(schema, null, 2) : yaml.stringify(schema); fs.writeFileSync(options.output, output, 'utf-8'); console.log(`✓ Schema generated: ${options.output}`); @@ -1559,9 +1571,9 @@ export interface Project { // 2. Schema-Registry mit Metadaten export const tableSchemas = { users: { - tableName: 'extract_user', // Tatsächlicher AppSheet Tabellenname - keyField: 'id', // Primary Key - type: {} as User, // Type Inference Helper + tableName: 'extract_user', // Tatsächlicher AppSheet Tabellenname + keyField: 'id', // Primary Key + type: {} as User, // Type Inference Helper }, worklogs: { tableName: 'worklog', @@ -1600,21 +1612,25 @@ const client = new AppSheetClient({ const usersTable = tables.users(client); // Alle Operationen sind jetzt typsicher! -const users = await usersTable.findAll(); // Type: User[] +const users = await usersTable.findAll(); // Type: User[] const user = await usersTable.findOne('[Email] = "test@example.com"'); // Type: User | null -await usersTable.add([{ - id: '123', - name: 'John', - email: 'john@example.com', - role: 'user', // ✓ Autocomplete + Validation - createdAt: new Date(), -}]); +await usersTable.add([ + { + id: '123', + name: 'John', + email: 'john@example.com', + role: 'user', // ✓ Autocomplete + Validation + createdAt: new Date(), + }, +]); -await usersTable.update([{ - id: '123', - name: 'Jane', // Partial updates möglich -}]); +await usersTable.update([ + { + id: '123', + name: 'Jane', // Partial updates möglich + }, +]); await usersTable.delete([{ id: '123' }]); ``` @@ -1650,8 +1666,8 @@ export const schema = new SchemaBuilder() keyField: 'id', fields: [ { name: 'email', required: true, type: 'string' }, - { name: 'role', required: true, type: 'enum', values: ['admin', 'user'] } - ] + { name: 'role', required: true, type: 'enum', values: ['admin', 'user'] }, + ], }) .define({ name: 'worklogs', @@ -1710,6 +1726,7 @@ npx @techdivision/appsheet generate-types --config appsheet.config.yaml --output ``` Generiert automatisch: + - TypeScript Interfaces - Table Registry - Validation Functions @@ -1811,7 +1828,7 @@ export class TypedQueryBuilder> { } whereIn(field: keyof T, values: any[]) { - const conditions = values.map(v => `[${String(field)}] = "${v}"`).join(' OR '); + const conditions = values.map((v) => `[${String(field)}] = "${v}"`).join(' OR '); this.filters.push(`(${conditions})`); return this; } @@ -1865,7 +1882,11 @@ export const db = { import { db } from './db'; const users = await db.users().findAll(); -const worklog = await db.worklogs().add([{ /* ... */ }]); +const worklog = await db.worklogs().add([ + { + /* ... */ + }, +]); ``` ### Vorteile dieses Ansatzes @@ -1887,10 +1908,10 @@ Ein MCP-Server muss oft mehrere verschiedene AppSheet-Instanzen ansteuern (z.B. ```typescript // src/utils/connectionManager.ts (in der Library) export interface ConnectionConfig { - name: string; // Eindeutiger Name (z.B. "worklog-app", "hr-app") + name: string; // Eindeutiger Name (z.B. "worklog-app", "hr-app") appId: string; applicationAccessKey: string; - runAsUserEmail?: string; // Optional: Default User für alle Operationen dieser Connection + runAsUserEmail?: string; // Optional: Default User für alle Operationen dieser Connection baseUrl?: string; timeout?: number; retryAttempts?: number; @@ -1925,7 +1946,9 @@ export class ConnectionManager { get(name: string): AppSheetClient { const client = this.connections.get(name); if (!client) { - throw new Error(`Connection "${name}" not found. Available: ${[...this.connections.keys()].join(', ')}`); + throw new Error( + `Connection "${name}" not found. Available: ${[...this.connections.keys()].join(', ')}` + ); } return client; } @@ -1976,7 +1999,7 @@ export function initializeConnections() { name: 'worklog', appId: process.env.APPSHEET_WORKLOG_APP_ID!, applicationAccessKey: process.env.APPSHEET_WORKLOG_ACCESS_KEY!, - runAsUserEmail: process.env.APPSHEET_WORKLOG_USER_EMAIL, // Optional + runAsUserEmail: process.env.APPSHEET_WORKLOG_USER_EMAIL, // Optional }); // HR App @@ -1984,7 +2007,7 @@ export function initializeConnections() { name: 'hr', appId: process.env.APPSHEET_HR_APP_ID!, applicationAccessKey: process.env.APPSHEET_HR_ACCESS_KEY!, - runAsUserEmail: process.env.APPSHEET_HR_USER_EMAIL, // Optional + runAsUserEmail: process.env.APPSHEET_HR_USER_EMAIL, // Optional }); // Customer Portal App @@ -1992,7 +2015,7 @@ export function initializeConnections() { name: 'customer-portal', appId: process.env.APPSHEET_PORTAL_APP_ID!, applicationAccessKey: process.env.APPSHEET_PORTAL_ACCESS_KEY!, - runAsUserEmail: process.env.APPSHEET_PORTAL_USER_EMAIL, // Optional + runAsUserEmail: process.env.APPSHEET_PORTAL_USER_EMAIL, // Optional }); } ``` @@ -2119,7 +2142,7 @@ export function createAppConnection>( // Wandle Registry in Table-Clients um const tableClients = {} as { - [K in keyof T]: ReturnType; + [K in keyof T]: ReturnType<(typeof tables)[K]>; }; for (const key of Object.keys(schemas)) { diff --git a/examples/basic-usage.ts b/examples/basic-usage.ts index d25234b..a2b02ea 100644 --- a/examples/basic-usage.ts +++ b/examples/basic-usage.ts @@ -1,54 +1,76 @@ /** - * Basic usage example for AppSheet library + * Basic usage example for AppSheet library (v3.1.0) + * + * Pattern 1: Direct AppSheetClient usage + * + * This example shows how to use AppSheetClient directly with a ConnectionDefinition. + * For schema-based usage with factory injection, see schema-based-usage.ts. */ -import { AppSheetClient } from '../src'; +import { AppSheetClient, ConnectionDefinition } from '../src'; async function main() { - // Create client with optional runAsUserEmail - const client = new AppSheetClient({ + // Define connection with table schemas + const connectionDef: ConnectionDefinition = { appId: process.env.APPSHEET_APP_ID!, applicationAccessKey: process.env.APPSHEET_ACCESS_KEY!, - runAsUserEmail: 'default@example.com', // Optional: run all operations as this user - }); + tables: { + users: { + tableName: 'extract_user', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + name: { type: 'Name', required: true }, + email: { type: 'Email', required: true }, + status: { type: 'Enum', allowedValues: ['Active', 'Inactive'] }, + }, + }, + }, + }; - // Add rows (uses global runAsUserEmail) + // Create client — runAsUserEmail is required (v3.0.0+) + const client = new AppSheetClient(connectionDef, 'user@example.com'); + + // Add rows const newUsers = await client.add({ - tableName: 'Users', + tableName: 'extract_user', rows: [ - { name: 'John Doe', email: 'john@example.com' }, - { name: 'Jane Smith', email: 'jane@example.com' }, + { name: 'John Doe', email: 'john@example.com', status: 'Active' }, + { name: 'Jane Smith', email: 'jane@example.com', status: 'Active' }, ], }); console.log('Created users:', newUsers.rows); // Find all - const allUsers = await client.findAll('Users'); + const allUsers = await client.findAll('extract_user'); console.log('All users:', allUsers); - // Find with selector + // Find with selector — automatically wrapped in Filter() by SelectorBuilder const activeUsers = await client.find({ - tableName: 'Users', + tableName: 'extract_user', selector: '[Status] = "Active"', }); console.log('Active users:', activeUsers.rows); + // Find one + const john = await client.findOne('extract_user', '[Email] = "john@example.com"'); + console.log('Found user:', john); + // Update - const updated = await client.updateOne('Users', { + const updated = await client.updateOne('extract_user', { id: '123', name: 'John Updated', }); console.log('Updated user:', updated); - // Delete with per-operation runAsUserEmail override - await client.delete({ - tableName: 'Users', - rows: [{ id: '123' }], - properties: { - RunAsUserEmail: 'admin@example.com', // Override for this operation - }, - }); + // Delete + await client.deleteOne('extract_user', { id: '123' }); console.log('User deleted'); + + // Get table definition from connection + const tableDef = client.getTable('users'); + console.log('Table name:', tableDef.tableName); // 'extract_user' + console.log('Key field:', tableDef.keyField); // 'id' } main().catch(console.error); diff --git a/examples/config/example-schema.yaml b/examples/config/example-schema.yaml index dd2d6aa..47c6f74 100644 --- a/examples/config/example-schema.yaml +++ b/examples/config/example-schema.yaml @@ -1,29 +1,86 @@ -# Example AppSheet schema configuration +# Example AppSheet schema configuration (v2.0.0+ format) +# +# All fields must use AppSheet-specific types with FieldDefinition objects. +# Environment variables are resolved at runtime via ${VAR_NAME} syntax. connections: worklog: appId: ${APPSHEET_WORKLOG_APP_ID} applicationAccessKey: ${APPSHEET_WORKLOG_ACCESS_KEY} tables: + users: + tableName: extract_user + keyField: id + fields: + id: + type: Text + required: true + name: + type: Name + required: true + email: + type: Email + required: true + status: + type: Enum + required: true + allowedValues: + - Active + - Inactive + - Pending + role: + type: Enum + allowedValues: + - admin + - user + phone: + type: Phone + website: + type: URL + hireDate: + type: Date + worklogs: tableName: worklog keyField: id fields: - id: string - userId: string - date: string - hours: number - description: string - projectKey: string + id: + type: Text + required: true + userId: + type: Text + required: true + date: + type: Date + required: true + hours: + type: Decimal + required: true + description: + type: Text + projectKey: + type: Text + required: true issues: tableName: extract_issue keyField: key fields: - key: string - summary: string - status: string - assignee: string + key: + type: Text + required: true + summary: + type: Text + required: true + status: + type: Enum + allowedValues: + - Open + - In Progress + - Done + - Closed + assignee: + type: Email hr: appId: ${APPSHEET_HR_APP_ID} @@ -33,8 +90,21 @@ connections: tableName: employees keyField: id fields: - id: string - name: string - email: string - department: string - hireDate: date + id: + type: Text + required: true + name: + type: Name + required: true + email: + type: Email + required: true + department: + type: Enum + allowedValues: + - Engineering + - Sales + - HR + - Marketing + hireDate: + type: Date diff --git a/examples/schema-based-usage.ts b/examples/schema-based-usage.ts index ad3cd93..0e81c21 100644 --- a/examples/schema-based-usage.ts +++ b/examples/schema-based-usage.ts @@ -1,41 +1,98 @@ /** - * Schema-based usage example + * Schema-based usage example (v3.1.0) + * + * Pattern 2: SchemaManager with Factory Injection (recommended) + * + * This is the recommended pattern for production use. It provides: + * - Schema-driven table definitions from YAML/JSON + * - Factory injection for easy testing (swap real/mock clients) + * - Per-request user context for multi-tenant MCP servers + * - Runtime validation based on AppSheet field types */ -import { SchemaLoader, SchemaManager } from '../src'; +import { SchemaLoader, SchemaManager, AppSheetClientFactory, SelectorBuilder } from '../src'; -async function main() { - // Load schema from file - const schema = SchemaLoader.fromYaml('./config/appsheet-schema.yaml'); +// TypeScript interfaces for type safety (optional but recommended) +interface User { + id: string; + name: string; + email: string; + status: 'Active' | 'Inactive' | 'Pending'; +} + +interface Worklog { + id: string; + userId: string; + date: string; + hours: number; + description: string; +} - // Create schema manager - const db = new SchemaManager(schema); +async function main() { + // Load schema from YAML file (resolves ${ENV_VAR} placeholders) + const schema = SchemaLoader.fromYaml('./config/example-schema.yaml'); - // Use type-safe table clients - interface User { - id: string; - name: string; - email: string; - status: string; - } + // Create factory and schema manager + const clientFactory = new AppSheetClientFactory(); + const db = new SchemaManager(clientFactory, schema); - // Get table client - const usersTable = db.table('worklog', 'users'); + // Get table client — runAsUserEmail is required (v3.0.0+) + const usersTable = db.table('worklog', 'users', 'user@example.com'); - // CRUD operations + // CRUD operations with runtime validation const users = await usersTable.findAll(); console.log('All users:', users); + // Find with selector (automatically wrapped in Filter() for API compliance) const activeUsers = await usersTable.find('[Status] = "Active"'); console.log('Active users:', activeUsers); - await usersTable.add([ - { id: '456', name: 'Bob', email: 'bob@example.com', status: 'Active' }, - ]); + // Find one + const john = await usersTable.findOne('[Email] = "john@example.com"'); + console.log('Found user:', john); + + // Add with validation (Email format, Enum values checked at runtime) + await usersTable.add([{ id: '456', name: 'Bob', email: 'bob@example.com', status: 'Active' }]); + // Update await usersTable.update([{ id: '456', status: 'Inactive' }]); + // Delete await usersTable.delete([{ id: '456' }]); + + // --- Schema Introspection --- + + // Get table definition + const tableDef = db.getTableDefinition('worklog', 'users'); + console.log('Table:', tableDef?.tableName); // 'extract_user' + + // Get field definition + const statusField = db.getFieldDefinition('worklog', 'users', 'status'); + console.log('Status type:', statusField?.type); // 'Enum' + + // Get allowed values for Enum fields + const statusValues = db.getAllowedValues('worklog', 'users', 'status'); + console.log('Allowed:', statusValues); // ['Active', 'Inactive', 'Pending'] + + // --- Safe Selector Building --- + + const selector = new SelectorBuilder(); + + // Build injection-safe filter from user input + const userInput = 'some"malicious"input'; + const safeFilter = selector.buildFilter('extract_user', '[name]', userInput); + console.log('Safe filter:', safeFilter); + // => 'Filter(extract_user, [name] = "some\"malicious\"input")' + + // --- Multi-Tenant MCP Server Pattern --- + + // Each request gets its own user context (lightweight, on-demand) + const userATable = db.table('worklog', 'users', 'alice@example.com'); + const userBTable = db.table('worklog', 'users', 'bob@example.com'); + + // Operations execute with respective user's AppSheet permissions + const aliceData = await userATable.findAll(); + const bobData = await userBTable.findAll(); } main().catch(console.error); diff --git a/examples/selector-builder-usage.ts b/examples/selector-builder-usage.ts new file mode 100644 index 0000000..4b4c9c0 --- /dev/null +++ b/examples/selector-builder-usage.ts @@ -0,0 +1,152 @@ +/** + * SelectorBuilder usage example (v3.1.0) + * + * The SelectorBuilder provides: + * 1. API compliance — wraps raw expressions in Filter() as required by AppSheet API + * 2. Injection safety — escapes user input to prevent filter injection attacks + * 3. DI extensibility — injectable via AppSheetClientFactory for custom/AOP behavior + * + * Note: When using AppSheetClient or DynamicTable, selector wrapping happens + * automatically in find()/findOne(). You only need SelectorBuilder directly + * when building selectors from untrusted user input. + */ + +import { SelectorBuilder, SelectorBuilderInterface, AppSheetClientFactory } from '../src'; + +// --- 1. Direct Usage: Value Escaping --- + +function directUsageExample() { + const builder = new SelectorBuilder(); + + // Escape user input for safe use in expressions + const userInput = 'O"Brien'; + const escaped = builder.escapeValue(userInput); + console.log(escaped); // => 'O\"Brien' + + // Use escaped value in a manually built expression + const expr = `[LastName] = "${escaped}" AND [Status] = "Active"`; + console.log(expr); + // => '[LastName] = "O\"Brien" AND [Status] = "Active"' +} + +// --- 2. Direct Usage: Build Safe Filters --- + +function buildFilterExample() { + const builder = new SelectorBuilder(); + + // Build a complete, injection-safe Filter() expression + const filter = builder.buildFilter('extract_user', '[user_id]', 'abc-123'); + console.log(filter); + // => 'Filter(extract_user, [user_id] = "abc-123")' + + // Injection attempt is safely escaped + const malicious = '123" OR "1"="1'; + const safeFilter = builder.buildFilter('extract_user', '[user_id]', malicious); + console.log(safeFilter); + // => 'Filter(extract_user, [user_id] = "123\" OR \"1\"=\"1")' + + // Validate identifiers before using them + console.log(builder.isSafeIdentifier('user_id')); // true + console.log(builder.isSafeIdentifier('user-id')); // false (hyphen) + console.log(builder.isSafeIdentifier('user id')); // false (space) +} + +// --- 3. Automatic Wrapping (happens inside AppSheetClient.find()) --- + +function automaticWrappingExample() { + const builder = new SelectorBuilder(); + + // Raw expression gets wrapped in Filter() + const wrapped = builder.ensureFunction('[Status] = "Active"', 'People'); + console.log(wrapped); + // => 'Filter(People, [Status] = "Active")' + + // Already-wrapped expressions pass through unchanged (idempotent) + const existing = builder.ensureFunction('Filter(People, [Status] = "Active")', 'People'); + console.log(existing); + // => 'Filter(People, [Status] = "Active")' + + // Other AppSheet functions are also recognized + const orderBy = builder.ensureFunction('OrderBy(Filter(People, true), [Name], true)', 'People'); + console.log(orderBy); + // => 'OrderBy(Filter(People, true), [Name], true)' + + // Select() and Top() are recognized too + const select = builder.ensureFunction( + 'Select(People[Name], [Status] = "Active", true)', + 'People' + ); + console.log(select); + // => 'Select(People[Name], [Status] = "Active", true)' +} + +// --- 4. DI Injection: Custom SelectorBuilder via Factory --- + +function diInjectionExample() { + // Create a custom SelectorBuilder (e.g. with logging) + class LoggedSelectorBuilder extends SelectorBuilder { + ensureFunction(selector: string, tableName: string): string { + const result = super.ensureFunction(selector, tableName); + console.log(`[SelectorBuilder] ${selector} => ${result}`); + return result; + } + + buildFilter(tableName: string, fieldName: string, value: string): string { + const result = super.buildFilter(tableName, fieldName, value); + console.log(`[SelectorBuilder] buildFilter => ${result}`); + return result; + } + } + + // Inject custom builder into factory + const factory = new AppSheetClientFactory(new LoggedSelectorBuilder()); + + // All clients created by this factory will use the LoggedSelectorBuilder + // const client = factory.create(connectionDef, 'user@example.com'); + // client.find({ tableName: 'Users', selector: '[Status] = "Active"' }); + // => Console: [SelectorBuilder] [Status] = "Active" => Filter(Users, [Status] = "Active") + + console.log('Factory created with custom SelectorBuilder'); +} + +// --- 5. AOP-Compatible Subclass (for projects using @LogExecution etc.) --- + +function aopExample() { + // In projects using TypeScript decorators for AOP logging (e.g. service_portfolio_mcp): + // + // class AopSelectorBuilder extends SelectorBuilder { + // @LogExecution({ level: 'debug' }) + // ensureFunction(selector: string, tableName: string): string { + // return super.ensureFunction(selector, tableName); + // } + // + // @LogExecution({ level: 'debug' }) + // buildFilter(tableName: string, fieldName: string, value: string): string { + // return super.buildFilter(tableName, fieldName, value); + // } + // } + // + // // Register in tsyringe DI container: + // container.register(TOKENS.AppSheetClientFactory, { + // useFactory: () => new AppSheetClientFactory(new AopSelectorBuilder()), + // }); + + console.log('AOP example (see comments in source)'); +} + +// --- Run examples --- + +console.log('=== 1. Direct Usage: Value Escaping ==='); +directUsageExample(); + +console.log('\n=== 2. Direct Usage: Build Safe Filters ==='); +buildFilterExample(); + +console.log('\n=== 3. Automatic Wrapping ==='); +automaticWrappingExample(); + +console.log('\n=== 4. DI Injection ==='); +diInjectionExample(); + +console.log('\n=== 5. AOP-Compatible Subclass ==='); +aopExample();