diff --git a/src/__tests__/resources/advertisers.test.ts b/src/__tests__/resources/advertisers.test.ts index 604df65..81a534d 100644 --- a/src/__tests__/resources/advertisers.test.ts +++ b/src/__tests__/resources/advertisers.test.ts @@ -15,6 +15,7 @@ describe('AdvertisersResource', () => { version: 'v2', persona: 'buyer' as const, debug: false, + validate: undefined, request: jest.fn(), connect: jest.fn(), disconnect: jest.fn(), diff --git a/src/__tests__/resources/campaigns.test.ts b/src/__tests__/resources/campaigns.test.ts index 9304761..613f322 100644 --- a/src/__tests__/resources/campaigns.test.ts +++ b/src/__tests__/resources/campaigns.test.ts @@ -15,6 +15,7 @@ describe('CampaignsResource', () => { version: 'v2', persona: 'buyer' as const, debug: false, + validate: undefined, request: jest.fn(), connect: jest.fn(), disconnect: jest.fn(), diff --git a/src/__tests__/resources/products.test.ts b/src/__tests__/resources/products.test.ts index 2498612..556e542 100644 --- a/src/__tests__/resources/products.test.ts +++ b/src/__tests__/resources/products.test.ts @@ -15,6 +15,7 @@ describe('BundleProductsResource', () => { version: 'v2', persona: 'buyer' as const, debug: false, + validate: undefined, request: jest.fn(), connect: jest.fn(), disconnect: jest.fn(), diff --git a/src/__tests__/validation.test.ts b/src/__tests__/validation.test.ts new file mode 100644 index 0000000..d3ebf55 --- /dev/null +++ b/src/__tests__/validation.test.ts @@ -0,0 +1,95 @@ +import { z } from 'zod'; +import { Scope3ApiError } from '../adapters/base'; +import { + validateInput, + validateResponse, + shouldValidateInput, + shouldValidateResponse, +} from '../validation'; + +const testSchema = z.object({ + name: z.string(), + count: z.number(), +}); + +describe('shouldValidateInput', () => { + it('returns true for true', () => { + expect(shouldValidateInput(true)).toBe(true); + }); + + it('returns true for "input"', () => { + expect(shouldValidateInput('input')).toBe(true); + }); + + it('returns false for "response"', () => { + expect(shouldValidateInput('response')).toBe(false); + }); + + it('returns false for false', () => { + expect(shouldValidateInput(false)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(shouldValidateInput(undefined)).toBe(false); + }); +}); + +describe('shouldValidateResponse', () => { + it('returns true for true', () => { + expect(shouldValidateResponse(true)).toBe(true); + }); + + it('returns true for "response"', () => { + expect(shouldValidateResponse('response')).toBe(true); + }); + + it('returns false for "input"', () => { + expect(shouldValidateResponse('input')).toBe(false); + }); + + it('returns false for false', () => { + expect(shouldValidateResponse(false)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(shouldValidateResponse(undefined)).toBe(false); + }); +}); + +describe('validateInput', () => { + it('returns parsed data on valid input', () => { + const result = validateInput(testSchema, { name: 'test', count: 5 }); + expect(result).toEqual({ name: 'test', count: 5 }); + }); + + it('throws Scope3ApiError with status 400 on invalid input', () => { + expect(() => validateInput(testSchema, { name: 123 })).toThrow(Scope3ApiError); + try { + validateInput(testSchema, { name: 123 }); + } catch (e) { + const err = e as Scope3ApiError; + expect(err.status).toBe(400); + expect(err.message).toContain('Input validation failed'); + expect(err.details?.validationErrors).toBeDefined(); + } + }); +}); + +describe('validateResponse', () => { + it('returns parsed data on valid response', () => { + const result = validateResponse(testSchema, { name: 'test', count: 5 }); + expect(result).toEqual({ name: 'test', count: 5 }); + }); + + it('throws Scope3ApiError with status 502 on invalid response', () => { + expect(() => validateResponse(testSchema, { bad: 'data' })).toThrow(Scope3ApiError); + try { + validateResponse(testSchema, { bad: 'data' }); + } catch (e) { + const err = e as Scope3ApiError; + expect(err.status).toBe(502); + expect(err.message).toContain('Response validation failed'); + expect(err.details?.validationErrors).toBeDefined(); + } + }); +}); diff --git a/src/adapters/base.ts b/src/adapters/base.ts index a6d24c1..3a8dbfa 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -3,6 +3,7 @@ */ import type { ApiVersion, Persona, Scope3ClientConfig } from '../types'; +import type { ValidateMode } from '../validation'; /** * HTTP methods supported by the adapter @@ -31,6 +32,8 @@ export interface BaseAdapter { readonly persona: Persona; /** Whether debug mode is enabled */ readonly debug: boolean; + /** Validation mode */ + readonly validate: ValidateMode | undefined; /** * Make an API request diff --git a/src/adapters/mcp.ts b/src/adapters/mcp.ts index 455158f..314ab83 100644 --- a/src/adapters/mcp.ts +++ b/src/adapters/mcp.ts @@ -6,6 +6,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import type { ApiVersion, Persona, Scope3ClientConfig } from '../types'; +import type { ValidateMode } from '../validation'; import { BaseAdapter, HttpMethod, @@ -29,6 +30,7 @@ export class McpAdapter implements BaseAdapter { readonly version: ApiVersion; readonly persona: Persona; readonly debug: boolean; + readonly validate: ValidateMode | undefined; private readonly apiKey: string; private mcpClient: Client; @@ -42,6 +44,7 @@ export class McpAdapter implements BaseAdapter { this.version = resolveVersion(config); this.persona = resolvePersona(config); this.debug = config.debug ?? false; + this.validate = config.validate; // Initialize MCP client this.mcpClient = new Client( diff --git a/src/adapters/rest.ts b/src/adapters/rest.ts index f9f4695..6094bd9 100644 --- a/src/adapters/rest.ts +++ b/src/adapters/rest.ts @@ -4,6 +4,7 @@ */ import type { ApiVersion, Persona, Scope3ClientConfig } from '../types'; +import type { ValidateMode } from '../validation'; import { BaseAdapter, HttpMethod, @@ -24,6 +25,7 @@ export class RestAdapter implements BaseAdapter { readonly version: ApiVersion; readonly persona: Persona; readonly debug: boolean; + readonly validate: ValidateMode | undefined; private readonly apiKey: string; private readonly timeout: number; @@ -34,6 +36,7 @@ export class RestAdapter implements BaseAdapter { this.version = resolveVersion(config); this.persona = resolvePersona(config); this.debug = config.debug ?? false; + this.validate = config.validate; this.timeout = config.timeout ?? 30000; if (this.debug) { diff --git a/src/index.ts b/src/index.ts index 6584b09..9b3780e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,10 @@ export type { ParsedSkill, SkillCommand, SkillParameter, SkillExample } from './ export { WebhookServer } from './webhook-server'; export type { WebhookEvent, WebhookHandler, WebhookServerConfig } from './webhook-server'; +// Validation +export { validateInput, validateResponse } from './validation'; +export type { ValidateMode } from './validation'; + // Schemas (auto-generated from OpenAPI spec) export * from './schemas'; diff --git a/src/resources/bundles.ts b/src/resources/bundles.ts index d54bfc1..9913af7 100644 --- a/src/resources/bundles.ts +++ b/src/resources/bundles.ts @@ -11,6 +11,13 @@ import type { BrowseProductsInput, ApiResponse, } from '../types'; +import { discoverySchemas } from '../schemas/registry'; +import { + shouldValidateInput, + shouldValidateResponse, + validateInput, + validateResponse, +} from '../validation'; import { BundleProductsResource } from './products'; /** @@ -25,6 +32,9 @@ export class BundlesResource { * @returns Created bundle with bundleId */ async create(data: CreateBundleInput): Promise> { + if (shouldValidateInput(this.adapter.validate)) { + validateInput(discoverySchemas.discoverInput, data); + } return this.adapter.request>('POST', '/bundles', data); } @@ -38,7 +48,7 @@ export class BundlesResource { bundleId: string, params?: DiscoverProductsParams ): Promise> { - return this.adapter.request>( + const result = await this.adapter.request>( 'GET', `/bundles/${validateResourceId(bundleId)}/discover-products`, undefined, @@ -54,6 +64,10 @@ export class BundlesResource { }, } ); + if (shouldValidateResponse(this.adapter.validate)) { + validateResponse(discoverySchemas.discoverResponse, result.data); + } + return result; } /** @@ -62,11 +76,18 @@ export class BundlesResource { * @returns Discovered product groups with auto-created bundleId */ async browseProducts(data: BrowseProductsInput): Promise> { - return this.adapter.request>( + if (shouldValidateInput(this.adapter.validate)) { + validateInput(discoverySchemas.discoverInput, data); + } + const result = await this.adapter.request>( 'POST', '/bundles/discover-products', data ); + if (shouldValidateResponse(this.adapter.validate)) { + validateResponse(discoverySchemas.discoverResponse, result.data); + } + return result; } /** diff --git a/src/resources/campaigns.ts b/src/resources/campaigns.ts index 58f5788..6adbc83 100644 --- a/src/resources/campaigns.ts +++ b/src/resources/campaigns.ts @@ -14,6 +14,8 @@ import type { PaginatedApiResponse, ApiResponse, } from '../types'; +import { campaignSchemas } from '../schemas/registry'; +import { shouldValidateResponse, validateResponse } from '../validation'; /** * Resource for managing campaigns (Buyer persona) @@ -44,10 +46,14 @@ export class CampaignsResource { * @returns Campaign details */ async get(id: string): Promise> { - return this.adapter.request>( + const result = await this.adapter.request>( 'GET', `/campaigns/${validateResourceId(id)}` ); + if (shouldValidateResponse(this.adapter.validate)) { + validateResponse(campaignSchemas.response, result.data); + } + return result; } /** @@ -56,7 +62,15 @@ export class CampaignsResource { * @returns Created campaign */ async createDiscovery(data: CreateDiscoveryCampaignInput): Promise> { - return this.adapter.request>('POST', '/campaigns/discovery', data); + const result = await this.adapter.request>( + 'POST', + '/campaigns/discovery', + data + ); + if (shouldValidateResponse(this.adapter.validate)) { + validateResponse(campaignSchemas.response, result.data); + } + return result; } /** @@ -69,11 +83,15 @@ export class CampaignsResource { id: string, data: UpdateDiscoveryCampaignInput ): Promise> { - return this.adapter.request>( + const result = await this.adapter.request>( 'PUT', `/campaigns/discovery/${validateResourceId(id)}`, data ); + if (shouldValidateResponse(this.adapter.validate)) { + validateResponse(campaignSchemas.response, result.data); + } + return result; } /** @@ -82,7 +100,15 @@ export class CampaignsResource { * @returns Created campaign */ async createPerformance(data: CreatePerformanceCampaignInput): Promise> { - return this.adapter.request>('POST', '/campaigns/performance', data); + const result = await this.adapter.request>( + 'POST', + '/campaigns/performance', + data + ); + if (shouldValidateResponse(this.adapter.validate)) { + validateResponse(campaignSchemas.response, result.data); + } + return result; } /** @@ -95,11 +121,15 @@ export class CampaignsResource { id: string, data: UpdatePerformanceCampaignInput ): Promise> { - return this.adapter.request>( + const result = await this.adapter.request>( 'PUT', `/campaigns/performance/${validateResourceId(id)}`, data ); + if (shouldValidateResponse(this.adapter.validate)) { + validateResponse(campaignSchemas.response, result.data); + } + return result; } /** @@ -108,7 +138,15 @@ export class CampaignsResource { * @returns Created campaign */ async createAudience(data: CreateAudienceCampaignInput): Promise> { - return this.adapter.request>('POST', '/campaigns/audience', data); + const result = await this.adapter.request>( + 'POST', + '/campaigns/audience', + data + ); + if (shouldValidateResponse(this.adapter.validate)) { + validateResponse(campaignSchemas.response, result.data); + } + return result; } /** diff --git a/src/resources/products.ts b/src/resources/products.ts index 8a64f0e..7461ea8 100644 --- a/src/resources/products.ts +++ b/src/resources/products.ts @@ -10,6 +10,13 @@ import type { RemoveBundleProductsInput, ApiResponse, } from '../types'; +import { discoverySchemas } from '../schemas/registry'; +import { + shouldValidateInput, + shouldValidateResponse, + validateInput, + validateResponse, +} from '../validation'; /** * Resource for managing products within a bundle @@ -25,10 +32,14 @@ export class BundleProductsResource { * @returns Bundle products response with product list and budget context */ async list(): Promise> { - return this.adapter.request>( + const result = await this.adapter.request>( 'GET', `/bundles/${this.bundleId}/products` ); + if (shouldValidateResponse(this.adapter.validate)) { + validateResponse(discoverySchemas.sessionProductsResponse, result.data); + } + return result; } /** @@ -37,11 +48,18 @@ export class BundleProductsResource { * @returns Updated bundle products response */ async add(data: AddBundleProductsInput): Promise> { - return this.adapter.request>( + if (shouldValidateInput(this.adapter.validate)) { + validateInput(discoverySchemas.addProductsInput, data); + } + const result = await this.adapter.request>( 'POST', `/bundles/${this.bundleId}/products`, data ); + if (shouldValidateResponse(this.adapter.validate)) { + validateResponse(discoverySchemas.sessionProductsResponse, result.data); + } + return result; } /** @@ -49,6 +67,9 @@ export class BundleProductsResource { * @param data Product IDs to remove */ async remove(data: RemoveBundleProductsInput): Promise { + if (shouldValidateInput(this.adapter.validate)) { + validateInput(discoverySchemas.removeProductsInput, data); + } await this.adapter.request('DELETE', `/bundles/${this.bundleId}/products`, data); } } diff --git a/src/resources/reporting.ts b/src/resources/reporting.ts index 014c4ba..4c91073 100644 --- a/src/resources/reporting.ts +++ b/src/resources/reporting.ts @@ -4,6 +4,8 @@ import type { BaseAdapter } from '../adapters/base'; import type { ReportingParams } from '../types'; +import { reportingSchemas } from '../schemas/registry'; +import { shouldValidateResponse, validateResponse } from '../validation'; /** * Resource for accessing reporting data (Buyer persona) @@ -17,7 +19,7 @@ export class ReportingResource { * @returns Reporting response (summary or timeseries depending on view param) */ async get(params?: ReportingParams): Promise { - return this.adapter.request('GET', '/reporting/metrics', undefined, { + const result = await this.adapter.request('GET', '/reporting/metrics', undefined, { params: { view: params?.view, days: params?.days, @@ -28,5 +30,9 @@ export class ReportingResource { demo: params?.demo, }, }); + if (shouldValidateResponse(this.adapter.validate)) { + validateResponse(reportingSchemas.response, result); + } + return result; } } diff --git a/src/resources/sales-agents.ts b/src/resources/sales-agents.ts index 7342714..e78ad5d 100644 --- a/src/resources/sales-agents.ts +++ b/src/resources/sales-agents.ts @@ -8,6 +8,13 @@ import type { ListSalesAgentsParams, RegisterSalesAgentAccountInput, } from '../types'; +import { salesAgentSchemas } from '../schemas/registry'; +import { + shouldValidateInput, + shouldValidateResponse, + validateInput, + validateResponse, +} from '../validation'; /** * Resource for managing sales agents (Buyer persona) @@ -21,7 +28,7 @@ export class SalesAgentsResource { * @returns Sales agents with account info */ async list(params?: ListSalesAgentsParams): Promise { - return this.adapter.request('GET', '/sales-agents', undefined, { + const result = await this.adapter.request('GET', '/sales-agents', undefined, { params: { status: params?.status, relationship: params?.relationship, @@ -30,6 +37,10 @@ export class SalesAgentsResource { offset: params?.offset, }, }); + if (shouldValidateResponse(this.adapter.validate)) { + validateResponse(salesAgentSchemas.listResponse, result); + } + return result; } /** @@ -42,10 +53,17 @@ export class SalesAgentsResource { agentId: string, data: RegisterSalesAgentAccountInput ): Promise { - return this.adapter.request( + if (shouldValidateInput(this.adapter.validate)) { + validateInput(salesAgentSchemas.registerAccountInput, data); + } + const result = await this.adapter.request( 'POST', `/sales-agents/${validateResourceId(agentId)}/accounts`, data ); + if (shouldValidateResponse(this.adapter.validate)) { + validateResponse(salesAgentSchemas.accountResponse, result); + } + return result; } } diff --git a/src/schemas/registry.ts b/src/schemas/registry.ts new file mode 100644 index 0000000..ad4f88b --- /dev/null +++ b/src/schemas/registry.ts @@ -0,0 +1,34 @@ +import { schemas } from './buyer'; + +export const advertiserSchemas = { + createInput: schemas.CreateAdvertiserBody, + updateInput: schemas.UpdateAdvertiserBody, +}; + +export const campaignSchemas = { + createInput: schemas.CreateCampaignBody, + updateInput: schemas.UpdateCampaignBody, + executeInput: schemas.ExecuteCampaignBody, + response: schemas.Campaign, + listResponse: schemas.CampaignListResponse, + statusChangeResponse: schemas.CampaignStatusChangeResponse, +}; + +export const discoverySchemas = { + discoverInput: schemas.DiscoverProductsBody, + discoverResponse: schemas.DiscoverProductsResponse, + addProductsInput: schemas.AddProductsRequest, + removeProductsInput: schemas.RemoveProductsRequest, + sessionProductsResponse: schemas.SessionProductsResponse, +}; + +export const reportingSchemas = { + response: schemas.ReportingMetricsResponse, +}; + +export const salesAgentSchemas = { + registerAccountInput: schemas.RegisterSalesAgentAccountBody, + response: schemas.Agent, + listResponse: schemas.AgentList, + accountResponse: schemas.AgentAccount, +}; diff --git a/src/types/index.ts b/src/types/index.ts index 629f7b5..ec226c4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -38,6 +38,8 @@ export interface Scope3ClientConfig { timeout?: number; /** Enable debug logging */ debug?: boolean; + /** Enable runtime validation with Zod schemas: true (both), 'input', or 'response' */ + validate?: boolean | 'input' | 'response'; } // ============================================================================ diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..04f1314 --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,36 @@ +import { type ZodSchema, type ZodError } from 'zod'; +import { Scope3ApiError } from './adapters/base'; + +export type ValidateMode = boolean | 'input' | 'response'; + +export function shouldValidateInput(mode: ValidateMode | undefined): boolean { + return mode === true || mode === 'input'; +} + +export function shouldValidateResponse(mode: ValidateMode | undefined): boolean { + return mode === true || mode === 'response'; +} + +export function validateInput(schema: ZodSchema, data: unknown): T { + const result = schema.safeParse(data); + if (!result.success) { + throw new Scope3ApiError(400, `Input validation failed: ${formatZodError(result.error)}`, { + validationErrors: result.error.issues, + }); + } + return result.data; +} + +export function validateResponse(schema: ZodSchema, data: unknown): T { + const result = schema.safeParse(data); + if (!result.success) { + throw new Scope3ApiError(502, `Response validation failed: ${formatZodError(result.error)}`, { + validationErrors: result.error.issues, + }); + } + return result.data; +} + +function formatZodError(error: ZodError): string { + return error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; '); +}