From a5caffa4a5ecf8dea24f3fdca57a3dbd87911c6a Mon Sep 17 00:00:00 2001 From: Matteo Date: Sun, 10 May 2026 12:43:08 +0200 Subject: [PATCH] Migrate weclapp connector to API v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter was pinned to /webapp/api/v1, which weclapp marks as legacy and discourages for new integrations. More importantly, /customer was removed in v2 and replaced by /party (customers and suppliers unified), so weclapp_list_customers / weclapp_get_customer were broken on any v2 tenant — the most common cause of users reporting the connector "doesn't work". Changes to weclapp.json: - baseUrl /webapp/api/v1 -> /webapp/api/v2 - /customer, /customer/id/{customerId} -> /party, /party/id/{partyId} - Add optional properties / filter / sort query params on list tools (matches v2 spec: properties_qs, filter_qs, sort_qs) - Fix pageSize description (default 100, max 1000 per spec) Add weclapp.live.spec.ts with two layers: - Static (always runs): asserts baseUrl is on v2, auth header is AuthenticationToken, no path references /customer, and the v2 paths /party, /salesOrder, /salesInvoice, /article are all present. - Live (RUN_WECLAPP_LIVE=1, skipped in CI): hits weclapp's edge with a bogus tenant, asserts 404 with `server: weclapp` (proves baseUrl resolves to weclapp infrastructure), and asserts the AuthenticationToken header is actually injected by RestEngine. End-to-end validation against a real tenant still requires a weclapp account (no public sandbox exists) and is out of scope for this change. --- packages/backend/src/adapters/de/weclapp.json | 114 +++++++++++++----- .../src/adapters/de/weclapp.live.spec.ts | 112 +++++++++++++++++ 2 files changed, 199 insertions(+), 27 deletions(-) create mode 100644 packages/backend/src/adapters/de/weclapp.live.spec.ts diff --git a/packages/backend/src/adapters/de/weclapp.json b/packages/backend/src/adapters/de/weclapp.json index 681d2444..658135b7 100644 --- a/packages/backend/src/adapters/de/weclapp.json +++ b/packages/backend/src/adapters/de/weclapp.json @@ -1,11 +1,11 @@ { "slug": "weclapp", "name": "weclapp Cloud ERP", - "description": "Access weclapp Cloud ERP data including customers, orders, invoices, articles, and warehouse management. Popular ERP for German SMBs.", + "description": "Access weclapp Cloud ERP data including parties (customers/suppliers), sales orders, invoices, and articles. Popular ERP for German SMBs.", "region": "de", "category": "erp", "icon": "weclapp", - "docsUrl": "https://www.weclapp.com/api2/", + "docsUrl": "https://www.weclapp.com/api/", "requiredEnvVars": [ "WECLAPP_TENANT", "WECLAPP_API_TOKEN" @@ -13,7 +13,7 @@ "connector": { "name": "weclapp ERP", "type": "REST", - "baseUrl": "https://{{WECLAPP_TENANT}}.weclapp.com/webapp/api/v1", + "baseUrl": "https://{{WECLAPP_TENANT}}.weclapp.com/webapp/api/v2", "authType": "API_KEY", "authConfig": { "headerName": "AuthenticationToken", @@ -23,62 +23,89 @@ "tools": [ { "name": "weclapp_list_customers", - "description": "List customers from weclapp ERP. Returns customer names, IDs, contact info, and customer numbers.", + "description": "List parties (customers/suppliers/contacts) from weclapp ERP. In API v2 customers and suppliers are unified under /party — pass filter='customerNumber-notnull=true' to restrict to customers, or filter='supplierNumber-notnull=true' for suppliers. Use `properties` to limit returned fields (e.g. 'id,customerNumber,company,firstName,lastName,email') to reduce payload size.", "parameters": { "type": "object", "properties": { "page": { "type": "number", - "description": "Page number (default: 1)" + "description": "Page number (1-based)" }, "pageSize": { "type": "number", - "description": "Results per page (default: 25, max: 100)" + "description": "Results per page (default: 100, max: 1000)" + }, + "properties": { + "type": "string", + "description": "Comma-separated list of fields to return, e.g. 'id,customerNumber,company,firstName,lastName,email'" + }, + "filter": { + "type": "string", + "description": "weclapp v2 filter expression, e.g. 'customerNumber-notnull=true' or 'partyType-eq=ORGANIZATION'" + }, + "sort": { + "type": "string", + "description": "Sort field, prefix with '-' for descending (e.g. '-createdDate')" } } }, "endpointMapping": { "method": "GET", - "path": "/customer", + "path": "/party", "queryParams": { "page": "$page", - "pageSize": "$pageSize" + "pageSize": "$pageSize", + "properties": "$properties", + "filter": "$filter", + "sort": "$sort" } } }, { "name": "weclapp_get_customer", - "description": "Get detailed information about a specific customer by ID.", + "description": "Get a specific party (customer/supplier/contact) by ID. In API v2 this maps to /party/id/{id}.", "parameters": { "type": "object", "properties": { - "customerId": { + "partyId": { "type": "string", - "description": "The weclapp customer ID" + "description": "The weclapp party ID (numeric string)" } }, "required": [ - "customerId" + "partyId" ] }, "endpointMapping": { "method": "GET", - "path": "/customer/id/{customerId}" + "path": "/party/id/{partyId}" } }, { "name": "weclapp_list_sales_orders", - "description": "List sales orders from weclapp ERP. Returns order numbers, customer info, amounts, and status.", + "description": "List sales orders from weclapp ERP. Returns order numbers, customer info, amounts, and status. Use `properties` to limit fields and `filter` to narrow results (e.g. 'orderNumber-eq=SO-1042').", "parameters": { "type": "object", "properties": { "page": { "type": "number", - "description": "Page number (default: 1)" + "description": "Page number (1-based)" }, "pageSize": { "type": "number", - "description": "Results per page (default: 25, max: 100)" + "description": "Results per page (default: 100, max: 1000)" + }, + "properties": { + "type": "string", + "description": "Comma-separated list of fields to return" + }, + "filter": { + "type": "string", + "description": "weclapp v2 filter expression (e.g. 'status-eq=ORDER_ENTRY_IN_PROGRESS')" + }, + "sort": { + "type": "string", + "description": "Sort field, prefix with '-' for descending" } } }, @@ -87,23 +114,38 @@ "path": "/salesOrder", "queryParams": { "page": "$page", - "pageSize": "$pageSize" + "pageSize": "$pageSize", + "properties": "$properties", + "filter": "$filter", + "sort": "$sort" } } }, { "name": "weclapp_list_invoices", - "description": "List invoices from weclapp ERP. Returns invoice numbers, amounts, due dates, and payment status.", + "description": "List sales invoices from weclapp ERP. Returns invoice numbers, amounts, due dates, and payment status. Use `filter` for queries like 'paid-eq=false' or 'invoiceDate-gt=2026-01-01'.", "parameters": { "type": "object", "properties": { "page": { "type": "number", - "description": "Page number (default: 1)" + "description": "Page number (1-based)" }, "pageSize": { "type": "number", - "description": "Results per page (default: 25, max: 100)" + "description": "Results per page (default: 100, max: 1000)" + }, + "properties": { + "type": "string", + "description": "Comma-separated list of fields to return" + }, + "filter": { + "type": "string", + "description": "weclapp v2 filter expression" + }, + "sort": { + "type": "string", + "description": "Sort field, prefix with '-' for descending" } } }, @@ -112,23 +154,38 @@ "path": "/salesInvoice", "queryParams": { "page": "$page", - "pageSize": "$pageSize" + "pageSize": "$pageSize", + "properties": "$properties", + "filter": "$filter", + "sort": "$sort" } } }, { "name": "weclapp_list_articles", - "description": "List articles (products) from weclapp ERP. Returns article numbers, names, prices, and stock levels.", + "description": "List articles (products) from weclapp ERP. Returns article numbers, names, prices, and stock info. Use `filter` like 'articleNumber-eq=ABC123' for SKU lookups.", "parameters": { "type": "object", "properties": { "page": { "type": "number", - "description": "Page number (default: 1)" + "description": "Page number (1-based)" }, "pageSize": { "type": "number", - "description": "Results per page (default: 25, max: 100)" + "description": "Results per page (default: 100, max: 1000)" + }, + "properties": { + "type": "string", + "description": "Comma-separated list of fields to return" + }, + "filter": { + "type": "string", + "description": "weclapp v2 filter expression" + }, + "sort": { + "type": "string", + "description": "Sort field, prefix with '-' for descending" } } }, @@ -137,19 +194,22 @@ "path": "/article", "queryParams": { "page": "$page", - "pageSize": "$pageSize" + "pageSize": "$pageSize", + "properties": "$properties", + "filter": "$filter", + "sort": "$sort" } } }, { "name": "weclapp_get_article", - "description": "Get detailed information about a specific article (product) by ID, including stock, pricing, and warehouse data.", + "description": "Get a specific article (product) by ID, including stock, pricing, and warehouse data.", "parameters": { "type": "object", "properties": { "articleId": { "type": "string", - "description": "The weclapp article ID" + "description": "The weclapp article ID (numeric string)" } }, "required": [ diff --git a/packages/backend/src/adapters/de/weclapp.live.spec.ts b/packages/backend/src/adapters/de/weclapp.live.spec.ts new file mode 100644 index 00000000..8a2eafb6 --- /dev/null +++ b/packages/backend/src/adapters/de/weclapp.live.spec.ts @@ -0,0 +1,112 @@ +import * as adapter from './weclapp.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; + +/** + * Two layers of verification for the weclapp adapter: + * + * 1. Static — always runs. Asserts the adapter is on API v2 and that the + * paths/auth match the official OpenAPI v2 spec at + * https://www.weclapp.com/api/openapi_v2.yaml. Catches the most common + * failure mode: someone pinning back to v1 (legacy) or re-introducing the + * `/customer` endpoint that v2 replaced with `/party`. + * + * 2. Live — skipped in CI. Hits weclapp's edge against a non-existent tenant + * to prove the URL pattern resolves to weclapp infrastructure (DNS + + * Akamai + ALB + `server: weclapp`). A real end-to-end test needs a tenant + * with a valid token; weclapp does not offer a public sandbox. + * + * Run live with: RUN_WECLAPP_LIVE=1 npx jest src/adapters/de/weclapp.live.spec.ts + */ + +describe('weclapp adapter — static spec conformance', () => { + const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ name: string; endpointMapping: { method: string; path: string } }>; + }; + + it('uses API v2 (v1 is legacy and being deprecated)', () => { + expect(a.connector.baseUrl).toContain('/webapp/api/v2'); + expect(a.connector.baseUrl).not.toContain('/webapp/api/v1'); + }); + + it('authenticates via the AuthenticationToken header (weclapp-specific, not Authorization)', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('AuthenticationToken'); + }); + + it('does not reference /customer (renamed to /party in v2)', () => { + for (const tool of a.tools) { + expect(tool.endpointMapping.path).not.toMatch(/^\/customer(\/|$)/); + } + }); + + it('uses the /party, /salesOrder, /salesInvoice, /article paths from the v2 spec', () => { + const paths = a.tools.map((t) => t.endpointMapping.path); + expect(paths).toContain('/party'); + expect(paths).toContain('/party/id/{partyId}'); + expect(paths).toContain('/salesOrder'); + expect(paths).toContain('/salesInvoice'); + expect(paths).toContain('/article'); + expect(paths).toContain('/article/id/{articleId}'); + }); +}); + +const maybe = process.env.RUN_WECLAPP_LIVE ? describe : describe.skip; + +maybe('weclapp adapter — live edge reachability', () => { + const oauth = {} as unknown as OAuth2TokenService; + const engine = new RestEngine(oauth); + + // Bogus tenant: weclapp uses wildcard DNS (*.weclapp.com → Akamai → ALB), + // so the request reaches weclapp's edge but the ALB returns 404 because + // no tenant matches. That 404 with `server: weclapp` proves the baseUrl + // resolves to weclapp infrastructure. + const baseUrl = 'https://anythingmcp-smoke-test-tenant.weclapp.com/webapp/api/v2'; + + it('reaches weclapp edge (404 from server: weclapp) when calling /party with a bogus tenant', async () => { + let err: any; + try { + await engine.execute( + { + baseUrl, + authType: 'API_KEY', + authConfig: { headerName: 'AuthenticationToken', apiKey: 'bogus-token-for-test' }, + }, + { method: 'GET', path: '/party', queryParams: { pageSize: '$pageSize' } }, + { pageSize: 1 }, + ); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + expect(err.response?.status).toBe(404); + // The `server` response header proves we hit weclapp's edge, not a generic + // CDN/error. If this ever fails, the baseUrl pattern probably changed. + expect(String(err.response?.headers?.server || '').toLowerCase()).toContain('weclapp'); + }, 30000); + + it('AuthenticationToken header is actually injected by RestEngine', async () => { + // Round-trip the engine and inspect the outgoing request via the AxiosError. + // The error config carries the headers we sent — proves the API_KEY branch + // wrote to `AuthenticationToken`, not `X-API-Key` or `Authorization`. + let err: any; + try { + await engine.execute( + { + baseUrl, + authType: 'API_KEY', + authConfig: { headerName: 'AuthenticationToken', apiKey: 'sentinel-token-12345' }, + }, + { method: 'GET', path: '/party' }, + {}, + ); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + const sentHeaders = err.config?.headers || {}; + expect(sentHeaders.AuthenticationToken).toBe('sentinel-token-12345'); + expect(sentHeaders.Authorization).toBeUndefined(); + }, 30000); +});