Skip to content
Merged
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: false,
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: false,
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: false,
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 ?? true;

// 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 ?? true;
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
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)) {
result.data = validateResponse(campaignSchemas.response, result.data) as unknown as Campaign;
}
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)) {
result.data = validateResponse(campaignSchemas.response, result.data) as unknown as Campaign;
}
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)) {
result.data = validateResponse(campaignSchemas.response, result.data) as unknown as Campaign;
}
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)) {
result.data = validateResponse(campaignSchemas.response, result.data) as unknown as Campaign;
}
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)) {
result.data = validateResponse(campaignSchemas.response, result.data) as unknown as Campaign;
}
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)) {
result.data = validateResponse(campaignSchemas.response, result.data) as unknown as Campaign;
}
return result;
}

/**
Expand Down
37 changes: 35 additions & 2 deletions src/resources/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,10 +32,17 @@ export class BundleProductsResource {
* @returns Bundle products response with product list and budget context
*/
async list(): Promise<ApiResponse<BundleProductsResponse>> {
return this.adapter.request<ApiResponse<BundleProductsResponse>>(
const result = await this.adapter.request<ApiResponse<BundleProductsResponse>>(
'GET',
`/bundles/${this.bundleId}/products`
);
if (shouldValidateResponse(this.adapter.validate)) {
result.data = validateResponse(
discoverySchemas.sessionProductsResponse,
result.data
) as unknown as BundleProductsResponse;
}
return result;
}

/**
Expand All @@ -37,18 +51,37 @@ export class BundleProductsResource {
* @returns Updated bundle products response
*/
async add(data: AddBundleProductsInput): Promise<ApiResponse<BundleProductsResponse>> {
return this.adapter.request<ApiResponse<BundleProductsResponse>>(
if (shouldValidateInput(this.adapter.validate)) {
data = validateInput(
discoverySchemas.addProductsInput,
data
) as unknown as AddBundleProductsInput;
}
const result = await this.adapter.request<ApiResponse<BundleProductsResponse>>(
'POST',
`/bundles/${this.bundleId}/products`,
data
);
if (shouldValidateResponse(this.adapter.validate)) {
result.data = validateResponse(
discoverySchemas.sessionProductsResponse,
result.data
) as unknown as BundleProductsResponse;
}
return result;
}

/**
* Remove products from this bundle
* @param data Product IDs to remove
*/
async remove(data: RemoveBundleProductsInput): Promise<void> {
if (shouldValidateInput(this.adapter.validate)) {
data = validateInput(
discoverySchemas.removeProductsInput,
data
) as unknown as RemoveBundleProductsInput;
}
await this.adapter.request<void>('DELETE', `/bundles/${this.bundleId}/products`, data);
}
}
Loading
Loading