Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/__tests__/resources/advertisers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/resources/campaigns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/resources/products.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
95 changes: 95 additions & 0 deletions src/__tests__/validation.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
3 changes: 3 additions & 0 deletions src/adapters/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import type { ApiVersion, Persona, Scope3ClientConfig } from '../types';
import type { ValidateMode } from '../validation';

/**
* HTTP methods supported by the adapter
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/adapters/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions src/adapters/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import type { ApiVersion, Persona, Scope3ClientConfig } from '../types';
import type { ValidateMode } from '../validation';
import {
BaseAdapter,
HttpMethod,
Expand All @@ -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;
Expand All @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
25 changes: 23 additions & 2 deletions src/resources/bundles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -25,6 +32,9 @@ export class BundlesResource {
* @returns Created bundle with bundleId
*/
async create(data: CreateBundleInput): Promise<ApiResponse<Bundle>> {
if (shouldValidateInput(this.adapter.validate)) {
validateInput(discoverySchemas.discoverInput, data);
}
return this.adapter.request<ApiResponse<Bundle>>('POST', '/bundles', data);
}

Expand All @@ -38,7 +48,7 @@ export class BundlesResource {
bundleId: string,
params?: DiscoverProductsParams
): Promise<ApiResponse<DiscoverProductsResponse>> {
return this.adapter.request<ApiResponse<DiscoverProductsResponse>>(
const result = await this.adapter.request<ApiResponse<DiscoverProductsResponse>>(
'GET',
`/bundles/${validateResourceId(bundleId)}/discover-products`,
undefined,
Expand All @@ -54,6 +64,10 @@ export class BundlesResource {
},
}
);
if (shouldValidateResponse(this.adapter.validate)) {
validateResponse(discoverySchemas.discoverResponse, result.data);
}
return result;
}

/**
Expand All @@ -62,11 +76,18 @@ export class BundlesResource {
* @returns Discovered product groups with auto-created bundleId
*/
async browseProducts(data: BrowseProductsInput): Promise<ApiResponse<DiscoverProductsResponse>> {
return this.adapter.request<ApiResponse<DiscoverProductsResponse>>(
if (shouldValidateInput(this.adapter.validate)) {
validateInput(discoverySchemas.discoverInput, data);
}
const result = await this.adapter.request<ApiResponse<DiscoverProductsResponse>>(
'POST',
'/bundles/discover-products',
data
);
if (shouldValidateResponse(this.adapter.validate)) {
validateResponse(discoverySchemas.discoverResponse, result.data);
}
return result;
}

/**
Expand Down
50 changes: 44 additions & 6 deletions src/resources/campaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -44,10 +46,14 @@ export class CampaignsResource {
* @returns Campaign details
*/
async get(id: string): Promise<ApiResponse<Campaign>> {
return this.adapter.request<ApiResponse<Campaign>>(
const result = await this.adapter.request<ApiResponse<Campaign>>(
'GET',
`/campaigns/${validateResourceId(id)}`
);
if (shouldValidateResponse(this.adapter.validate)) {
validateResponse(campaignSchemas.response, result.data);
}
return result;
}

/**
Expand All @@ -56,7 +62,15 @@ export class CampaignsResource {
* @returns Created campaign
*/
async createDiscovery(data: CreateDiscoveryCampaignInput): Promise<ApiResponse<Campaign>> {
return this.adapter.request<ApiResponse<Campaign>>('POST', '/campaigns/discovery', data);
const result = await this.adapter.request<ApiResponse<Campaign>>(
'POST',
'/campaigns/discovery',
data
);
if (shouldValidateResponse(this.adapter.validate)) {
validateResponse(campaignSchemas.response, result.data);
}
return result;
}

/**
Expand All @@ -69,11 +83,15 @@ export class CampaignsResource {
id: string,
data: UpdateDiscoveryCampaignInput
): Promise<ApiResponse<Campaign>> {
return this.adapter.request<ApiResponse<Campaign>>(
const result = await this.adapter.request<ApiResponse<Campaign>>(
'PUT',
`/campaigns/discovery/${validateResourceId(id)}`,
data
);
if (shouldValidateResponse(this.adapter.validate)) {
validateResponse(campaignSchemas.response, result.data);
}
return result;
}

/**
Expand All @@ -82,7 +100,15 @@ export class CampaignsResource {
* @returns Created campaign
*/
async createPerformance(data: CreatePerformanceCampaignInput): Promise<ApiResponse<Campaign>> {
return this.adapter.request<ApiResponse<Campaign>>('POST', '/campaigns/performance', data);
const result = await this.adapter.request<ApiResponse<Campaign>>(
'POST',
'/campaigns/performance',
data
);
if (shouldValidateResponse(this.adapter.validate)) {
validateResponse(campaignSchemas.response, result.data);
}
return result;
}

/**
Expand All @@ -95,11 +121,15 @@ export class CampaignsResource {
id: string,
data: UpdatePerformanceCampaignInput
): Promise<ApiResponse<Campaign>> {
return this.adapter.request<ApiResponse<Campaign>>(
const result = await this.adapter.request<ApiResponse<Campaign>>(
'PUT',
`/campaigns/performance/${validateResourceId(id)}`,
data
);
if (shouldValidateResponse(this.adapter.validate)) {
validateResponse(campaignSchemas.response, result.data);
}
return result;
}

/**
Expand All @@ -108,7 +138,15 @@ export class CampaignsResource {
* @returns Created campaign
*/
async createAudience(data: CreateAudienceCampaignInput): Promise<ApiResponse<Campaign>> {
return this.adapter.request<ApiResponse<Campaign>>('POST', '/campaigns/audience', data);
const result = await this.adapter.request<ApiResponse<Campaign>>(
'POST',
'/campaigns/audience',
data
);
if (shouldValidateResponse(this.adapter.validate)) {
validateResponse(campaignSchemas.response, result.data);
}
return result;
}

/**
Expand Down
Loading
Loading