diff --git a/__tests__/schema.test.ts b/__tests__/schema.test.ts index ddce418..0a59fc9 100644 --- a/__tests__/schema.test.ts +++ b/__tests__/schema.test.ts @@ -1,21 +1,16 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; -import { generateAPISchema } from '../src/interface/schema'; +import { generateAPISchema, GET, POST, PUT, DELETE, PATCH } from '../src/interface/schema'; import { Number, Record, String, Array } from 'runtypes'; describe('generateAPISchema', () => { it('should generate schema from single endpoint definition', () => { const schema = generateAPISchema({ - fields: { - 'GET /users': { - fields: { - request: {}, - responses: { - 200: Record({ users: Array(String) }), - }, - }, + 'users': GET({ + responses: { + 200: { users: Array(String) }, }, - }, + }), }); assert.ok(schema, 'Schema should be generated'); @@ -24,31 +19,20 @@ describe('generateAPISchema', () => { it('should merge multiple schemas with intersectees', () => { const schema1 = { - fields: { - 'GET /users': { - fields: { - request: {}, - responses: { - 200: Record({ users: Array(String) }), - }, - }, + 'users': GET({ + responses: { + 200: { users: Array(String) }, }, - }, + }), }; const schema2 = { - fields: { - 'POST /users': { - fields: { - request: { - body: Record({ name: String }), - }, - responses: { - 201: Record({ id: Number }), - }, - }, + 'users': POST({ + body: { name: String }, + responses: { + 201: { id: Number }, }, - }, + }), }; const merged = generateAPISchema({ @@ -61,9 +45,7 @@ describe('generateAPISchema', () => { }); it('should handle empty schema gracefully', () => { - const schema = generateAPISchema({ - fields: {}, - }); + const schema = generateAPISchema({}); assert.ok(schema, 'Empty schema should be generated'); assert.deepStrictEqual(Object.keys(schema), [], 'Empty schema should have no keys'); @@ -73,19 +55,13 @@ describe('generateAPISchema', () => { describe('Schema structure validation', () => { it('should accept schema with path parameters', () => { const schema = { - fields: { - 'GET /users/{id}': { - fields: { - request: { - path: { id: Number }, - }, - responses: { - 200: Record({ name: String }), - 404: Record({ error: String }), - }, - }, + 'users/{id}': GET({ + path: { id: Number }, + responses: { + 200: { name: String }, + 404: { error: String }, }, - }, + }), }; const result = generateAPISchema(schema); @@ -95,19 +71,13 @@ describe('Schema structure validation', () => { it('should accept schema with request body', () => { const schema = { - fields: { - 'POST /users': { - fields: { - request: { - body: Record({ name: String, email: String }), - }, - responses: { - 201: Record({ id: Number }), - 400: Record({ error: String }), - }, - }, + '/users': POST({ + body: { name: String, email: String }, + responses: { + 201: { id: Number }, + 400: { error: String }, }, - }, + }), }; const result = generateAPISchema(schema); @@ -117,18 +87,12 @@ describe('Schema structure validation', () => { it('should accept schema with query parameters', () => { const schema = { - fields: { - 'GET /users': { - fields: { - request: { - query: { page: Number, limit: Number }, - }, - responses: { - 200: Record({ users: Array(String) }), - }, - }, + 'users': GET({ + query: { page: Number, limit: Number }, + responses: { + 200: { users: Array(String) }, }, - }, + }), }; const result = generateAPISchema(schema); @@ -138,22 +102,16 @@ describe('Schema structure validation', () => { it('should accept schema with multiple status codes', () => { const schema = { - fields: { - 'POST /users': { - fields: { - request: { - body: Record({ name: String }), - }, - responses: { - 200: Record({ success: String }), - 201: Record({ id: Number }), - 400: Record({ error: String }), - 401: Record({ message: String }), - 500: Record({ error: String }), - }, - }, + 'users': POST({ + body: { name: String }, + responses: { + 200: { success: String }, + 201: { id: Number }, + 400: { error: String }, + 401: { message: String }, + 500: { error: String }, }, - }, + }), }; const result = generateAPISchema(schema); diff --git a/__tests__/types.test.ts b/__tests__/types.test.ts index 4704a21..39331b0 100644 --- a/__tests__/types.test.ts +++ b/__tests__/types.test.ts @@ -3,9 +3,11 @@ * These tests validate that types are correctly inferred at compile-time */ -// Import the schema types +// Import the schema types and helpers import type { APISchema } from '../src/interface/schema'; +import { GET, POST, generateAPISchema } from '../src/interface/schema'; import type { Runtype } from 'runtypes'; +import { String } from 'runtypes'; // Type alias for compatibility type RuntypeBase = Runtype.Core; @@ -83,33 +85,25 @@ type Test4_Invalid = 670; // ============================================================================= // Test that APISchema accepts the new structure -type TestAPISchema = { - 'POST /users/{id}': { - fields: { - request: { - path: { id: RuntypeBase }; - body: RuntypeBase<{ name: string }>; - }; - responses: { - 200: RuntypeBase<{ success: boolean }>; - 404: RuntypeBase<{ error: string }>; - }; - }; - }; - 'GET /users': { - fields: { - request: { - query?: { page: RuntypeBase }; - }; - responses: { - 200: RuntypeBase<{ users: unknown[] }>; - }; - }; - }; +const typedSchema = { + 'users/{id}': POST({ + path: { id: String }, + body: { name: String }, + responses: { + 200: { success: String }, + 404: { error: String }, + }, + }), + '/users': GET({ + query: { page: String }, + responses: { + 200: { users: String }, + }, + }), }; // Verify this compiles as valid APISchema -type TestSchemaIsValid = TestAPISchema extends APISchema ? true : false; +type TestSchemaIsValid = ReturnType> extends APISchema ? true : false; // Export a marker to indicate all type tests passed export const TYPE_TESTS_PASSED = true; diff --git a/package.json b/package.json index a431de2..d1c0988 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,6 @@ "lint": "eslint {src, __tests__}/**/*.ts --fix", "build": "tsx build.ts", "type-check": "tsc --noEmit", - "test": "tsx --test __tests__/**/*.test.ts" + "test": "tsx --test __tests__/*.test.ts" } -} \ No newline at end of file +} diff --git a/src/entry/server.ts b/src/entry/server.ts index 9cba165..58c7880 100644 --- a/src/entry/server.ts +++ b/src/entry/server.ts @@ -44,11 +44,15 @@ export class TypedHttpAPIServer async request => { const payload = request.body; // eslint-disable-next-line @typescript-eslint/no-explicit-any - if(!(v.io.fields.request as any).guard(payload)) return option.incorrectTypeMessage; + if (v.io?.request?.body && !(v.io.request.body as any).guard(payload)) { + return option.incorrectTypeMessage; + } return HttpAPIResponse.unpack(await v.processor(new HttpAPIRequest(request), payload)); }, })); - const shortage = Object.entries(this.schema).map(v => v[0]).filter(v => types.find(e => `${e.endpoint.method} ${e.endpoint.uri}` === v) === undefined); + const shortage = Object.entries(this.schema) + .map(([key]) => key) + .filter(key => types.find(e => `${e.endpoint.method} ${e.endpoint.uri}` === key) === undefined); if(summary) generateSummary({ apiCount: HTTP_REQUEST_METHODS.map(v => ({ method: v, count: types.filter(e => e.endpoint.method === v).length })), doublingEndpoints: detectDuplicate(types.map(v => `${v.endpoint.method} ${v.endpoint.uri}`)), diff --git a/src/interface/api.ts b/src/interface/api.ts index 14a5295..af5a48c 100644 --- a/src/interface/api.ts +++ b/src/interface/api.ts @@ -6,7 +6,7 @@ export type GetSchema< APISchemaType extends APISchema, EndPoint extends (keyof APISchemaType & APIEndPoint), Type extends 'request' | 'responses' -> = NonNullable['fields'][Type]; +> = NonNullable[Type]; // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars export type GetStaticSchema< diff --git a/src/interface/schema.ts b/src/interface/schema.ts index e99a09f..118bfbc 100644 --- a/src/interface/schema.ts +++ b/src/interface/schema.ts @@ -1,158 +1,160 @@ -import type { Runtype } from 'runtypes'; +import { Object as RtObject } from 'runtypes'; +type AnyRuntype = { + guard: (value: unknown) => boolean; + tag?: string; +}; import type { HttpRequestMethod } from './httpMethod'; -// Type alias for compatibility -type RuntypeBase = Runtype.Core; - -// ============================================================================= -// Task 1.1: Path Parameter Extraction from Endpoint String -// ============================================================================= - -// Extract path parameter names from endpoint string -type ExtractPathParams = - T extends `${string}/{${infer Param}}${infer Rest}` - ? { [K in Param | keyof ExtractPathParams]: number | string } - : Record; - -// Check if path params exist -type HasPathParams = - keyof ExtractPathParams extends never ? false : true; - -// ============================================================================= -// Task 1.2: HTTP Method-Based Body Inference -// ============================================================================= - -// Methods that support request body -type HttpMethodWithBody = 'POST' | 'PUT' | 'PATCH'; +type ExtractPathParams = + T extends `${string}{${infer Param}}${infer Rest}` + ? Param | ExtractPathParams + : never; -// Check if method allows body -type MethodAllowsBody = - M extends HttpMethodWithBody ? true : false; +type HasPathParams = ExtractPathParams extends never ? false : true; -// ============================================================================= -// Task 1.3: Strict Request Schema with Conditional Fields -// ============================================================================= - -// Allowed query parameter types -type QueryParamValue = string | number | boolean | string[] | number[] | boolean[]; - -// Strict query parameter definition -type QuerySchema = { - [key: string]: RuntypeBase -}; - -// Build request schema conditionally -type RequestSchema< - Endpoint extends APIEndPoint, - Body, - Query extends QuerySchema | undefined -> = ( - Endpoint extends `${infer Method extends HttpRequestMethod} ${infer Path}` - ? ( - // Conditionally add path - (HasPathParams extends true - ? { path: Record, RuntypeBase> } - : Record - ) - & // Conditionally add body - (MethodAllowsBody extends true - ? { body: RuntypeBase } - : Record - ) - & // Always allow optional query - (Query extends QuerySchema - ? { query?: Query } - : Record - ) - ) - : never -); - -// ============================================================================= -// Task 1.4: Strict HTTP Status Code Validation for Responses -// ============================================================================= - -// Comprehensive HTTP status codes -type HttpStatusCode = - // 1xx Informational +type HttpStatusCode = | 100 | 101 | 102 | 103 - // 2xx Success | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 - // 3xx Redirection | 300 | 301 | 302 | 303 | 304 | 305 | 307 | 308 - // 4xx Client Error | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 451 - // 5xx Server Error | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511; -// Validate that all response keys are valid HTTP status codes -type ValidateResponseStatus = { - [K in keyof T]: K extends HttpStatusCode ? T[K] : never -}; - -// Response schema with validation -type ResponseSchema = ValidateResponseStatus<{ - [Status in keyof Responses]: RuntypeBase -}>; - -// ============================================================================= -// Task 1.5: Complete APISchemaIO Rebuild -// ============================================================================= - -// Complete strict schema -type APISchemaIO< - Endpoint extends APIEndPoint, - ReqBody, - ReqQuery extends QuerySchema | undefined, - Responses -> = { - request: RequestSchema, - responses: ResponseSchema +const HTTP_STATUS_CODES: readonly HttpStatusCode[] = [ + 100, 101, 102, 103, + 200, 201, 202, 203, 204, 205, 206, 207, 208, 226, + 300, 301, 302, 303, 304, 305, 307, 308, + 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, + 410, 411, 412, 413, 414, 415, 416, 417, 418, 421, + 422, 423, 424, 425, 426, 428, 429, 431, 451, + 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511, +] as const; + +const isHttpStatusCode = (code: number): code is HttpStatusCode => + HTTP_STATUS_CODES.includes(code as HttpStatusCode); + +type RequestIO = { + path?: AnyRuntype; + query?: AnyRuntype; + body?: AnyRuntype; }; -// Updated AnyAPISchemaIO -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type AnyAPISchemaIO = FieldReference>; +type ResponseIO = Partial>; -interface FieldReference { - fields: T, -} +export type APISchemaIO = { + request: RequestIO; + responses: ResponseIO; +}; -type FieldOut = T extends { fields: unknown } ? T['fields'] : never; +export type AnyAPISchemaIO = APISchemaIO; -export type APIEndPoint =`${HttpRequestMethod} /${string}`; +export type APIEndPoint = `${HttpRequestMethod} /${string}`; -/** - * For each API endpoint, specify the request method, request interface and response interfaces. - */ export type APISchema = { - [E in APIEndPoint]?: FieldReference> + [E in APIEndPoint]?: APISchemaIO; +}; + +type PathField = + HasPathParams extends true + ? { path: Record, AnyRuntype> } + : { path?: never }; + +type BodyField = + Method extends 'GET' | 'DELETE' + ? { body?: never } + : { body?: Record }; + +type EndpointSchemaInput = + PathField & { + query?: Record; + responses: Record>; + } & BodyField; + +type EndpointBuilderResult = { + method: Method; + schema: EndpointSchemaInput; }; +type SchemaInput = { [Path in string]: EndpointBuilderResult }; + +const isRuntype = (value: unknown): value is AnyRuntype => + typeof value === 'object' + && value !== null + && 'guard' in value + && typeof (value as AnyRuntype).guard === 'function' + && 'tag' in value; + +const toRecordRuntype = (value: AnyRuntype | Record): AnyRuntype => + isRuntype(value) + ? value + : (RtObject as unknown as (v: Record) => AnyRuntype)(value as Record); + +const normalizeResponses = (responses: EndpointSchemaInput['responses']): ResponseIO => + Object.entries(responses).reduce((acc, [status, payload]) => { + const statusCode = Number(status); + if (!Number.isFinite(statusCode) || !isHttpStatusCode(statusCode)) { + throw new Error(`Invalid HTTP status code in responses: ${status}. Expected one of ${HTTP_STATUS_CODES.join(', ')}.`); + } + acc[statusCode] = toRecordRuntype(payload as AnyRuntype | Record); + return acc; + }, {}); + +const normalizePath = (path: string) => (path.startsWith('/') ? path : `/${path}`); + +const buildEndpointWithBody = (method: HttpRequestMethod) => + (schema: EndpointSchemaInput): EndpointBuilderResult => ({ + method, + schema, + }); + +const buildEndpointWithoutBody = (method: HttpRequestMethod) => + (schema: EndpointSchemaInput): EndpointBuilderResult => ({ + method, + schema, + }); + +export const GET = buildEndpointWithoutBody('GET'); +export const DELETE = buildEndpointWithoutBody('DELETE'); +export const POST = buildEndpointWithBody('POST'); +export const PUT = buildEndpointWithBody('PUT'); +export const PATCH = buildEndpointWithBody('PATCH'); type UnionToIntersection = (U extends unknown ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; - -export const generateAPISchema = , T extends readonly FieldReference[] = []>(input: U | { - intersectees: [U, ...T] -}): UnionToIntersection<{ - [P in keyof [U, ...T] & number]: FieldOut<[U, ...T][P]> -}[number]> => bundleAPISchema(arraylizeAPISchema(input)); - -//HACK -const arraylizeAPISchema = , T extends readonly FieldReference[] = []>(input: U | { +const arraylizeAPISchema = (input: U | { intersectees: [U, ...T] }): [U, ...T] => ('intersectees' in input ? input.intersectees : [input]) as [U, ...T]; -//HACK -const bundleAPISchema = (input: - { - [P in keyof T]: FieldReference - }, -): UnionToIntersection => - input.map(v => v.fields).reduce((a, b) => ({ ...a, ...b }), {}) as UnionToIntersection; +const bundleAPISchema = (input: T): UnionToIntersection => + input.reduce((a, b) => ({ ...a, ...b }), {}) as UnionToIntersection; +type ValidatedSchemaInput = { + [K in keyof U]: EndpointBuilderResult; +}; +export const generateAPISchema = (input: ValidatedSchemaInput | { + intersectees: [U, ...T] +}): UnionToIntersection<{ + [P in keyof [U, ...T] & number]: APISchema +}[number]> => { + const schemas: readonly APISchema[] = arraylizeAPISchema(input as unknown as U | { intersectees: [U, ...T] }).map(def => + (Object.entries(def) as [string, EndpointBuilderResult][]).reduce((acc, [pathKey, endpoint]) => { + const normalizedPath = normalizePath(pathKey); + const key = `${endpoint.method} ${normalizedPath}` as APIEndPoint; + acc[key] = { + request: { + ...(endpoint.schema.path ? { path: toRecordRuntype(endpoint.schema.path) } : {}), + ...(endpoint.schema.query ? { query: toRecordRuntype(endpoint.schema.query) } : {}), + ...(endpoint.schema.body ? { body: toRecordRuntype(endpoint.schema.body) } : {}), + }, + responses: normalizeResponses(endpoint.schema.responses), + }; + return acc; + }, {}), + ); + return bundleAPISchema(schemas) as UnionToIntersection<{ + [P in keyof [U, ...T] & number]: APISchema + }[number]>; +}; diff --git a/tsconfig.json b/tsconfig.json index 228ff29..564dc35 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,8 @@ "compilerOptions": { "target": "ESNext", "module": "node20", + "moduleResolution": "node16", + "types": ["node"], "resolveJsonModule": true, "declaration": true, "strict": true, @@ -14,4 +16,4 @@ "src", "build.ts" ], -} \ No newline at end of file +}