From 2ee76672da07fa3709520e8d9086288962d533b7 Mon Sep 17 00:00:00 2001 From: Nathan LaFreniere Date: Tue, 20 May 2025 14:10:40 -0700 Subject: [PATCH 1/2] feat: make responses discriminated unions for stronger type safety while not throwing for invalid status codes --- lib/build/index.ts | 83 +++++++++++------ lib/build/nodes.ts | 37 +++++--- lib/index.ts | 216 +++++++++++++++------------------------------ lib/types.ts | 150 +++++++++++++++++++++++++++++++ test/fetch.ts | 43 +++++++-- test/fixture.ts | 32 ++++++- test/inject.ts | 29 ++++-- 7 files changed, 388 insertions(+), 202 deletions(-) create mode 100644 lib/types.ts diff --git a/lib/build/index.ts b/lib/build/index.ts index ba6a8c3..c211254 100644 --- a/lib/build/index.ts +++ b/lib/build/index.ts @@ -1,12 +1,15 @@ import type { RequestRoute, Server } from "@hapi/hapi"; import Joi from "joi"; import type ts from "typescript"; -import { addSyntheticLeadingComment, factory as f } from "typescript"; +import { addSyntheticLeadingComment } from "typescript"; import type { ExtendedObjectSchema, ExtendedSchema } from "./joi.ts"; import { + importDeclaration, typeAliasDeclaration, typeLiteralNode, + typeReferenceNode, + unionTypeNode, } from "./nodes.ts"; import { generateType, @@ -51,9 +54,11 @@ export function getTypeName(routeName: string, suffix: string): string { } export function generateClientType(server: Server) { - const routeMap: Record>> = {}; + const routeList: ts.TypeNode[] = []; const statements: ts.Statement[] = []; + statements.push(importDeclaration(["Route", "StatusCode"], "@code4rena/typed-client", true)); + for (const route of server.table()) { const routeName = getRouteName(route); const routeOptions: Record = {}; @@ -108,40 +113,62 @@ export function generateClientType(server: Server) { statements.push(typeAliasDeclaration(payloadTypeName, generateType(route.settings.validate.payload as ExtendedObjectSchema), true)); } + // the result type is the type of the actual response from the api + // const resultTypeName = getTypeName(routeName, "result"); + // the response type is the result wrapped in the higher level object including url, status, etc. const responseTypeName = getTypeName(routeName, "response"); - const responseValidator = Object.entries(route.settings.response?.status ?? {}) - .filter(([code]) => +code >= 200 && +code < 300) - .map(([_, schema]) => schema)[0] ?? Joi.any(); + const matchedCodes: string[] = []; + const responseTypeList: string[] = []; + // first, we iterate our response validators and get everything that has a specific response code + for (const [code, schema] of Object.entries(route.settings.response?.status ?? {})) { + matchedCodes.push(code); + + const responseCodeTypeName = getTypeName(responseTypeName, code); + const responseNode = typeLiteralNode({ + status: { name: `${code}`, required: true }, + ok: { name: (+code >= 200 && +code < 300) ? "true" : "false", required: true }, + headers: { name: "Headers", required: true }, + url: { name: "string", required: true }, + data: { node: generateType(schema as ExtendedObjectSchema), required: isRequired(schema as ExtendedSchema) }, + }); + statements.push(typeAliasDeclaration(responseCodeTypeName, responseNode, true)); + responseTypeList.push(responseCodeTypeName); + } + + // now we insert a final response type where the status code is Exclude + const unknownResponseName = getTypeName(responseTypeName, "Unknown"); + const unknownResponseNode = typeLiteralNode({ + status: { + name: matchedCodes.length + // HACK: this could probably build a real node with a real union passed to a real generic + ? `Exclude` + : "StatusCode", + required: true, + }, + ok: { name: "boolean", required: true }, + headers: { name: "Headers", required: true }, + url: { name: "string", required: true }, + data: { name: "unknown", required: false }, + }); + statements.push(typeAliasDeclaration(unknownResponseName, unknownResponseNode, true)); + responseTypeList.push(unknownResponseName); + + const responseUnionType = unionTypeNode(responseTypeList.map((responseType) => typeReferenceNode(responseType))); + statements.push(typeAliasDeclaration(responseTypeName, responseUnionType, true)); + routeOptions.response = { name: responseTypeName, required: true, }; - statements.push(typeAliasDeclaration(responseTypeName, generateType(responseValidator as ExtendedObjectSchema), true)); - routeMap[route.method.toUpperCase()] ??= {}; - routeMap[route.method.toUpperCase()][route.path] = routeOptions; + const routeType = typeReferenceNode("Route", route.method.toUpperCase(), route.path, typeLiteralNode(routeOptions)); + routeList.push(routeType); } - const clientTypeNode = f.createTypeLiteralNode(Object.entries(routeMap).map(([method, route]) => { - return f.createPropertySignature( - [], - f.createStringLiteral(method), - undefined, - f.createTypeLiteralNode(Object.entries(route).map(([path, options]) => { - return f.createPropertySignature( - [], - f.createStringLiteral(path), - undefined, - typeLiteralNode(options), - ); - }))); - })); - - statements.push(typeAliasDeclaration("RouteMap", clientTypeNode, true)); - - addSyntheticLeadingComment(statements[0], SyntaxKind.MultiLineCommentTrivia, " eslint-disable @stylistic/indent ", true); - addSyntheticLeadingComment(statements[0], SyntaxKind.MultiLineCommentTrivia, " eslint-disable @stylistic/quote-props ", true); - addSyntheticLeadingComment(statements[0], SyntaxKind.MultiLineCommentTrivia, " eslint-disable @stylistic/comma-dangle ", true); + statements.push(typeAliasDeclaration("Routes", unionTypeNode(routeList), true)); + + // throw a comment at the front to disable eslint for the file since it may not align with formatting + addSyntheticLeadingComment(statements[0], SyntaxKind.MultiLineCommentTrivia, " eslint-disable ", true); const sourceFile = factory.createSourceFile(statements, factory.createToken(SyntaxKind.EndOfFileToken), NodeFlags.None); const printer = createPrinter(); diff --git a/lib/build/nodes.ts b/lib/build/nodes.ts index 77b5a5b..48c8043 100644 --- a/lib/build/nodes.ts +++ b/lib/build/nodes.ts @@ -9,6 +9,7 @@ import type { QuestionToken, Statement, TypeNode, + TypeParameterDeclaration, } from "typescript"; import { EmitHint, @@ -54,15 +55,13 @@ export function dedupeNodes(nodes: TypeNode[]): TypeNode[] { }); } -export function importDeclaration(name: string, from: string, typeOnly?: boolean) { +export function importDeclaration(name: string | string[], from: string, typeOnly?: boolean) { return f.createImportDeclaration( undefined, f.createImportClause( typeOnly ?? false, undefined, - f.createNamedImports([ - f.createImportSpecifier(false, undefined, f.createIdentifier(name)), - ]), + f.createNamedImports([...name].map((name) => f.createImportSpecifier(false, undefined, f.createIdentifier(name)))), ), f.createStringLiteral(from), ); @@ -81,8 +80,12 @@ export function exportDeclaration(name: string, from: string) { ); } -export function typeAliasDeclaration(name: string, node: TypeNode, exported: boolean = false) { - return f.createTypeAliasDeclaration(exported ? [exportToken()] : [], name, undefined, node); +export function typeAliasDeclaration(name: string, node: TypeNode, exported: boolean = false, types: TypeParameterDeclaration[] = []) { + return f.createTypeAliasDeclaration(exported ? [exportToken()] : [], name, types, node); +} + +export function typeParameterDeclaration(name: string, constraint: TypeNode = undefined, def: TypeNode = undefined) { + return f.createTypeParameterDeclaration(undefined, name, constraint, def); } export function questionToken(schema?: Schema): QuestionToken | undefined { @@ -149,21 +152,29 @@ export function unknownTypeNode() { return f.createKeywordTypeNode(SyntaxKind.UnknownKeyword); } -export function typeLiteralNode(props: Record) { +type typeLiteralNodeProps = Record; +export function typeLiteralNode(props: typeLiteralNodeProps) { return f.createTypeLiteralNode(Object.entries(props).map(([key, prop]) => { return f.createPropertySignature( [], - f.createStringLiteral(key), + f.createIdentifier(key), prop.required ? undefined : questionToken(), - f.createTypeReferenceNode(f.createIdentifier(prop.name)), + typeof prop.name === "string" + ? f.createTypeReferenceNode(f.createIdentifier(prop.name), prop.types) + : prop.node, ); })); } -export function typeReferenceNode(name: string, ...typeArguments: string[]) { +export function typeReferenceNode(name: string, ...typeArguments: (string | TypeNode)[]) { return f.createTypeReferenceNode( f.createIdentifier(name), - typeArguments.map((typeArgument) => f.createTypeReferenceNode(f.createIdentifier(typeArgument))), + typeArguments.map((typeArgument) => typeof typeArgument === "string" + ? f.createLiteralTypeNode(f.createStringLiteral(typeArgument)) + : typeArgument), ); } @@ -173,13 +184,13 @@ export function objectTypeNode(props: Property[] | Map, options: const mappedProps = Array.isArray(props) ? props.map((prop) => f.createPropertySignature( [], - f.createStringLiteral(prop.key), + f.createIdentifier(prop.key), questionToken(prop.schema), generateType(prop.schema, _options)), ) : Array.from(props.entries()).map(([key, schema]) => f.createPropertySignature( [], - f.createStringLiteral(key), + f.createIdentifier(key), questionToken(schema), generateType(schema, _options), )); diff --git a/lib/index.ts b/lib/index.ts index 02cf7d1..eac2bab 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,72 +1,18 @@ import type { Server } from "@hapi/hapi"; - -export type Simplify = { [K in keyof T]: T[K] } & {}; - -export const RequestMethod = { - DELETE: "DELETE", - GET: "GET", - OPTIONS: "OPTIONS", - PATCH: "PATCH", - POST: "POST", - PUT: "PUT", -} as const; -export type RequestMethod = keyof typeof RequestMethod; - -export type WithoutResponse = Simplify>; -export type HasRequiredFields = { - [K in keyof T]-?: object extends { [P in K]: T[P] } ? never : K; -}[keyof T] extends never ? false : true; - -export type ApplyDefaults = { - [K in keyof Defaults as K extends keyof Target ? never : K]: Defaults[K] -} & { - [K in keyof Target as K extends keyof Defaults ? K : never]: Target[K] -}; - -declare global { - interface RequestInit { - next?: { - revalidate?: boolean | number; - tags?: string[]; - }; - } -} - -export type RouteOptions = { - headers?: HeadersInit; - params?: unknown; - payload?: unknown; - query?: unknown; - response?: unknown; -}; - -export type DefaultRouteOptions = { - headers?: HeadersInit; - params?: never; - payload?: never; - query?: never; - response?: unknown; - cache?: RequestInit["cache"]; - next?: RequestInit["next"]; -}; - -export type RouteDefinition = Record; - -export type RouteTable = Partial>; - -export type ExtractRoutes = M extends keyof T - ? T[M] - : never; - -export type ExtractPaths = D extends never - ? never - : keyof D; - -export type ExtractOptions = P extends never - ? never - : P extends keyof D - ? ApplyDefaults - : never; +import type { + AvailablePaths, + FetchOptions, + HasRequiredFields, + MatchingRoute, + RequestMethod, + ResponseType, + ResultType, + Route, + Simplify, + StatusCode, +} from "./types.ts"; + +export type * from "./types.ts"; export type ClientOptions = Simplify< { headers?: HeadersInit } @@ -74,38 +20,7 @@ export type ClientOptions = Simplify< & ({ bearerToken: string; bearerTokenType: string; token?: never } | { bearerToken?: never; bearerTokenType?: never; token?: string }) >; -export type ClientResponse = { - url: string; - status: number; - headers: Headers; - data: T; -}; - -export class ClientResponseError extends Error { - url: string; - status: number; - headers: Headers; - data: T; - - constructor(response: ClientResponse) { - super(`Received invalid response: ${response.status}`); - this.url = response.url; - this.status = response.status; - this.headers = response.headers; - this.data = response.data; - } -} - -export function replaceParams(path: string, params: Record) { - return path.split("/").map((segment) => { - if (segment.startsWith("{") && segment.endsWith("}")) { - return params[segment.slice(1, -1)]; - } - return segment; - }).join("/"); -} - -export class Client { +export class Client { #url: URL; #server: Server | undefined; #headers: Headers; @@ -114,6 +29,11 @@ export class Client { this.#url = typeof options.url === "string" ? new URL(options.url) : options.url; + + if (this.#url && !this.#url.pathname.endsWith("/")) { + this.#url.pathname += "/"; + } + this.#server = options.server; this.#headers = new Headers(options.headers); @@ -125,12 +45,17 @@ export class Client { } } - async #fetch(method: RequestMethod, path: string, options?: WithoutResponse): Promise> { + async #fetch< + M extends RequestMethod, + P extends AvailablePaths, + R extends MatchingRoute, + O extends FetchOptions, + >(method: M, path: P, options: O = {} as O): Promise> { const realPath = options && "params" in options ? replaceParams(path, options.params as Record) : path; - const headers = new Headers(options?.headers ?? {}); + const headers = new Headers(options?.headers ?? {} as Record); for (const [key, value] of this.#headers.entries()) { headers.set(key, value); } @@ -154,22 +79,19 @@ export class Client { const isJsonResponse = injectResponse.headers["content-type"]?.startsWith("application/json"); - const response: ClientResponse = { + const response: ResponseType = { url, - status: injectResponse.statusCode, + status: injectResponse.statusCode as StatusCode, + ok: injectResponse.statusCode >= 200 && injectResponse.statusCode < 300, headers: new Headers(injectResponse.headers as Record), - data: isJsonResponse ? JSON.parse(injectResponse.payload) as O["response"] : injectResponse.payload, + data: isJsonResponse ? JSON.parse(injectResponse.payload) as ResultType : injectResponse.payload, }; - if (injectResponse.statusCode < 200 || injectResponse.statusCode >= 300) { - throw new ClientResponseError(response); - } - return response; } // use a real fetch - const url = new URL(path, this.#url); + const url = new URL(realPath.startsWith("/") ? realPath.slice(1) : realPath, this.#url); for (const [key, value] of query.entries()) { url.searchParams.set(key, value); } @@ -184,71 +106,71 @@ export class Client { const isJsonResponse = fetchResponse.headers.get("content-type")?.startsWith("application/json"); - const response: ClientResponse = { + const response: ResponseType = { url: url.href, - status: fetchResponse.status, + status: fetchResponse.status as StatusCode, + ok: fetchResponse.ok, headers: fetchResponse.headers, - data: isJsonResponse ? (await fetchResponse.json()) as O["response"] : (await fetchResponse.text()), + data: isJsonResponse ? (await fetchResponse.json()) as ResultType : (await fetchResponse.text()), }; - if (!fetchResponse.ok) { - throw new ClientResponseError(response); - } - return response; } async delete< - M extends ExtractRoutes, - P extends ExtractPaths, - R extends ExtractOptions, - O extends WithoutResponse, - >(path: P, ...args: (HasRequiredFields extends true ? [O] : [O?])): Promise> { + P extends AvailablePaths, + R extends MatchingRoute, + O extends FetchOptions, + >(path: P, ...args: (HasRequiredFields extends true ? [O] : [O?])): Promise> { return await this.#fetch("DELETE", path, args[0]); } async get< - M extends ExtractRoutes, - P extends ExtractPaths, - R extends ExtractOptions, - O extends WithoutResponse, - >(path: P, ...args: (HasRequiredFields extends true ? [O] : [O?])): Promise> { + P extends AvailablePaths, + R extends MatchingRoute, + O extends FetchOptions, + >(path: P, ...args: (HasRequiredFields extends true ? [O] : [O?])): Promise> { return await this.#fetch("GET", path, args[0]); } async options< - M extends ExtractRoutes, - P extends ExtractPaths, - R extends ExtractOptions, - O extends WithoutResponse, - >(path: P, ...args: (HasRequiredFields extends true ? [O] : [O?])): Promise> { + P extends AvailablePaths, + R extends MatchingRoute, + O extends FetchOptions, + >(path: P, ...args: (HasRequiredFields extends true ? [O] : [O?])): Promise> { return await this.#fetch("OPTIONS", path, args[0]); } async patch< - M extends ExtractRoutes, - P extends ExtractPaths, - R extends ExtractOptions, - O extends WithoutResponse, - >(path: P, ...args: (HasRequiredFields extends true ? [O] : [O?])): Promise> { + P extends AvailablePaths, + R extends MatchingRoute, + O extends FetchOptions, + >(path: P, ...args: (HasRequiredFields extends true ? [O] : [O?])): Promise> { return await this.#fetch("PATCH", path, args[0]); } async post< - M extends ExtractRoutes, - P extends ExtractPaths, - R extends ExtractOptions, - O extends WithoutResponse, - >(path: P, ...args: (HasRequiredFields extends true ? [O] : [O?])): Promise> { + P extends AvailablePaths, + R extends MatchingRoute, + O extends FetchOptions, + >(path: P, ...args: (HasRequiredFields extends true ? [O] : [O?])): Promise> { return await this.#fetch("POST", path, args[0]); } async put< - M extends ExtractRoutes, - P extends ExtractPaths, - R extends ExtractOptions, - O extends WithoutResponse, - >(path: P, ...args: (HasRequiredFields extends true ? [O] : [O?])): Promise> { + P extends AvailablePaths, + R extends MatchingRoute, + O extends FetchOptions, + >(path: P, ...args: (HasRequiredFields extends true ? [O] : [O?])): Promise> { return await this.#fetch("PUT", path, args[0]); } } + +export function replaceParams(path: string, params: Record) { + return path.split("/").map((segment) => { + if (segment.startsWith("{") && segment.endsWith("}")) { + return params[segment.slice(1, -1)]; + } + return segment; + }).join("/"); +} diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..00ca34c --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,150 @@ +/** + * All available request methods + * This list was taken from `require("node:http").METHODS` + */ +export type RequestMethod = + | "ACL" + | "BIND" + | "CHECKOUT" + | "CONNECT" + | "COPY" + | "DELETE" + | "GET" + | "HEAD" + | "LINK" + | "LOCK" + | "M-SEARCH" + | "MERGE" + | "MKACTIVITY" + | "MKCALENDAR" + | "MKCOL" + | "MOVE" + | "NOTIFY" + | "OPTIONS" + | "PATCH" + | "POST" + | "PROPFIND" + | "PROPPATCH" + | "PURGE" + | "PUT" + | "QUERY" + | "REBIND" + | "REPORT" + | "SEARCH" + | "SOURCE" + | "SUBSCRIBE" + | "TRACE" + | "UNBIND" + | "UNLINK" + | "UNLOCK" + | "UNSUBSCRIBE"; + +/** + * All available status codes + * This list was taken from `require("node:http").STATUS_CODES` + */ +export type StatusCode = + | 100 | 101 | 102 | 103 + | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 + | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 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 | 509 | 510 | 511; + +/** + * Simplify a complex type, this can be helpful for making a complex series of unions and intersections + * easier for a human to follow + */ +export type Simplify = { [K in keyof T]: T[K] } & {}; + +/** + * Returns `true` if the type parameter has at least one property that is required + */ +export type HasRequiredFields = { + [K in keyof T]-?: object extends { [P in K]: T[P] } ? never : K; +}[keyof T] extends never ? false : true; + +/** + * Represents user-specified options associated with a RouteDefinition + */ +export type RouteSettings = { + params?: unknown; + query?: unknown; + payload?: unknown; + response?: { + status: StatusCode; + ok: boolean; + headers: Headers; + url: string; + data?: unknown; + }; +}; + +/** + * Represents a route in the server + */ +export type Route = { + method: M; + path: P; + settings: S; +}; + +/** + * Given a route, extract the fetch options + */ +export type FetchOptions = Simplify & { + headers?: HeadersInit; + cache?: RequestInit["cache"]; + next?: RequestInit["next"]; +}>; + +/** + * Get the params type for a route + */ +export type ParamsType = R["settings"]["params"]; +/** + * Get the query type for a route + */ +export type QueryType = R["settings"]["query"]; +/** + * Get the request payload type for a route + */ +export type PayloadType = R["settings"]["payload"]; + +/** + * Get the response type for a route (this is likely a union) + */ +export type ResponseType = R["settings"]["response"]; +/** + * Get the result type for a route (this is likely a union) + */ +export type ResultType = R["settings"]["response"]["data"]; +/** + * Get the response type for a route matching a specific status code + */ +export type SpecificResponseType = Extract; +/** + * Get the result type for a route matching a specific status code + */ +export type SpecificResultType = SpecificResponseType["data"]; + +/** + * Get a list of all available paths for a given method on a route union + */ +export type AvailablePaths = Extract>["path"]; +/** + * Find a route that matches the given method and path + */ +export type MatchingRoute = Extract>; + +/** + * Extend the global RequestInit type to understand nextjs extensions + */ +declare global { + interface RequestInit { + next?: { + revalidate?: boolean | number; + tags?: string[]; + }; + } +} diff --git a/test/fetch.ts b/test/fetch.ts index 5b15061..91b44b1 100644 --- a/test/fetch.ts +++ b/test/fetch.ts @@ -1,6 +1,7 @@ import { test, type TestContext } from "node:test"; import { server, Client } from "./fixture.ts"; -import type { RouteMap } from "./generated.ts"; +import type { MatchingRoute, QueryType, SpecificResultType } from "@code4rena/typed-client"; +import type { Routes } from "./generated.ts"; await test("fetch()", async (t) => { await server.start(); @@ -10,6 +11,20 @@ await test("fetch()", async (t) => { await server.stop(); }); + await t.test("functionality: allows a base url with a path", async (t) => { + const fetchSpy = t.mock.method(global, "fetch"); + t.after(() => { + fetchSpy.mock.restore(); + }); + + const client = new Client({ url: `${server.info.uri}/subpath` }); + const res = await client.get("/simple"); + t.assert.equal(res.status, 404); + t.assert.equal(fetchSpy.mock.callCount(), 1); + const firstCall = fetchSpy.mock.calls[0]; + t.assert.equal(firstCall.arguments[0], `${server.info.uri}/subpath/simple`); + }); + await t.test("flags: allows passing `next` property to fetch", async (t) => { const fetchSpy = t.mock.method(global, "fetch"); t.after(() => { @@ -24,22 +39,40 @@ await test("fetch()", async (t) => { }); await t.test("/simple", async (t: TestContext) => { + type routeType = MatchingRoute; + const res = await client.get("/simple"); t.assert.equal(res.status, 200); t.assert.equal(res.url, `${server.info.uri}/simple`); - t.assert.deepStrictEqual(res.data, { success: true }); + t.assert.deepStrictEqual>(res.data, { success: true }); }); await t.test("/query", async (t: TestContext) => { + type routeType = MatchingRoute; + const withoutQueryRes = await client.get("/query"); t.assert.equal(withoutQueryRes.status, 200); t.assert.equal(withoutQueryRes.url, `${server.info.uri}/query`); - t.assert.deepStrictEqual(withoutQueryRes.data, { flag: false }); + t.assert.deepStrictEqual>(withoutQueryRes.data, { flag: false }); - const query: RouteMap["GET"]["/query"]["query"] = { flag: true }; + const query: QueryType = { flag: true }; const withQueryRes = await client.get("/query", { query }); t.assert.equal(withQueryRes.status, 200); t.assert.equal(withQueryRes.url, `${server.info.uri}/query?flag=true`); - t.assert.deepStrictEqual(withQueryRes.data, { flag: true }); + t.assert.deepStrictEqual>(withQueryRes.data, { flag: true }); + }); + + await t.test("/param/{param}", async (t: TestContext) => { + type routeType = MatchingRoute; + + const passingRes = await client.get("/param/{param}", { params: { param: "pass" } }); + t.assert.equal(passingRes.status, 200); + t.assert.equal(passingRes.url, `${server.info.uri}/param/pass`); + t.assert.deepStrictEqual>(passingRes.data, { success: true }); + + const failingRes = await client.get("/param/{param}", { params: { param: "fail" } }); + t.assert.equal(failingRes.status, 400); + t.assert.equal(failingRes.url, `${server.info.uri}/param/fail`); + t.assert.deepStrictEqual>(failingRes.data, { success: false, message: "failed" }); }); }); diff --git a/test/fixture.ts b/test/fixture.ts index ff3084d..6edffe3 100644 --- a/test/fixture.ts +++ b/test/fixture.ts @@ -1,7 +1,7 @@ import Hapi, { type Request } from "@hapi/hapi"; import Joi from "joi"; import { Client as BaseClient } from "../lib/index.ts"; -import type { RouteMap } from "./generated.ts"; +import type { Routes } from "./generated.ts"; export const expectType = (_: T): void => void 0; export type TypeEqual = (() => T extends Target ? 1 : 2) extends () => T extends Value ? 1 : 2 ? true : false; @@ -14,7 +14,7 @@ server.route([ options: { response: { status: { - 200: Joi.object({ success: Joi.boolean().required() }), + 200: Joi.object({ success: Joi.boolean().required() }).required(), }, }, handler() { @@ -33,7 +33,7 @@ server.route([ }, response: { status: { - 200: Joi.object({ flag: Joi.boolean().required() }), + 200: Joi.object({ flag: Joi.boolean().required() }).required(), }, }, handler(request: Request<{ Query: { flag: boolean } }>) { @@ -41,8 +41,32 @@ server.route([ }, }, }, + { + method: "GET", + path: "/param/{param}", + options: { + validate: { + params: Joi.object({ + param: Joi.string().required(), + }).required(), + }, + response: { + status: { + 200: Joi.object({ success: Joi.boolean().valid(true) }).required(), + 400: Joi.object({ success: Joi.boolean().valid(false), message: Joi.string().required() }).required(), + }, + }, + handler(request: Request<{ Params: { param: string } }>, h) { + if (request.params.param === "pass") { + return { success: true }; + } + + return h.response({ success: false, message: "failed" }).code(400); + }, + }, + }, ]); // TODO: test the types in here somehow -export class Client extends BaseClient {}; +export class Client extends BaseClient {}; diff --git a/test/inject.ts b/test/inject.ts index 5725302..013d045 100644 --- a/test/inject.ts +++ b/test/inject.ts @@ -1,27 +1,46 @@ import { test, type TestContext } from "node:test"; import { server, Client } from "./fixture.ts"; -import type { RouteMap } from "./generated.ts"; +import type { MatchingRoute, QueryType, SpecificResultType } from "@code4rena/typed-client"; +import type { Routes } from "./generated.ts"; await test("GET - server.inject", async (t) => { const client = new Client({ server }); await t.test("/simple", async (t: TestContext) => { + type routeType = MatchingRoute; + const res = await client.get("/simple"); t.assert.equal(res.status, 200); t.assert.equal(res.url, "/simple"); - t.assert.deepStrictEqual(res.data, { success: true }); + t.assert.deepStrictEqual>(res.data, { success: true }); }); await t.test("/query", async (t: TestContext) => { + type routeType = MatchingRoute; + const withoutQueryRes = await client.get("/query"); t.assert.equal(withoutQueryRes.status, 200); t.assert.equal(withoutQueryRes.url, "/query"); - t.assert.deepStrictEqual(withoutQueryRes.data, { flag: false }); + t.assert.deepStrictEqual>(withoutQueryRes.data, { flag: false }); - const query: RouteMap["GET"]["/query"]["query"] = { flag: true }; + const query: QueryType = { flag: true }; const withQueryRes = await client.get("/query", { query }); t.assert.equal(withQueryRes.status, 200); t.assert.equal(withQueryRes.url, "/query?flag=true"); - t.assert.deepStrictEqual(withQueryRes.data, { flag: true }); + t.assert.deepStrictEqual>(withQueryRes.data, { flag: true }); + }); + + await t.test("/param/{param}", async (t: TestContext) => { + type routeType = MatchingRoute; + + const passingRes = await client.get("/param/{param}", { params: { param: "pass" } }); + t.assert.equal(passingRes.status, 200); + t.assert.equal(passingRes.url, "/param/pass"); + t.assert.deepStrictEqual>(passingRes.data, { success: true }); + + const failingRes = await client.get("/param/{param}", { params: { param: "fail" } }); + t.assert.equal(failingRes.status, 400); + t.assert.equal(failingRes.url, "/param/fail"); + t.assert.deepStrictEqual>(failingRes.data, { success: false, message: "failed" }); }); }); From ce694752206dbdb0eff70e41dc3e0bb7b4d8b370 Mon Sep 17 00:00:00 2001 From: Nathan LaFreniere Date: Tue, 20 May 2025 14:16:29 -0700 Subject: [PATCH 2/2] chore: move the type generation for tests to prelint --- package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index d5149c7..e8c791c 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,10 @@ ], "scripts": { "clean": "rm -f lib/*.js lib/*.d.ts lib/**/*.js lib/**/*.d.ts", - "pretest": "node --env-file=.env.test bin/generate-test-fixture.ts", "test": "node --env-file=.env.test --test", "posttest": "npm run lint", - "lint": "eslint .", - "prelint": "tsc", + "lint": "tsc && eslint .", + "prelint": "node --env-file=.env.test bin/generate-test-fixture.ts", "prepack": "tsc -p tsconfig.build.json", "postpublish": "npm run clean" },