Skip to content
Open
25 changes: 22 additions & 3 deletions packages/typescript/src/interface-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Enum,
Generator,
getTypeByName,
HttpMethod,
Interface,
isRequired,
Method,
Expand Down Expand Up @@ -61,9 +62,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,
Expand Down Expand Up @@ -151,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<string> {
function* buildMethod(
method: Method,
httpMethod: HttpMethod | undefined,
): Iterable<string> {
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<string> {
Expand Down
17 changes: 14 additions & 3 deletions packages/typescript/src/name-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { pascal, camel } from 'case';

import {
Enum,
HttpMethod,
HttpParameter,
Interface,
MemberValue,
Expand Down Expand Up @@ -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 {
Expand All @@ -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',
);
}
114 changes: 114 additions & 0 deletions packages/typescript/src/spec/4.1-structure/4.1.24-http-method.spec.ts
Original file line number Diff line number Diff line change
@@ -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<number>;
}
`);

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<number>;
}
`);

expect(result[0].contents).toContainAst(`
export type StreamWidgetsParams { a: string; }
`);
});
});
});
38 changes: 37 additions & 1 deletion utils/jest-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as ts from 'typescript';
import type { Options } from 'prettier';
import { format } from '@prettier/sync';

export { Factory } from './factory';
Expand Down Expand Up @@ -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());
}

Expand Down