diff --git a/package-lock.json b/package-lock.json index 4053513..c6a227d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9011,7 +9011,7 @@ }, "packages/express": { "name": "@basketry/express", - "version": "0.4.5", + "version": "0.5.0-alpha.0", "license": "MIT", "dependencies": { "@basketry/typescript": "^0.2.3", @@ -9032,12 +9032,28 @@ "url": "https://github.com/sponsors/basketry" } }, + "packages/express/node_modules/@basketry/typescript": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@basketry/typescript/-/typescript-0.2.4.tgz", + "integrity": "sha512-ZS7qRbs484P84rA7RI49iO6qAm88LIKH8Guag1e5cUdN7ol+rCcWl8cZAokTSiKWPUyCpYVnmhQ7u/JUkfkSaQ==", + "license": "MIT", + "dependencies": { + "basketry": "^0.2.1", + "case": "^1.6.3" + }, + "bin": { + "basketry-typescript": "lib/rpc.js" + }, + "funding": { + "url": "https://github.com/sponsors/basketry" + } + }, "packages/http-client": { "name": "@basketry/typescript-http-client", "version": "0.3.2", "license": "MIT", "dependencies": { - "@basketry/typescript": "^0.2.3", + "@basketry/typescript": "^0.3.0-alpha.0", "@basketry/typescript-dtos": "^0.2.2", "basketry": "^0.2.1", "case": "^1.6.3", @@ -9056,7 +9072,7 @@ }, "packages/typescript": { "name": "@basketry/typescript", - "version": "0.2.4", + "version": "0.3.0-alpha.0", "license": "MIT", "dependencies": { "basketry": "^0.2.1", @@ -9086,6 +9102,22 @@ "url": "https://github.com/sponsors/basketry" } }, + "packages/typescript-dtos/node_modules/@basketry/typescript": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@basketry/typescript/-/typescript-0.2.4.tgz", + "integrity": "sha512-ZS7qRbs484P84rA7RI49iO6qAm88LIKH8Guag1e5cUdN7ol+rCcWl8cZAokTSiKWPUyCpYVnmhQ7u/JUkfkSaQ==", + "license": "MIT", + "dependencies": { + "basketry": "^0.2.1", + "case": "^1.6.3" + }, + "bin": { + "basketry-typescript": "lib/rpc.js" + }, + "funding": { + "url": "https://github.com/sponsors/basketry" + } + }, "packages/zod": { "name": "@basketry/zod", "version": "0.2.9", @@ -9104,6 +9136,22 @@ "url": "https://github.com/sponsors/basketry" } }, + "packages/zod/node_modules/@basketry/typescript": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@basketry/typescript/-/typescript-0.2.4.tgz", + "integrity": "sha512-ZS7qRbs484P84rA7RI49iO6qAm88LIKH8Guag1e5cUdN7ol+rCcWl8cZAokTSiKWPUyCpYVnmhQ7u/JUkfkSaQ==", + "license": "MIT", + "dependencies": { + "basketry": "^0.2.1", + "case": "^1.6.3" + }, + "bin": { + "basketry-typescript": "lib/rpc.js" + }, + "funding": { + "url": "https://github.com/sponsors/basketry" + } + }, "utils/jest-utils": { "name": "@basketry/jest-utils", "version": "0.0.0", diff --git a/packages/express/package.json b/packages/express/package.json index 27c2627..0e0ff97 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@basketry/express", - "version": "0.4.5", + "version": "0.5.0-alpha.0", "description": "Basketry generator for ExpressJS routers and handlers", "main": "./lib/index.js", "bin": { diff --git a/packages/express/src/base-factory.ts b/packages/express/src/base-factory.ts index 2079d5a..7cf63ef 100644 --- a/packages/express/src/base-factory.ts +++ b/packages/express/src/base-factory.ts @@ -16,7 +16,7 @@ const schemasModule = 'schemas'; export abstract class BaseFactory { constructor( protected readonly service: Service, - protected readonly options: NamespacedExpressOptions, + protected readonly options?: NamespacedExpressOptions, ) {} abstract build(): Promise; @@ -50,7 +50,7 @@ export abstract class BaseFactory { private *buildTypesImport(): Iterable { if (this._needsTypesImport) { yield `import type * as ${typesModule} from "${ - this.options.express?.typesImportPath || '../types' + this.options?.express?.typesImportPath || '../types' }"`; } } @@ -63,7 +63,7 @@ export abstract class BaseFactory { private *buildValidatorsImport(): Iterable { if (this._needsValidatorsImport) { yield `import * as ${validatorsModule} from "${ - this.options.express?.validatorsImportPath || '../validators' + this.options?.express?.validatorsImportPath || '../validators' }"`; } } @@ -76,7 +76,7 @@ export abstract class BaseFactory { private *buildSchemasImport(): Iterable { if (this._needsSchemasImport) { yield `import * as ${schemasModule} from "${ - this.options.express?.schemasImportPath || '../schemas' + this.options?.express?.schemasImportPath || '../schemas' }"`; } } diff --git a/packages/express/src/builder.ts b/packages/express/src/builder.ts index 41eeaf2..e6e3f80 100644 --- a/packages/express/src/builder.ts +++ b/packages/express/src/builder.ts @@ -15,7 +15,7 @@ const openAPIVariableRegex = /\{(\w+)\}/g; export class Builder { constructor( private readonly service: Service, - private readonly options: NamespacedExpressOptions, + private readonly options?: NamespacedExpressOptions, ) {} buildAccessor( diff --git a/packages/express/src/errors-factory.ts b/packages/express/src/errors-factory.ts index 0fed1b0..63a0d44 100644 --- a/packages/express/src/errors-factory.ts +++ b/packages/express/src/errors-factory.ts @@ -25,7 +25,7 @@ export class ExpressErrorsFactory extends BaseFactory { private *buildErrors(): Iterable { const ErrorType = () => { - switch (this.options.express?.validation) { + switch (this.options?.express?.validation) { case 'zod': this.touchZodIssueImport(); return 'ZodIssue'; diff --git a/packages/express/src/handler-factory.ts b/packages/express/src/handler-factory.ts index 62f1350..22eb1b2 100644 --- a/packages/express/src/handler-factory.ts +++ b/packages/express/src/handler-factory.ts @@ -31,6 +31,15 @@ type Handler = { expression: Iterable; }; +// TODO: use the one from typescript name-factory once that' +function isStreamingMethod(httpMethod: HttpMethod | undefined): boolean { + if (!httpMethod) return false; + + return httpMethod.responseMediaTypes.some( + (mt) => mt.value === 'text/event-stream', + ); +} + export class ExpressHandlerFactory extends BaseFactory { constructor(service: Service, options: NamespacedExpressOptions) { super(service, options); @@ -110,6 +119,10 @@ export class ExpressHandlerFactory extends BaseFactory { const isEnvelope = returnType?.properties.find( (prop) => prop.name.value === 'errors', ); + + // Detect if this is a streaming response + const isStreaming = isStreamingMethod(httpMethod); + yield `/** ${upper(httpMethod.verb.value)} ${this.builder.buildExpressRoute( path, )} ${method.deprecated?.value ? '@deprecated ' : ''}*/`; @@ -124,9 +137,26 @@ export class ExpressHandlerFactory extends BaseFactory { )}): ${this.expressTypesModule}.${buildRequestHandlerTypeName( method.name.value, )} => async (req, res, next) => {`; + + // Add streaming setup if needed + if (isStreaming) { + yield ` // Set response headers for streaming`; + yield ` res.setHeader('Content-Type', 'text/event-stream');`; + yield ` res.setHeader('Cache-Control', 'no-cache');`; + yield ` res.setHeader('Connection', 'keep-alive');`; + yield ''; + yield ` const closeHandler = () => {`; + yield ` res.end();`; + yield ` };`; + yield ` `; + yield ` req.on('close', closeHandler);`; + yield ` req.on('finish', closeHandler);`; + yield ''; + } + yield ' try {'; if (hasParams) { - switch (this.options.express?.validation) { + switch (this.options?.express?.validation) { case 'zod': default: { const paramsRequired = method.parameters.some((p) => @@ -144,6 +174,93 @@ export class ExpressHandlerFactory extends BaseFactory { } yield ' // Execute service method'; yield ` const service = getService(req, res);`; + + if (isStreaming) { + yield* this.buildStreamingServiceCall(method, httpMethod, returnType, isEnvelope, hasParams); + } else { + yield* this.buildStandardServiceCall(method, httpMethod, returnType, isEnvelope, hasParams); + } + yield ' } catch (err) {'; + switch (this.options?.express?.validation) { + case 'zod': + default: { + this.touchZodErrorImport(); + yield `if (err instanceof ZodError) {`; + yield ` const statusCode = res.headersSent ? 500 : 400;`; + yield ` return next(${this.errorsModule}.validationErrors(statusCode, err.errors));`; + yield `} else {`; + yield ` next(${this.errorsModule}.unhandledException(err));`; + yield `}`; + break; + } + } + yield ' }'; + + // Add finally block for streaming cleanup + if (isStreaming) { + yield ' finally {'; + yield ` closeHandler();`; + yield ` // Ensure handlers are removed`; + yield ` req.off('close', closeHandler);`; + yield ` req.off('finish', closeHandler);`; + yield ' }'; + } + + yield '}'; + } + + private *buildStreamingServiceCall( + method: Method, + httpMethod: HttpMethod, + returnType: Type | undefined, + isEnvelope: any, + hasParams: boolean, + ): Iterable { + yield ''; + yield ` const stream = service.${buildMethodName(method)}(${ + hasParams ? 'params' : '' + });`; + if (returnType) { + yield ` for await (const event of stream) {`; + + // Handle validation based on responseValidation option + if (this.options?.express?.responseValidation === 'strict') { + yield* this.buildStreamingResponseValidationStanza(returnType); + } + + yield ` // Respond`; + yield ` const responseDto = ${ + this.mappersModule + }.${this.builder.buildMapperName( + returnType?.name.value, + 'output', + )}(event);`; + yield ` res.write(\`data: \${JSON.stringify(responseDto)}\\n\\n\`);`; + yield ` }`; + } + } + + private *buildStreamingResponseValidationStanza( + returnType: Type, + ): Iterable { + switch (this.options?.express?.validation) { + case 'zod': + default: { + yield ` // Validate response`; + yield `${this.schemasModule}.${buildTypeName(returnType)}ResponseSchema.parse(event);`; + break; + } + } + yield ''; + } + + private *buildStandardServiceCall( + method: Method, + httpMethod: HttpMethod, + returnType: Type | undefined, + isEnvelope: any, + hasParams: boolean, + ): Iterable { if (returnType) { yield ` const result = await service.${buildMethodName(method)}(${ hasParams ? 'params' : '' @@ -166,7 +283,7 @@ export class ExpressHandlerFactory extends BaseFactory { yield '} else {'; } if (returnType) { - switch (this.options.express?.responseValidation) { + switch (this.options?.express?.responseValidation) { case 'none': { // Only build respond stanza yield* this.buildRespondStanza(returnType); @@ -193,22 +310,6 @@ export class ExpressHandlerFactory extends BaseFactory { if (isEnvelope) { yield '}'; } - yield ' } catch (err) {'; - switch (this.options.express?.validation) { - case 'zod': - default: { - this.touchZodErrorImport(); - yield `if (err instanceof ZodError) {`; - yield ` const statusCode = res.headersSent ? 500 : 400;`; - yield ` return next(${this.errorsModule}.validationErrors(statusCode, err.errors));`; - yield `} else {`; - yield ` next(${this.errorsModule}.unhandledException(err));`; - yield `}`; - break; - } - } - yield ' }'; - yield '}'; } private *buildRespondStanza(returnType: Type): Iterable { @@ -224,7 +325,7 @@ export class ExpressHandlerFactory extends BaseFactory { } private *buildResponseValidationStanza(returnType: Type): Iterable { - switch (this.options.express?.validation) { + switch (this.options?.express?.validation) { case 'zod': default: { yield `// Validate response`; diff --git a/packages/express/src/snapshot/zod/v1/types.ts b/packages/express/src/snapshot/zod/v1/types.ts index 1aa8292..869e6e1 100644 --- a/packages/express/src/snapshot/zod/v1/types.ts +++ b/packages/express/src/snapshot/zod/v1/types.ts @@ -135,7 +135,6 @@ export type ExhaustiveParamsParams = { }; export type GetGizmosParams = { - /** @deprecated */ search?: string; }; diff --git a/packages/express/src/spec/4.1-structure/4.1.24-http-method.spec.ts b/packages/express/src/spec/4.1-structure/4.1.24-http-method.spec.ts new file mode 100644 index 0000000..3d36e2e --- /dev/null +++ b/packages/express/src/spec/4.1-structure/4.1.24-http-method.spec.ts @@ -0,0 +1,380 @@ +import { Factory } from '@basketry/jest-utils'; +import generator from '../..'; + +const factory = new Factory(); + +describe('4.1.24 HttpMethod', () => { + describe('application/json response', () => { + it('creates a method for a single response', async () => { + // ARRANGE + const service = factory.service({ + interfaces: [ + factory.interface({ + name: factory.stringLiteral('Widgets'), + methods: [ + factory.method({ + name: factory.stringLiteral('listWidgets'), + parameters: [ + factory.parameter({ + name: factory.stringLiteral('a'), + value: factory.primitiveValue({ + typeName: factory.primitiveLiteral('string'), + }), + }), + ], + returns: factory.returnValue({ + value: factory.complexValue({ + typeName: factory.stringLiteral('Widget'), + }), + }), + }), + ], + protocols: factory.protocols({ + http: [ + factory.httpRoute({ + pattern: factory.stringLiteral('/widgets'), + methods: [ + factory.httpMethod({ + name: factory.stringLiteral('listWidgets'), + verb: factory.httpVerbLiteral('get'), + responseMediaTypes: [ + factory.stringLiteral('application/json'), + ], + parameters: [ + factory.httpParameter({ + name: factory.stringLiteral('a'), + location: factory.httpLocationLiteral('query'), + }), + ], + }), + ], + }), + ], + }), + }), + ], + types: [ + factory.type({ + name: factory.stringLiteral('Widget'), + properties: [ + factory.property({ + name: factory.stringLiteral('id'), + value: factory.primitiveValue({ + typeName: factory.primitiveLiteral('string'), + }), + }), + ], + }), + ], + }); + + // ACT + const result = await generator(service); + + const handlers = result.find((file) => file.path.includes('handlers.ts')); + + // ASSERT + expect(handlers?.contents).toContainAst(` + /** GET /widgets */ + export const handleListWidgets = + ( + getService: (req: Request, res: Response) => types.WidgetsService, + ): expressTypes.ListWidgetsRequestHandler => + async (req, res, next) => { + try { + // Parse parameters from request + const params: types.ListWidgetsParams = + schemas.ListWidgetsParamsSchema.parse({ + a: req.query.a, + }); + + // Execute service method + const service = getService(req, res); + const result = await service.listWidgets(params); + const status = 200; + + // Respond + const responseDto = mappers.mapToWidgetDto(result); + res.status(status).json(responseDto); + + // Validate response + schemas.WidgetSchema.parse(result); + } catch (err) { + if (err instanceof ZodError) { + const statusCode = res.headersSent ? 500 : 400; + return next(errors.validationErrors(statusCode, err.errors)); + } else { + next(errors.unhandledException(err)); + } + } + }; + `); + }); + }); + describe('text/event-stream response', () => { + function createService() { + const service = factory.service({ + interfaces: [ + factory.interface({ + name: factory.stringLiteral('Widgets'), + methods: [ + factory.method({ + name: factory.stringLiteral('streamWidgets'), + parameters: [ + factory.parameter({ + name: factory.stringLiteral('a'), + value: factory.primitiveValue({ + typeName: factory.primitiveLiteral('string'), + }), + }), + ], + returns: factory.returnValue({ + value: factory.complexValue({ + typeName: factory.stringLiteral('Widget'), + }), + }), + }), + ], + protocols: factory.protocols({ + http: [ + factory.httpRoute({ + pattern: factory.stringLiteral('/widgets/stream'), + methods: [ + factory.httpMethod({ + name: factory.stringLiteral('streamWidgets'), + verb: factory.httpVerbLiteral('get'), + responseMediaTypes: [ + factory.stringLiteral('text/event-stream'), + ], + parameters: [ + factory.httpParameter({ + name: factory.stringLiteral('a'), + location: factory.httpLocationLiteral('query'), + }), + ], + }), + ], + }), + ], + }), + }), + ], + types: [ + factory.type({ + name: factory.stringLiteral('Widget'), + properties: [ + factory.property({ + name: factory.stringLiteral('id'), + value: factory.primitiveValue({ + typeName: factory.primitiveLiteral('string'), + }), + }), + ], + }), + ], + }); + return service; + }; + + it('creates a method for a streamed response with no validation', async () => { + // ARRANGE + const service = createService(); + + // ACT + const result = await generator(service, { + express: { + responseValidation: 'none', + }, + }); + + const handlers = result.find((file) => file.path.includes('handlers.ts')); + + // ASSERT + expect(handlers?.contents).toContainAst(` + /** GET /widgets/stream */ + export const handleStreamWidgets = + ( + getService: (req: Request, res: Response) => types.WidgetsService, + ): expressTypes.StreamWidgetsRequestHandler => + async (req, res, next) => { + // Set response headers for streaming + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + const closeHandler = () => { + res.end(); + }; + + req.on('close', closeHandler); + req.on('finish', closeHandler); + + try { + // Parse parameters from request + const params: types.StreamWidgetsParams = + schemas.StreamWidgetsParamsSchema.parse({ + a: req.query.a, + }); + + // Execute service method + const service = getService(req, res); + + const stream = service.streamWidgets(params); + for await (const event of stream) { + // Respond + const responseDto = mappers.mapToWidgetDto(event); + res.write(\`data: \${JSON.stringify(responseDto)}\\n\\n\`); + } + } catch (err) { + if (err instanceof ZodError) { + const statusCode = res.headersSent ? 500 : 400; + return next(errors.validationErrors(statusCode, err.errors)); + } else { + next(errors.unhandledException(err)); + } + } finally { + closeHandler(); + // Ensure handlers are removed + req.off('close', closeHandler); + req.off('finish', closeHandler); + } + }; + `); + }); + + it('creates a method for a streamed response with validation warning', async () => { + // ARRANGE + const service = createService(); + + // ACT + const result = await generator(service, { + express: { + responseValidation: 'warn', + }, + }); + + const handlers = result.find((file) => file.path.includes('handlers.ts')); + + // ASSERT + expect(handlers?.contents).toContainAst(` + /** GET /widgets/stream */ + export const handleStreamWidgets = + ( + getService: (req: Request, res: Response) => types.WidgetsService, + ): expressTypes.StreamWidgetsRequestHandler => + async (req, res, next) => { + // Set response headers for streaming + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + const closeHandler = () => { + res.end(); + }; + + req.on('close', closeHandler); + req.on('finish', closeHandler); + + try { + // Parse parameters from request + const params: types.StreamWidgetsParams = + schemas.StreamWidgetsParamsSchema.parse({ + a: req.query.a, + }); + + // Execute service method + const service = getService(req, res); + + const stream = service.streamWidgets(params); + for await (const event of stream) { + // Respond + const responseDto = mappers.mapToWidgetDto(event); + res.write(\`data: \${JSON.stringify(responseDto)}\\n\\n\`); + } + } catch (err) { + if (err instanceof ZodError) { + const statusCode = res.headersSent ? 500 : 400; + return next(errors.validationErrors(statusCode, err.errors)); + } else { + next(errors.unhandledException(err)); + } + } finally { + closeHandler(); + // Ensure handlers are removed + req.off('close', closeHandler); + req.off('finish', closeHandler); + } + }; + `); + }); + + it('creates a method for a streamed response with validation error', async () => { + // ARRANGE + const service = createService(); + + // ACT + const result = await generator(service, { + express: { + responseValidation: 'strict', + }, + }); + + const handlers = result.find((file) => file.path.includes('handlers.ts')); + + // ASSERT + expect(handlers?.contents).toContainAst(` + /** GET /widgets/stream */ + export const handleStreamWidgets = + ( + getService: (req: Request, res: Response) => types.WidgetsService, + ): expressTypes.StreamWidgetsRequestHandler => + async (req, res, next) => { + // Set response headers for streaming + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + const closeHandler = () => { + res.end(); + }; + + req.on('close', closeHandler); + req.on('finish', closeHandler); + + try { + // Parse parameters from request + const params: types.StreamWidgetsParams = + schemas.StreamWidgetsParamsSchema.parse({ + a: req.query.a, + }); + + // Execute service method + const service = getService(req, res); + + const stream = service.streamWidgets(params); + for await (const event of stream) { + // Validate response + schemas.WidgetResponseSchema.parse(event); + + // Respond + const responseDto = mappers.mapToWidgetDto(event); + res.write(\`data: \${JSON.stringify(responseDto)}\\n\\n\`); + } + } catch (err) { + if (err instanceof ZodError) { + const statusCode = res.headersSent ? 500 : 400; + return next(errors.validationErrors(statusCode, err.errors)); + } else { + next(errors.unhandledException(err)); + } + } finally { + closeHandler(); + // Ensure handlers are removed + req.off('close', closeHandler); + req.off('finish', closeHandler); + } + }; + `); + }); + }); +}); diff --git a/packages/http-client/package.json b/packages/http-client/package.json index a17c601..3db436e 100644 --- a/packages/http-client/package.json +++ b/packages/http-client/package.json @@ -1,7 +1,7 @@ { "name": "@basketry/typescript-http-client", "version": "0.3.2", - "description": "Basketry generator for generating Express JS routers", + "description": "Basketry generator for TypeScript HTTP API clients", "main": "./lib/index.js", "bin": { "basketry-typescript-http-client": "./lib/rpc.js" @@ -21,10 +21,10 @@ "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/basketry/typescript-http-client.git" + "url": "git+https://github.com/basketry/typescript.git" }, "bugs": { - "url": "https://github.com/basketry/typescript-http-client/issues" + "url": "https://github.com/basketry/typescript/issues" }, "homepage": "https://basketry.io/docs/components/@basketry/typescript-http-client", "funding": "https://github.com/sponsors/basketry", @@ -33,7 +33,7 @@ "zod": "^3.24.4" }, "dependencies": { - "@basketry/typescript": "^0.2.3", + "@basketry/typescript": "^0.3.0-alpha.0", "@basketry/typescript-dtos": "^0.2.2", "basketry": "^0.2.1", "case": "^1.6.3", diff --git a/packages/http-client/src/http-client-generator.ts b/packages/http-client/src/http-client-generator.ts index a955b1d..97f156d 100644 --- a/packages/http-client/src/http-client-generator.ts +++ b/packages/http-client/src/http-client-generator.ts @@ -23,6 +23,7 @@ import { buildMethodReturnValue, buildParameterName, buildTypeName, + isStreamingMethod, } from '@basketry/typescript'; import { buildHttpClientName } from './name-factory'; import { Builder as DtoBuilder } from '@basketry/typescript-dtos/lib/builder'; @@ -81,6 +82,16 @@ function* buildImports( service: Service, options?: NamespacedTypescriptHttpClientOptions, ): Iterable { + const httpMethods = service.interfaces + .flatMap((int) => int.protocols?.http ?? []) + .flatMap((p) => p.methods); + + const hasStreamingMethods = httpMethods.some(isStreamingMethod); + + if (hasStreamingMethods) { + yield `import { events } from 'fetch-event-stream';`; + } + if (options?.httpClient?.validation === 'zod') { yield `import * as z from 'zod';`; } @@ -148,11 +159,11 @@ function* buildStandardTypes( yield `}`; yield ``; yield `export interface FetchLike {`; - yield `(resource: string, init?: {`; + yield `(resource: string, init?: {`; if (methods.size) yield ` method?: ${Array.from(methods).join(' | ')},`; yield ` headers?: Record,`; yield ` body?: string,`; - yield `}): Promise<{ json(): Promise, status: number }>;`; + yield `}): Promise;`; yield `}`; if (includeFormatDate || includeFormatDateTime) { yield ''; @@ -186,27 +197,6 @@ function* buildStandardTypes( } } -// function* buildAuth( -// int: Interface, -// options: NamespacedTypescriptHttpClientOptions, -// ): Iterable { -// const schemes = getSecuritySchemes(int); - -// if (schemes.length && options?.httpClient?.includeAuthSchemes) { -// yield 'private readonly auth: {'; -// for (const scheme of schemes) { -// if (isApiKeyScheme(scheme)) { -// yield `'${scheme.name.value}'?: {key: string}`; -// } else if (isBasicScheme(scheme)) { -// yield `'${scheme.name.value}'?: {username: string, password: string}`; -// } else if (isOAuth2Scheme(scheme)) { -// yield `'${scheme.name.value}'?: {accessToken: string}`; -// } -// } -// yield '},'; -// } -// } - function* buildClasses( service: Service, options: NamespacedTypescriptHttpClientOptions, @@ -310,19 +300,6 @@ function sep(httpParam: HttpParameter): string { } } -// function getSecuritySchemes(int: Interface): SecurityScheme[] { -// return Array.from( -// int.methods -// .flatMap((m) => m.security) -// .flatMap((opt) => opt) -// .reduce( -// (map, scheme) => map.set(scheme.name.value, scheme), -// new Map(), -// ) -// .values(), -// ); -// } - class MethodFactory { private constructor( private readonly service: Service, @@ -363,14 +340,24 @@ class MethodFactory { private *buildMethod( options: NamespacedTypescriptHttpClientOptions, ): Iterable { + const isStreaming = isStreamingMethod(this.httpMethod); yield* buildDescription( this.method.description, this.method.deprecated?.value, ); - yield `async ${buildMethodName(this.method)}(`; + yield `async ${ + isStreamingMethod(this.httpMethod) ? '*' : '' + }${buildMethodName(this.method)}(`; yield* buildMethodParams(this.method, 'types'); - yield `): ${buildMethodReturnValue(this.method, 'types')} {`; - yield ` try {`; + yield `): ${buildMethodReturnValue( + this.method, + this.httpMethod, + 'types', + )} {`; + + if (!isStreaming) { + yield ` try {`; + } if (this.method.parameters.length) { switch (options?.httpClient?.validation) { @@ -398,10 +385,12 @@ class MethodFactory { yield* this.buildBody(); yield ''; yield* this.buildFetch(); - yield ' } catch (unhandledException) {'; - yield ' console.error(unhandledException);'; - yield ' return { errors: this.mapErrors([], unhandledException) } as any;'; - yield ' }'; + if (!isStreaming) { + yield ' } catch (unhandledException) {'; + yield ' console.error(unhandledException);'; + yield ' return { errors: this.mapErrors([], unhandledException) } as any;'; + yield ' }'; + } yield '}'; } @@ -441,35 +430,6 @@ class MethodFactory { yield '}'; } } - - // if (options?.httpClient?.includeAuthSchemes) { - // for (const scheme of this.schemes) { - // if (isApiKeyScheme(scheme)) { - // if (scheme.in.value === 'header') { - // yield `if(this.auth${safe(scheme.name.value)}) {`; - // yield ` headers${safe(scheme.parameter.value)} = this.auth${safe( - // scheme.name.value, - // )}.key`; - // yield '}'; - // } - // } else if (isBasicScheme(scheme)) { - // yield `if(this.auth${safe(scheme.name.value)}) {`; - // yield `// TODO: remove deprecated method for node targets`; - // yield ` headers.authorization = \`Basic $\{ btoa(\`$\{this.auth${safe( - // scheme.name.value, - // )}.username\}:$\{this.auth${safe( - // scheme.name.value, - // )}.password\}\`) \}\``; - // yield '}'; - // } else if (isOAuth2Scheme(scheme)) { - // yield `if(this.auth${safe(scheme.name.value)}) {`; - // yield ` headers.authorization = \`Bearer $\{ this.auth${safe( - // scheme.name.value, - // )}.accessToken \}\``; - // yield '}'; - // } - // } - // } } private *buildQuery(): Iterable { @@ -525,20 +485,6 @@ class MethodFactory { yield '}'; } - - // if (this.options?.httpClient?.includeAuthSchemes) { - // for (const scheme of this.schemes) { - // if (isApiKeyScheme(scheme)) { - // if (scheme.in.value === 'query') { - // yield `if(this.auth${safe(scheme.name.value)}) {`; - // yield ` query.push(\`${scheme.parameter.value}=$\{this.auth${safe( - // scheme.name.value, - // )}.key\}\`);`; - // yield '}'; - // } - // } - // } - // } } private *buildPath(): Iterable { @@ -592,12 +538,7 @@ class MethodFactory { } private *buildFetch(): Iterable { - const returnType = this.method.returns - ? `` - : ''; - - if (this.method.returns) - yield `const res = await this.fetch${returnType}(path`; + if (this.method.returns) yield `const res = await this.fetch(path`; else { yield `await this.fetch(path`; } @@ -625,14 +566,23 @@ class MethodFactory { 'client-outbound', ); - if (this.options?.httpClient?.validation === 'zod') { - yield `return mappers.${mapperName}(await res.json());`; + if (isStreamingMethod(this.httpMethod)) { + yield `if (res.ok) { + let stream = events(res); + for await (let event of stream) { + yield mappers.${mapperName}(JSON.stringify(event.data ?? '') as any); + } + }`; } else { - const sanitizerName = camel( - `sanitize_${snake(responseTypeName.name.value)}`, - ); + if (this.options?.httpClient?.validation === 'zod') { + yield `return mappers.${mapperName}((await res.json()) as any);`; + } else { + const sanitizerName = camel( + `sanitize_${snake(responseTypeName.name.value)}`, + ); - yield `return sanitizers.${sanitizerName}(mappers.${mapperName}(await res.json()));`; + yield `return sanitizers.${sanitizerName}(mappers.${mapperName}((await res.json()) as any));`; + } } } } diff --git a/packages/http-client/src/snapshot/zod/v1/http-client.ts b/packages/http-client/src/snapshot/zod/v1/http-client.ts index 1bf563b..277e6a6 100644 --- a/packages/http-client/src/snapshot/zod/v1/http-client.ts +++ b/packages/http-client/src/snapshot/zod/v1/http-client.ts @@ -31,14 +31,14 @@ export interface BasketryExampleOptions { } export interface FetchLike { - ( + ( resource: string, init?: { method?: 'DELETE' | 'POST' | 'PUT'; headers?: Record; body?: string; }, - ): Promise<{ json(): Promise; status: number }>; + ): Promise; } function lpad(n: number, len: number): string { @@ -110,11 +110,11 @@ export class HttpGizmoService implements types.GizmoService { const path = [`${prefix}/gizmos`, query.join('&')].join('?'); - const res = await this.fetch(path, { + const res = await this.fetch(path, { headers, }); - return mappers.mapFromGizmosResponseDto(await res.json()); + return mappers.mapFromGizmosResponseDto((await res.json()) as any); } catch (unhandledException) { console.error(unhandledException); return { errors: this.mapErrors([], unhandledException) } as any; @@ -158,12 +158,12 @@ export class HttpGizmoService implements types.GizmoService { const path = [`${prefix}/gizmos`, query.join('&')].join('?'); - const res = await this.fetch(path, { + const res = await this.fetch(path, { method: 'POST', headers, }); - return mappers.mapFromGizmoDto(await res.json()); + return mappers.mapFromGizmoDto((await res.json()) as any); } catch (unhandledException) { console.error(unhandledException); return { errors: this.mapErrors([], unhandledException) } as any; @@ -204,12 +204,12 @@ export class HttpGizmoService implements types.GizmoService { const path = [`${prefix}/gizmos`, query.join('&')].join('?'); - const res = await this.fetch(path, { + const res = await this.fetch(path, { method: 'PUT', headers, }); - return mappers.mapFromGizmoDto(await res.json()); + return mappers.mapFromGizmoDto((await res.json()) as any); } catch (unhandledException) { console.error(unhandledException); return { errors: this.mapErrors([], unhandledException) } as any; @@ -272,11 +272,11 @@ export class HttpWidgetService implements types.WidgetService { const path = [`${prefix}/widgets`, query.join('&')].join('?'); - const res = await this.fetch(path, { + const res = await this.fetch(path, { headers, }); - return mappers.mapFromWidgetDto(await res.json()); + return mappers.mapFromWidgetDto((await res.json()) as any); } catch (unhandledException) { console.error(unhandledException); return { errors: this.mapErrors([], unhandledException) } as any; @@ -391,11 +391,11 @@ export class HttpWidgetService implements types.WidgetService { query.join('&'), ].join('?'); - const res = await this.fetch(path, { + const res = await this.fetch(path, { headers, }); - return mappers.mapFromWidgetDto(await res.json()); + return mappers.mapFromWidgetDto((await res.json()) as any); } catch (unhandledException) { console.error(unhandledException); return { errors: this.mapErrors([], unhandledException) } as any; @@ -858,11 +858,11 @@ export class HttpMapDemoService implements types.MapDemoService { const path = [`${prefix}/mapDemo`, query.join('&')].join('?'); - const res = await this.fetch(path, { + const res = await this.fetch(path, { headers, }); - return mappers.mapFromAllMapsDto(await res.json()); + return mappers.mapFromAllMapsDto((await res.json()) as any); } catch (unhandledException) { console.error(unhandledException); return { errors: this.mapErrors([], unhandledException) } as any; diff --git a/packages/typescript-dtos/src/snapshot/client/v1/types.ts b/packages/typescript-dtos/src/snapshot/client/v1/types.ts index 3b9e71f..21d0965 100644 --- a/packages/typescript-dtos/src/snapshot/client/v1/types.ts +++ b/packages/typescript-dtos/src/snapshot/client/v1/types.ts @@ -245,10 +245,6 @@ export type WidgetFoo = { export type Animal = Cat | Dog; -export type SomeTypeNestedUnion = PartA | PartB; - -export type TopLevelUnion = PartA | PartB; - export function isCat(obj: Animal): obj is Cat { return obj.type === 'cat'; } @@ -256,3 +252,7 @@ export function isCat(obj: Animal): obj is Cat { export function isDog(obj: Animal): obj is Dog { return obj.type === 'dog'; } + +export type SomeTypeNestedUnion = PartA | PartB; + +export type TopLevelUnion = PartA | PartB; diff --git a/packages/typescript-dtos/src/snapshot/server/v1/types.ts b/packages/typescript-dtos/src/snapshot/server/v1/types.ts index 3b9e71f..21d0965 100644 --- a/packages/typescript-dtos/src/snapshot/server/v1/types.ts +++ b/packages/typescript-dtos/src/snapshot/server/v1/types.ts @@ -245,10 +245,6 @@ export type WidgetFoo = { export type Animal = Cat | Dog; -export type SomeTypeNestedUnion = PartA | PartB; - -export type TopLevelUnion = PartA | PartB; - export function isCat(obj: Animal): obj is Cat { return obj.type === 'cat'; } @@ -256,3 +252,7 @@ export function isCat(obj: Animal): obj is Cat { export function isDog(obj: Animal): obj is Dog { return obj.type === 'dog'; } + +export type SomeTypeNestedUnion = PartA | PartB; + +export type TopLevelUnion = PartA | PartB; diff --git a/packages/typescript/package.json b/packages/typescript/package.json index 472e134..e977331 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@basketry/typescript", - "version": "0.2.4", + "version": "0.3.0-alpha.0", "description": "Basketry generator for generating Typescript interfaces", "main": "./lib/index.js", "bin": { diff --git a/packages/typescript/src/interface-factory.ts b/packages/typescript/src/interface-factory.ts index 1522580..971b370 100644 --- a/packages/typescript/src/interface-factory.ts +++ b/packages/typescript/src/interface-factory.ts @@ -3,6 +3,7 @@ import { Enum, Generator, getTypeByName, + HttpMethod, Interface, isRequired, MemberValue, @@ -212,17 +213,23 @@ function* buildInterface( for (const method of int.methods.sort((a, b) => a.name.value.localeCompare(b.name.value), )) { - yield* buildMethod(method); + const httpMethod = int.protocols?.http + ?.flatMap((route) => route.methods) + .find((m) => m.name.value === method.name.value); + yield* buildMethod(method, httpMethod); yield ''; } yield `}`; } -function* buildMethod(method: Method): Iterable { +function* buildMethod( + method: Method, + httpMethod: HttpMethod | undefined, +): Iterable { yield* buildDescription(method.description, method.deprecated?.value); yield `${buildMethodName(method)}(`; yield* buildMethodParams(method); - yield `): ${buildMethodReturnValue(method)};`; + yield `): ${buildMethodReturnValue(method, httpMethod)};`; } function* buildType(type: Type): Iterable { diff --git a/packages/typescript/src/name-factory.ts b/packages/typescript/src/name-factory.ts index 789823e..6795656 100644 --- a/packages/typescript/src/name-factory.ts +++ b/packages/typescript/src/name-factory.ts @@ -2,6 +2,7 @@ import { pascal, camel } from 'case'; import { Enum, + HttpMethod, HttpParameter, Interface, MemberValue, @@ -186,11 +187,13 @@ export function buildRootTypeName( export function buildMethodReturnValue( method: Method, + httpMethod: HttpMethod | undefined, typeModule?: string, ): string { - return `Promise<${ - method.returns ? buildTypeName(method.returns.value, typeModule) : 'void' - }>`; + const wrapperType = isStreamingMethod(httpMethod) + ? 'AsyncIterable' + : 'Promise'; + return `${wrapperType}<${method.returns ? buildTypeName(method.returns.value, typeModule) : 'void'}>`; } function isUnion(type: Type | MemberValue | Enum | Union): type is Union { @@ -212,3 +215,11 @@ export function buildEnumName(e: Enum): string { export function buildUnionName(union: Union): string { return pascal(union.name.value); } + +export function isStreamingMethod(httpMethod: HttpMethod | undefined): boolean { + if (!httpMethod) return false; + + return httpMethod.responseMediaTypes.some( + (mt) => mt.value === 'text/event-stream', + ); +} diff --git a/packages/typescript/src/spec/4.1-structure/4.1.24-http-method.spec.ts b/packages/typescript/src/spec/4.1-structure/4.1.24-http-method.spec.ts new file mode 100644 index 0000000..31221a3 --- /dev/null +++ b/packages/typescript/src/spec/4.1-structure/4.1.24-http-method.spec.ts @@ -0,0 +1,114 @@ +import { Factory } from '@basketry/jest-utils'; +import generator from '../..'; + +const factory = new Factory(); + +describe('4.1.24 HttpMethod', () => { + describe('responseMediaTypes', () => { + it('creates a method for a single response', async () => { + // ARRANGE + const service = factory.service({ + interfaces: [ + factory.interface({ + name: factory.stringLiteral('Widgets'), + methods: [ + factory.method({ + name: factory.stringLiteral('listWidgets'), + parameters: [ + factory.parameter({ + name: factory.stringLiteral('a'), + value: factory.primitiveValue({ + typeName: factory.primitiveLiteral('string'), + }), + }), + ], + returns: factory.returnValue({ + value: factory.primitiveValue({ + typeName: factory.primitiveLiteral('number'), + }), + }), + }), + ], + }), + ], + }); + + // ACT + const result = await generator(service); + + // ASSERT + expect(result[0].contents).toContainAst(` + export interface WidgetsService { + listWidgets(params: ListWidgetsParams): Promise; + } + `); + + expect(result[0].contents).toContainAst(` + export type ListWidgetsParams { a: string; } + `); + }); + + it('creates a method for a streamed response', async () => { + // ARRANGE + const service = factory.service({ + interfaces: [ + factory.interface({ + name: factory.stringLiteral('Widgets'), + methods: [ + factory.method({ + name: factory.stringLiteral('streamWidgets'), + parameters: [ + factory.parameter({ + name: factory.stringLiteral('a'), + value: factory.primitiveValue({ + typeName: factory.primitiveLiteral('string'), + }), + }), + ], + returns: factory.returnValue({ + value: factory.primitiveValue({ + typeName: factory.primitiveLiteral('number'), + }), + }), + }), + ], + protocols: factory.protocols({ + http: [ + factory.httpRoute({ + methods: [ + factory.httpMethod({ + name: factory.stringLiteral('streamWidgets'), + responseMediaTypes: [ + factory.stringLiteral('text/event-stream'), + ], + parameters: [ + factory.httpParameter({ + name: factory.stringLiteral('a'), + location: factory.httpLocationLiteral('query'), + }), + ], + }), + ], + }), + ], + }), + }), + ], + }); + + // ACT + const result = await generator(service); + + // ASSERT + expect(result[0].contents).toContainAst(` + export interface WidgetsService { + streamWidgets(params: StreamWidgetsParams): AsyncIterable; + } + `); + + expect(result[0].contents).toContainAst(` + export type StreamWidgetsParams { a: string; } + `); + }); + }); +});