From 41c45dfd1fc3167d31534ed1456d785993f65532 Mon Sep 17 00:00:00 2001 From: Steve Konves Date: Sat, 1 Nov 2025 13:16:08 -0700 Subject: [PATCH 01/12] Only print first JSDoc comment --- utils/jest-utils/src/index.ts | 38 ++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/utils/jest-utils/src/index.ts b/utils/jest-utils/src/index.ts index 09e20dc..5bab5ac 100644 --- a/utils/jest-utils/src/index.ts +++ b/utils/jest-utils/src/index.ts @@ -1,5 +1,4 @@ import * as ts from 'typescript'; -import type { Options } from 'prettier'; import { format } from '@prettier/sync'; export { Factory } from './factory'; @@ -148,6 +147,43 @@ function parse(code: string, fileName = 'virtual.ts'): ts.SourceFile { } function printNode(sf: ts.SourceFile, node: ts.Node): string { + // Drop existing leading comments then re-attach the nearest JSDoc (if any) + ts.setEmitFlags(node, ts.EmitFlags.NoLeadingComments); + + const jsdocs = ts.getJSDocCommentsAndTags(node); + if (jsdocs && jsdocs.length) { + const last = jsdocs[jsdocs.length - 1]; + // Grab the raw JSDoc text from the source file + const raw = sf.text.slice(last.getFullStart(), last.end); + + // Strip the /** ... */ delimiters and any leading '*' on lines + const inner = raw + .replace(/^\/\*\*\s?/, '') + .replace(/\*\/\s*$/, '') + .split(/\r?\n/) + .map((ln) => ln.replace(/^\s*\*? ?/, '')) + .join('\n') + .trim(); + + if (inner) { + // Build a JSDoc-style multi-line comment body. Passing a leading '*' makes + // addSyntheticLeadingComment emit a /** ... */ comment. + const commentBody = inner + ? `*\n${inner + .split(/\r?\n/) + .map((l) => ` * ${l}`) + .join('\n')}\n ` + : '*'; + + ts.addSyntheticLeadingComment( + node, + ts.SyntaxKind.MultiLineCommentTrivia, + commentBody, + /* hasTrailingNewLine */ true, + ); + } + } + return f(printer.printNode(ts.EmitHint.Unspecified, node, sf).trim()); } From 3a73d6b17b8c5ac4467dd912e5e390dea112d950 Mon Sep 17 00:00:00 2001 From: Steve Konves Date: Sat, 1 Nov 2025 15:05:40 -0700 Subject: [PATCH 02/12] Fix bug wherein first declaration can use header as its JSDoc --- packages/typescript/src/interface-factory.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/typescript/src/interface-factory.ts b/packages/typescript/src/interface-factory.ts index 23aab00..b1e08e8 100644 --- a/packages/typescript/src/interface-factory.ts +++ b/packages/typescript/src/interface-factory.ts @@ -61,9 +61,21 @@ export const generateTypes: Generator = async ( const ignore = from(eslintDisable(options)); + // Prevents the first declaration from using the header as a JSDoc comment + // if it doesn't already have one. + const interstitial = ( + (interfaces || params || enums || types || unions) ?? + '' + ) + .trim() + .startsWith('/') + ? '' + : '/** */'; + const contents = [ header, ignore, + interstitial, interfaces, params, enums, From 4616588bccc6f2255c06a05ceaeceef80f811465 Mon Sep 17 00:00:00 2001 From: Steve Konves Date: Sat, 1 Nov 2025 15:06:49 -0700 Subject: [PATCH 03/12] Emit AsyncIterator for streamed methods --- packages/typescript/src/interface-factory.ts | 13 +- packages/typescript/src/name-factory.ts | 17 ++- .../4.1-structure/4.1.24-http-method.spec.ts | 114 ++++++++++++++++++ 3 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 packages/typescript/src/spec/4.1-structure/4.1.24-http-method.spec.ts diff --git a/packages/typescript/src/interface-factory.ts b/packages/typescript/src/interface-factory.ts index b1e08e8..a7d9a90 100644 --- a/packages/typescript/src/interface-factory.ts +++ b/packages/typescript/src/interface-factory.ts @@ -2,6 +2,7 @@ import { Enum, Generator, getTypeByName, + HttpMethod, Interface, isRequired, Method, @@ -163,17 +164,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; } + `); + }); + }); +}); From 7dc2ffb70a29f0ddd51622eb3c9c4341c6aa9e7c Mon Sep 17 00:00:00 2001 From: Steve Konves Date: Sat, 1 Nov 2025 17:35:58 -0700 Subject: [PATCH 04/12] Handle undefined options --- packages/express/src/base-factory.ts | 8 ++++---- packages/express/src/builder.ts | 2 +- packages/express/src/errors-factory.ts | 2 +- packages/express/src/handler-factory.ts | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) 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..d82deb6 100644 --- a/packages/express/src/handler-factory.ts +++ b/packages/express/src/handler-factory.ts @@ -126,7 +126,7 @@ export class ExpressHandlerFactory extends BaseFactory { )} => async (req, res, next) => {`; yield ' try {'; if (hasParams) { - switch (this.options.express?.validation) { + switch (this.options?.express?.validation) { case 'zod': default: { const paramsRequired = method.parameters.some((p) => @@ -166,7 +166,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); @@ -194,7 +194,7 @@ export class ExpressHandlerFactory extends BaseFactory { yield '}'; } yield ' } catch (err) {'; - switch (this.options.express?.validation) { + switch (this.options?.express?.validation) { case 'zod': default: { this.touchZodErrorImport(); @@ -224,7 +224,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`; From deabc42ffb9d4a6f066bc321c5c28448ce779ad8 Mon Sep 17 00:00:00 2001 From: Steve Konves Date: Sat, 1 Nov 2025 17:36:49 -0700 Subject: [PATCH 05/12] Stub out handler tests --- .../4.1-structure/4.1.24-http-method.spec.ts | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 packages/express/src/spec/4.1-structure/4.1.24-http-method.spec.ts 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..b130da2 --- /dev/null +++ b/packages/express/src/spec/4.1-structure/4.1.24-http-method.spec.ts @@ -0,0 +1,188 @@ +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'), + }), + }), + }), + ], + 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'), + }), + ], + }), + ], + }), + ], + }), + }), + ], + }); + + // 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); + await service.listWidgets(params); + const status = 200; + + // Respond + res.sendStatus(status); + } catch (err) { + if (err instanceof ZodError) { + const statusCode = res.headersSent ? 500 : 400; + return next(errors.validationErrors(statusCode, err.errors)); + } else { + next(errors.unhandledException(err)); + } + } + }; + `); + }); + + 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({ + 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'), + }), + ], + }), + ], + }), + ], + }), + }), + ], + }); + + // ACT + const result = await generator(service); + + const handlers = result.find((file) => file.path.includes('handlers.ts')); + + console.log(handlers?.contents); + + // ASSERT + expect(handlers?.contents).toContainAst(` + /** GET /widgets/stream */ + export const handleStreamWidgets = + ( + getService: (req: Request, res: Response) => types.WidgetsService, + ): expressTypes.StreamWidgetsRequestHandler => + async (req, res, next) => { + try { + // Parse parameters from request + const params: types.StreamWidgetsParams = + schemas.StreamWidgetsParamsSchema.parse({ + a: req.query.a, + }); + + // Execute service method + const service = getService(req, res); + await service.streamWidgets(params); + const status = 200; + + // Respond + res.sendStatus(status); + } catch (err) { + if (err instanceof ZodError) { + const statusCode = res.headersSent ? 500 : 400; + return next(errors.validationErrors(statusCode, err.errors)); + } else { + next(errors.unhandledException(err)); + } + } + }; + `); + }); + }); +}); From 15076e85db8f14b9eb08d1988840c3926c582eb9 Mon Sep 17 00:00:00 2001 From: Istvan Fedak Date: Sat, 1 Nov 2025 21:15:56 -0400 Subject: [PATCH 06/12] Added stream code sample --- .../4.1-structure/4.1.24-http-method.spec.ts | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) 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 index b130da2..ac51064 100644 --- 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 @@ -77,11 +77,19 @@ describe('4.1.24 HttpMethod', () => { // Execute service method const service = getService(req, res); - await service.listWidgets(params); - const status = 200; - - // Respond - res.sendStatus(status); + const result = await service.listWidgets(params); + const status = getHttpStatus(200, result); + + if(result.errors.length) { + next(errors.handleException(status, result.errors)); + } else { + // Respond + const responseDto = mappers.mapToListWidgetsResponseDto(result); + res.status(status).json(responseDto); + + // Validate response + schemas.WidgetResponseSchema.parse(result); + } } catch (err) { if (err instanceof ZodError) { const statusCode = res.headersSent ? 500 : 400; @@ -159,6 +167,18 @@ describe('4.1.24 HttpMethod', () => { 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 = @@ -168,18 +188,24 @@ describe('4.1.24 HttpMethod', () => { // Execute service method const service = getService(req, res); - await service.streamWidgets(params); - const status = 200; - - // Respond - res.sendStatus(status); + + const stream = await service.streamWidgets(params); + for await (const event of stream) { + res.write(\`data: \${JSON.stringify(event)}\\n\\n\`); + } + closeHandler(); } catch (err) { + closeHandler(); if (err instanceof ZodError) { const statusCode = res.headersSent ? 500 : 400; return next(errors.validationErrors(statusCode, err.errors)); } else { next(errors.unhandledException(err)); } + } finally { + // Ensure handlers are removed + req.off('close', closeHandler); + req.off('finish', closeHandler); } }; `); From cb51117851e292ac5a551258179ff9e3abc2dade Mon Sep 17 00:00:00 2001 From: Istvan Fedak Date: Sun, 2 Nov 2025 16:53:53 -0500 Subject: [PATCH 07/12] Initial implementation of streaming responses --- packages/express/src/handler-factory.ts | 125 +++++++++-- .../4.1-structure/4.1.24-http-method.spec.ts | 212 ++++++++++++++++-- 2 files changed, 298 insertions(+), 39 deletions(-) diff --git a/packages/express/src/handler-factory.ts b/packages/express/src/handler-factory.ts index d82deb6..e534c94 100644 --- a/packages/express/src/handler-factory.ts +++ b/packages/express/src/handler-factory.ts @@ -20,6 +20,7 @@ import { buildMethodParamsTypeName, buildParameterName, buildTypeName, + isStreamingMethod, } from '@basketry/typescript'; import { buildRequestHandlerTypeName } from '@basketry/typescript-dtos/lib/dto-factory'; import { BaseFactory } from './base-factory'; @@ -110,6 +111,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,6 +129,23 @@ 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) { @@ -144,6 +166,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' : '' @@ -193,22 +302,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 { 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 index ac51064..3d36e2e 100644 --- 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 @@ -4,7 +4,7 @@ import generator from '../..'; const factory = new Factory(); describe('4.1.24 HttpMethod', () => { - describe('responseMediaTypes', () => { + describe('application/json response', () => { it('creates a method for a single response', async () => { // ARRANGE const service = factory.service({ @@ -23,8 +23,8 @@ describe('4.1.24 HttpMethod', () => { }), ], returns: factory.returnValue({ - value: factory.primitiveValue({ - typeName: factory.primitiveLiteral('number'), + value: factory.complexValue({ + typeName: factory.stringLiteral('Widget'), }), }), }), @@ -53,6 +53,19 @@ describe('4.1.24 HttpMethod', () => { }), }), ], + types: [ + factory.type({ + name: factory.stringLiteral('Widget'), + properties: [ + factory.property({ + name: factory.stringLiteral('id'), + value: factory.primitiveValue({ + typeName: factory.primitiveLiteral('string'), + }), + }), + ], + }), + ], }); // ACT @@ -78,18 +91,14 @@ describe('4.1.24 HttpMethod', () => { // Execute service method const service = getService(req, res); const result = await service.listWidgets(params); - const status = getHttpStatus(200, result); + const status = 200; - if(result.errors.length) { - next(errors.handleException(status, result.errors)); - } else { - // Respond - const responseDto = mappers.mapToListWidgetsResponseDto(result); - res.status(status).json(responseDto); + // Respond + const responseDto = mappers.mapToWidgetDto(result); + res.status(status).json(responseDto); - // Validate response - schemas.WidgetResponseSchema.parse(result); - } + // Validate response + schemas.WidgetSchema.parse(result); } catch (err) { if (err instanceof ZodError) { const statusCode = res.headersSent ? 500 : 400; @@ -101,9 +110,9 @@ describe('4.1.24 HttpMethod', () => { }; `); }); - - it('creates a method for a streamed response', async () => { - // ARRANGE + }); + describe('text/event-stream response', () => { + function createService() { const service = factory.service({ interfaces: [ factory.interface({ @@ -120,8 +129,8 @@ describe('4.1.24 HttpMethod', () => { }), ], returns: factory.returnValue({ - value: factory.primitiveValue({ - typeName: factory.primitiveLiteral('number'), + value: factory.complexValue({ + typeName: factory.stringLiteral('Widget'), }), }), }), @@ -150,15 +159,36 @@ describe('4.1.24 HttpMethod', () => { }), }), ], + 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); + const result = await generator(service, { + express: { + responseValidation: 'none', + }, + }); const handlers = result.find((file) => file.path.includes('handlers.ts')); - console.log(handlers?.contents); - // ASSERT expect(handlers?.contents).toContainAst(` /** GET /widgets/stream */ @@ -189,13 +219,148 @@ describe('4.1.24 HttpMethod', () => { // Execute service method const service = getService(req, res); - const stream = await service.streamWidgets(params); + const stream = service.streamWidgets(params); for await (const event of stream) { - res.write(\`data: \${JSON.stringify(event)}\\n\\n\`); + // 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)); @@ -203,6 +368,7 @@ describe('4.1.24 HttpMethod', () => { next(errors.unhandledException(err)); } } finally { + closeHandler(); // Ensure handlers are removed req.off('close', closeHandler); req.off('finish', closeHandler); From 34f53e5e3531a843027ba67644c21aa12fbd45b0 Mon Sep 17 00:00:00 2001 From: Steve Konves Date: Mon, 3 Nov 2025 08:59:11 -0700 Subject: [PATCH 08/12] Temporarily duplicate method --- packages/express/src/handler-factory.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/express/src/handler-factory.ts b/packages/express/src/handler-factory.ts index e534c94..22eb1b2 100644 --- a/packages/express/src/handler-factory.ts +++ b/packages/express/src/handler-factory.ts @@ -20,7 +20,6 @@ import { buildMethodParamsTypeName, buildParameterName, buildTypeName, - isStreamingMethod, } from '@basketry/typescript'; import { buildRequestHandlerTypeName } from '@basketry/typescript-dtos/lib/dto-factory'; import { BaseFactory } from './base-factory'; @@ -32,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); From 1d0842aef94099de71bd051f363e0f477daf33ea Mon Sep 17 00:00:00 2001 From: skonves Date: Mon, 3 Nov 2025 16:02:46 +0000 Subject: [PATCH 09/12] chore(publish): @basketry/typescript@0.3.0-alpha.0 Co-authored-by: github-actions[bot] --- package-lock.json | 50 +++++++++++++++++++++++++++++++- packages/typescript/package.json | 2 +- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec54974..72a1934 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9028,9 +9028,25 @@ "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/typescript": { "name": "@basketry/typescript", - "version": "0.2.4", + "version": "0.3.0-alpha.0", "license": "MIT", "dependencies": { "basketry": "^0.2.1", @@ -9060,6 +9076,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", @@ -9078,6 +9110,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/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": { From 25f2b360be7f998f76d019831862b1c5e9807dfc Mon Sep 17 00:00:00 2001 From: skonves Date: Mon, 3 Nov 2025 16:06:04 +0000 Subject: [PATCH 10/12] chore(publish): @basketry/express@0.5.0-alpha.0 Co-authored-by: github-actions[bot] --- package-lock.json | 2 +- packages/express/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 72a1934..93a5141 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9007,7 +9007,7 @@ }, "packages/express": { "name": "@basketry/express", - "version": "0.4.5", + "version": "0.5.0-alpha.0", "license": "MIT", "dependencies": { "@basketry/typescript": "^0.2.3", 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": { From d2e2ab1e717a83e3d1f852574468bcb28f4587e8 Mon Sep 17 00:00:00 2001 From: Steve Konves Date: Thu, 13 Nov 2025 09:39:27 -0700 Subject: [PATCH 11/12] Update snapshots --- packages/express/src/snapshot/zod/v1/types.ts | 1 - packages/http-client/src/snapshot/zod/v1/types.ts | 1 - packages/typescript-dtos/src/snapshot/client/v1/types.ts | 8 ++++---- packages/typescript-dtos/src/snapshot/server/v1/types.ts | 8 ++++---- 4 files changed, 8 insertions(+), 10 deletions(-) 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/http-client/src/snapshot/zod/v1/types.ts b/packages/http-client/src/snapshot/zod/v1/types.ts index 1aa8292..869e6e1 100644 --- a/packages/http-client/src/snapshot/zod/v1/types.ts +++ b/packages/http-client/src/snapshot/zod/v1/types.ts @@ -135,7 +135,6 @@ export type ExhaustiveParamsParams = { }; export type GetGizmosParams = { - /** @deprecated */ search?: string; }; 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; From 183ddadfe11de15d61e9e952e49d07c5ff7de122 Mon Sep 17 00:00:00 2001 From: Steve Konves Date: Fri, 14 Nov 2025 10:44:54 -0700 Subject: [PATCH 12/12] Import streaming changes from legacy repo --- package-lock.json | 17 +- packages/http-client/package.json | 8 +- .../http-client/src/http-client-generator.ts | 146 ++++++------------ .../src/snapshot/zod/v1/http-client.ts | 28 ++-- .../http-client/src/snapshot/zod/v1/types.ts | 1 + 5 files changed, 68 insertions(+), 132 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5b72e69..c6a227d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9053,7 +9053,7 @@ "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", @@ -9070,21 +9070,6 @@ "url": "https://github.com/sponsors/basketry" } }, - "packages/http-client/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==", - "dependencies": { - "basketry": "^0.2.1", - "case": "^1.6.3" - }, - "bin": { - "basketry-typescript": "lib/rpc.js" - }, - "funding": { - "url": "https://github.com/sponsors/basketry" - } - }, "packages/typescript": { "name": "@basketry/typescript", "version": "0.3.0-alpha.0", 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/http-client/src/snapshot/zod/v1/types.ts b/packages/http-client/src/snapshot/zod/v1/types.ts index 869e6e1..1aa8292 100644 --- a/packages/http-client/src/snapshot/zod/v1/types.ts +++ b/packages/http-client/src/snapshot/zod/v1/types.ts @@ -135,6 +135,7 @@ export type ExhaustiveParamsParams = { }; export type GetGizmosParams = { + /** @deprecated */ search?: string; };