From 3fa2bdfead54ffc45b7127f7e260194d82432ade Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Thu, 22 Oct 2026 17:29:50 +0500 Subject: [PATCH 1/5] feat(portal): read the samples filed by spec and place each spec's own The samples file from /portal-artifacts gains `specs` when the operations come from two or more spec files: each spec's samples, keyed by its main file relative to `src/spec/`. A spec filed so now takes its samples from its own group alone, and one with no group takes none. A file without `specs` is placed as after #2297. code-samples.json becomes `{ paths, specs }`, with `specs` keyed by slug, and a test holds the CLI's and the template's declarations of it to one shape. Refs apimatic/apimatic-io#2298 Co-Authored-By: Claude Opus 5.5 --- portal-template/portal-config.ts | 2 +- .../src/lib/code-samples.server.ts | 17 +- .../src/lib/openapi-section.server.ts | 2 +- src/infrastructure/portal-project-service.ts | 9 +- src/types/portal/code-samples.ts | 147 +++++++--- .../portal-project-service.test.ts | 55 +++- .../services/portal-artifacts-service.test.ts | 7 +- test/portal-template.test.ts | 4 + .../code-samples-placement.test.ts | 46 ++- test/types/portal/code-samples.test.ts | 276 +++++++++++++----- 10 files changed, 421 insertions(+), 144 deletions(-) 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/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/types/portal/code-samples.ts b/src/types/portal/code-samples.ts index cccef9c8a..9bfa020ab 100644 --- a/src/types/portal/code-samples.ts +++ b/src/types/portal/code-samples.ts @@ -1,6 +1,9 @@ +import { DirectoryPath } from '../file/directoryPath.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 } from './shared.js'; type Sources = Record; @@ -10,49 +13,47 @@ 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 { +/** What the portal template reads: each spec's own samples by its slug, and the samples of the others in `paths`. */ +export interface CodeSamplesFile { + paths: CodeSamplesByEndpoint; + specs: Record; +} + +export interface EndpointSample { endpoint: Endpoint; sample: CodeSample; } +/** 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 the spec's main file relative to `spec/`, as the platform records it; none when the operations come from one spec. */ + 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); + public samples(): EndpointSample[] { + return 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 +64,96 @@ export class CodeSampleCatalogs { return this.catalogs.length === 0; } - public samplesFor(endpoint: Endpoint): CodeSample[] { - return this.catalogs.flatMap((catalog) => catalog.sampleFor(endpoint) ?? []); - } - - public without(endpoints: Endpoint[]): CodeSampleCatalogs { - return new CodeSampleCatalogs(this.catalogs.map((catalog) => catalog.without(endpoints))); + public placedOn(specs: PortalSpec[], specDirectory: DirectoryPath): PlacedCodeSamples { + return new PlacedCodeSamples(this.catalogs, specs, specDirectory); } public unplacedIn(documented: Endpoint[]): string[] { const names = new Set(documented.map(String)); - return [...new Set(this.endpoints().map(String))].filter((endpoint) => !names.has(endpoint)); + const endpoints = this.catalogs.flatMap((catalog) => catalog.samples().map(({ endpoint }) => String(endpoint))); + return [...new Set(endpoints)].filter((endpoint) => !names.has(endpoint)); } +} - public toJson(): CodeSamplesByEndpoint { - const json: CodeSamplesByEndpoint = {}; - for (const endpoint of this.endpoints()) { - json[endpoint.path] = { ...json[endpoint.path], [endpoint.method]: this.samplesFor(endpoint) }; +/** + * The samples of one run, placed on the specs the portal documents: those filed by spec on their + * spec alone, and the others on every spec, but for an operation two specs declare, which one + * sample per method and path cannot serve. + */ +export class PlacedCodeSamples { + private readonly sharedEndpoints: Set; + + constructor( + private readonly catalogs: CodeSampleCatalog[], + private readonly specs: PortalSpec[], + private readonly specDirectory: DirectoryPath + ) { + const claims = specs.flatMap(({ file, endpoints }) => endpoints.map((key) => ({ key, file }))); + this.sharedEndpoints = new Set(shared(claims).map(({ key }) => String(key))); + } + + public toJson(): CodeSamplesFile { + const unfiled = this.catalogs + .filter((catalog) => !catalog.isFiledBySpec()) + .flatMap((catalog) => catalog.samples()) + .filter(({ endpoint }) => !this.sharedEndpoints.has(String(endpoint))); + return { + paths: byEndpoint(unfiled), + specs: Object.fromEntries( + this.specs.flatMap(({ slug, file }): [string, CodeSamplesByEndpoint][] => { + const specFile = file.relativeTo(this.specDirectory); + const own = this.catalogs.flatMap((catalog) => catalog.samplesOf(specFile)); + return own.length === 0 ? [] : [[slug, byEndpoint(own)]]; + }) + ) + }; + } +} + +/** 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/test/infrastructure/portal-project-service.test.ts b/test/infrastructure/portal-project-service.test.ts index f6ace380a..5cdc2b0d7 100644 --- a/test/infrastructure/portal-project-service.test.ts +++ b/test/infrastructure/portal-project-service.test.ts @@ -308,27 +308,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/types/portal/code-samples.test.ts b/test/types/portal/code-samples.test.ts index 2a2729da7..956e3b106 100644 --- a/test/types/portal/code-samples.test.ts +++ b/test/types/portal/code-samples.test.ts @@ -1,16 +1,35 @@ +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 } 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 SPEC_DIRECTORY = new DirectoryPath(path.resolve('portal', 'src', '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 +38,205 @@ 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', () => { +describe('CodeSampleCatalogs', () => { + it('names the samples no document has an operation for', () => { const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.CSHARP, { '/pets': { GET: { Example: 'List()' } } }) + catalog(Language.TYPESCRIPT, { + paths: { '/pets': { GET: { Example: 'a' } }, '/owners': { GET: { Example: 'b' } } } + }), + catalog(Language.CSHARP, { paths: { '/owners': { GET: { Example: 'c' } } } }) ]); - expect(samples(codeSampleCatalogs, new Endpoint('GET', '/pets'))).to.deep.equal([ - { label: 'C#', sources: { Example: 'List()' } } - ]); + expect(codeSampleCatalogs.unplacedIn([new Endpoint('get', '/pets')])).to.deep.equal(['GET /owners']); }); +}); - 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('PlacedCodeSamples', () => { + describe('samples not filed by spec', () => { + const calculator = spec('calculator', 'openapi.json', ['GET /pets', 'POST /pets']); - const [sample] = codeSampleCatalogs.samplesFor(new Endpoint('POST', '/pets')); - expect(Object.keys(sample.sources)).to.deep.equal(['minimal', 'full', 'basic']); - }); + 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] + ); - 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(file).to.deep.equal({ + paths: { '/pets': { GET: [typescript('a'), csharp('c')], POST: [typescript('b')] } }, + specs: {} + }); + }); - expect(samples(codeSampleCatalogs, new Endpoint('POST', '/pets'))).to.deep.equal([ - { label: 'Python', sources: { Example: 'x', minimal: 'a', full: 'b' } } - ]); - }); -}); + 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] + ); -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(Object.keys(file.paths['/pets'].POST[0].sources)).to.deep.equal(['minimal', 'Example', 'full']); + }); - expect(samples(codeSampleCatalogs, new Endpoint('POST', '/pets')).map(({ label }) => label)).to.deep.equal([ - 'TypeScript', - 'C#' - ]); - }); + // 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 + ); - it('has nothing for an operation a language has no sample for', () => { - const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.TYPESCRIPT, { '/pets': { GET: { Example: 'a' } } }) - ]); + expect(file).to.deep.equal({ paths: { '/alpha': { GET: [typescript('alpha')] } }, specs: {} }); + }); - expect(codeSampleCatalogs.samplesFor(new Endpoint('DELETE', '/pets'))).to.be.empty; + 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('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('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')] } + } + } + }); + }); - expect(codeSampleCatalogs.unplacedIn([new Endpoint('get', '/pets')])).to.deep.equal(['GET /owners']); - }); + 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]); - 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')]); + expect(file.specs.inventory['/status'].GET).to.deep.equal([ + typescript('ts inventory status'), + csharp('cs inventory status') + ]); + }); - 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' } } - ]); - }); + 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' } } } } } + }) + ], + [inventory] + ); - 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(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']); - expect(codeSampleCatalogs.toJson()).to.deep.equal({ - '/pets': { - GET: [ - { lang: 'typescript', label: 'TypeScript', sources: { Example: 'a' } }, - { lang: 'csharp', label: 'C#', sources: { Example: 'c' } } + const file = placed( + [ + catalog(Language.TYPESCRIPT, { + paths: { '/orders': { GET: { Example: 'orders' } } }, + specs: { 'apis/orders/openapi.yaml': { paths: { '/orders': { GET: { Example: 'orders' } } } } } + }) ], - POST: [{ lang: 'typescript', label: 'TypeScript', sources: { Example: 'b' } }] - } + [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: {} }); }); }); }); From 776a5c9b49914323c7d1342a13c755b492cdcfce Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Thu, 22 Oct 2026 17:40:46 +0500 Subject: [PATCH 2/5] feat(portal): warn about shared operations and unplaced samples by how the samples are filed Whether an operation two specs declare shows an SDK sample now depends on the samples file, so the warning about it moves from the source to the placed samples, and prints once the samples arrive. It names the operations only when the samples are not filed by spec. Samples that no operation takes are checked within each spec when the samples are filed by spec, and the warning names that spec's file. Without the filing, the check and its message are as before. Both warnings now live in src/prompts/portal/code-samples.ts. Refs apimatic/apimatic-io#2298 Co-Authored-By: Claude Opus 5.5 --- src/actions/portal/prepare-project.ts | 7 +- src/prompts/portal/code-samples.ts | 21 ++++- src/prompts/portal/prepare-project.ts | 10 +-- src/prompts/portal/source.ts | 18 +--- src/types/portal-source-context.ts | 2 - src/types/portal/code-samples.ts | 74 +++++++++++----- src/types/portal/portal-source.ts | 5 -- test/actions/portal/generate.test.ts | 40 +++++++++ .../portal-project-service.test.ts | 1 - test/prompts/portal/code-samples.test.ts | 85 ++++++++++++++++++ test/prompts/portal/source.test.ts | 51 ----------- test/types/portal-source-context.test.ts | 26 +----- test/types/portal/code-samples.test.ts | 88 ++++++++++++++++--- 13 files changed, 286 insertions(+), 142 deletions(-) create mode 100644 test/prompts/portal/code-samples.test.ts 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/prompts/portal/code-samples.ts b/src/prompts/portal/code-samples.ts index 73d727489..d38bd3f48 100644 --- a/src/prompts/portal/code-samples.ts +++ b/src/prompts/portal/code-samples.ts @@ -1,9 +1,24 @@ 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 { relative, 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 { spec, endpoints } of unplaced) { + const where = spec === null ? f.var('spec') : relative(spec, sourceDirectory); + log.warn(`No operation in ${where} 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..529fbadc7 100644 --- a/src/prompts/portal/prepare-project.ts +++ b/src/prompts/portal/prepare-project.ts @@ -8,16 +8,16 @@ import { FilePath } from '../../types/file/filePath.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 { SharedEndpoint, UnplacedSamples } from '../../types/portal/code-samples.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..38d747c78 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'; @@ -52,12 +51,13 @@ export const contentPath = (sourceDirectory: DirectoryPath): string => export const staticPath = (sourceDirectory: DirectoryPath): string => f.path(sourceDirectory.join(STATIC_DIRECTORY_NAME)); -const relative = (file: FilePath, sourceDirectory: DirectoryPath): string => f.var(file.relativeTo(sourceDirectory)); +export const relative = (file: FilePath, sourceDirectory: DirectoryPath): string => + f.var(file.relativeTo(sourceDirectory)); 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 +417,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 9bfa020ab..bf6c008c7 100644 --- a/src/types/portal/code-samples.ts +++ b/src/types/portal/code-samples.ts @@ -1,9 +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 } from './shared.js'; +import { Shared, shared } from './shared.js'; type Sources = Record; @@ -27,11 +28,26 @@ export interface EndpointSample { 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 takes: those filed under `spec` that it does not declare, or, for the + * samples not filed by spec (`null`), those no spec declares. + */ +export interface UnplacedSamples { + spec: FilePath | null; + endpoints: Endpoint[]; +} + /** One language's samples, as one `/portal-artifacts` run delivers them. */ export class CodeSampleCatalog { private constructor( private readonly paths: EndpointSample[], - /** By the spec's main file relative to `spec/`, as the platform records it; none when the operations come from one spec. */ + /** + * By the spec's main file relative to `spec/`, as the platform records it; none when the + * operations come from one spec. + */ private readonly specs: ReadonlyMap ) {} @@ -67,12 +83,6 @@ export class CodeSampleCatalogs { public placedOn(specs: PortalSpec[], specDirectory: DirectoryPath): PlacedCodeSamples { return new PlacedCodeSamples(this.catalogs, specs, specDirectory); } - - public unplacedIn(documented: Endpoint[]): string[] { - const names = new Set(documented.map(String)); - const endpoints = this.catalogs.flatMap((catalog) => catalog.samples().map(({ endpoint }) => String(endpoint))); - return [...new Set(endpoints)].filter((endpoint) => !names.has(endpoint)); - } } /** @@ -81,33 +91,57 @@ export class CodeSampleCatalogs { * sample per method and path cannot serve. */ export class PlacedCodeSamples { - private readonly sharedEndpoints: Set; + private readonly shared: SharedEndpoint[]; constructor( private readonly catalogs: CodeSampleCatalog[], private readonly specs: PortalSpec[], private readonly specDirectory: DirectoryPath ) { - const claims = specs.flatMap(({ file, endpoints }) => endpoints.map((key) => ({ key, file }))); - this.sharedEndpoints = new Set(shared(claims).map(({ key }) => String(key))); + 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) => ({ spec: spec.file, endpoints: undeclared(this.ownSamples(spec), spec.endpoints) })), + { spec: null, endpoints: undeclared(this.unfiledSamples(), declared) } + ].filter(({ endpoints }) => endpoints.length > 0); } public toJson(): CodeSamplesFile { - const unfiled = this.catalogs - .filter((catalog) => !catalog.isFiledBySpec()) - .flatMap((catalog) => catalog.samples()) - .filter(({ endpoint }) => !this.sharedEndpoints.has(String(endpoint))); + const sharedKeys = new Set(this.shared.map(({ key }) => String(key))); return { - paths: byEndpoint(unfiled), + paths: byEndpoint(this.unfiledSamples().filter(({ endpoint }) => !sharedKeys.has(String(endpoint)))), specs: Object.fromEntries( - this.specs.flatMap(({ slug, file }): [string, CodeSamplesByEndpoint][] => { - const specFile = file.relativeTo(this.specDirectory); - const own = this.catalogs.flatMap((catalog) => catalog.samplesOf(specFile)); - return own.length === 0 ? [] : [[slug, byEndpoint(own)]]; + this.specs.flatMap((spec): [string, CodeSamplesByEndpoint][] => { + const own = this.ownSamples(spec); + return own.length === 0 ? [] : [[spec.slug, byEndpoint(own)]]; }) ) }; } + + private ownSamples({ file }: PortalSpec): EndpointSample[] { + const specFile = file.relativeTo(this.specDirectory); + return this.catalogs.flatMap((catalog) => catalog.samplesOf(specFile)); + } + + private unfiledSamples(): EndpointSample[] { + return this.catalogs.filter((catalog) => !catalog.isFiledBySpec()).flatMap((catalog) => catalog.samples()); + } +} + +/** 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 left = samples.map(({ endpoint }) => endpoint).filter((endpoint) => !names.has(String(endpoint))); + return left.filter((endpoint, index) => index === left.findIndex((other) => other.isEqual(endpoint))); } /** Languages in the order given, each endpoint's samples together. */ 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/infrastructure/portal-project-service.test.ts b/test/infrastructure/portal-project-service.test.ts index 5cdc2b0d7..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: [], diff --git a/test/prompts/portal/code-samples.test.ts b/test/prompts/portal/code-samples.test.ts new file mode 100644 index 000000000..8322feb3f --- /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( + [ + { spec: files[0], endpoints: [new Endpoint('get', '/refunds'), new Endpoint('post', '/refunds')] }, + { spec: null, 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 956e3b106..3fd04602e 100644 --- a/test/types/portal/code-samples.test.ts +++ b/test/types/portal/code-samples.test.ts @@ -47,20 +47,84 @@ describe('CodeSampleCatalog', () => { }); }); -describe('CodeSampleCatalogs', () => { - it('names the samples no document has an operation for', () => { - const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.TYPESCRIPT, { - paths: { '/pets': { GET: { Example: 'a' } }, '/owners': { GET: { Example: 'b' } } } - }), - catalog(Language.CSHARP, { paths: { '/owners': { GET: { Example: 'c' } } } }) - ]); - - expect(codeSampleCatalogs.unplacedIn([new Endpoint('get', '/pets')])).to.deep.equal(['GET /owners']); +describe('PlacedCodeSamples', () => { + const placedOn = (catalogs: CodeSampleCatalog[], specs: PortalSpec[]) => + new CodeSampleCatalogs(catalogs).placedOn(specs, SPEC_DIRECTORY); + + /** Each spec named by its path from `spec/`, or null for the samples not filed by spec. */ + const named = (file: FilePath | null) => (file === null ? null : file.relativeTo(SPEC_DIRECTORY)); + + 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' } } } }); + + it('names each operation more than one spec declares, with the specs that do', () => { + const shared = placedOn([unfiled], [alpha, beta]).sharedEndpoints(); + + expect(shared.map(({ key, files }) => [`${key}`, files.map(named)])).to.deep.equal([ + ['GET /status', ['alpha/openapi.json', 'beta/openapi.json']] + ]); + }); + + it('names none when no two specs declare the same operation', () => { + const gamma = spec('gamma', 'gamma/openapi.json', ['GET /gamma']); + + expect(placedOn([unfiled], [alpha, gamma]).sharedEndpoints()).to.be.empty; + }); + + 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(placedOn([filed], [alpha, beta]).sharedEndpoints()).to.be.empty; + }); + }); + + 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(unplaced.map(({ spec, endpoints }) => [named(spec), endpoints.map(String)])).to.deep.equal([ + [null, ['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(unplaced.map(({ spec, endpoints }) => [named(spec), endpoints.map(String)])).to.deep.equal([ + ['orders/openapi.yaml', ['POST /refunds']] + ]); + }); }); -}); -describe('PlacedCodeSamples', () => { describe('samples not filed by spec', () => { const calculator = spec('calculator', 'openapi.json', ['GET /pets', 'POST /pets']); From 3b6cbc7b78ade43ad11138bf98b92a98432b43c6 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Fri, 23 Oct 2026 10:02:06 +0500 Subject: [PATCH 3/5] test(portal): build the merged fixture with its samples filed by spec A second end-to-end build of the merged fixture gives each spec its own samples, as the platform files them, with `paths` holding the orders call for the operation both specs declare. Each status page shows its own API's call and not the other's. The two merged blocks now share their page paths and sample helper. Refs apimatic/apimatic-io#2298 Co-Authored-By: Claude Opus 5.5 --- test/e2e/portal-build.test.ts | 54 +++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 5 deletions(-) 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()'); + }); +}); From 9e3857f3d1bdf8e4d9dbecb3936c101312cd3ee5 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Fri, 23 Oct 2026 10:20:29 +0500 Subject: [PATCH 4/5] docs(portal): state each samples rule once, and point the CLI's samples file at the template's The CLI's `CodeSamplesFile` now points to the template's declaration instead of restating it, as `PortalBuildPaths` does, and the placed samples' doc names the shared endpoints rather than repeating why they get no sample. Refs apimatic/apimatic-io#2298 Co-Authored-By: Claude Opus 5.5 --- src/types/portal/code-samples.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/types/portal/code-samples.ts b/src/types/portal/code-samples.ts index bf6c008c7..8e1030b30 100644 --- a/src/types/portal/code-samples.ts +++ b/src/types/portal/code-samples.ts @@ -17,7 +17,7 @@ export interface CodeSample { /** Each endpoint's samples, keyed by path and then by upper-case method. */ export type CodeSamplesByEndpoint = Record>; -/** What the portal template reads: each spec's own samples by its slug, and the samples of the others in `paths`. */ +/** `code-samples.json`, which the template reads as `CodeSamplesFile`; a test holds the two to one shape. */ export interface CodeSamplesFile { paths: CodeSamplesByEndpoint; specs: Record; @@ -87,8 +87,7 @@ export class CodeSampleCatalogs { /** * The samples of one run, placed on the specs the portal documents: those filed by spec on their - * spec alone, and the others on every spec, but for an operation two specs declare, which one - * sample per method and path cannot serve. + * spec alone, and the others on every spec but for the shared endpoints. */ export class PlacedCodeSamples { private readonly shared: SharedEndpoint[]; From d8ec0ef2b24ec48d3c84fc616b3b6c1bc16c360a Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Fri, 23 Oct 2026 15:52:55 +0500 Subject: [PATCH 5/5] refactor(portal): keep the samples comments to one line, and name where unplaced samples sit by path `UnplacedSamples.where` is the spec file, or `spec/` for the samples not filed by spec, in place of a null that needed a comment; that warning now names './spec', as other warnings name a folder. A catalog filed by spec answers no unfiled samples itself, and `undeclared` de-duplicates by the key it compares with. Refs apimatic/apimatic-io#2298 Co-Authored-By: Claude Opus 5.5 --- src/prompts/portal/code-samples.ts | 9 ++++--- src/prompts/portal/prepare-project.ts | 2 +- src/prompts/portal/source.ts | 3 +-- src/types/portal/code-samples.ts | 32 +++++++++--------------- test/prompts/portal/code-samples.test.ts | 6 ++--- test/types/portal/code-samples.test.ts | 24 ++++++++++-------- 6 files changed, 36 insertions(+), 40 deletions(-) diff --git a/src/prompts/portal/code-samples.ts b/src/prompts/portal/code-samples.ts index d38bd3f48..dca226e95 100644 --- a/src/prompts/portal/code-samples.ts +++ b/src/prompts/portal/code-samples.ts @@ -2,7 +2,7 @@ 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 { relative, sharedLines } from './source.js'; +import { sharedLines } from './source.js'; export function reportSharedEndpoints(shared: SharedEndpoint[], sourceDirectory: DirectoryPath): void { if (shared.length === 0) { @@ -17,8 +17,9 @@ export function reportSharedEndpoints(shared: SharedEndpoint[], sourceDirectory: } export function reportUnplacedSamples(unplaced: UnplacedSamples[], sourceDirectory: DirectoryPath): void { - for (const { spec, endpoints } of unplaced) { - const where = spec === null ? f.var('spec') : relative(spec, sourceDirectory); - log.warn(`No operation in ${where} takes the code samples for ${endpoints.join(', ')}.`); + 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 529fbadc7..11754550f 100644 --- a/src/prompts/portal/prepare-project.ts +++ b/src/prompts/portal/prepare-project.ts @@ -5,10 +5,10 @@ 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 { SharedEndpoint, UnplacedSamples } from '../../types/portal/code-samples.js'; import { PortalSourceProblem } from '../../types/portal/portal-source.js'; import { format as f } from '../format.js'; import { describeMissingArtifacts, generateArtifacts } from './artifacts.js'; diff --git a/src/prompts/portal/source.ts b/src/prompts/portal/source.ts index 38d747c78..5af9d2e46 100644 --- a/src/prompts/portal/source.ts +++ b/src/prompts/portal/source.ts @@ -51,8 +51,7 @@ export const contentPath = (sourceDirectory: DirectoryPath): string => export const staticPath = (sourceDirectory: DirectoryPath): string => f.path(sourceDirectory.join(STATIC_DIRECTORY_NAME)); -export const relative = (file: FilePath, sourceDirectory: DirectoryPath): string => - f.var(file.relativeTo(sourceDirectory)); +const relative = (file: FilePath, sourceDirectory: DirectoryPath): string => f.var(file.relativeTo(sourceDirectory)); const listed = (files: FilePath[], sourceDirectory: DirectoryPath): string => listedInProse(files.map((file) => relative(file, sourceDirectory))); diff --git a/src/types/portal/code-samples.ts b/src/types/portal/code-samples.ts index 8e1030b30..10350f5df 100644 --- a/src/types/portal/code-samples.ts +++ b/src/types/portal/code-samples.ts @@ -31,12 +31,9 @@ export interface EndpointSample { /** An operation more than one spec declares, which one code sample per method and path cannot serve. */ export type SharedEndpoint = Shared; -/** - * Samples no operation takes: those filed under `spec` that it does not declare, or, for the - * samples not filed by spec (`null`), those no spec declares. - */ +/** Samples no operation in `where` takes: a spec, for its own samples, or `spec/`, for the rest. */ export interface UnplacedSamples { - spec: FilePath | null; + where: FilePath | DirectoryPath; endpoints: Endpoint[]; } @@ -44,10 +41,7 @@ export interface UnplacedSamples { export class CodeSampleCatalog { private constructor( private readonly paths: EndpointSample[], - /** - * By the spec's main file relative to `spec/`, as the platform records it; none when the - * operations come from one spec. - */ + /** By each spec's main file from `spec/`, as the platform keys it; empty when the operations come from one. */ private readonly specs: ReadonlyMap ) {} @@ -64,8 +58,9 @@ export class CodeSampleCatalog { return this.specs.size > 0; } - public samples(): EndpointSample[] { - return this.paths; + // Filed by spec, every operation in `paths` is in a spec's samples too. + public unfiledSamples(): EndpointSample[] { + return this.isFiledBySpec() ? [] : this.paths; } public samplesOf(specFile: string): EndpointSample[] { @@ -85,10 +80,7 @@ export class CodeSampleCatalogs { } } -/** - * The samples of one run, placed on the specs the portal documents: those filed by spec on their - * spec alone, and the others on every spec but for the shared endpoints. - */ +/** 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[]; @@ -108,8 +100,8 @@ export class PlacedCodeSamples { public unplaced(): UnplacedSamples[] { const declared = this.specs.flatMap(({ endpoints }) => endpoints); return [ - ...this.specs.map((spec) => ({ spec: spec.file, endpoints: undeclared(this.ownSamples(spec), spec.endpoints) })), - { spec: null, endpoints: undeclared(this.unfiledSamples(), declared) } + ...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); } @@ -132,15 +124,15 @@ export class PlacedCodeSamples { } private unfiledSamples(): EndpointSample[] { - return this.catalogs.filter((catalog) => !catalog.isFiledBySpec()).flatMap((catalog) => catalog.samples()); + 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 left = samples.map(({ endpoint }) => endpoint).filter((endpoint) => !names.has(String(endpoint))); - return left.filter((endpoint, index) => index === left.findIndex((other) => other.isEqual(endpoint))); + 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. */ diff --git a/test/prompts/portal/code-samples.test.ts b/test/prompts/portal/code-samples.test.ts index 8322feb3f..87613946b 100644 --- a/test/prompts/portal/code-samples.test.ts +++ b/test/prompts/portal/code-samples.test.ts @@ -70,15 +70,15 @@ describe('the code samples prompts', () => { it('names the spec of the samples filed by spec, and spec/ for the others', () => { reportUnplacedSamples( [ - { spec: files[0], endpoints: [new Endpoint('get', '/refunds'), new Endpoint('post', '/refunds')] }, - { spec: null, endpoints: [new Endpoint('get', '/owners')] } + { 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." + "No operation in './spec' takes the code samples for GET /owners." ]); }); }); diff --git a/test/types/portal/code-samples.test.ts b/test/types/portal/code-samples.test.ts index 3fd04602e..eaddfa580 100644 --- a/test/types/portal/code-samples.test.ts +++ b/test/types/portal/code-samples.test.ts @@ -2,12 +2,18 @@ import path from 'path'; import { expect } from 'chai'; import { DirectoryPath } from '../../../src/types/file/directoryPath'; import { FilePath } from '../../../src/types/file/filePath'; -import { CodeSampleCatalog, CodeSampleCatalogs, CodeSamplesFile } from '../../../src/types/portal/code-samples'; +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 SPEC_DIRECTORY = new DirectoryPath(path.resolve('portal', 'src', 'spec')); +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 }); @@ -51,8 +57,10 @@ describe('PlacedCodeSamples', () => { const placedOn = (catalogs: CodeSampleCatalog[], specs: PortalSpec[]) => new CodeSampleCatalogs(catalogs).placedOn(specs, SPEC_DIRECTORY); - /** Each spec named by its path from `spec/`, or null for the samples not filed by spec. */ - const named = (file: FilePath | null) => (file === null ? null : file.relativeTo(SPEC_DIRECTORY)); + 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)]); describe('the operations shown with no SDK sample', () => { const alpha = spec('alpha', 'alpha/openapi.json', ['GET /status', 'GET /alpha']); @@ -98,9 +106,7 @@ describe('PlacedCodeSamples', () => { [spec('pets', 'openapi.json', ['GET /pets'])] ).unplaced(); - expect(unplaced.map(({ spec, endpoints }) => [named(spec), endpoints.map(String)])).to.deep.equal([ - [null, ['GET /owners']] - ]); + 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', () => { @@ -119,9 +125,7 @@ describe('PlacedCodeSamples', () => { [spec('orders', 'orders/openapi.yaml', ['GET /orders'])] ).unplaced(); - expect(unplaced.map(({ spec, endpoints }) => [named(spec), endpoints.map(String)])).to.deep.equal([ - ['orders/openapi.yaml', ['POST /refunds']] - ]); + expect(unplacedIn(unplaced)).to.deep.equal([['spec/orders/openapi.yaml', ['POST /refunds']]]); }); });