@prism-engineer/router
Version:
Type-safe Express.js router with automatic client generation
428 lines • 18.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const typebox_1 = require("@sinclair/typebox");
const createApiRoute_1 = require("../../../createApiRoute");
(0, vitest_1.describe)('createApiRoute - Request/Response Schema Validation', () => {
(0, vitest_1.it)('should validate TypeBox request body schema', () => {
const bodySchema = typebox_1.Type.Object({
name: typebox_1.Type.String(),
age: typebox_1.Type.Number(),
email: typebox_1.Type.String({ format: 'email' }),
metadata: typebox_1.Type.Optional(typebox_1.Type.Object({
tags: typebox_1.Type.Array(typebox_1.Type.String()),
priority: typebox_1.Type.Union([typebox_1.Type.Literal('low'), typebox_1.Type.Literal('medium'), typebox_1.Type.Literal('high')])
}))
});
const route = (0, createApiRoute_1.createApiRoute)({
path: '/api/users',
method: 'POST',
request: {
body: bodySchema
},
response: {
201: {
contentType: 'application/json',
body: typebox_1.Type.Object({
id: typebox_1.Type.Number(),
message: typebox_1.Type.String()
})
}
},
handler: async (_req) => {
return {
status: 201,
body: { id: 1, message: 'User created' }
};
}
});
(0, vitest_1.expect)(route.request?.body).toBe(bodySchema);
(0, vitest_1.expect)(route.request?.body?.type).toBe('object');
(0, vitest_1.expect)(route.request?.body?.properties).toBeDefined();
(0, vitest_1.expect)(route.request?.body?.properties?.name).toBeDefined();
(0, vitest_1.expect)(route.request?.body?.properties?.age).toBeDefined();
(0, vitest_1.expect)(route.request?.body?.properties.email).toBeDefined();
});
(0, vitest_1.it)('should validate TypeBox query parameter schema', () => {
const querySchema = typebox_1.Type.Object({
search: typebox_1.Type.String(),
page: typebox_1.Type.Optional(typebox_1.Type.Number({ minimum: 1 })),
limit: typebox_1.Type.Optional(typebox_1.Type.Number({ minimum: 1, maximum: 100 })),
sortBy: typebox_1.Type.Optional(typebox_1.Type.Union([
typebox_1.Type.Literal('name'),
typebox_1.Type.Literal('date'),
typebox_1.Type.Literal('relevance')
])),
filters: typebox_1.Type.Optional(typebox_1.Type.Array(typebox_1.Type.String()))
});
const route = (0, createApiRoute_1.createApiRoute)({
path: '/api/search',
method: 'GET',
request: {
query: querySchema
},
response: {
200: {
contentType: 'application/json',
body: typebox_1.Type.Array(typebox_1.Type.Object({
id: typebox_1.Type.Number(),
title: typebox_1.Type.String()
}))
}
},
handler: async (_req) => {
return {
status: 200,
body: [{ id: 1, title: 'Result' }]
};
}
});
(0, vitest_1.expect)(route.request?.query).toBe(querySchema);
(0, vitest_1.expect)(route.request?.query?.type).toBe('object');
(0, vitest_1.expect)(route.request?.query?.properties.search).toBeDefined();
(0, vitest_1.expect)(route.request?.query?.properties.page).toBeDefined();
(0, vitest_1.expect)(route.request?.query?.properties.limit).toBeDefined();
});
(0, vitest_1.it)('should validate TypeBox header schema', () => {
const headerSchema = typebox_1.Type.Object({
authorization: typebox_1.Type.String({ pattern: '^Bearer .+' }),
'x-api-version': typebox_1.Type.Union([typebox_1.Type.Literal('v1'), typebox_1.Type.Literal('v2')]),
'x-request-id': typebox_1.Type.String({ format: 'uuid' }),
'x-client-version': typebox_1.Type.Optional(typebox_1.Type.String()),
'accept': typebox_1.Type.Optional(typebox_1.Type.Literal('application/json'))
});
const route = (0, createApiRoute_1.createApiRoute)({
path: '/api/protected',
method: 'GET',
request: {
headers: headerSchema
},
response: {
200: {
contentType: 'application/json',
body: typebox_1.Type.Object({
message: typebox_1.Type.String()
})
}
},
handler: async (_req) => {
return {
status: 200,
body: { message: 'Access granted' }
};
}
});
(0, vitest_1.expect)(route.request?.headers).toBe(headerSchema);
(0, vitest_1.expect)(route.request?.headers?.type).toBe('object');
(0, vitest_1.expect)(route.request?.headers?.properties.authorization).toBeDefined();
(0, vitest_1.expect)(route.request?.headers?.properties['x-api-version']).toBeDefined();
});
(0, vitest_1.it)('should validate TypeBox response body schema', () => {
const responseSchema = typebox_1.Type.Object({
user: typebox_1.Type.Object({
id: typebox_1.Type.Number(),
name: typebox_1.Type.String(),
email: typebox_1.Type.String(),
profile: typebox_1.Type.Object({
avatar: typebox_1.Type.Optional(typebox_1.Type.String()),
bio: typebox_1.Type.Optional(typebox_1.Type.String()),
location: typebox_1.Type.Optional(typebox_1.Type.String())
})
}),
metadata: typebox_1.Type.Object({
createdAt: typebox_1.Type.String(),
updatedAt: typebox_1.Type.String(),
version: typebox_1.Type.Number()
})
});
const route = (0, createApiRoute_1.createApiRoute)({
path: '/api/users/{id}',
method: 'GET',
response: {
200: {
contentType: 'application/json',
body: responseSchema
},
404: {
contentType: 'application/json',
body: typebox_1.Type.Object({
error: typebox_1.Type.String(),
code: typebox_1.Type.Number(),
details: typebox_1.Type.Optional(typebox_1.Type.Array(typebox_1.Type.String()))
})
}
},
handler: async (_req) => {
return {
status: 200,
body: {
user: {
id: 1,
name: 'John Doe',
email: 'john@example.com',
profile: {
avatar: 'https://example.com/avatar.png',
bio: 'Software developer'
}
},
metadata: {
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
version: 1
}
}
};
}
});
(0, vitest_1.expect)(route.response?.[200]?.body).toBe(responseSchema);
(0, vitest_1.expect)(route.response?.[200]?.body.type).toBe('object');
(0, vitest_1.expect)(route.response?.[200]?.body.properties.user).toBeDefined();
(0, vitest_1.expect)(route.response?.[200]?.body.properties.metadata).toBeDefined();
});
(0, vitest_1.it)('should validate complex nested schemas', () => {
const complexSchema = typebox_1.Type.Object({
data: typebox_1.Type.Array(typebox_1.Type.Object({
id: typebox_1.Type.Number(),
attributes: typebox_1.Type.Object({
name: typebox_1.Type.String(),
tags: typebox_1.Type.Array(typebox_1.Type.String()),
metadata: typebox_1.Type.Record(typebox_1.Type.String(), typebox_1.Type.Union([
typebox_1.Type.String(),
typebox_1.Type.Number(),
typebox_1.Type.Boolean()
]))
}),
relationships: typebox_1.Type.Optional(typebox_1.Type.Object({
parent: typebox_1.Type.Optional(typebox_1.Type.Object({
id: typebox_1.Type.Number(),
type: typebox_1.Type.String()
})),
children: typebox_1.Type.Array(typebox_1.Type.Object({
id: typebox_1.Type.Number(),
type: typebox_1.Type.String()
}))
}))
})),
meta: typebox_1.Type.Object({
total: typebox_1.Type.Number(),
page: typebox_1.Type.Number(),
limit: typebox_1.Type.Number(),
hasMore: typebox_1.Type.Boolean()
})
});
const route = (0, createApiRoute_1.createApiRoute)({
path: '/api/complex',
method: 'GET',
response: {
200: {
contentType: 'application/json',
body: complexSchema
}
},
handler: async (_req) => {
return {
status: 200,
body: {
data: [{
id: 1,
attributes: {
name: 'Item 1',
tags: ['tag1', 'tag2'],
metadata: {
key1: 'string_value',
key2: 42,
key3: true
}
},
relationships: {
children: []
}
}],
meta: {
total: 1,
page: 1,
limit: 10,
hasMore: false
}
}
};
}
});
(0, vitest_1.expect)(route.response?.[200]?.body).toBe(complexSchema);
(0, vitest_1.expect)(route.response?.[200]?.body.type).toBe('object');
(0, vitest_1.expect)(route.response?.[200]?.body.properties.data).toBeDefined();
(0, vitest_1.expect)(route.response?.[200]?.body.properties.meta).toBeDefined();
});
(0, vitest_1.it)('should handle different JSON content types', () => {
const jsonContentTypes = [
'application/json',
'application/vnd.api+json',
'application/ld+json',
'text/json'
];
jsonContentTypes.forEach(contentType => {
const route = (0, createApiRoute_1.createApiRoute)({
path: `/api/test-${contentType.replace(/[^a-zA-Z0-9]/g, '')}`,
method: 'GET',
response: {
200: {
contentType: contentType,
body: typebox_1.Type.Object({
message: typebox_1.Type.String(),
contentType: typebox_1.Type.String()
})
}
},
handler: async (_req) => {
return {
status: 200,
body: { message: 'Success', contentType }
};
}
});
(0, vitest_1.expect)(route.response?.[200]?.contentType).toBe(contentType);
(0, vitest_1.expect)(route.response?.[200]?.body).toBeDefined();
});
});
(0, vitest_1.it)('should handle custom content types without body schema', () => {
const customContentTypes = [
'text/plain',
'text/html',
'application/octet-stream',
'image/png',
'application/pdf',
'text/csv'
];
customContentTypes.forEach(contentType => {
const route = (0, createApiRoute_1.createApiRoute)({
path: `/api/custom-${contentType.replace(/[^a-zA-Z0-9]/g, '')}`,
method: 'GET',
response: {
200: {
contentType: contentType
}
},
handler: async (_req) => {
return {
status: 200,
custom: (res) => {
res.setHeader('Content-Type', contentType);
res.send('Custom content');
}
};
}
});
(0, vitest_1.expect)(route.response?.[200]?.contentType).toBe(contentType);
(0, vitest_1.expect)(route.response?.[200]).not.toHaveProperty('body');
});
});
(0, vitest_1.it)('should validate response headers schema', () => {
const responseHeaderSchema = typebox_1.Type.Object({
'x-rate-limit': typebox_1.Type.String(),
'x-rate-limit-remaining': typebox_1.Type.String(),
'x-rate-limit-reset': typebox_1.Type.String(),
'cache-control': typebox_1.Type.Optional(typebox_1.Type.String()),
'etag': typebox_1.Type.Optional(typebox_1.Type.String())
});
const route = (0, createApiRoute_1.createApiRoute)({
path: '/api/with-headers',
method: 'GET',
response: {
200: {
contentType: 'application/json',
body: typebox_1.Type.Object({
message: typebox_1.Type.String()
}),
headers: responseHeaderSchema
}
},
handler: async (_req) => {
return {
status: 200,
body: { message: 'Success' },
headers: {
'x-rate-limit': '1000',
'x-rate-limit-remaining': '999',
'x-rate-limit-reset': '2023-01-01T00:00:00Z',
'cache-control': 'no-cache'
}
};
}
});
(0, vitest_1.expect)(route.response?.[200]?.headers).toBe(responseHeaderSchema);
(0, vitest_1.expect)(route.response?.[200]?.headers?.type).toBe('object');
(0, vitest_1.expect)(route.response?.[200]?.headers?.properties['x-rate-limit']).toBeDefined();
});
(0, vitest_1.it)('should validate multiple response status codes with different schemas', () => {
const route = (0, createApiRoute_1.createApiRoute)({
path: '/api/multi-status',
method: 'POST',
request: {
body: typebox_1.Type.Object({
action: typebox_1.Type.Union([
typebox_1.Type.Literal('success'),
typebox_1.Type.Literal('not-found'),
typebox_1.Type.Literal('error')
])
})
},
response: {
200: {
contentType: 'application/json',
body: typebox_1.Type.Object({
result: typebox_1.Type.String(),
data: typebox_1.Type.Object({
id: typebox_1.Type.Number(),
name: typebox_1.Type.String()
})
})
},
404: {
contentType: 'application/json',
body: typebox_1.Type.Object({
error: typebox_1.Type.String(),
resource: typebox_1.Type.String()
})
},
500: {
contentType: 'application/json',
body: typebox_1.Type.Object({
error: typebox_1.Type.String(),
stack: typebox_1.Type.Optional(typebox_1.Type.String())
})
}
},
handler: async (req) => {
switch (req.body.action) {
case 'success':
return {
status: 200,
body: { result: 'success', data: { id: 1, name: 'Test' } }
};
case 'not-found':
return {
status: 404,
body: { error: 'Not found', resource: 'user' }
};
case 'error':
return {
status: 500,
body: { error: 'Internal server error' }
};
default:
return {
status: 500,
body: { error: 'Unknown action' }
};
}
}
});
(0, vitest_1.expect)(route.response?.[200]?.body).toBeDefined();
(0, vitest_1.expect)(route.response?.[404]?.body).toBeDefined();
(0, vitest_1.expect)(route.response?.[500]?.body).toBeDefined();
(0, vitest_1.expect)(route.response?.[200]?.body.properties.result).toBeDefined();
(0, vitest_1.expect)(route.response?.[404]?.body.properties.error).toBeDefined();
(0, vitest_1.expect)(route.response?.[500]?.body.properties.error).toBeDefined();
});
});
//# sourceMappingURL=schema-validation.test.js.map