diff --git a/portal-template/portal-config.ts b/portal-template/portal-config.ts index eaedaf55c..6c8b771d1 100644 --- a/portal-template/portal-config.ts +++ b/portal-template/portal-config.ts @@ -9,7 +9,7 @@ export interface BuildPaths { specs: Record; /** Absolute: the `spec/` every document sits in, with the files they refer to. */ specDir: string; - /** Absolute path of the code samples, keyed by path then method; null when there are none. */ + /** Absolute path of the code samples, a `CodeSamplesFile`; null when there are none. */ codeSamples: string | null; /** Absolute. Always exists, and may be empty. */ contentDir: string; diff --git a/portal-template/src/lib/code-samples.server.ts b/portal-template/src/lib/code-samples.server.ts index 04613546e..b3d46411c 100644 --- a/portal-template/src/lib/code-samples.server.ts +++ b/portal-template/src/lib/code-samples.server.ts @@ -3,13 +3,22 @@ import type { Document } from 'fumadocs-openapi'; import { CODE_SAMPLES_EXTENSION } from './code-samples'; import { isJsonObject, type JsonObject } from './json'; -/** Each endpoint's code samples, keyed by path and then by upper-case method, as the CLI writes them. */ -export type CodeSamplesByEndpoint = Record>; +/** Each endpoint's code samples, keyed by path and then by upper-case method. */ +export type CodeSamplesByEndpoint = Record>; + +/** What the CLI writes: each spec's own samples by its slug, and the samples of the others in `paths`. */ +export interface CodeSamplesFile { + paths: CodeSamplesByEndpoint; + specs: Record>; +} const HTTP_METHODS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']); -export async function readCodeSamples(file: string | null): Promise { - return file === null ? {} : (JSON.parse(await readFile(file, 'utf8')) as CodeSamplesByEndpoint); +/** The samples of the section mounted at `slug`. */ +export async function readCodeSamples(file: string | null, slug: string): Promise { + if (file === null) return {}; + const { paths, specs } = JSON.parse(await readFile(file, 'utf8')) as CodeSamplesFile; + return Object.hasOwn(specs, slug) ? specs[slug] : paths; } /** diff --git a/portal-template/src/lib/openapi-section.server.ts b/portal-template/src/lib/openapi-section.server.ts index 0fd5aee7a..9a070afe3 100644 --- a/portal-template/src/lib/openapi-section.server.ts +++ b/portal-template/src/lib/openapi-section.server.ts @@ -16,7 +16,7 @@ import { apiBaseDir } from './shared'; */ export async function openApiSection(slug: string, file: string, codeSamplesFile: string | null) { const load = async () => - placeCodeSamples(withoutInternalOperations(await bundleSpec(file)), await readCodeSamples(codeSamplesFile)); + placeCodeSamples(withoutInternalOperations(await bundleSpec(file)), await readCodeSamples(codeSamplesFile, slug)); const specName = path.basename(file); const baseDir = `${apiBaseDir}/${slug}`; const unsafe = new Set(); diff --git a/src/actions/portal/prepare-project.ts b/src/actions/portal/prepare-project.ts index fe380f6b5..9c773c189 100644 --- a/src/actions/portal/prepare-project.ts +++ b/src/actions/portal/prepare-project.ts @@ -72,7 +72,6 @@ export class PreparePortalProjectAction { this.prompts.contentNotices(source.value.contentNotices, sourceDirectory); this.prompts.unmergedSpecs(source.value.unmergedSpecs, sourceDirectory); this.prompts.leftOutSpecs(source.value.leftOutSpecs, sourceDirectory); - this.prompts.sharedEndpoints(source.value.sharedEndpoints, sourceDirectory); if (!(await confirm())) { return ActionResult.cancelled(); @@ -94,9 +93,9 @@ export class PreparePortalProjectAction { return ActionResult.failed(); } - this.prompts.unplacedSamples( - artifacts.value.codeSampleCatalogs.unplacedIn(source.value.specs.flatMap((spec) => spec.endpoints)) - ); + const codeSamples = artifacts.value.codeSampleCatalogs.placedOn(source.value.specs, source.value.specDirectory); + this.prompts.sharedEndpoints(codeSamples.sharedEndpoints(), sourceDirectory); + this.prompts.unplacedSamples(codeSamples.unplaced(), sourceDirectory); const missing = source.value.generatedPages.missingFrom(artifacts.value); if (missing !== null) { diff --git a/src/infrastructure/portal-project-service.ts b/src/infrastructure/portal-project-service.ts index ab2622372..87334ad8e 100644 --- a/src/infrastructure/portal-project-service.ts +++ b/src/infrastructure/portal-project-service.ts @@ -128,10 +128,7 @@ export class PortalProjectService { projectDirectory, source, await this.contentDirectory(projectDirectory, source), - await this.writeCodeSamples( - projectDirectory, - artifacts.codeSampleCatalogs.without(source.sharedEndpoints.map(({ key }) => key)) - ), + await this.writeCodeSamples(projectDirectory, source, artifacts.codeSampleCatalogs), await this.writeDownloads(projectDirectory, artifacts) ); @@ -192,13 +189,15 @@ export class PortalProjectService { // The template places the samples on the specs as it bundles them, so the specs are read where they are. private async writeCodeSamples( projectDirectory: DirectoryPath, + source: PortalSource, codeSampleCatalogs: CodeSampleCatalogs ): Promise { if (codeSampleCatalogs.isEmpty()) { return null; } const file = new FilePath(projectDirectory, new FileName('code-samples.json')); - await this.fileService.writeContents(file, JSON.stringify(codeSampleCatalogs.toJson())); + const placed = codeSampleCatalogs.placedOn(source.specs, source.specDirectory); + await this.fileService.writeContents(file, JSON.stringify(placed.toJson())); return file; } diff --git a/src/prompts/portal/code-samples.ts b/src/prompts/portal/code-samples.ts index 73d727489..dca226e95 100644 --- a/src/prompts/portal/code-samples.ts +++ b/src/prompts/portal/code-samples.ts @@ -1,9 +1,25 @@ import { log } from '@clack/prompts'; +import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { SharedEndpoint, UnplacedSamples } from '../../types/portal/code-samples.js'; import { format as f } from '../format.js'; +import { sharedLines } from './source.js'; -export function reportUnplacedSamples(endpoints: string[]): void { - if (endpoints.length === 0) { +export function reportSharedEndpoints(shared: SharedEndpoint[], sourceDirectory: DirectoryPath): void { + if (shared.length === 0) { return; } - log.warn(`No operation in ${f.var('spec')} takes the code samples for ${endpoints.join(', ')}.`); + const one = shared.length === 1; + log.warn( + `More than one API definition declares ${one ? 'this operation' : 'these operations'}, so the portal shows no ` + + `SDK sample on ${one ? 'it' : 'them'}:` + ); + log.message(sharedLines(shared, sourceDirectory)); +} + +export function reportUnplacedSamples(unplaced: UnplacedSamples[], sourceDirectory: DirectoryPath): void { + for (const { where, endpoints } of unplaced) { + log.warn( + `No operation in ${f.var(where.relativeTo(sourceDirectory))} takes the code samples for ${endpoints.join(', ')}.` + ); + } } diff --git a/src/prompts/portal/prepare-project.ts b/src/prompts/portal/prepare-project.ts index a9b65963e..11754550f 100644 --- a/src/prompts/portal/prepare-project.ts +++ b/src/prompts/portal/prepare-project.ts @@ -5,19 +5,19 @@ import { PortalAuthorizationFailure } from '../../infrastructure/services/portal import { DirectoryPath } from '../../types/file/directoryPath.js'; import { FileName } from '../../types/file/fileName.js'; import { FilePath } from '../../types/file/filePath.js'; +import { SharedEndpoint, UnplacedSamples } from '../../types/portal/code-samples.js'; import { ContentNotices } from '../../types/portal/content-notices.js'; import { MissingArtifacts } from '../../types/portal/generated-pages.js'; import { PortalArtifacts } from '../../types/portal/portal-artifacts.js'; -import { PortalSourceProblem, SharedEndpoint } from '../../types/portal/portal-source.js'; +import { PortalSourceProblem } from '../../types/portal/portal-source.js'; import { format as f } from '../format.js'; import { describeMissingArtifacts, generateArtifacts } from './artifacts.js'; import { reportAuthorizationFailure } from './authorization.js'; -import { reportUnplacedSamples } from './code-samples.js'; +import { reportSharedEndpoints, reportUnplacedSamples } from './code-samples.js'; import { reportContentNotices, reportLeftOutSpecs, reportShadowedFiles, - reportSharedEndpoints, reportSourceProblem, reportUnmergedSpecs } from './source.js'; @@ -75,8 +75,8 @@ export class PreparePortalProjectPrompts { reportSharedEndpoints(shared, sourceDirectory); } - public unplacedSamples(endpoints: string[]) { - reportUnplacedSamples(endpoints); + public unplacedSamples(unplaced: UnplacedSamples[], sourceDirectory: DirectoryPath) { + reportUnplacedSamples(unplaced, sourceDirectory); } // A run delivers everything or nothing, so a gap is the server's, and trying again is the fix. diff --git a/src/prompts/portal/source.ts b/src/prompts/portal/source.ts index b0136e51a..5af9d2e46 100644 --- a/src/prompts/portal/source.ts +++ b/src/prompts/portal/source.ts @@ -11,7 +11,6 @@ import { PortalSourceProblem, ReservedAddressPage, SharedAddress, - SharedEndpoint, SpecConversion, UnreadableSpecReason } from '../../types/portal/portal-source.js'; @@ -57,7 +56,7 @@ const relative = (file: FilePath, sourceDirectory: DirectoryPath): string => f.v const listed = (files: FilePath[], sourceDirectory: DirectoryPath): string => listedInProse(files.map((file) => relative(file, sourceDirectory))); -const sharedLines = (claims: Shared[], sourceDirectory: DirectoryPath): string => +export const sharedLines = (claims: Shared[], sourceDirectory: DirectoryPath): string => claims.map(({ key, files }) => ` • ${f.var(String(key))}: ${listed(files, sourceDirectory)}`).join('\n'); const spelt = (found: FilePath, sourceDirectory: DirectoryPath): string => @@ -417,18 +416,6 @@ export function reportLeftOutSpecs(files: FilePath[], sourceDirectory: Directory ); } -export function reportSharedEndpoints(shared: SharedEndpoint[], sourceDirectory: DirectoryPath): void { - if (shared.length === 0) { - return; - } - const one = shared.length === 1; - log.warn( - `More than one API definition declares ${one ? 'this operation' : 'these operations'}, so the portal shows no ` + - `SDK sample on ${one ? 'it' : 'them'}:` - ); - log.message(sharedLines(shared, sourceDirectory)); -} - export function reportIgnoredNavigationFiles(files: FilePath[], sourceDirectory: DirectoryPath): void { if (files.length === 0) { return; diff --git a/src/types/portal-source-context.ts b/src/types/portal-source-context.ts index 8230a3cea..f81e4e271 100644 --- a/src/types/portal-source-context.ts +++ b/src/types/portal-source-context.ts @@ -38,7 +38,6 @@ import { SpecConversion, UnreadableSpecReason } from './portal/portal-source.js'; -import { shared } from './portal/shared.js'; import { ReferencedFile, SpecBundle } from './portal/spec-bundle.js'; import { SpecMetadata } from './portal/spec-metadata.js'; import { SpecContext } from './spec-context.js'; @@ -172,7 +171,6 @@ export class PortalSourceContext { specDirectory: this.specDirectory, unmergedSpecs, leftOutSpecs, - sharedEndpoints: shared(specs.flatMap(({ file, endpoints }) => endpoints.map((key) => ({ key, file })))), contentDirectory, staticDirectory, shadowedFiles: staticDirectory === null ? [] : await this.shadowedFiles(staticDirectory), diff --git a/src/types/portal/code-samples.ts b/src/types/portal/code-samples.ts index cccef9c8a..10350f5df 100644 --- a/src/types/portal/code-samples.ts +++ b/src/types/portal/code-samples.ts @@ -1,6 +1,10 @@ +import { DirectoryPath } from '../file/directoryPath.js'; +import { FilePath } from '../file/filePath.js'; import { Language, languageLabel } from '../sdk/generate.js'; import { isJsonObject } from '../../utils/json-utils.js'; import { Endpoint } from './endpoint.js'; +import { PortalSpec } from './portal-source.js'; +import { Shared, shared } from './shared.js'; type Sources = Record; @@ -10,49 +14,57 @@ export interface CodeSample { sources: Sources; } -/** Each endpoint's samples, keyed by path and then by upper-case method: what the portal template reads. */ +/** Each endpoint's samples, keyed by path and then by upper-case method. */ export type CodeSamplesByEndpoint = Record>; -interface CatalogEntry { +/** `code-samples.json`, which the template reads as `CodeSamplesFile`; a test holds the two to one shape. */ +export interface CodeSamplesFile { + paths: CodeSamplesByEndpoint; + specs: Record; +} + +export interface EndpointSample { endpoint: Endpoint; sample: CodeSample; } +/** An operation more than one spec declares, which one code sample per method and path cannot serve. */ +export type SharedEndpoint = Shared; + +/** Samples no operation in `where` takes: a spec, for its own samples, or `spec/`, for the rest. */ +export interface UnplacedSamples { + where: FilePath | DirectoryPath; + endpoints: Endpoint[]; +} + +/** One language's samples, as one `/portal-artifacts` run delivers them. */ export class CodeSampleCatalog { - private constructor(private readonly samplesByEndpoint: Map) {} + private constructor( + private readonly paths: EndpointSample[], + /** By each spec's main file from `spec/`, as the platform keys it; empty when the operations come from one. */ + private readonly specs: ReadonlyMap + ) {} public static fromJson(language: Language, json: unknown): CodeSampleCatalog | undefined { - if (!isJsonObject(json) || !isJsonObject(json.paths)) { + if (!isJsonObject(json)) { return undefined; } - - const samplesByEndpoint = new Map(); - for (const [path, methods] of Object.entries(json.paths)) { - if (!isJsonObject(methods)) { - return undefined; - } - for (const [method, sources] of Object.entries(methods)) { - if (!isSources(sources)) { - return undefined; - } - const endpoint = new Endpoint(method, path); - samplesByEndpoint.set(`${endpoint}`, { endpoint, sample: toCodeSample(language, sources) }); - } - } - return new CodeSampleCatalog(samplesByEndpoint); + const paths = samplesIn(language, json.paths); + const specs = json.specs === undefined ? new Map() : specsIn(language, json.specs); + return paths && specs ? new CodeSampleCatalog(paths, specs) : undefined; } - public sampleFor(endpoint: Endpoint): CodeSample | undefined { - return this.samplesByEndpoint.get(`${endpoint}`)?.sample; + public isFiledBySpec(): boolean { + return this.specs.size > 0; } - public endpoints(): Endpoint[] { - return [...this.samplesByEndpoint.values()].map(({ endpoint }) => endpoint); + // Filed by spec, every operation in `paths` is in a spec's samples too. + public unfiledSamples(): EndpointSample[] { + return this.isFiledBySpec() ? [] : this.paths; } - public without(endpoints: Endpoint[]): CodeSampleCatalog { - const left = new Set(endpoints.map(String)); - return new CodeSampleCatalog(new Map([...this.samplesByEndpoint].filter(([key]) => !left.has(key)))); + public samplesOf(specFile: string): EndpointSample[] { + return this.specs.get(specFile) ?? []; } } @@ -63,30 +75,110 @@ export class CodeSampleCatalogs { return this.catalogs.length === 0; } - public samplesFor(endpoint: Endpoint): CodeSample[] { - return this.catalogs.flatMap((catalog) => catalog.sampleFor(endpoint) ?? []); + public placedOn(specs: PortalSpec[], specDirectory: DirectoryPath): PlacedCodeSamples { + return new PlacedCodeSamples(this.catalogs, specs, specDirectory); + } +} + +/** One run's samples on the specs: filed ones on their spec alone, others on every spec but for shared endpoints. */ +export class PlacedCodeSamples { + private readonly shared: SharedEndpoint[]; + + constructor( + private readonly catalogs: CodeSampleCatalog[], + private readonly specs: PortalSpec[], + private readonly specDirectory: DirectoryPath + ) { + this.shared = shared(specs.flatMap(({ file, endpoints }) => endpoints.map((key) => ({ key, file })))); + } + + /** The operations shown with no SDK sample because more than one spec declares them. */ + public sharedEndpoints(): SharedEndpoint[] { + return this.catalogs.some((catalog) => !catalog.isFiledBySpec()) ? this.shared : []; + } + + public unplaced(): UnplacedSamples[] { + const declared = this.specs.flatMap(({ endpoints }) => endpoints); + return [ + ...this.specs.map((spec) => ({ where: spec.file, endpoints: undeclared(this.ownSamples(spec), spec.endpoints) })), + { where: this.specDirectory, endpoints: undeclared(this.unfiledSamples(), declared) } + ].filter(({ endpoints }) => endpoints.length > 0); } - public without(endpoints: Endpoint[]): CodeSampleCatalogs { - return new CodeSampleCatalogs(this.catalogs.map((catalog) => catalog.without(endpoints))); + public toJson(): CodeSamplesFile { + const sharedKeys = new Set(this.shared.map(({ key }) => String(key))); + return { + paths: byEndpoint(this.unfiledSamples().filter(({ endpoint }) => !sharedKeys.has(String(endpoint)))), + specs: Object.fromEntries( + this.specs.flatMap((spec): [string, CodeSamplesByEndpoint][] => { + const own = this.ownSamples(spec); + return own.length === 0 ? [] : [[spec.slug, byEndpoint(own)]]; + }) + ) + }; } - public unplacedIn(documented: Endpoint[]): string[] { - const names = new Set(documented.map(String)); - return [...new Set(this.endpoints().map(String))].filter((endpoint) => !names.has(endpoint)); + private ownSamples({ file }: PortalSpec): EndpointSample[] { + const specFile = file.relativeTo(this.specDirectory); + return this.catalogs.flatMap((catalog) => catalog.samplesOf(specFile)); } - public toJson(): CodeSamplesByEndpoint { - const json: CodeSamplesByEndpoint = {}; - for (const endpoint of this.endpoints()) { - json[endpoint.path] = { ...json[endpoint.path], [endpoint.method]: this.samplesFor(endpoint) }; + private unfiledSamples(): EndpointSample[] { + return this.catalogs.flatMap((catalog) => catalog.unfiledSamples()); + } +} + +/** The endpoints of `samples` that `declared` leaves out, each once. */ +function undeclared(samples: EndpointSample[], declared: Endpoint[]): Endpoint[] { + const names = new Set(declared.map(String)); + const sampled = new Map(samples.map(({ endpoint }) => [String(endpoint), endpoint])); + return [...sampled.values()].filter((endpoint) => !names.has(String(endpoint))); +} + +/** Languages in the order given, each endpoint's samples together. */ +function byEndpoint(samples: EndpointSample[]): CodeSamplesByEndpoint { + const json: CodeSamplesByEndpoint = {}; + for (const { endpoint, sample } of samples) { + const methods = json[endpoint.path] ?? {}; + json[endpoint.path] = { ...methods, [endpoint.method]: [...(methods[endpoint.method] ?? []), sample] }; + } + return json; +} + +function specsIn(language: Language, specs: unknown): Map | undefined { + if (!isJsonObject(specs)) { + return undefined; + } + const samplesBySpec = new Map(); + for (const [specFile, spec] of Object.entries(specs)) { + const samples = isJsonObject(spec) ? samplesIn(language, spec.paths) : undefined; + if (samples === undefined) { + return undefined; } - return json; + samplesBySpec.set(specFile, samples); } + return samplesBySpec; +} - private endpoints(): Endpoint[] { - return this.catalogs.flatMap((catalog) => catalog.endpoints()); +/** One sample per endpoint, whatever the case its method is written in. */ +function samplesIn(language: Language, paths: unknown): EndpointSample[] | undefined { + if (!isJsonObject(paths)) { + return undefined; + } + const samples = new Map(); + for (const [path, methods] of Object.entries(paths)) { + if (!isJsonObject(methods)) { + return undefined; + } + for (const [method, sources] of Object.entries(methods)) { + if (!isSources(sources)) { + return undefined; + } + const endpoint = new Endpoint(method, path); + samples.set(`${endpoint}`, { endpoint, sample: toCodeSample(language, sources) }); + } } + return [...samples.values()]; } function toCodeSample(language: Language, sources: Sources): CodeSample { diff --git a/src/types/portal/portal-source.ts b/src/types/portal/portal-source.ts index 064b524f8..d96f48377 100644 --- a/src/types/portal/portal-source.ts +++ b/src/types/portal/portal-source.ts @@ -18,9 +18,6 @@ export interface PortalSpec { endpoints: Endpoint[]; } -/** An operation more than one specification declares, which one code sample per method and path cannot serve. */ -export type SharedEndpoint = Shared; - /** A part of the source directory that `portal serve` watches, and checks on each save. */ export type WatchedSource = 'config' | 'content' | 'spec'; @@ -44,8 +41,6 @@ export interface PortalSource extends PortalSettings { unmergedSpecs: FilePath[]; /** API definitions at the top of `spec/` while its folders are merged, which the portal and the SDKs leave out. */ leftOutSpecs: FilePath[]; - /** Shown with no SDK sample, since the samples hold one per method and path. */ - sharedEndpoints: SharedEndpoint[]; contentDirectory: DirectoryPath | null; staticDirectory: DirectoryPath | null; shadowedFiles: FileName[]; diff --git a/test/actions/portal/generate.test.ts b/test/actions/portal/generate.test.ts index c1e5f15d3..c5f30bf22 100644 --- a/test/actions/portal/generate.test.ts +++ b/test/actions/portal/generate.test.ts @@ -326,6 +326,46 @@ describe('GenerateAction', () => { expect(shared.prompts.sharedEndpoints.firstCall.args[0]).to.deep.equal([]); }); + describe('an operation two merged specs declare', () => { + const MERGED_FIXTURE = new DirectoryPath(process.cwd()).join('test/resources/portal-inputs/merged'); + const STATUS = { GET: { Example: 'const result = await client.health.getOrdersStatus();' } }; + + const deliverSamples = (catalog: object) => + shared.artifacts.resolves( + ok( + completeArtifacts(undefined, { + codeSampleCatalogs: new CodeSampleCatalogs([CodeSampleCatalog.fromJson(Language.TYPESCRIPT, catalog)!]) + }) + ) + ); + + it('is named with the specs declaring it once the samples arrive, when they are not filed by spec', async () => { + deliverSamples({ paths: { '/status': STATUS } }); + + const result = await execute(MERGED_FIXTURE); + + expect(result.isSuccess()).to.be.true; + const [endpoints] = shared.prompts.sharedEndpoints.firstCall.args; + const named = endpoints.map(({ key, files }) => [`${key}`, files.map((file) => file.relativeTo(MERGED_FIXTURE))]); + expect(named).to.deep.equal([ + ['GET /status', ['src/spec/orders/openapi.yaml', 'src/spec/inventory/openapi.json']] + ]); + expect(shared.prompts.sharedEndpoints.calledAfter(shared.artifacts)).to.be.true; + }); + + it('is not named when the samples are filed by spec', async () => { + deliverSamples({ + paths: { '/status': STATUS }, + specs: { 'orders/openapi.yaml': { paths: { '/status': STATUS } } } + }); + + const result = await execute(MERGED_FIXTURE); + + expect(result.isSuccess()).to.be.true; + expect(shared.prompts.sharedEndpoints.firstCall.args[0]).to.deep.equal([]); + }); + }); + // The fixture's root nav.json makes a tab of `guides`, and no two of its tabs share a name. it('reports the content notices, with no tab name shared in the fixture', async () => { const result = await execute(); diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index 9e6fc12a6..cb2491bd1 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -927,6 +927,14 @@ const stylesheetOf = (output: DirectoryPath) => { }); }); +/** A TypeScript sample of `call`, as the platform delivers one. */ +const sample = (call: string) => ({ Example: `const result = await ${call};` }); + +const LIST_ORDERS = 'api/orders/orders/listOrders'; +const LIST_STOCK = 'api/inventory/stock/listStock'; +const ORDERS_STATUS = 'api/orders/health/getOrdersStatus'; +const INVENTORY_STATUS = 'api/inventory/health/getInventoryStatus'; + /** * The layout the platform merges several APIs into one SDK from: a folder to each API, named in * `APIMATIC-META.json`. Both APIs declare `GET /status`, and the orders API keeps a schema in a @@ -935,7 +943,6 @@ const stylesheetOf = (output: DirectoryPath) => { (enabled ? describe : describe.skip)('portal build, merged spec folders (end to end)', function () { this.timeout(10 * 60 * 1000); - const sample = (call: string) => ({ Example: `const result = await ${call};` }); const portal = portalBuilt('merged', { codeSampleCatalogs: new CodeSampleCatalogs([ CodeSampleCatalog.fromJson(Language.TYPESCRIPT, { @@ -949,9 +956,7 @@ const stylesheetOf = (output: DirectoryPath) => { }); const { read, exists } = portal; - const LIST_ORDERS = 'api/orders/orders/listOrders'; - const LIST_STOCK = 'api/inventory/stock/listStock'; - const STATUS_PAGES = ['api/orders/health/getOrdersStatus', 'api/inventory/health/getInventoryStatus']; + const STATUS_PAGES = [ORDERS_STATUS, INVENTORY_STATUS]; it('writes a section per merged folder, named after it, with a page per operation', () => { for (const page of [LIST_ORDERS, LIST_STOCK, ...STATUS_PAGES]) { @@ -971,7 +976,7 @@ const stylesheetOf = (output: DirectoryPath) => { expect(stock).to.contain('https://inventory.store.test'); }); - // The samples hold one per method and path: the inventory page would show the orders API's call. + // Not filed by spec, the samples hold one per method and path: the inventory page would show the orders API's call. it('shows no SDK sample on an operation both APIs declare', () => { for (const page of STATUS_PAGES) { expect(read(`${page}/index.html`), page).to.not.contain('x-apimatic-codeSamples'); @@ -983,3 +988,42 @@ const stylesheetOf = (output: DirectoryPath) => { expect(read(`${LIST_ORDERS}.md`)).to.contain('grandTotalCents'); }); }); + +/** + * The same folders with the samples filed by spec, as the platform files them for two specs: each + * under its main file, while `paths` keeps the first call it reached on `GET /status`. + */ +(enabled ? describe : describe.skip)('portal build, samples filed by spec (end to end)', function () { + this.timeout(10 * 60 * 1000); + + const ordersStatus = 'client.health.getOrdersStatus()'; + const inventoryStatus = 'client.health.getInventoryStatus()'; + const listOrders = { GET: sample('client.orders.listOrders()') }; + const listStock = { GET: sample('client.stock.listStock()') }; + const { read } = portalBuilt('merged', { + codeSampleCatalogs: new CodeSampleCatalogs([ + CodeSampleCatalog.fromJson(Language.TYPESCRIPT, { + paths: { '/orders': listOrders, '/stock': listStock, '/status': { GET: sample(ordersStatus) } }, + specs: { + 'orders/openapi.yaml': { paths: { '/orders': listOrders, '/status': { GET: sample(ordersStatus) } } }, + 'inventory/openapi.json': { paths: { '/stock': listStock, '/status': { GET: sample(inventoryStatus) } } } + } + }) as CodeSampleCatalog + ]) + }); + + it("shows each API's own SDK call on the operation both declare", () => { + const orders = read(`${ORDERS_STATUS}/index.html`); + const inventory = read(`${INVENTORY_STATUS}/index.html`); + + expect(orders).to.contain(ordersStatus); + expect(orders).to.not.contain(inventoryStatus); + expect(inventory).to.contain(inventoryStatus); + expect(inventory).to.not.contain(ordersStatus); + }); + + it("shows each API's own SDK call on an operation only it declares", () => { + expect(read(`${LIST_ORDERS}/index.html`)).to.contain('client.orders.listOrders()'); + expect(read(`${LIST_STOCK}/index.html`)).to.contain('client.stock.listStock()'); + }); +}); diff --git a/test/infrastructure/portal-project-service.test.ts b/test/infrastructure/portal-project-service.test.ts index f6ace380a..5724fe4cd 100644 --- a/test/infrastructure/portal-project-service.test.ts +++ b/test/infrastructure/portal-project-service.test.ts @@ -64,7 +64,6 @@ describe('PortalProjectService', () => { specDirectory: new DirectoryPath(root).join('spec'), unmergedSpecs: [], leftOutSpecs: [], - sharedEndpoints: [], contentDirectory: null, staticDirectory: null, shadowedFiles: [], @@ -308,27 +307,62 @@ describe('PortalProjectService', () => { undefined ); + const LIST_PETS = { lang: 'typescript', label: 'TypeScript', sources: { Example: 'await client.pets.list();' } }; + + /** Two specs in folders of their own, both declaring `GET /pets`. */ + const sharingSource = () => { + const spec = new DirectoryPath(root).join('spec'); + return sourceFor({ + specs: ['alpha', 'beta'].map((folder) => ({ + slug: folder, + file: new FilePath(spec.join(folder), new FileName('openapi.json')), + endpoints: [new Endpoint('get', '/pets')] + })) + }); + }; + + const writtenSamples = () => JSON.parse(fs.readFileSync(readConfig().codeSamples, 'utf8')); + it('writes the samples beside the configuration, keyed by path and method, and names them in it', async () => { (await service.prepare(project, sourceFor(), withCatalogs))._unsafeUnwrap(); const config = readConfig(); expect(config.codeSamples).to.equal(path.join(project.toString(), 'code-samples.json').split(path.sep).join('/')); - expect(JSON.parse(fs.readFileSync(config.codeSamples, 'utf8'))).to.deep.equal({ - '/pets': { - GET: [{ lang: 'typescript', label: 'TypeScript', sources: { Example: 'await client.pets.list();' } }] - } - }); + expect(writtenSamples()).to.deep.equal({ paths: { '/pets': { GET: [LIST_PETS] } }, specs: {} }); }); // The samples hold one per method and path, which would be another API's call on all but one of the pages. it('leaves out the samples of an operation more than one spec declares', async () => { - const spec = new DirectoryPath(root).join('spec'); - const files = ['alpha', 'beta'].map((folder) => new FilePath(spec.join(folder), new FileName('openapi.json'))); - const source = sourceFor({ sharedEndpoints: [{ key: new Endpoint('get', '/pets'), files }] }); + (await service.prepare(project, sharingSource(), withCatalogs))._unsafeUnwrap(); - (await service.prepare(project, source, withCatalogs))._unsafeUnwrap(); + expect(writtenSamples()).to.deep.equal({ paths: {}, specs: {} }); + }); + + it("writes each spec's own samples under its slug when the run files them by spec", async () => { + const filed = new PortalArtifacts( + new CodeSampleCatalogs([ + CodeSampleCatalog.fromJson(Language.TYPESCRIPT, { + paths: { '/pets': { GET: { Example: 'await client.pets.list();' } } }, + specs: { + 'alpha/openapi.json': { paths: { '/pets': { GET: { Example: 'await client.pets.list();' } } } }, + 'beta/openapi.json': { paths: { '/pets': { GET: { Example: 'await client.stock.list();' } } } } + } + }) as CodeSampleCatalog + ]), + new Map(), + new Map(), + undefined + ); + + (await service.prepare(project, sharingSource(), filed))._unsafeUnwrap(); - expect(JSON.parse(fs.readFileSync(readConfig().codeSamples, 'utf8'))).to.deep.equal({}); + expect(writtenSamples()).to.deep.equal({ + paths: {}, + specs: { + alpha: { '/pets': { GET: [LIST_PETS] } }, + beta: { '/pets': { GET: [{ ...LIST_PETS, sources: { Example: 'await client.stock.list();' } }] } } + } + }); }); it('points each spec at its own file, samples or not', async () => { diff --git a/test/infrastructure/services/portal-artifacts-service.test.ts b/test/infrastructure/services/portal-artifacts-service.test.ts index e7d0574b5..185db4c85 100644 --- a/test/infrastructure/services/portal-artifacts-service.test.ts +++ b/test/infrastructure/services/portal-artifacts-service.test.ts @@ -12,7 +12,6 @@ import { envInfo } from '../../../src/infrastructure/env-info'; import { DirectoryPath } from '../../../src/types/file/directoryPath'; import { FileName } from '../../../src/types/file/fileName'; import { FilePath } from '../../../src/types/file/filePath'; -import { Endpoint } from '../../../src/types/portal/endpoint'; const GENERATION_ID = '11111111-2222-3333-4444-555555555555'; const AUTH_KEY = 'test-auth-key'; @@ -140,8 +139,8 @@ describe('PortalArtifactsService', () => { it('reads a catalog per language into the samples the build merges', async () => { const artifacts = (await generate())._unsafeUnwrap(); - const samples = artifacts.codeSampleCatalogs.samplesFor(new Endpoint('GET', '/payments')); - expect(samples.map((sample) => sample.lang)).to.deep.equal(['csharp']); + const { paths } = artifacts.codeSampleCatalogs.placedOn([], source.join('spec')).toJson(); + expect(paths['/payments'].GET.map((sample) => sample.lang)).to.deep.equal(['csharp']); }); // Keyed by the delivered name rather than parsed, so placement is a copy under the same name. @@ -174,7 +173,7 @@ describe('PortalArtifactsService', () => { it('succeeds with no samples, no SDKs and no plugin', async () => { const artifacts = (await generate())._unsafeUnwrap(); - expect(artifacts.codeSampleCatalogs.samplesFor(new Endpoint('GET', '/payments'))).to.be.empty; + expect(artifacts.codeSampleCatalogs.isEmpty()).to.be.true; expect(artifacts.sdks.size).to.equal(0); expect(artifacts.sdkDocs.size).to.equal(0); expect(artifacts.plugin).to.be.undefined; diff --git a/test/portal-template.test.ts b/test/portal-template.test.ts index 83d5799e2..5c5c4f321 100644 --- a/test/portal-template.test.ts +++ b/test/portal-template.test.ts @@ -10,10 +10,12 @@ import { PortalBuildPaths, TEMPLATE_DEPENDENCIES } from '../src/infrastructure/portal-project-service'; +import { CodeSample, CodeSamplesFile } from '../src/types/portal/code-samples'; import { GENERATED_DIRECTORY_NAME, PAGE_TEMPLATES } from '../src/types/portal/generated-pages'; import { GENERATED_INCLUDES_DIRECTORY_NAME } from '../src/types/portal/page-fragments'; import { PortalIdentity } from '../src/types/portal/portal-config'; import type { BuildPaths } from '../portal-template/portal-config'; +import type { CodeSamplesFile as TemplateCodeSamplesFile } from '../portal-template/src/lib/code-samples.server'; import type { Portal } from '../portal-template/src/lib/portal-types'; /** True only when the two types are identical, every nested field and union member included. */ @@ -204,9 +206,11 @@ describe('portal template packaging', () => { it('declares exactly what the CLI writes for it', () => { const identity: Equal = true; const paths: Equal = true; + const codeSamples: Equal, CodeSamplesFile> = true; expect(identity).to.equal(true); expect(paths).to.equal(true); + expect(codeSamples).to.equal(true); }); // The CLI writes these into the prepared project; a copy in the template would be a second diff --git a/test/portal-template/code-samples-placement.test.ts b/test/portal-template/code-samples-placement.test.ts index b40f07c05..eef7c077c 100644 --- a/test/portal-template/code-samples-placement.test.ts +++ b/test/portal-template/code-samples-placement.test.ts @@ -70,7 +70,7 @@ describe('placeCodeSamples', () => { it('drops the samples an operation carries when the build has no samples file', async () => { const written = { paths: { '/pets': { get: { ...operation('List'), [EXTENSION]: [typescript('own')] } } } }; - expect(placed(written, await readCodeSamples(null)).paths['/pets'].get).to.not.have.property(EXTENSION); + expect(placed(written, await readCodeSamples(null, 'pets')).paths['/pets'].get).to.not.have.property(EXTENSION); }); it('gives two paths that share one operation object their own samples', () => { @@ -112,11 +112,11 @@ describe('placeCodeSamples', () => { components: { pathItems: { Owners: { get: operation('List owners') } } } }); const samplesFile = write('code-samples.json', { - '/pets': { GET: [typescript('pets')] }, - '/owners': { GET: [typescript('owners')] } + paths: { '/pets': { GET: [typescript('pets')] }, '/owners': { GET: [typescript('owners')] } }, + specs: {} }); - const samples = await readCodeSamples(samplesFile); + const samples = await readCodeSamples(samplesFile, 'api'); const server = createOpenAPI({ input: { api: async () => placeCodeSamples(await bundleSpec(file), samples) } }); @@ -127,3 +127,41 @@ describe('placeCodeSamples', () => { }); }); }); + +describe('readCodeSamples', () => { + let root: string; + + /** The samples file the CLI writes: `orders` filed apart, every other section reading `paths`. */ + const samplesFile = () => { + const file = path.join(root, 'code-samples.json'); + fs.writeFileSync( + file, + JSON.stringify({ + paths: { '/status': { GET: [typescript('shared')] } }, + specs: { orders: { '/status': { GET: [typescript('orders')] } } } + }) + ); + return file; + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'code-samples-read-')); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it("gives a section filed apart its own samples, and any other section's the rest", async () => { + const file = samplesFile(); + + expect(await readCodeSamples(file, 'orders')).to.deep.equal({ '/status': { GET: [typescript('orders')] } }); + expect(await readCodeSamples(file, 'inventory')).to.deep.equal({ '/status': { GET: [typescript('shared')] } }); + }); + + it("gives a section named after one of an object's own members the rest", async () => { + expect(await readCodeSamples(samplesFile(), 'constructor')).to.deep.equal({ + '/status': { GET: [typescript('shared')] } + }); + }); +}); diff --git a/test/prompts/portal/code-samples.test.ts b/test/prompts/portal/code-samples.test.ts new file mode 100644 index 000000000..87613946b --- /dev/null +++ b/test/prompts/portal/code-samples.test.ts @@ -0,0 +1,85 @@ +import { stripVTControlCharacters } from 'node:util'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { log } from '@clack/prompts'; +import { reportSharedEndpoints, reportUnplacedSamples } from '../../../src/prompts/portal/code-samples.js'; +import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; +import { FileName } from '../../../src/types/file/fileName.js'; +import { FilePath } from '../../../src/types/file/filePath.js'; +import { Endpoint } from '../../../src/types/portal/endpoint.js'; + +const source = new DirectoryPath('project').join('src'); +const files = ['alpha', 'beta'].map( + (folder) => new FilePath(source.join('spec').join(folder), new FileName('openapi.json')) +); + +describe('the code samples prompts', () => { + let lines: string[]; + + beforeEach(() => { + lines = []; + const record = (text?: string | string[]) => { + lines.push(stripVTControlCharacters(String(text))); + }; + sinon.stub(log, 'warn').callsFake(record); + sinon.stub(log, 'message').callsFake(record); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('reportSharedEndpoints', () => { + it('says nothing when no two specs declare the same operation', () => { + reportSharedEndpoints([], source); + + expect(lines).to.deep.equal([]); + }); + + it('names each operation and the specs declaring it, and why it shows no SDK sample', () => { + reportSharedEndpoints( + [ + { key: new Endpoint('get', '/status'), files }, + { key: new Endpoint('post', '/status'), files } + ], + source + ); + + expect(lines.join('\n').split('\n')).to.deep.equal([ + 'More than one API definition declares these operations, so the portal shows no SDK sample on them:', + " • 'GET /status': 'spec/alpha/openapi.json' and 'spec/beta/openapi.json'", + " • 'POST /status': 'spec/alpha/openapi.json' and 'spec/beta/openapi.json'" + ]); + }); + + it('speaks of one operation when there is one', () => { + reportSharedEndpoints([{ key: new Endpoint('get', '/status'), files }], source); + + expect(lines[0]).to.contain('declares this operation,'); + expect(lines[0]).to.contain('no SDK sample on it:'); + }); + }); + + describe('reportUnplacedSamples', () => { + it('says nothing when every sample has an operation', () => { + reportUnplacedSamples([], source); + + expect(lines).to.deep.equal([]); + }); + + it('names the spec of the samples filed by spec, and spec/ for the others', () => { + reportUnplacedSamples( + [ + { where: files[0], endpoints: [new Endpoint('get', '/refunds'), new Endpoint('post', '/refunds')] }, + { where: source.join('spec'), endpoints: [new Endpoint('get', '/owners')] } + ], + source + ); + + expect(lines).to.deep.equal([ + "No operation in 'spec/alpha/openapi.json' takes the code samples for GET /refunds, POST /refunds.", + "No operation in './spec' takes the code samples for GET /owners." + ]); + }); + }); +}); diff --git a/test/prompts/portal/source.test.ts b/test/prompts/portal/source.test.ts index 62fe5069f..dfdd1c749 100644 --- a/test/prompts/portal/source.test.ts +++ b/test/prompts/portal/source.test.ts @@ -7,13 +7,11 @@ import { reportLeftOutSpecs, reportNoTabsListed, reportShadowedFiles, - reportSharedEndpoints, reportSharedTabNames, reportSourceProblem, reportUnmergedSpecs, reportUnseenHomeTitle } from '../../../src/prompts/portal/source.js'; -import { Endpoint } from '../../../src/types/portal/endpoint.js'; import { ContentProblem, UnreadableSpecReason } from '../../../src/types/portal/portal-source.js'; import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; import { FileName } from '../../../src/types/file/fileName.js'; @@ -855,52 +853,3 @@ describe('reportLeftOutSpecs', () => { ]); }); }); - -describe('reportSharedEndpoints', () => { - const source = new DirectoryPath('project').join('src'); - const spec = source.join('spec'); - const files = ['alpha', 'beta'].map((folder) => new FilePath(spec.join(folder), new FileName('openapi.json'))); - let lines: string[]; - - beforeEach(() => { - lines = []; - const record = (text?: string | string[]) => { - lines.push(stripVTControlCharacters(String(text))); - }; - sinon.stub(log, 'warn').callsFake(record); - sinon.stub(log, 'message').callsFake(record); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('says nothing when no two specs declare the same operation', () => { - reportSharedEndpoints([], source); - - expect(lines).to.deep.equal([]); - }); - - it('names each operation and the specs declaring it, and why it shows no SDK sample', () => { - reportSharedEndpoints( - [ - { key: new Endpoint('get', '/status'), files }, - { key: new Endpoint('post', '/status'), files } - ], - source - ); - - expect(lines.join('\n').split('\n')).to.deep.equal([ - 'More than one API definition declares these operations, so the portal shows no SDK sample on them:', - " • 'GET /status': 'spec/alpha/openapi.json' and 'spec/beta/openapi.json'", - " • 'POST /status': 'spec/alpha/openapi.json' and 'spec/beta/openapi.json'" - ]); - }); - - it('speaks of one operation when there is one', () => { - reportSharedEndpoints([{ key: new Endpoint('get', '/status'), files }], source); - - expect(lines[0]).to.contain('declares this operation,'); - expect(lines[0]).to.contain('no SDK sample on it:'); - }); -}); diff --git a/test/types/portal-source-context.test.ts b/test/types/portal-source-context.test.ts index 7b55d3e59..16a694193 100644 --- a/test/types/portal-source-context.test.ts +++ b/test/types/portal-source-context.test.ts @@ -959,34 +959,12 @@ describe('PortalSourceContext', () => { expect((await resolve())._unsafeUnwrap().unmergedSpecs).to.be.empty; }); - it('names each operation more than one spec declares, with the specs that do', async () => { - write('spec/APIMATIC-META.json', JSON.stringify({ MergeConfiguration: { MergeApis: true } })); - write('spec/alpha/openapi.json', withPaths({ '/status': { get: {} }, '/alpha': { get: {} } })); - write('spec/beta/openapi.json', withPaths({ '/status': { get: {}, post: {} }, '/beta': { get: {} } })); - - const shared = (await resolve())._unsafeUnwrap().sharedEndpoints; - - expect(shared.map(({ key, files }) => [`${key}`, files.map(relative)])).to.deep.equal([ - ['GET /status', ['spec/alpha/openapi.json', 'spec/beta/openapi.json']] - ]); - }); - - it('names none when no two specs declare the same operation', async () => { - write('spec/a.json', withPaths({ '/a': { get: {} } })); - write('spec/b.json', withPaths({ '/b': { get: {} } })); - - expect((await resolve())._unsafeUnwrap().sharedEndpoints).to.be.empty; - }); - // A path item's own method beside its `$ref` declares the operation a second time, in the one spec. - it('reads an operation one spec declares twice once, and names none shared', async () => { + it('reads an operation one spec declares twice once', async () => { write('spec/paths/pets.json', JSON.stringify({ get: {} })); write('spec/api.json', withPaths({ '/pets': { $ref: './paths/pets.json', get: {} } })); - const source = (await resolve())._unsafeUnwrap(); - - expect(source.specs[0].endpoints.map(String)).to.deep.equal(['GET /pets']); - expect(source.sharedEndpoints).to.be.empty; + expect((await resolve())._unsafeUnwrap().specs[0].endpoints.map(String)).to.deep.equal(['GET /pets']); }); }); }); diff --git a/test/types/portal/code-samples.test.ts b/test/types/portal/code-samples.test.ts index 2a2729da7..eaddfa580 100644 --- a/test/types/portal/code-samples.test.ts +++ b/test/types/portal/code-samples.test.ts @@ -1,16 +1,41 @@ +import path from 'path'; import { expect } from 'chai'; -import { CodeSampleCatalog, CodeSampleCatalogs } from '../../../src/types/portal/code-samples'; +import { DirectoryPath } from '../../../src/types/file/directoryPath'; +import { FilePath } from '../../../src/types/file/filePath'; +import { + CodeSampleCatalog, + CodeSampleCatalogs, + CodeSamplesFile, + UnplacedSamples +} from '../../../src/types/portal/code-samples'; import { Endpoint } from '../../../src/types/portal/endpoint'; +import { PortalSpec } from '../../../src/types/portal/portal-source'; import { Language } from '../../../src/types/sdk/generate'; -const catalog = (language: Language, paths: unknown): CodeSampleCatalog => { - const parsed = CodeSampleCatalog.fromJson(language, { paths, webhooks: {} }); - expect(parsed, `unparsable: ${JSON.stringify(paths)}`).to.not.be.undefined; +const SOURCE_DIRECTORY = new DirectoryPath(path.resolve('portal', 'src')); +const SPEC_DIRECTORY = SOURCE_DIRECTORY.join('spec'); + +const catalog = (language: Language, json: object): CodeSampleCatalog => { + const parsed = CodeSampleCatalog.fromJson(language, { webhooks: {}, ...json }); + expect(parsed, `unparsable: ${JSON.stringify(json)}`).to.not.be.undefined; return parsed as CodeSampleCatalog; }; -const samples = (codeSampleCatalogs: CodeSampleCatalogs, endpoint: Endpoint) => - codeSampleCatalogs.samplesFor(endpoint).map(({ label, sources }) => ({ label, sources })); +/** A spec at `file`, relative to `spec/`, declaring each of `endpoints` ("GET /status"). */ +const spec = (slug: string, file: string, endpoints: string[]): PortalSpec => ({ + slug, + file: FilePath.resolve(SPEC_DIRECTORY, file), + endpoints: endpoints.map((endpoint) => { + const [method, route] = endpoint.split(' '); + return new Endpoint(method, route); + }) +}); + +const placed = (catalogs: CodeSampleCatalog[], specs: PortalSpec[]): CodeSamplesFile => + new CodeSampleCatalogs(catalogs).placedOn(specs, SPEC_DIRECTORY).toJson(); + +const typescript = (call: string) => ({ lang: 'typescript', label: 'TypeScript', sources: { Example: call } }); +const csharp = (call: string) => ({ lang: 'csharp', label: 'C#', sources: { Example: call } }); describe('CodeSampleCatalog', () => { it('rejects a catalog that is not the wire shape', () => { @@ -19,98 +44,267 @@ describe('CodeSampleCatalog', () => { expect(CodeSampleCatalog.fromJson(Language.TYPESCRIPT, { paths: { '/a': { GET: { x: 1 } } } })).to.be.undefined; }); - it('looks a sample up by method whatever its case', () => { - const typescript = catalog(Language.TYPESCRIPT, { '/pets': { GET: { Example: 'list()' } } }); + it('rejects samples filed by spec that are not the wire shape', () => { + const filed = (specs: unknown) => CodeSampleCatalog.fromJson(Language.TYPESCRIPT, { paths: {}, specs }); - expect(typescript.sampleFor(new Endpoint('get', '/pets'))).to.not.be.undefined; + expect(filed([])).to.be.undefined; + expect(filed({ 'a/a.json': {} })).to.be.undefined; + expect(filed({ 'a/a.json': { paths: { '/a': { GET: { x: 1 } } } } })).to.be.undefined; }); +}); - it('labels a sample with the language name alone', () => { - const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.CSHARP, { '/pets': { GET: { Example: 'List()' } } }) - ]); +describe('PlacedCodeSamples', () => { + const placedOn = (catalogs: CodeSampleCatalog[], specs: PortalSpec[]) => + new CodeSampleCatalogs(catalogs).placedOn(specs, SPEC_DIRECTORY); - expect(samples(codeSampleCatalogs, new Endpoint('GET', '/pets'))).to.deep.equal([ - { label: 'C#', sources: { Example: 'List()' } } - ]); - }); + const named = (file: FilePath) => file.relativeTo(SPEC_DIRECTORY); + /** Each place named as a warning names it, from the source directory. */ + const unplacedIn = (unplaced: UnplacedSamples[]) => + unplaced.map(({ where, endpoints }) => [where.relativeTo(SOURCE_DIRECTORY), endpoints.map(String)]); - it('keeps every example of an operation in declaration order', () => { - const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.TYPESCRIPT, { '/pets': { POST: { minimal: 'a', full: 'b', basic: 'c' } } }) - ]); + describe('the operations shown with no SDK sample', () => { + const alpha = spec('alpha', 'alpha/openapi.json', ['GET /status', 'GET /alpha']); + const beta = spec('beta', 'beta/openapi.json', ['GET /status', 'POST /status', 'GET /beta']); + const unfiled = catalog(Language.TYPESCRIPT, { paths: { '/status': { GET: { Example: 'status' } } } }); - const [sample] = codeSampleCatalogs.samplesFor(new Endpoint('POST', '/pets')); - expect(Object.keys(sample.sources)).to.deep.equal(['minimal', 'full', 'basic']); - }); + it('names each operation more than one spec declares, with the specs that do', () => { + const shared = placedOn([unfiled], [alpha, beta]).sharedEndpoints(); - it('keeps an example named Example beside other examples', () => { - const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.PYTHON, { '/pets': { POST: { Example: 'x', minimal: 'a', full: 'b' } } }) - ]); + expect(shared.map(({ key, files }) => [`${key}`, files.map(named)])).to.deep.equal([ + ['GET /status', ['alpha/openapi.json', 'beta/openapi.json']] + ]); + }); - expect(samples(codeSampleCatalogs, new Endpoint('POST', '/pets'))).to.deep.equal([ - { label: 'Python', sources: { Example: 'x', minimal: 'a', full: 'b' } } - ]); - }); -}); + it('names none when no two specs declare the same operation', () => { + const gamma = spec('gamma', 'gamma/openapi.json', ['GET /gamma']); -describe('CodeSampleCatalogs', () => { - it('gives one sample per language, in catalog order', () => { - const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.TYPESCRIPT, { '/pets': { POST: { minimal: 'a', full: 'b' } } }), - catalog(Language.CSHARP, { '/pets': { POST: { minimal: 'c', full: 'd' } } }) - ]); - - expect(samples(codeSampleCatalogs, new Endpoint('POST', '/pets')).map(({ label }) => label)).to.deep.equal([ - 'TypeScript', - 'C#' - ]); - }); + expect(placedOn([unfiled], [alpha, gamma]).sharedEndpoints()).to.be.empty; + }); - it('has nothing for an operation a language has no sample for', () => { - const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.TYPESCRIPT, { '/pets': { GET: { Example: 'a' } } }) - ]); + it('names none when the samples are filed by spec, each spec showing its own', () => { + const filed = catalog(Language.TYPESCRIPT, { + paths: { '/status': { GET: { Example: 'alpha status' } } }, + specs: { + 'alpha/openapi.json': { paths: { '/status': { GET: { Example: 'alpha status' } } } }, + 'beta/openapi.json': { paths: { '/status': { GET: { Example: 'beta status' } } } } + } + }); - expect(codeSampleCatalogs.samplesFor(new Endpoint('DELETE', '/pets'))).to.be.empty; + expect(placedOn([filed], [alpha, beta]).sharedEndpoints()).to.be.empty; + }); }); - it('names the samples no document has an operation for', () => { - const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.TYPESCRIPT, { '/pets': { GET: { Example: 'a' } }, '/owners': { GET: { Example: 'b' } } }), - catalog(Language.CSHARP, { '/owners': { GET: { Example: 'c' } } }) - ]); + describe('the samples no operation takes', () => { + it('names, of the samples not filed by spec, those no spec declares, once across languages', () => { + const unplaced = placedOn( + [ + catalog(Language.TYPESCRIPT, { + paths: { '/pets': { GET: { Example: 'a' } }, '/owners': { GET: { Example: 'b' } } } + }), + catalog(Language.CSHARP, { paths: { '/owners': { GET: { Example: 'c' } } } }) + ], + [spec('pets', 'openapi.json', ['GET /pets'])] + ).unplaced(); + + expect(unplacedIn(unplaced)).to.deep.equal([['./spec', ['GET /owners']]]); + }); + + it('names, of the samples filed under a spec, those it does not declare, and checks nothing else', () => { + const unplaced = placedOn( + [ + catalog(Language.TYPESCRIPT, { + paths: { '/orders': { GET: { Example: 'orders' } }, '/refunds': { POST: { Example: 'refund' } } }, + specs: { + 'orders/openapi.yaml': { + paths: { '/orders': { GET: { Example: 'orders' } }, '/refunds': { POST: { Example: 'refund' } } } + }, + 'gamma/openapi.json': { paths: { '/gamma': { GET: { Example: 'gamma' } } } } + } + }) + ], + [spec('orders', 'orders/openapi.yaml', ['GET /orders'])] + ).unplaced(); - expect(codeSampleCatalogs.unplacedIn([new Endpoint('get', '/pets')])).to.deep.equal(['GET /owners']); + expect(unplacedIn(unplaced)).to.deep.equal([['spec/orders/openapi.yaml', ['POST /refunds']]]); + }); }); - it('leaves out the samples of the given endpoints, in every language, and keeps the rest', () => { - const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.TYPESCRIPT, { '/pets': { GET: { Example: 'a' }, POST: { Example: 'b' } } }), - catalog(Language.CSHARP, { '/pets': { GET: { Example: 'c' } } }) - ]).without([new Endpoint('get', '/pets')]); + describe('samples not filed by spec', () => { + const calculator = spec('calculator', 'openapi.json', ['GET /pets', 'POST /pets']); + + it('keys each endpoint by path and upper-case method, languages in catalog order, labelled by name alone', () => { + const file = placed( + [ + catalog(Language.TYPESCRIPT, { paths: { '/pets': { get: { Example: 'a' }, POST: { Example: 'b' } } } }), + catalog(Language.CSHARP, { paths: { '/pets': { GET: { Example: 'c' } } } }) + ], + [calculator] + ); + + expect(file).to.deep.equal({ + paths: { '/pets': { GET: [typescript('a'), csharp('c')], POST: [typescript('b')] } }, + specs: {} + }); + }); + + it('keeps every example of an operation in declaration order, one named Example among them', () => { + const file = placed( + [catalog(Language.PYTHON, { paths: { '/pets': { POST: { minimal: 'a', Example: 'x', full: 'b' } } } })], + [calculator] + ); - expect(codeSampleCatalogs.samplesFor(new Endpoint('GET', '/pets'))).to.be.empty; - expect(samples(codeSampleCatalogs, new Endpoint('POST', '/pets'))).to.deep.equal([ - { label: 'TypeScript', sources: { Example: 'b' } } - ]); + expect(Object.keys(file.paths['/pets'].POST[0].sources)).to.deep.equal(['minimal', 'Example', 'full']); + }); + + // One sample per method and path, which would be another API's call on all but one of the pages. + it('leaves out an operation two specs declare, in every language, and keeps the rest', () => { + const specs = [ + spec('alpha', 'alpha/alpha.json', ['GET /status', 'GET /alpha']), + spec('beta', 'beta/beta.json', ['GET /status']) + ]; + + const file = placed( + [ + catalog(Language.TYPESCRIPT, { + paths: { '/status': { GET: { Example: 'alpha status' } }, '/alpha': { GET: { Example: 'alpha' } } } + }), + catalog(Language.CSHARP, { paths: { '/status': { GET: { Example: 'Alpha status' } } } }) + ], + specs + ); + + expect(file).to.deep.equal({ paths: { '/alpha': { GET: [typescript('alpha')] } }, specs: {} }); + }); + + it('reads an empty set of samples by spec as none', () => { + const file = placed( + [catalog(Language.TYPESCRIPT, { paths: { '/pets': { GET: { Example: 'a' } } }, specs: {} })], + [calculator] + ); + + expect(file).to.deep.equal({ paths: { '/pets': { GET: [typescript('a')] } }, specs: {} }); + }); }); - it("writes each endpoint's samples keyed by path and upper-case method, languages in catalog order", () => { - const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.TYPESCRIPT, { '/pets': { get: { Example: 'a' }, POST: { Example: 'b' } } }), - catalog(Language.CSHARP, { '/pets': { GET: { Example: 'c' } } }) - ]); - - expect(codeSampleCatalogs.toJson()).to.deep.equal({ - '/pets': { - GET: [ - { lang: 'typescript', label: 'TypeScript', sources: { Example: 'a' } }, - { lang: 'csharp', label: 'C#', sources: { Example: 'c' } } + describe('samples filed by spec', () => { + const orders = spec('orders', 'orders/openapi.yaml', ['GET /status', 'GET /orders']); + const inventory = spec('inventory', 'inventory/openapi.json', ['GET /status', 'GET /stock']); + + /** As the platform files the two: `paths` holds the first operation on `GET /status` it reached. */ + const filed = (language: Language, prefix: string) => + catalog(language, { + paths: { + '/status': { GET: { Example: `${prefix} orders status` } }, + '/orders': { GET: { Example: `${prefix} orders` } }, + '/stock': { GET: { Example: `${prefix} stock` } } + }, + specs: { + 'orders/openapi.yaml': { + paths: { + '/status': { GET: { Example: `${prefix} orders status` } }, + '/orders': { GET: { Example: `${prefix} orders` } } + } + }, + 'inventory/openapi.json': { + paths: { + '/status': { GET: { Example: `${prefix} inventory status` } }, + '/stock': { GET: { Example: `${prefix} stock` } } + } + } + } + }); + + it('gives each spec its own samples, its own call on an operation both declare, and none from paths', () => { + expect(placed([filed(Language.TYPESCRIPT, 'ts')], [orders, inventory])).to.deep.equal({ + paths: {}, + specs: { + orders: { + '/status': { GET: [typescript('ts orders status')] }, + '/orders': { GET: [typescript('ts orders')] } + }, + inventory: { + '/status': { GET: [typescript('ts inventory status')] }, + '/stock': { GET: [typescript('ts stock')] } + } + } + }); + }); + + it("keeps languages in catalog order within a spec's own samples", () => { + const file = placed([filed(Language.TYPESCRIPT, 'ts'), filed(Language.CSHARP, 'cs')], [orders, inventory]); + + expect(file.specs.inventory['/status'].GET).to.deep.equal([ + typescript('ts inventory status'), + csharp('cs inventory status') + ]); + }); + + it('gives a spec nothing for an operation its own samples leave out, though paths has it', () => { + const file = placed( + [ + catalog(Language.TYPESCRIPT, { + paths: { '/status': { GET: { Example: 'inventory status' } }, '/stock': { GET: { Example: 'stock' } } }, + specs: { 'inventory/openapi.json': { paths: { '/status': { GET: { Example: 'inventory status' } } } } } + }) ], - POST: [{ lang: 'typescript', label: 'TypeScript', sources: { Example: 'b' } }] - } + [inventory] + ); + + expect(file).to.deep.equal({ + paths: {}, + specs: { inventory: { '/status': { GET: [typescript('inventory status')] } } } + }); + }); + + it('gives a spec with no samples of its own none at all', () => { + const file = placed( + [ + catalog(Language.TYPESCRIPT, { + paths: { '/orders': { GET: { Example: 'orders' } }, '/stock': { GET: { Example: 'stock' } } }, + specs: { 'inventory/openapi.json': { paths: { '/stock': { GET: { Example: 'stock' } } } } } + }) + ], + [orders, inventory] + ); + + expect(file).to.deep.equal({ paths: {}, specs: { inventory: { '/stock': { GET: [typescript('stock')] } } } }); + }); + + it('matches a spec in a nested folder by its path from spec/', () => { + const nested = spec('orders', 'apis/orders/openapi.yaml', ['GET /orders']); + + const file = placed( + [ + catalog(Language.TYPESCRIPT, { + paths: { '/orders': { GET: { Example: 'orders' } } }, + specs: { 'apis/orders/openapi.yaml': { paths: { '/orders': { GET: { Example: 'orders' } } } } } + }) + ], + [nested] + ); + + expect(file.specs).to.deep.equal({ orders: { '/orders': { GET: [typescript('orders')] } } }); + }); + + it('matches a spec by its path only as the platform writes it', () => { + const group = { paths: { '/orders': { GET: { Example: 'orders' } } } }; + + const file = placed( + [ + catalog(Language.TYPESCRIPT, { + paths: {}, + specs: { + './orders/openapi.yaml': group, + 'orders\\openapi.yaml': group, + 'Orders/openapi.yaml': group, + orders: group + } + }) + ], + [orders] + ); + + expect(file).to.deep.equal({ paths: {}, specs: {} }); }); }); });