From c6a0117ca9e1f9238379f81f41e6cd545e806c62 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Thu, 29 Aug 2024 15:29:30 +0000 Subject: [PATCH] feat: Add formatCode option (#69) * Make blueprint async * Make createBlueprint and createCodeSample async * Add formatCode support --- README.md | 2 +- examples/blueprint.ts | 3 +- src/lib/blueprint.ts | 110 ++++--- src/lib/code-sample/format.ts | 43 +++ src/lib/code-sample/index.ts | 1 + src/lib/code-sample/schema.ts | 117 ++++--- src/lib/index.ts | 2 + test/blueprint.test.ts | 12 +- test/seam-blueprint.test.ts | 4 +- test/snapshots/blueprint.test.ts.md | 444 ++++++++++++++++++++++++++ test/snapshots/blueprint.test.ts.snap | Bin 3167 -> 4008 bytes 11 files changed, 638 insertions(+), 100 deletions(-) create mode 100644 src/lib/code-sample/format.ts diff --git a/README.md b/README.md index eb2975b6..ede2746f 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ import { createBlueprint, TypesModuleSchema } from '@seamapi/blueprint' import * as types from '@seamapi/types/connect' const typesModule = TypesModuleSchema.parse(types) -const blueprint = createBlueprint(typesModule) +const blueprint = await createBlueprint(typesModule) console.log(JSON.stringify(blueprint) ``` diff --git a/examples/blueprint.ts b/examples/blueprint.ts index 1f560b0e..d659c0cc 100644 --- a/examples/blueprint.ts +++ b/examples/blueprint.ts @@ -20,5 +20,6 @@ export const builder: Builder = { export const handler: Handler = async ({ moduleName, logger }) => { const types = TypesModuleSchema.parse(await import(moduleName)) - logger.info({ data: createBlueprint(types) }, 'blueprint') + const blueprint = await createBlueprint(types) + logger.info({ blueprint }, 'blueprint') } diff --git a/src/lib/blueprint.ts b/src/lib/blueprint.ts index 3473695a..86bfbd26 100644 --- a/src/lib/blueprint.ts +++ b/src/lib/blueprint.ts @@ -5,7 +5,10 @@ import { CodeSampleDefinitionSchema, createCodeSample, } from './code-sample/index.js' -import type { CodeSampleDefinition } from './code-sample/schema.js' +import type { + CodeSampleDefinition, + CodeSampleSyntax, +} from './code-sample/schema.js' import type { Openapi, OpenapiOperation, @@ -206,7 +209,7 @@ interface IdProperty extends BaseProperty { export type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' -interface Context { +interface Context extends Required { codeSampleDefinitions: CodeSampleDefinition[] } @@ -220,7 +223,14 @@ export type TypesModuleInput = z.input export type TypesModule = z.output -export const createBlueprint = (typesModule: TypesModuleInput): Blueprint => { +export interface BlueprintOptions { + formatCode?: (content: string, syntax: CodeSampleSyntax) => Promise +} + +export const createBlueprint = async ( + typesModule: TypesModuleInput, + { formatCode = async (content) => content }: BlueprintOptions = {}, +): Promise => { const { codeSampleDefinitions } = TypesModuleSchema.parse(typesModule) // TODO: Move openapi to TypesModuleSchema @@ -232,11 +242,12 @@ export const createBlueprint = (typesModule: TypesModuleInput): Blueprint => { const context = { codeSampleDefinitions, + formatCode, } return { title: openapi.info.title, - routes: createRoutes(openapi.paths, isFakeData, targetPath, context), + routes: await createRoutes(openapi.paths, isFakeData, targetPath, context), resources: createResources( openapi.components.schemas, isFakeData, @@ -245,77 +256,82 @@ export const createBlueprint = (typesModule: TypesModuleInput): Blueprint => { } } -const createRoutes = ( +const createRoutes = async ( paths: OpenapiPaths, isFakeData: boolean, targetPath: string, context: Context, -): Route[] => { +): Promise => { const routeMap = new Map() - Object.entries(paths) - .filter(([path]) => isFakeData || path.startsWith(targetPath)) - .forEach(([path, pathItem]) => { - const route = createRoute(path, pathItem, context) + const pathEntries = Object.entries(paths).filter( + ([path]) => isFakeData || path.startsWith(targetPath), + ) - const existingRoute = routeMap.get(route.path) - if (existingRoute != null) { - existingRoute.endpoints.push(...route.endpoints) - } else { - routeMap.set(route.path, route) - } - }) + for (const [path, pathItem] of pathEntries) { + const route = await createRoute(path, pathItem, context) + + const existingRoute = routeMap.get(route.path) + if (existingRoute != null) { + existingRoute.endpoints.push(...route.endpoints) + continue + } + + routeMap.set(route.path, route) + } return Array.from(routeMap.values()) } -const createRoute = ( +const createRoute = async ( path: string, pathItem: OpenapiPathItem, context: Context, -): Route => { +): Promise => { const pathParts = path.split('/') const routePath = `/${pathParts.slice(1, -1).join('/')}` return { path: routePath, namespace: { path: `/${pathParts[1]}` }, - endpoints: createEndpoints(path, pathItem, context), + endpoints: await createEndpoints(path, pathItem, context), subroutes: [], } } -const createEndpoints = ( +const createEndpoints = async ( path: string, pathItem: OpenapiPathItem, context: Context, -): Endpoint[] => { +): Promise => { const validMethods: Method[] = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] - return Object.entries(pathItem) - .filter( - ([method, operation]) => - validMethods.includes(method.toUpperCase() as Method) && - typeof operation === 'object' && - operation !== null, - ) - .map(([method, operation]) => { - const uppercaseMethod = method.toUpperCase() as Method - return createEndpoint( - [uppercaseMethod], - operation as OpenapiOperation, - path, - context, + return await Promise.all( + Object.entries(pathItem) + .filter( + ([method, operation]) => + validMethods.includes(method.toUpperCase() as Method) && + typeof operation === 'object' && + operation !== null, ) - }) + .map(async ([method, operation]) => { + const uppercaseMethod = method.toUpperCase() as Method + return await createEndpoint( + [uppercaseMethod], + operation as OpenapiOperation, + path, + context, + ) + }), + ) } -const createEndpoint = ( +const createEndpoint = async ( methods: Method[], operation: OpenapiOperation, path: string, context: Context, -): Endpoint => { +): Promise => { const pathParts = path.split('/') const endpointPath = `/${pathParts.slice(1).join('/')}` @@ -346,11 +362,17 @@ const createEndpoint = ( return { ...endpoint, - codeSamples: context.codeSampleDefinitions - .filter(({ request }) => request.path === endpointPath) - .map((codeSampleDefinition) => - createCodeSample(codeSampleDefinition, { endpoint }), - ), + codeSamples: await Promise.all( + context.codeSampleDefinitions + .filter(({ request }) => request.path === endpointPath) + .map( + async (codeSampleDefinition) => + await createCodeSample(codeSampleDefinition, { + endpoint, + formatCode: context.formatCode, + }), + ), + ), } } diff --git a/src/lib/code-sample/format.ts b/src/lib/code-sample/format.ts new file mode 100644 index 00000000..e7c4653e --- /dev/null +++ b/src/lib/code-sample/format.ts @@ -0,0 +1,43 @@ +import type { Code, Context } from './schema.js' + +type CodeEntries = Entries +type CodeEntry = NonNullable + +export const formatCodeRecords = async ( + code: Code, + context: Context, +): Promise => { + const entries = Object.entries(code) as unknown as CodeEntries + const formattedEntries = await Promise.all( + entries.map(async (entry): Promise => { + if (entry == null) throw new Error('Unexpected null code entry') + return await formatCodeEntry(entry, context) + }), + ) + return Object.fromEntries(formattedEntries) +} + +const formatCodeEntry = async ( + [key, code]: CodeEntry, + { formatCode }: Context, +): Promise => { + if (code == null) throw new Error(`Unexpected null in code object for ${key}`) + const [request, response] = await Promise.all([ + await formatCode(code.request, code.request_syntax), + await formatCode(code.response, code.response_syntax), + ]) + return [ + key, + { + ...code, + request, + response, + }, + ] +} + +type Entries = Array< + { + [K in keyof T]: [K, T[K]] + }[keyof T] +> diff --git a/src/lib/code-sample/index.ts b/src/lib/code-sample/index.ts index d0949875..6768c78e 100644 --- a/src/lib/code-sample/index.ts +++ b/src/lib/code-sample/index.ts @@ -2,5 +2,6 @@ export { type CodeSample, type CodeSampleDefinitionInput, CodeSampleDefinitionSchema, + type CodeSampleSyntax, createCodeSample, } from './schema.js' diff --git a/src/lib/code-sample/schema.ts b/src/lib/code-sample/schema.ts index db9739a0..937e5b3e 100644 --- a/src/lib/code-sample/schema.ts +++ b/src/lib/code-sample/schema.ts @@ -7,6 +7,7 @@ import { } from 'lib/code-sample/seam-cli.js' import { JsonSchema } from 'lib/json.js' +import { formatCodeRecords } from './format.js' import { createJavascriptRequest, createJavascriptResponse, @@ -39,69 +40,85 @@ export type CodeSampleDefinitionInput = z.input< export type CodeSampleDefinition = z.output -const syntax = z.enum(['javascript', 'json', 'python', 'php', 'ruby', 'bash']) +const CodeSampleSyntaxSchema = z.enum([ + 'javascript', + 'json', + 'python', + 'php', + 'ruby', + 'bash', +]) + +export type CodeSampleSyntax = z.infer + +const CodeSchema = z.record( + z.enum(['javascript', 'python', 'php', 'ruby', 'seam_cli']), + z.object({ + title: z.string().min(1), + request: z.string(), + response: z.string(), + request_syntax: CodeSampleSyntaxSchema, + response_syntax: CodeSampleSyntaxSchema, + }), +) + +export type Code = z.infer const CodeSampleSchema = CodeSampleDefinitionSchema.extend({ - code: z.record( - z.enum(['javascript', 'python', 'php', 'ruby', 'seam_cli']), - z.object({ - title: z.string().min(1), - request: z.string(), - response: z.string(), - request_syntax: syntax, - response_syntax: syntax, - }), - ), + code: CodeSchema, }) export type CodeSample = z.output export interface Context { endpoint: Omit + formatCode: (content: string, syntax: CodeSampleSyntax) => Promise } -export const createCodeSample = ( +export const createCodeSample = async ( codeSampleDefinition: CodeSampleDefinition, context: Context, -): CodeSample => { +): Promise => { + const code: Code = { + javascript: { + title: 'JavaScript', + request: createJavascriptRequest(codeSampleDefinition, context), + response: createJavascriptResponse(codeSampleDefinition, context), + request_syntax: 'javascript', + response_syntax: 'javascript', + }, + python: { + title: 'Python', + request: createPythonRequest(codeSampleDefinition, context), + response: createPythonResponse(codeSampleDefinition, context), + request_syntax: 'python', + response_syntax: 'python', + }, + ruby: { + title: 'Ruby', + request: createRubyRequest(codeSampleDefinition, context), + response: createRubyResponse(codeSampleDefinition, context), + request_syntax: 'ruby', + response_syntax: 'ruby', + }, + php: { + title: 'PHP', + request: createPhpRequest(codeSampleDefinition, context), + response: createPhpResponse(codeSampleDefinition, context), + request_syntax: 'php', + response_syntax: 'json', + }, + seam_cli: { + title: 'Seam CLI', + request: createSeamCliRequest(codeSampleDefinition, context), + response: createSeamCliResponse(codeSampleDefinition, context), + request_syntax: 'bash', + response_syntax: 'json', + }, + } + return { ...codeSampleDefinition, - code: { - javascript: { - title: 'JavaScript', - request: createJavascriptRequest(codeSampleDefinition, context), - response: createJavascriptResponse(codeSampleDefinition, context), - request_syntax: 'javascript', - response_syntax: 'javascript', - }, - python: { - title: 'Python', - request: createPythonRequest(codeSampleDefinition, context), - response: createPythonResponse(codeSampleDefinition, context), - request_syntax: 'python', - response_syntax: 'python', - }, - ruby: { - title: 'Ruby', - request: createRubyRequest(codeSampleDefinition, context), - response: createRubyResponse(codeSampleDefinition, context), - request_syntax: 'ruby', - response_syntax: 'ruby', - }, - php: { - title: 'PHP', - request: createPhpRequest(codeSampleDefinition, context), - response: createPhpResponse(codeSampleDefinition, context), - request_syntax: 'php', - response_syntax: 'json', - }, - seam_cli: { - title: 'Seam CLI', - request: createSeamCliRequest(codeSampleDefinition, context), - response: createSeamCliResponse(codeSampleDefinition, context), - request_syntax: 'bash', - response_syntax: 'json', - }, - }, + code: await formatCodeRecords(code, context), } } diff --git a/src/lib/index.ts b/src/lib/index.ts index 48fa17d2..812a7a9c 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,5 +1,6 @@ export { type Blueprint, + type BlueprintOptions, createBlueprint, type Endpoint, type Namespace, @@ -17,4 +18,5 @@ export { type CodeSample, type CodeSampleDefinitionInput, CodeSampleDefinitionSchema, + type CodeSampleSyntax, } from './code-sample/index.js' diff --git a/test/blueprint.test.ts b/test/blueprint.test.ts index c898193a..21ff18e2 100644 --- a/test/blueprint.test.ts +++ b/test/blueprint.test.ts @@ -4,8 +4,16 @@ import { createBlueprint, TypesModuleSchema } from '@seamapi/blueprint' import * as types from './fixtures/types/index.js' -test('createBlueprint', (t) => { +test('createBlueprint', async (t) => { const typesModule = TypesModuleSchema.parse(types) - const blueprint = createBlueprint(typesModule) + const blueprint = await createBlueprint(typesModule) + t.snapshot(blueprint, 'blueprint') +}) + +test('createBlueprint: with formatCode', async (t) => { + const typesModule = TypesModuleSchema.parse(types) + const blueprint = await createBlueprint(typesModule, { + formatCode: async (content, syntax) => [`// ${syntax}`, content].join('\n'), + }) t.snapshot(blueprint, 'blueprint') }) diff --git a/test/seam-blueprint.test.ts b/test/seam-blueprint.test.ts index 02cd32ca..487939f3 100644 --- a/test/seam-blueprint.test.ts +++ b/test/seam-blueprint.test.ts @@ -3,8 +3,8 @@ import test from 'ava' import { createBlueprint, TypesModuleSchema } from '@seamapi/blueprint' -test('createBlueprint', (t) => { +test('createBlueprint', async (t) => { const typesModule = TypesModuleSchema.parse(types) - const blueprint = createBlueprint(typesModule) + const blueprint = await createBlueprint(typesModule) t.snapshot(blueprint, 'blueprint') }) diff --git a/test/snapshots/blueprint.test.ts.md b/test/snapshots/blueprint.test.ts.md index 04d368f7..f783d05b 100644 --- a/test/snapshots/blueprint.test.ts.md +++ b/test/snapshots/blueprint.test.ts.md @@ -407,3 +407,447 @@ Generated by [AVA](https://avajs.dev). ], title: 'Foo', } + +## createBlueprint: with formatCode + +> blueprint + + { + resources: { + foo: { + description: 'A foo resource.', + properties: [ + { + deprecationMessage: '', + description: 'Foo id', + format: 'id', + isDeprecated: false, + isUndocumented: false, + jsonType: 'string', + name: 'foo_id', + }, + { + deprecationMessage: '', + description: 'Foo name', + format: 'string', + isDeprecated: false, + isUndocumented: false, + jsonType: 'string', + name: 'name', + }, + { + deprecationMessage: 'This prop will be removed in the next version', + description: 'This prop is deprecated', + format: 'string', + isDeprecated: true, + isUndocumented: false, + jsonType: 'string', + name: 'deprecated_prop', + }, + { + deprecationMessage: '', + description: 'This prop is undocumented', + format: 'string', + isDeprecated: false, + isUndocumented: true, + jsonType: 'string', + name: 'undocumented_prop', + }, + { + deprecationMessage: '', + description: 'This prop is nullable', + format: 'string', + isDeprecated: false, + isUndocumented: false, + jsonType: 'string', + name: 'nullable_property', + }, + ], + resourceType: 'foo', + }, + }, + routes: [ + { + endpoints: [ + { + codeSamples: [ + { + code: { + javascript: { + request: `// javascript␊ + await seam.foos.get({"foo_id":"8d7e0b3a-b889-49a7-9164-4b71a0506a33"})`, + request_syntax: 'javascript', + response: `// javascript␊ + {"foo_id":"8d7e0b3a-b889-49a7-9164-4b71a0506a33","name":"Best foo","nullable_property":null}`, + response_syntax: 'javascript', + title: 'JavaScript', + }, + php: { + request: `// php␊ + $seam->foos->get(foo_id:"8d7e0b3a-b889-49a7-9164-4b71a0506a33")`, + request_syntax: 'php', + response: `// json␊ + {"foo_id":"8d7e0b3a-b889-49a7-9164-4b71a0506a33","name":"Best foo","nullable_property":null}`, + response_syntax: 'json', + title: 'PHP', + }, + python: { + request: `// python␊ + seam.foos.get(foo_id="8d7e0b3a-b889-49a7-9164-4b71a0506a33")`, + request_syntax: 'python', + response: `// python␊ + Foo(foo_id="8d7e0b3a-b889-49a7-9164-4b71a0506a33", name="Best foo", nullable_property=None)`, + response_syntax: 'python', + title: 'Python', + }, + ruby: { + request: `// ruby␊ + seam.foos.get(foo_id: "8d7e0b3a-b889-49a7-9164-4b71a0506a33")`, + request_syntax: 'ruby', + response: `// ruby␊ + `, + response_syntax: 'ruby', + title: 'Ruby', + }, + seam_cli: { + request: `// bash␊ + seam foos get --foo_id "8d7e0b3a-b889-49a7-9164-4b71a0506a33"`, + request_syntax: 'bash', + response: `// json␊ + {"foo_id":"8d7e0b3a-b889-49a7-9164-4b71a0506a33","name":"Best foo","nullable_property":null}`, + response_syntax: 'json', + title: 'Seam CLI', + }, + }, + description: 'This is the way to get a foo', + request: { + parameters: { + foo_id: '8d7e0b3a-b889-49a7-9164-4b71a0506a33', + }, + path: '/foos/get', + }, + response: { + body: { + foo: { + foo_id: '8d7e0b3a-b889-49a7-9164-4b71a0506a33', + name: 'Best foo', + nullable_property: null, + }, + }, + }, + title: 'Get a foo by ID', + }, + ], + deprecationMessage: '', + description: '', + isDeprecated: false, + isUndocumented: false, + path: '/foos/get', + request: { + methods: [ + 'GET', + ], + parameters: [], + preferredMethod: 'GET', + semanticMethod: 'GET', + }, + response: { + description: 'Get a foo by ID.', + resourceType: 'foo', + responseKey: 'foo', + responseType: 'resource', + }, + title: 'Get a foo', + }, + { + codeSamples: [ + { + code: { + javascript: { + request: `// javascript␊ + await seam.foos.get({"foo_id":"8d7e0b3a-b889-49a7-9164-4b71a0506a33"})`, + request_syntax: 'javascript', + response: `// javascript␊ + {"foo_id":"8d7e0b3a-b889-49a7-9164-4b71a0506a33","name":"Best foo","nullable_property":null}`, + response_syntax: 'javascript', + title: 'JavaScript', + }, + php: { + request: `// php␊ + $seam->foos->get(foo_id:"8d7e0b3a-b889-49a7-9164-4b71a0506a33")`, + request_syntax: 'php', + response: `// json␊ + {"foo_id":"8d7e0b3a-b889-49a7-9164-4b71a0506a33","name":"Best foo","nullable_property":null}`, + response_syntax: 'json', + title: 'PHP', + }, + python: { + request: `// python␊ + seam.foos.get(foo_id="8d7e0b3a-b889-49a7-9164-4b71a0506a33")`, + request_syntax: 'python', + response: `// python␊ + Foo(foo_id="8d7e0b3a-b889-49a7-9164-4b71a0506a33", name="Best foo", nullable_property=None)`, + response_syntax: 'python', + title: 'Python', + }, + ruby: { + request: `// ruby␊ + seam.foos.get(foo_id: "8d7e0b3a-b889-49a7-9164-4b71a0506a33")`, + request_syntax: 'ruby', + response: `// ruby␊ + `, + response_syntax: 'ruby', + title: 'Ruby', + }, + seam_cli: { + request: `// bash␊ + seam foos get --foo_id "8d7e0b3a-b889-49a7-9164-4b71a0506a33"`, + request_syntax: 'bash', + response: `// json␊ + {"foo_id":"8d7e0b3a-b889-49a7-9164-4b71a0506a33","name":"Best foo","nullable_property":null}`, + response_syntax: 'json', + title: 'Seam CLI', + }, + }, + description: 'This is the way to get a foo', + request: { + parameters: { + foo_id: '8d7e0b3a-b889-49a7-9164-4b71a0506a33', + }, + path: '/foos/get', + }, + response: { + body: { + foo: { + foo_id: '8d7e0b3a-b889-49a7-9164-4b71a0506a33', + name: 'Best foo', + nullable_property: null, + }, + }, + }, + title: 'Get a foo by ID', + }, + ], + deprecationMessage: '', + description: '', + isDeprecated: false, + isUndocumented: false, + path: '/foos/get', + request: { + methods: [ + 'POST', + ], + parameters: [], + preferredMethod: 'POST', + semanticMethod: 'POST', + }, + response: { + description: 'Get a foo by ID.', + resourceType: 'foo', + responseKey: 'foo', + responseType: 'resource', + }, + title: 'Get a foo', + }, + { + codeSamples: [ + { + code: { + javascript: { + request: `// javascript␊ + await seam.foos.list()`, + request_syntax: 'javascript', + response: `// javascript␊ + [{"foo_id":"8d7e0b3a-b889-49a7-9164-4b71a0506a33","name":"Best foo","nullable_property":null}]`, + response_syntax: 'javascript', + title: 'JavaScript', + }, + php: { + request: `// php␊ + $seam->foos->list()`, + request_syntax: 'php', + response: `// json␊ + [{"foo_id":"8d7e0b3a-b889-49a7-9164-4b71a0506a33","name":"Best foo","nullable_property":null}]`, + response_syntax: 'json', + title: 'PHP', + }, + python: { + request: `// python␊ + seam.foos.list()`, + request_syntax: 'python', + response: `// python␊ + [Foo(foo_id="8d7e0b3a-b889-49a7-9164-4b71a0506a33", name="Best foo", nullable_property=None)]`, + response_syntax: 'python', + title: 'Python', + }, + ruby: { + request: `// ruby␊ + seam.foos.list()`, + request_syntax: 'ruby', + response: `// ruby␊ + []`, + response_syntax: 'ruby', + title: 'Ruby', + }, + seam_cli: { + request: `// bash␊ + seam foos list `, + request_syntax: 'bash', + response: `// json␊ + [{"foo_id":"8d7e0b3a-b889-49a7-9164-4b71a0506a33","name":"Best foo","nullable_property":null}]`, + response_syntax: 'json', + title: 'Seam CLI', + }, + }, + description: 'This is the way to list foos', + request: { + parameters: {}, + path: '/foos/list', + }, + response: { + body: { + foos: [ + { + foo_id: '8d7e0b3a-b889-49a7-9164-4b71a0506a33', + name: 'Best foo', + nullable_property: null, + }, + ], + }, + }, + title: 'List foos', + }, + ], + deprecationMessage: '', + description: '', + isDeprecated: false, + isUndocumented: false, + path: '/foos/list', + request: { + methods: [ + 'GET', + ], + parameters: [], + preferredMethod: 'GET', + semanticMethod: 'GET', + }, + response: { + description: 'List all foos.', + resourceType: 'foo', + responseKey: 'foos', + responseType: 'resource_list', + }, + title: 'List foos', + }, + { + codeSamples: [ + { + code: { + javascript: { + request: `// javascript␊ + await seam.foos.list()`, + request_syntax: 'javascript', + response: `// javascript␊ + [{"foo_id":"8d7e0b3a-b889-49a7-9164-4b71a0506a33","name":"Best foo","nullable_property":null}]`, + response_syntax: 'javascript', + title: 'JavaScript', + }, + php: { + request: `// php␊ + $seam->foos->list()`, + request_syntax: 'php', + response: `// json␊ + [{"foo_id":"8d7e0b3a-b889-49a7-9164-4b71a0506a33","name":"Best foo","nullable_property":null}]`, + response_syntax: 'json', + title: 'PHP', + }, + python: { + request: `// python␊ + seam.foos.list()`, + request_syntax: 'python', + response: `// python␊ + [Foo(foo_id="8d7e0b3a-b889-49a7-9164-4b71a0506a33", name="Best foo", nullable_property=None)]`, + response_syntax: 'python', + title: 'Python', + }, + ruby: { + request: `// ruby␊ + seam.foos.list()`, + request_syntax: 'ruby', + response: `// ruby␊ + []`, + response_syntax: 'ruby', + title: 'Ruby', + }, + seam_cli: { + request: `// bash␊ + seam foos list `, + request_syntax: 'bash', + response: `// json␊ + [{"foo_id":"8d7e0b3a-b889-49a7-9164-4b71a0506a33","name":"Best foo","nullable_property":null}]`, + response_syntax: 'json', + title: 'Seam CLI', + }, + }, + description: 'This is the way to list foos', + request: { + parameters: {}, + path: '/foos/list', + }, + response: { + body: { + foos: [ + { + foo_id: '8d7e0b3a-b889-49a7-9164-4b71a0506a33', + name: 'Best foo', + nullable_property: null, + }, + ], + }, + }, + title: 'List foos', + }, + ], + deprecationMessage: '', + description: '', + isDeprecated: false, + isUndocumented: false, + path: '/foos/list', + request: { + methods: [ + 'POST', + ], + parameters: [], + preferredMethod: 'POST', + semanticMethod: 'POST', + }, + response: { + description: 'List all foos.', + resourceType: 'foo', + responseKey: 'foos', + responseType: 'resource_list', + }, + title: 'List foos', + }, + ], + namespace: { + path: '/foos', + }, + path: '/foos', + subroutes: [], + }, + ], + title: 'Foo', + } diff --git a/test/snapshots/blueprint.test.ts.snap b/test/snapshots/blueprint.test.ts.snap index 14aab88d9fabb5e2f6f95f50c78b59ae88d851c5..8162fb38a8771ffac54b90333d8a670428462cf9 100644 GIT binary patch literal 4008 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