From 5e6eea8ce4b537f948ee018693dca27d0b75412d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 23:02:25 +0000 Subject: [PATCH 1/9] Initial plan From 1dffc5b1215db6b5741629d21abec4d3087a22c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 23:26:26 +0000 Subject: [PATCH 2/9] fix: restore schema helpers and normalize endpoints Co-authored-by: hanc2006 <4517251+hanc2006@users.noreply.github.com> --- __tests__/schema.test.ts | 124 ++++++++--------------- __tests__/types.test.ts | 44 ++++---- package.json | 4 +- src/entry/server.ts | 4 +- src/interface/api.ts | 2 +- src/interface/schema.ts | 212 ++++++++++++++++----------------------- 6 files changed, 152 insertions(+), 238 deletions(-) diff --git a/__tests__/schema.test.ts b/__tests__/schema.test.ts index ddce418..e686a1c 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 { Number, Record, String, Array } from 'runtypes'; +import { generateAPISchema, GET, POST, PUT, DELETE, PATCH } from '../src/interface/schema'; +import { Number, Record, String, Array, Boolean } 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({ @@ -62,7 +46,7 @@ describe('generateAPISchema', () => { it('should handle empty schema gracefully', () => { const schema = generateAPISchema({ - fields: {}, + /* empty */ }); assert.ok(schema, 'Empty schema should be generated'); @@ -73,19 +57,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 +73,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 +89,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 +104,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..087189d 100644 --- a/src/entry/server.ts +++ b/src/entry/server.ts @@ -44,7 +44,9 @@ 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)); }, })); 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..0651320 100644 --- a/src/interface/schema.ts +++ b/src/interface/schema.ts @@ -1,158 +1,116 @@ +import { Record } from 'runtypes'; import type { Runtype } from 'runtypes'; 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'; - -// Check if method allows body -type MethodAllowsBody = - M extends HttpMethodWithBody ? true : false; - -// ============================================================================= -// 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 +type RequestIO = { + path?: RuntypeBase>; + query?: RuntypeBase>; + body?: RuntypeBase>; }; -// 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 +type ResponseIO = Partial>>; + +export type APISchemaIO = { + request: RequestIO; + responses: ResponseIO; }; -// Updated AnyAPISchemaIO -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type AnyAPISchemaIO = FieldReference>; +export type AnyAPISchemaIO = APISchemaIO; -interface FieldReference { - fields: T, -} +export type APIEndPoint = `${HttpRequestMethod} /${string}`; -type FieldOut = T extends { fields: unknown } ? T['fields'] : never; +export type APISchema = { + [E in APIEndPoint]?: APISchemaIO; +}; -export type APIEndPoint =`${HttpRequestMethod} /${string}`; +type EndpointSchemaInput = { + path?: Record; + query?: Record; + body?: Record; + responses: Record>; +}; -/** - * For each API endpoint, specify the request method, request interface and response interfaces. - */ -export type APISchema = { - [E in APIEndPoint]?: FieldReference> +type EndpointBuilderResult = { + method: HttpRequestMethod; + schema: EndpointSchemaInput; }; +type SchemaInput = Record; -type UnionToIntersection = - (U extends unknown ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; +const isRuntype = (value: unknown): value is Runtype => + typeof value === 'object' && value !== null && 'guard' in value; +const toRecordRuntype = (value?: Runtype | Record) => { + if (value === undefined) return undefined; + if (isRuntype(value)) return value; + return Record(value); +}; -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)); +const normalizeResponses = (responses: EndpointSchemaInput['responses']): ResponseIO => + Object.fromEntries( + Object.entries(responses ?? {}).map(([status, payload]) => [ + Number(status) as HttpStatusCode, + toRecordRuntype(payload), + ]), + ); + +const normalizePath = (path: string) => (path.startsWith('/') ? path : `/${path}`); + +const buildEndpoint = (method: HttpRequestMethod, allowBody: boolean) => + (schema: Omit): EndpointBuilderResult => ({ + method, + schema: allowBody ? schema as EndpointSchemaInput : { ...schema, body: undefined } as EndpointSchemaInput, + }); + +export const GET = buildEndpoint('GET', false); +export const DELETE = buildEndpoint('DELETE', false); +export const POST = buildEndpoint('POST', true); +export const PUT = buildEndpoint('PUT', true); +export const PATCH = buildEndpoint('PATCH', true); -//HACK -const arraylizeAPISchema = , T extends readonly FieldReference[] = []>(input: U | { +type UnionToIntersection = + (U extends unknown ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; + +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: { + [P in keyof T]: APISchema +}): UnionToIntersection => + input.reduce((a, b) => ({ ...a, ...b }), {}) as UnionToIntersection; +export const generateAPISchema = (input: U | { + intersectees: [U, ...T] +}): UnionToIntersection<{ + [P in keyof [U, ...T] & number]: APISchema +}[number]> => { + const schemas = arraylizeAPISchema(input).map(def => { + const result: APISchema = {}; + Object.entries(def).forEach(([pathKey, endpoint]) => { + const normalizedPath = normalizePath(pathKey); + const key = `${endpoint.method} ${normalizedPath}` as APIEndPoint; + result[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 result; + }); + return bundleAPISchema(schemas as unknown as readonly APISchema[]); +}; From 042158a9f670e99b2ee4fdaaf455ac7f4d71df0d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 23:28:07 +0000 Subject: [PATCH 3/9] chore: address review feedback Co-authored-by: hanc2006 <4517251+hanc2006@users.noreply.github.com> --- __tests__/schema.test.ts | 4 +--- src/interface/schema.ts | 30 +++++++++++++++++------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/__tests__/schema.test.ts b/__tests__/schema.test.ts index e686a1c..6748975 100644 --- a/__tests__/schema.test.ts +++ b/__tests__/schema.test.ts @@ -45,9 +45,7 @@ describe('generateAPISchema', () => { }); it('should handle empty schema gracefully', () => { - const schema = generateAPISchema({ - /* empty */ - }); + const schema = generateAPISchema({}); assert.ok(schema, 'Empty schema should be generated'); assert.deepStrictEqual(Object.keys(schema), [], 'Empty schema should have no keys'); diff --git a/src/interface/schema.ts b/src/interface/schema.ts index 0651320..a0a92c0 100644 --- a/src/interface/schema.ts +++ b/src/interface/schema.ts @@ -67,17 +67,23 @@ const normalizeResponses = (responses: EndpointSchemaInput['responses']): Respon const normalizePath = (path: string) => (path.startsWith('/') ? path : `/${path}`); -const buildEndpoint = (method: HttpRequestMethod, allowBody: boolean) => - (schema: Omit): EndpointBuilderResult => ({ +const buildEndpointWithBody = (method: HttpRequestMethod) => + (schema: EndpointSchemaInput): EndpointBuilderResult => ({ method, - schema: allowBody ? schema as EndpointSchemaInput : { ...schema, body: undefined } as EndpointSchemaInput, + schema, }); -export const GET = buildEndpoint('GET', false); -export const DELETE = buildEndpoint('DELETE', false); -export const POST = buildEndpoint('POST', true); -export const PUT = buildEndpoint('PUT', true); -export const PATCH = buildEndpoint('PATCH', true); +const buildEndpointWithoutBody = (method: HttpRequestMethod) => + (schema: Omit): 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; @@ -86,9 +92,7 @@ const arraylizeAPISchema = ('intersectees' in input ? input.intersectees : [input]) as [U, ...T]; -const bundleAPISchema = (input: { - [P in keyof T]: APISchema -}): UnionToIntersection => +const bundleAPISchema = (input: T): UnionToIntersection => input.reduce((a, b) => ({ ...a, ...b }), {}) as UnionToIntersection; export const generateAPISchema = (input: U | { @@ -96,7 +100,7 @@ export const generateAPISchema = => { - const schemas = arraylizeAPISchema(input).map(def => { + const schemas: readonly APISchema[] = arraylizeAPISchema(input).map(def => { const result: APISchema = {}; Object.entries(def).forEach(([pathKey, endpoint]) => { const normalizedPath = normalizePath(pathKey); @@ -112,5 +116,5 @@ export const generateAPISchema = Date: Mon, 22 Dec 2025 23:29:05 +0000 Subject: [PATCH 4/9] chore: harden schema normalization Co-authored-by: hanc2006 <4517251+hanc2006@users.noreply.github.com> --- src/interface/schema.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/interface/schema.ts b/src/interface/schema.ts index a0a92c0..c06cfba 100644 --- a/src/interface/schema.ts +++ b/src/interface/schema.ts @@ -49,7 +49,7 @@ type EndpointBuilderResult = { type SchemaInput = Record; const isRuntype = (value: unknown): value is Runtype => - typeof value === 'object' && value !== null && 'guard' in value; + typeof value === 'object' && value !== null && typeof (value as { guard?: unknown }).guard === 'function'; const toRecordRuntype = (value?: Runtype | Record) => { if (value === undefined) return undefined; @@ -58,12 +58,12 @@ const toRecordRuntype = (value?: Runtype | Record) => { }; const normalizeResponses = (responses: EndpointSchemaInput['responses']): ResponseIO => - Object.fromEntries( - Object.entries(responses ?? {}).map(([status, payload]) => [ - Number(status) as HttpStatusCode, - toRecordRuntype(payload), - ]), - ); + Object.entries(responses).reduce((acc, [status, payload]) => { + const statusCode = Number(status); + if (!Number.isFinite(statusCode)) return acc; + acc[statusCode as HttpStatusCode] = toRecordRuntype(payload); + return acc; + }, {}); const normalizePath = (path: string) => (path.startsWith('/') ? path : `/${path}`); From 9f239a8a5d0f5c713161746ac462e8afc6fe5460 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 23:30:24 +0000 Subject: [PATCH 5/9] chore: validate response status codes Co-authored-by: hanc2006 <4517251+hanc2006@users.noreply.github.com> --- src/interface/schema.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/interface/schema.ts b/src/interface/schema.ts index c06cfba..4e2b022 100644 --- a/src/interface/schema.ts +++ b/src/interface/schema.ts @@ -13,6 +13,19 @@ type HttpStatusCode = | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 451 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511; +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?: RuntypeBase>; query?: RuntypeBase>; @@ -49,7 +62,9 @@ type EndpointBuilderResult = { type SchemaInput = Record; const isRuntype = (value: unknown): value is Runtype => - typeof value === 'object' && value !== null && typeof (value as { guard?: unknown }).guard === 'function'; + typeof value === 'object' && value !== null + && typeof (value as { guard?: unknown }).guard === 'function' + && typeof (value as { validate?: unknown }).validate === 'function'; const toRecordRuntype = (value?: Runtype | Record) => { if (value === undefined) return undefined; @@ -60,8 +75,10 @@ const toRecordRuntype = (value?: Runtype | Record) => { const normalizeResponses = (responses: EndpointSchemaInput['responses']): ResponseIO => Object.entries(responses).reduce((acc, [status, payload]) => { const statusCode = Number(status); - if (!Number.isFinite(statusCode)) return acc; - acc[statusCode as HttpStatusCode] = toRecordRuntype(payload); + if (!Number.isFinite(statusCode) || !isHttpStatusCode(statusCode)) { + throw new Error(`Invalid HTTP status code in responses: ${status}`); + } + acc[statusCode] = toRecordRuntype(payload); return acc; }, {}); From aabb8c567c233f7c43c11c9ecfec87d588abb82c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 23:31:18 +0000 Subject: [PATCH 6/9] chore: clean up schema test imports Co-authored-by: hanc2006 <4517251+hanc2006@users.noreply.github.com> --- __tests__/schema.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/schema.test.ts b/__tests__/schema.test.ts index 6748975..0a59fc9 100644 --- a/__tests__/schema.test.ts +++ b/__tests__/schema.test.ts @@ -1,7 +1,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; import { generateAPISchema, GET, POST, PUT, DELETE, PATCH } from '../src/interface/schema'; -import { Number, Record, String, Array, Boolean } from 'runtypes'; +import { Number, Record, String, Array } from 'runtypes'; describe('generateAPISchema', () => { it('should generate schema from single endpoint definition', () => { From 49d30eaf369bb5cad1bedcf3d298e34de8933688 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 23:32:18 +0000 Subject: [PATCH 7/9] chore: refine runtype checks and status errors Co-authored-by: hanc2006 <4517251+hanc2006@users.noreply.github.com> --- src/interface/schema.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/interface/schema.ts b/src/interface/schema.ts index 4e2b022..2c3e06f 100644 --- a/src/interface/schema.ts +++ b/src/interface/schema.ts @@ -62,9 +62,12 @@ type EndpointBuilderResult = { type SchemaInput = Record; const isRuntype = (value: unknown): value is Runtype => - typeof value === 'object' && value !== null - && typeof (value as { guard?: unknown }).guard === 'function' - && typeof (value as { validate?: unknown }).validate === 'function'; + typeof value === 'object' + && value !== null + && 'guard' in value + && typeof (value as Runtype).guard === 'function' + && 'validate' in value + && typeof (value as Runtype).validate === 'function'; const toRecordRuntype = (value?: Runtype | Record) => { if (value === undefined) return undefined; @@ -76,7 +79,7 @@ const normalizeResponses = (responses: EndpointSchemaInput['responses']): Respon 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}`); + throw new Error(`Invalid HTTP status code in responses: ${status}. Expected one of ${HTTP_STATUS_CODES.join(', ')}.`); } acc[statusCode] = toRecordRuntype(payload); return acc; @@ -117,12 +120,11 @@ export const generateAPISchema = => { - const schemas: readonly APISchema[] = arraylizeAPISchema(input).map(def => { - const result: APISchema = {}; - Object.entries(def).forEach(([pathKey, endpoint]) => { + const schemas: readonly APISchema[] = arraylizeAPISchema(input).map(def => + Object.entries(def).reduce((acc, [pathKey, endpoint]) => { const normalizedPath = normalizePath(pathKey); const key = `${endpoint.method} ${normalizedPath}` as APIEndPoint; - result[key] = { + acc[key] = { request: { ...(endpoint.schema.path ? { path: toRecordRuntype(endpoint.schema.path) } : {}), ...(endpoint.schema.query ? { query: toRecordRuntype(endpoint.schema.query) } : {}), @@ -130,8 +132,8 @@ export const generateAPISchema = Date: Mon, 22 Dec 2025 23:54:10 +0000 Subject: [PATCH 8/9] fix: resolve schema.ts type errors Co-authored-by: hanc2006 <4517251+hanc2006@users.noreply.github.com> --- src/entry/server.ts | 4 +++- src/interface/schema.ts | 49 +++++++++++++++++++++-------------------- tsconfig.json | 4 +++- 3 files changed, 31 insertions(+), 26 deletions(-) diff --git a/src/entry/server.ts b/src/entry/server.ts index 087189d..58c7880 100644 --- a/src/entry/server.ts +++ b/src/entry/server.ts @@ -50,7 +50,9 @@ export class TypedHttpAPIServer 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/schema.ts b/src/interface/schema.ts index 2c3e06f..37ef8fc 100644 --- a/src/interface/schema.ts +++ b/src/interface/schema.ts @@ -1,9 +1,10 @@ -import { Record } from 'runtypes'; -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 RuntypeBase = Runtype.Core; - type HttpStatusCode = | 100 | 101 | 102 | 103 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 @@ -27,12 +28,12 @@ const isHttpStatusCode = (code: number): code is HttpStatusCode => HTTP_STATUS_CODES.includes(code as HttpStatusCode); type RequestIO = { - path?: RuntypeBase>; - query?: RuntypeBase>; - body?: RuntypeBase>; + path?: AnyRuntype; + query?: AnyRuntype; + body?: AnyRuntype; }; -type ResponseIO = Partial>>; +type ResponseIO = Partial>; export type APISchemaIO = { request: RequestIO; @@ -48,10 +49,10 @@ export type APISchema = { }; type EndpointSchemaInput = { - path?: Record; - query?: Record; - body?: Record; - responses: Record>; + path?: Record; + query?: Record; + body?: Record; + responses: Record>; }; type EndpointBuilderResult = { @@ -61,19 +62,17 @@ type EndpointBuilderResult = { type SchemaInput = Record; -const isRuntype = (value: unknown): value is Runtype => +const isRuntype = (value: unknown): value is AnyRuntype => typeof value === 'object' && value !== null && 'guard' in value - && typeof (value as Runtype).guard === 'function' - && 'validate' in value - && typeof (value as Runtype).validate === 'function'; - -const toRecordRuntype = (value?: Runtype | Record) => { - if (value === undefined) return undefined; - if (isRuntype(value)) return value; - return Record(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]) => { @@ -121,7 +120,7 @@ export const generateAPISchema = => { const schemas: readonly APISchema[] = arraylizeAPISchema(input).map(def => - Object.entries(def).reduce((acc, [pathKey, endpoint]) => { + (Object.entries(def) as [string, EndpointBuilderResult][]).reduce((acc, [pathKey, endpoint]) => { const normalizedPath = normalizePath(pathKey); const key = `${endpoint.method} ${normalizedPath}` as APIEndPoint; acc[key] = { @@ -135,5 +134,7 @@ export const generateAPISchema = ; }; 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 +} From 66c1fceb2c901eb4ae2e888e05dd83791fbba149 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 00:03:19 +0000 Subject: [PATCH 9/9] feat: enforce path param inference and validation Co-authored-by: hanc2006 <4517251+hanc2006@users.noreply.github.com> --- src/interface/schema.ts | 54 ++++++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/src/interface/schema.ts b/src/interface/schema.ts index 37ef8fc..118bfbc 100644 --- a/src/interface/schema.ts +++ b/src/interface/schema.ts @@ -5,6 +5,13 @@ type AnyRuntype = { }; import type { HttpRequestMethod } from './httpMethod'; +type ExtractPathParams = + T extends `${string}{${infer Param}}${infer Rest}` + ? Param | ExtractPathParams + : never; + +type HasPathParams = ExtractPathParams extends never ? false : true; + type HttpStatusCode = | 100 | 101 | 102 | 103 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 @@ -48,19 +55,28 @@ export type APISchema = { [E in APIEndPoint]?: APISchemaIO; }; -type EndpointSchemaInput = { - path?: Record; - query?: Record; - body?: Record; - responses: Record>; -}; - -type EndpointBuilderResult = { - method: HttpRequestMethod; - schema: EndpointSchemaInput; +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 = Record; +type SchemaInput = { [Path in string]: EndpointBuilderResult }; const isRuntype = (value: unknown): value is AnyRuntype => typeof value === 'object' @@ -74,26 +90,26 @@ const toRecordRuntype = (value: AnyRuntype | Record): AnyRun ? value : (RtObject as unknown as (v: Record) => AnyRuntype)(value as Record); -const normalizeResponses = (responses: EndpointSchemaInput['responses']): ResponseIO => +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); + acc[statusCode] = toRecordRuntype(payload as AnyRuntype | Record); return acc; }, {}); const normalizePath = (path: string) => (path.startsWith('/') ? path : `/${path}`); const buildEndpointWithBody = (method: HttpRequestMethod) => - (schema: EndpointSchemaInput): EndpointBuilderResult => ({ + (schema: EndpointSchemaInput): EndpointBuilderResult => ({ method, schema, }); const buildEndpointWithoutBody = (method: HttpRequestMethod) => - (schema: Omit): EndpointBuilderResult => ({ + (schema: EndpointSchemaInput): EndpointBuilderResult => ({ method, schema, }); @@ -114,12 +130,16 @@ const arraylizeAPISchema = (input: T): UnionToIntersection => input.reduce((a, b) => ({ ...a, ...b }), {}) as UnionToIntersection; -export const generateAPISchema = (input: U | { +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).map(def => + 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;