From da876ca5992311e417bb6b8a93d4272d8737ae92 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Wed, 14 Oct 2026 12:16:42 +0500 Subject: [PATCH 1/4] feat(portal): build a portal from the spec folders APIMATIC-META.json merges `portal generate` and `portal serve` read only the documents at the top of `src/spec`, so the layout the platform merges several APIs from, a folder to each API and an `APIMATIC-META.json` beside them, failed with "No OpenAPI 3.x document found". When `APIMATIC-META.json` turns on `MergeApis`, the portal now reads the folders it merges, in the order `MergeOrderOfDirectories` lists them or else every folder by name, one OpenAPI document to a folder and a section named after the folder. Refused, each with its own message: a merged folder with no document to read (empty, missing or outside `src/spec`), a merged folder with two OpenAPI documents, and an `APIMATIC-META.json` that is not JSON. Only the exact name `APIMATIC-META.json` is read. Warned, and built: - several documents at the top of `src/spec` that nothing merges, as the SDKs are built from one; - an API definition left at the top of `src/spec` while its folders are merged, which the portal and the SDKs leave out; - an operation that more than one definition declares: its SDK samples are left out, since the samples hold one per method and path and would show another API's call. The `portal generate` and `portal serve` help, the README and the Live preview note describe the layout. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +- src/actions/portal/prepare-project.ts | 3 + src/commands/portal/generate.ts | 2 + src/commands/portal/serve.ts | 2 +- src/infrastructure/portal-project-service.ts | 5 +- src/prompts/portal/prepare-project.ts | 24 +- src/prompts/portal/serve.ts | 4 +- src/prompts/portal/source.ts | 124 ++++++-- src/types/file/directory.ts | 3 +- src/types/file/directoryPath.ts | 5 + src/types/file/fileName.ts | 15 +- src/types/file/filePath.ts | 2 +- src/types/portal-source-context.ts | 212 +++++++++++-- src/types/portal/code-samples.ts | 9 + src/types/portal/content-tree.ts | 6 +- src/types/portal/openapi-document.ts | 6 + src/types/portal/portal-source.ts | 27 +- src/types/portal/spec-metadata.ts | 32 ++ src/types/project-layout.ts | 3 + test/actions/portal/generate.test.ts | 9 + test/e2e/portal-build.test.ts | 57 ++++ .../portal-project-service.test.ts | 15 + test/prompts/portal/serve.test.ts | 2 +- test/prompts/portal/source.test.ts | 241 ++++++++++++++- .../portal-inputs/merged/src/apimatic.json | 10 + .../merged/src/spec/APIMATIC-META.json | 7 + .../merged/src/spec/inventory/openapi.json | 24 ++ .../merged/src/spec/orders/openapi.yaml | 32 ++ .../merged/src/spec/orders/schemas/order.yaml | 8 + test/types/file/directory-path.test.ts | 13 + test/types/file/fileName.test.ts | 25 ++ test/types/portal-source-context.test.ts | 278 +++++++++++++++++- test/types/portal/code-samples.test.ts | 12 + test/types/portal/openapi-document.test.ts | 8 + test/types/portal/spec-metadata.test.ts | 62 ++++ 35 files changed, 1228 insertions(+), 66 deletions(-) create mode 100644 src/types/portal/spec-metadata.ts create mode 100644 test/resources/portal-inputs/merged/src/apimatic.json create mode 100644 test/resources/portal-inputs/merged/src/spec/APIMATIC-META.json create mode 100644 test/resources/portal-inputs/merged/src/spec/inventory/openapi.json create mode 100644 test/resources/portal-inputs/merged/src/spec/orders/openapi.yaml create mode 100644 test/resources/portal-inputs/merged/src/spec/orders/schemas/order.yaml create mode 100644 test/types/file/fileName.test.ts create mode 100644 test/types/portal/spec-metadata.test.ts diff --git a/README.md b/README.md index d2b246749..5f002c784 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,10 @@ DESCRIPTION Builds a documentation portal from the OpenAPI documents and Markdown pages in your 'src' directory. + Each OpenAPI document at the top of 'src/spec' gets a section of the API reference, and the SDKs are built from one of + them. To build the SDKs from several, put each in a folder of its own and turn on 'MergeApis' in an + 'APIMATIC-META.json' beside the folders; each folder then gets a section. + The portal is built on your machine and written as static files for any static host, served at its root or, when 'portal.site.url' has a path such as 'https://example.com/docs', at that path. Configure it with 'src/apimatic.json', whose 'languages' block gives the portal a page for each SDK language, and whose 'plugin' block, or a 'pluginUrl' in @@ -349,7 +353,8 @@ DESCRIPTION 'apimatic.json'. Adding a language or a 'plugin' block, adding or removing a page in 'src/content', creating 'src/static', changing - which documents are in 'src/spec', or changing the path in 'portal.site.url', needs the preview restarted. + which documents are in 'src/spec' or which folders its 'APIMATIC-META.json' merges, or changing the path in + 'portal.site.url', needs the preview restarted. Nothing is written to disk; run 'apimatic portal generate' to produce the static files. diff --git a/src/actions/portal/prepare-project.ts b/src/actions/portal/prepare-project.ts index 0d7b05e10..9f2f16ea4 100644 --- a/src/actions/portal/prepare-project.ts +++ b/src/actions/portal/prepare-project.ts @@ -64,6 +64,9 @@ export class PreparePortalProjectAction { } this.prompts.filesShadowedByStatic(source.value.shadowedFiles); 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(); diff --git a/src/commands/portal/generate.ts b/src/commands/portal/generate.ts index 4d6ea91e3..512151b19 100644 --- a/src/commands/portal/generate.ts +++ b/src/commands/portal/generate.ts @@ -11,6 +11,8 @@ export default class PortalGenerate extends Command { static readonly description = `Builds a documentation portal from the OpenAPI documents and Markdown pages in your 'src' directory. +Each OpenAPI document at the top of 'src/spec' gets a section of the API reference, and the SDKs are built from one of them. To build the SDKs from several, put each in a folder of its own and turn on 'MergeApis' in an 'APIMATIC-META.json' beside the folders; each folder then gets a section. + The portal is built on your machine and written as static files for any static host, served at its root or, when 'portal.site.url' has a path such as 'https://example.com/docs', at that path. Configure it with 'src/apimatic.json', whose 'languages' block gives the portal a page for each SDK language, and whose 'plugin' block, or a 'pluginUrl' in its 'portal' block for a plugin hosted elsewhere, a page for the context plugin.`; static readonly cmdTxt = format.cmd('apimatic', 'portal', 'generate'); diff --git a/src/commands/portal/serve.ts b/src/commands/portal/serve.ts index edc13b150..bd376f0d7 100644 --- a/src/commands/portal/serve.ts +++ b/src/commands/portal/serve.ts @@ -13,7 +13,7 @@ export default class PortalServe extends Command { static readonly description = `Serves the portal described by 'src/apimatic.json' from your machine, reloading the browser as you edit the Markdown pages in 'src/content', reorder them in a 'nav.json', or change the 'portal', 'languages' or 'plugin' block of 'apimatic.json'. -Adding a language or a 'plugin' block, adding or removing a page in 'src/content', creating 'src/static', changing which documents are in 'src/spec', or changing the path in 'portal.site.url', needs the preview restarted. +Adding a language or a 'plugin' block, adding or removing a page in 'src/content', creating 'src/static', changing which documents are in 'src/spec' or which folders its 'APIMATIC-META.json' merges, or changing the path in 'portal.site.url', needs the preview restarted. Nothing is written to disk; run 'apimatic portal generate' to produce the static files.`; diff --git a/src/infrastructure/portal-project-service.ts b/src/infrastructure/portal-project-service.ts index 6a53f3b0f..b2def4d7f 100644 --- a/src/infrastructure/portal-project-service.ts +++ b/src/infrastructure/portal-project-service.ts @@ -127,7 +127,10 @@ export class PortalProjectService { projectDirectory, source, await this.contentDirectory(projectDirectory, source), - await this.writeCodeSamples(projectDirectory, artifacts.codeSampleCatalogs), + await this.writeCodeSamples( + projectDirectory, + artifacts.codeSampleCatalogs.without(source.sharedEndpoints.map(({ endpoint }) => endpoint)) + ), await this.writeDownloads(projectDirectory, artifacts) ); diff --git a/src/prompts/portal/prepare-project.ts b/src/prompts/portal/prepare-project.ts index 0da362c21..78ae3a211 100644 --- a/src/prompts/portal/prepare-project.ts +++ b/src/prompts/portal/prepare-project.ts @@ -4,15 +4,23 @@ import { ServiceError } from '../../infrastructure/service-error.js'; import { PortalAuthorizationFailure } from '../../infrastructure/services/portal-authorization-service.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; import { FileName } from '../../types/file/fileName.js'; +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 } from '../../types/portal/portal-source.js'; +import { PortalSourceProblem, SharedEndpoint } 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 { reportContentNotices, reportShadowedFiles, reportSourceProblem } from './source.js'; +import { + reportContentNotices, + reportLeftOutSpecs, + reportShadowedFiles, + reportSharedEndpoints, + reportSourceProblem, + reportUnmergedSpecs +} from './source.js'; /** What both `portal generate` and `portal serve` say while the project they share is prepared. */ export class PreparePortalProjectPrompts { @@ -48,6 +56,18 @@ export class PreparePortalProjectPrompts { reportContentNotices(notices, sourceDirectory); } + public unmergedSpecs(files: FilePath[], sourceDirectory: DirectoryPath) { + reportUnmergedSpecs(files, sourceDirectory); + } + + public leftOutSpecs(files: FilePath[], sourceDirectory: DirectoryPath) { + reportLeftOutSpecs(files, sourceDirectory); + } + + public sharedEndpoints(shared: SharedEndpoint[], sourceDirectory: DirectoryPath) { + reportSharedEndpoints(shared, sourceDirectory); + } + public unplacedSamples(endpoints: string[]) { reportUnplacedSamples(endpoints); } diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index 183532f8f..8f34c69df 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -6,6 +6,7 @@ import { UrlPath } from '../../types/file/urlPath.js'; import { ContentNotices } from '../../types/portal/content-notices.js'; import { MissingArtifacts } from '../../types/portal/generated-pages.js'; import { NAVIGATION_FILE_NAME } from '../../types/portal/portal-navigation.js'; +import { METADATA_FILE_NAME } from '../../types/project-layout.js'; import { ContentProblem, PortalSourceProblem } from '../../types/portal/portal-source.js'; import { PortalDevServer, PortalDevServerFailure } from '../../infrastructure/portal-dev-server-service.js'; import { Result } from 'neverthrow'; @@ -56,7 +57,8 @@ export class PortalServePrompts { '', `Restart the preview after adding a language or a ${f.var('plugin')} block (its SDK or plugin is ` + `fetched at startup), adding or removing a page, creating ${staticPath(sourceDirectory)}, ` + - `changing which documents are in ${specPath(sourceDirectory)}, or changing the path in ` + + `changing which documents are in ${specPath(sourceDirectory)} or which folders its ` + + `${f.var(METADATA_FILE_NAME)} merges, or changing the path in ` + `${f.var('portal.site.url')}.`, '', 'Press CTRL+C to stop the server.' diff --git a/src/prompts/portal/source.ts b/src/prompts/portal/source.ts index f0b83043e..e632613b5 100644 --- a/src/prompts/portal/source.ts +++ b/src/prompts/portal/source.ts @@ -11,19 +11,32 @@ import { PortalSourceProblem, ReservedAddressPage, SharedAddress, + SharedEndpoint, SpecConversion } from '../../types/portal/portal-source.js'; +import { MERGE_SETTINGS } from '../../types/portal/spec-metadata.js'; import { TRANSFORMATIONS_DIRECTORY_NAME } from '../../types/transform-context.js'; import { PortalTab, SharedTabName } from '../../types/portal/portal-tabs.js'; import { FileName } from '../../types/file/fileName.js'; import { FilePath } from '../../types/file/filePath.js'; -import { CONTENT_DIRECTORY_NAME, SPEC_DIRECTORY_NAME, STATIC_DIRECTORY_NAME } from '../../types/project-layout.js'; +import { + CONTENT_DIRECTORY_NAME, + METADATA_FILE_NAME, + SPEC_DIRECTORY_NAME, + STATIC_DIRECTORY_NAME +} from '../../types/project-layout.js'; import { format as f } from '../format.js'; const TITLE_EXAMPLE = ['---', 'title: Getting started', '---'].join('\n'); const ROOT_NAVIGATION_FILE = `${CONTENT_DIRECTORY_NAME}/${NAVIGATION_FILE_NAME}`; +const API_MERGING_URL = 'https://docs.apimatic.io/manage-apis/api-merging/'; + +const mergeTheFolders = (): string => + `turn on ${f.var(MERGE_SETTINGS.mergeApis)} in an ${f.var(METADATA_FILE_NAME)} beside the folders: ` + + f.link(API_MERGING_URL); + export const specPath = (sourceDirectory: DirectoryPath): string => f.path(sourceDirectory.join(SPEC_DIRECTORY_NAME)); export const contentPath = (sourceDirectory: DirectoryPath): string => @@ -34,6 +47,9 @@ export const staticPath = (sourceDirectory: DirectoryPath): string => 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 spelt = (found: FilePath, sourceDirectory: DirectoryPath): string => `spelt ${relative(found, sourceDirectory)} on disk`; @@ -55,11 +71,10 @@ export const convertToOpenApi3 = (): string => `Convert it with ${transformCommand([f.flag('file', '')])}, and use the file it writes into a ` + `${f.var(TRANSFORMATIONS_DIRECTORY_NAME)} folder.`; -function reportSpecConversion({ file, format, converted, others }: SpecConversion, sourceDirectory: DirectoryPath) { +function reportSpecConversion({ file, format, converted, others }: SpecConversion) { const name = f.var(file.name().toString()); - log.error( - `No OpenAPI 3.x document found in ${specPath(sourceDirectory)}.` + (format === null ? '' : ` ${name} is ${format}.`) - ); + const directory = f.path(file.directory()); + log.error(`No OpenAPI 3.x document found in ${directory}.` + (format === null ? '' : ` ${name} is ${format}.`)); const command = transformCommand([ f.flag('file', f.relative(file)), f.flag('destination', f.relative(file.directory())) @@ -69,11 +84,28 @@ function reportSpecConversion({ file, format, converted, others }: SpecConversio log.message( `${convert} it with:\n` + ` ${command}\n` + - `then move ${f.relativePath(converted)} up into ${specPath(sourceDirectory)}, which is the only folder read.` + + `then move ${f.relativePath(converted)} up into ${directory}.` + (others === 0 ? '' : ` Convert the other ${documents} the same way.`) ); } +function reportEmptyMergedFolders(metadata: FilePath, folders: DirectoryPath[], sourceDirectory: DirectoryPath) { + const file = relative(metadata, sourceDirectory); + if (folders.length === 0) { + log.error(`${file} merges the folders of ${specPath(sourceDirectory)}, which has none.`); + const mergeApis = f.var(MERGE_SETTINGS.mergeApis); + log.message(`Put each API definition in a folder of its own there, or turn off ${mergeApis} in the file.`); + return; + } + const one = folders.length === 1; + const names = listedInProse(folders.map((folder) => f.var(folder.relativeTo(sourceDirectory)))); + log.error(`${file} merges ${names}, which ${one ? 'holds' : 'hold'} no API definition the portal can read.`); + log.message( + `A merged folder sits inside ${specPath(sourceDirectory)}, with its OpenAPI 3.x document at its top. Add ` + + `${one ? 'one to it' : 'one to each'}, or list only the folders to merge in ${f.var(MERGE_SETTINGS.order)}.` + ); +} + /** * Shared by `portal generate` and `portal serve`: both read the same source directory, so * a broken one has to be explained the same way in both. @@ -94,7 +126,11 @@ export function reportSourceProblem(problem: PortalSourceProblem, sourceDirector return; } case 'unreadableSpec': { - log.error(`${f.var(problem.fileName.toString())} could not be read as JSON or YAML.`); + log.error(`${relative(problem.file, sourceDirectory)} could not be read as JSON or YAML.`); + return; + } + case 'unreadableMetadata': { + log.error(`${relative(problem.file, sourceDirectory)} could not be read as JSON.`); return; } case 'missingStaticFiles': { @@ -120,17 +156,36 @@ export function reportSourceProblem(problem: PortalSourceProblem, sourceDirector } case 'emptySpecDirectory': { const message = - `${specPath(sourceDirectory)} has no files. Add your OpenAPI 3.x document to it as a ` + + `${specPath(sourceDirectory)} has no API definition. Add your OpenAPI 3.x document to it as a ` + `${f.var('.json')}, ${f.var('.yaml')} or ${f.var('.yml')} file.`; log.error(message); if (problem.folders.length > 0) { const folders = listedInProse(problem.folders.map((folder) => f.var(folder.leafName()))); - log.message(`A document in a folder inside it, such as ${folders}, is not read: move it up.`); + log.message( + `A document in a folder inside it, such as ${folders}, is not read. Move it up, or to keep one API ` + + `to a folder, ${mergeTheFolders()}` + ); } return; } + case 'emptyMergedFolders': { + reportEmptyMergedFolders(problem.metadata, problem.folders, sourceDirectory); + return; + } + case 'severalSpecsInFolder': { + const names = listed(problem.files, sourceDirectory); + log.error( + `${names} are each an OpenAPI document, but a folder that ${f.var(METADATA_FILE_NAME)} merges holds ` + + `one API definition.` + ); + log.message( + `Keep one there and move each of the others into a folder of its own, adding the new folders to ` + + `${f.var(MERGE_SETTINGS.order)} when the file lists folders.` + ); + return; + } case 'noOpenApiSpec': { - reportSpecConversion(problem.conversion, sourceDirectory); + reportSpecConversion(problem.conversion); return; } } @@ -153,7 +208,7 @@ function reportContentProblem(problem: ContentProblem, sourceDirectory: Director return; } case 'groupNamedPages': { - const names = listedInProse(problem.pages.map((page) => relative(page, sourceDirectory))); + const names = listed(problem.pages, sourceDirectory); const one = problem.pages.length === 1; log.error( `${names} ${one ? 'is' : 'are'} named like a ${f.var('(group)')} folder, which is left out of every ` + @@ -229,10 +284,7 @@ function reportReservedAddresses(pages: ReservedAddressPage[], sourceDirectory: function reportSharedAddresses(addresses: SharedAddress[], sourceDirectory: DirectoryPath): void { const one = addresses.length === 1; - const lines = addresses.map( - ({ address, pages }) => - ` • ${f.var(address)}: ${listedInProse(pages.map((page) => relative(page, sourceDirectory)))}` - ); + const lines = addresses.map(({ address, pages }) => ` • ${f.var(address)}: ${listed(pages, sourceDirectory)}`); log.error( `Pages in ${f.path(sourceDirectory)} would share ${one ? 'an address' : 'addresses'}, but only one page ` + `can be served at each:` @@ -254,11 +306,49 @@ export function reportShadowedFiles(shadowed: FileName[]): void { log.warn(`${names} in ${f.var('static')} ${replaces} the portal would have generated.`); } +export function reportUnmergedSpecs(files: FilePath[], sourceDirectory: DirectoryPath): void { + if (files.length === 0) { + return; + } + const names = listed(files, sourceDirectory); + log.warn( + `${names} are separate API definitions, and the SDKs are generated from one of them only. To cover them all, ` + + `give each its own folder and ${mergeTheFolders()}` + ); +} + +export function reportLeftOutSpecs(files: FilePath[], sourceDirectory: DirectoryPath): void { + if (files.length === 0) { + return; + } + const [verb, pronoun, moved, folders] = + files.length === 1 ? ['is', 'it', 'it', 'folder'] : ['are', 'them', 'each', 'folders']; + log.warn( + `${listed(files, sourceDirectory)} ${verb} left out of the portal and the SDKs, as ` + + `${f.var(METADATA_FILE_NAME)} merges only the folders beside it. To include ${pronoun}, move ${moved} into ` + + `a folder of its own, adding the ${folders} to ${f.var(MERGE_SETTINGS.order)} when the file lists folders.` + ); +} + +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( + shared.map(({ endpoint, files }) => ` • ${f.var(`${endpoint}`)}: ${listed(files, sourceDirectory)}`).join('\n') + ); +} + export function reportIgnoredNavigationFiles(files: FilePath[], sourceDirectory: DirectoryPath): void { if (files.length === 0) { return; } - const names = listedInProse(files.map((file) => relative(file, sourceDirectory))); + const names = listed(files, sourceDirectory); const verb = files.length === 1 ? 'is' : 'are'; // Not "rename it": on a case-sensitive filesystem a correctly named file may already sit // beside it, and the two would then need merging rather than renaming. @@ -355,7 +445,7 @@ export function reportHiddenPages(files: FilePath[], sourceDirectory: DirectoryP if (files.length === 0) { return; } - const names = listedInProse(files.map((file) => relative(file, sourceDirectory))); + const names = listed(files, sourceDirectory); const [verb, pronoun] = files.length === 1 ? ['sits', 'it'] : ['sit', 'them']; log.warn( `${names} ${verb} inside a specification's section under ${f.var('content/api')}, which lists only ` + diff --git a/src/types/file/directory.ts b/src/types/file/directory.ts index c6e06440c..a3e41ca5e 100644 --- a/src/types/file/directory.ts +++ b/src/types/file/directory.ts @@ -2,6 +2,7 @@ import { DirectoryPath } from './directoryPath.js'; import { FileName } from './fileName.js'; import { FilePath } from './filePath.js'; import { TreeNode } from '../../prompts/format.js'; +import { METADATA_FILE_NAME } from '../project-layout.js'; export type FileItem = { fileName: FileName; description?: string }; export type DirectoryItem = FileItem | Directory; @@ -24,7 +25,7 @@ export class Directory { private static readonly fileDescriptions: Record = { 'apimatic.json': '# Configures the documentation portal and the context plugin', 'APIMATIC-BUILD.json': '# Defines all configurations for SDK generation', - 'APIMATIC-META.json': '# Defines customization for SDK generation' + [METADATA_FILE_NAME]: '# Defines customization for SDK generation' }; public toTreeNode(): TreeNode { diff --git a/src/types/file/directoryPath.ts b/src/types/file/directoryPath.ts index bcbfe3419..9c70e875a 100644 --- a/src/types/file/directoryPath.ts +++ b/src/types/file/directoryPath.ts @@ -66,6 +66,11 @@ export class DirectoryPath { return this.directoryPath === other.directoryPath; } + /** By code point, as `FileName.compare` is and for its reason. */ + public compare(other: DirectoryPath): number { + return this.directoryPath < other.directoryPath ? -1 : Number(this.directoryPath > other.directoryPath); + } + /** True when `other` is this directory or sits anywhere inside it. */ public contains(other: DirectoryPath) { const relative = path.relative(this.directoryPath, other.directoryPath); diff --git a/src/types/file/fileName.ts b/src/types/file/fileName.ts index e08ab449e..32cd64b6e 100644 --- a/src/types/file/fileName.ts +++ b/src/types/file/fileName.ts @@ -19,6 +19,11 @@ export class FileName { return this.name.endsWith(extension); } + /** Whether this is `other` exactly, case included, as a host that serves or reads the file matches it. */ + public isEqual(other: FileName): boolean { + return this.name === other.name; + } + /** Whether this is `name`, compared without regard to case. */ public is(name: string): boolean { return this.name.toLowerCase() === name.toLowerCase(); @@ -36,13 +41,17 @@ export class FileName { } public normalize(): FileName { - const normalized = this.withoutExtension() - .toString() + return this.withoutExtension().slugified(); + } + + /** The whole name as a URL segment, for a folder's name, whose dots end no extension. */ + public slugified(): FileName { + const slug = this.name .toLowerCase() .replace(/[^a-z0-9-]/g, '-') .replace(/-+/g, '-') .replace(/(^-|-$)/g, ''); - return new FileName(normalized); + return new FileName(slug); } public toString(): string { diff --git a/src/types/file/filePath.ts b/src/types/file/filePath.ts index 335790640..fa25c3777 100644 --- a/src/types/file/filePath.ts +++ b/src/types/file/filePath.ts @@ -31,7 +31,7 @@ export class FilePath { /** The same path, spelt the same way: `Logo.png` is not `logo.png`. */ public isEqual(other: FilePath): boolean { - return this.directoryPath.isEqual(other.directoryPath) && this.fileName.compare(other.fileName) === 0; + return this.directoryPath.isEqual(other.directoryPath) && this.fileName.isEqual(other.fileName); } public toString(): string { diff --git a/src/types/portal-source-context.ts b/src/types/portal-source-context.ts index 16bd26e7e..449fd8516 100644 --- a/src/types/portal-source-context.ts +++ b/src/types/portal-source-context.ts @@ -10,7 +10,12 @@ import { DirectoryPath } from './file/directoryPath.js'; import { FileName } from './file/fileName.js'; import { FilePath } from './file/filePath.js'; import { NOT_FOUND_FILE_NAME, SHELL_FILE_NAME } from './portal-context.js'; -import { CONTENT_DIRECTORY_NAME, SPEC_DIRECTORY_NAME, STATIC_DIRECTORY_NAME } from './project-layout.js'; +import { + CONTENT_DIRECTORY_NAME, + METADATA_FILE_NAME, + SPEC_DIRECTORY_NAME, + STATIC_DIRECTORY_NAME +} from './project-layout.js'; import { PLACEHOLDER_SITE, SiteConfig, SuggestedSite } from './portal/config/site-config.js'; import { AcceptedContent, ContentFile, ContentTree, ReadPage } from './portal/content-tree.js'; import { Endpoint } from './portal/endpoint.js'; @@ -30,13 +35,50 @@ import { PortalSource, PortalSourceProblem, PortalSpec, + SharedEndpoint, SpecConversion } from './portal/portal-source.js'; +import { SpecMetadata } from './portal/spec-metadata.js'; import { SpecContext } from './spec-context.js'; import { TRANSFORMATIONS_DIRECTORY_NAME, transformedFileName } from './transform-context.js'; const SPEC_EXTENSIONS = ['.json', '.yaml', '.yml']; +const METADATA_FILE = new FileName(METADATA_FILE_NAME); + +/** The files of a directory that may be API definitions: all but the platform's metadata. */ +const definitionNames = (fileNames: FileName[]): FileName[] => fileNames.filter((name) => !name.isEqual(METADATA_FILE)); + +/** Whether the portal would read the file as a document, by its extension. */ +const isDocumentName = (name: FileName): boolean => SPEC_EXTENSIONS.some((extension) => name.hasExtension(extension)); + +/** The operations more than one specification declares, each with the files that do, in the order first declared. */ +function sharedEndpoints(specs: PortalSpec[]): SharedEndpoint[] { + const declared = new Map(); + for (const { file, endpoints } of specs) { + for (const endpoint of endpoints) { + const files = declared.get(`${endpoint}`)?.files ?? []; + declared.set(`${endpoint}`, { endpoint, files: [...files, file] }); + } + } + return [...declared.values()].filter(({ files }) => files.length > 1); +} + +/** What `spec/` holds, as `resolve` hands it on. */ +type DiscoveredSpecs = Pick; + +/** The specifications read from a directory, and the site the first of them suggests. */ +interface ReadSpecs { + specs: PortalSpec[]; + suggested: SuggestedSite; +} + +/** The folders `APIMATIC-META.json` merges, in the order the platform merges them. */ +interface MergedFolders { + metadata: FilePath; + folders: DirectoryPath[]; +} + // Empty on purpose: the portal's own routes under /api/ are files with extensions -- // /api/search.json -- so none can collide with a spec section, which is always a directory. // Pages the user puts under content/api/ share the directory, and `hiddenPages` reports the @@ -67,6 +109,10 @@ export class PortalSourceContext { return this.sourceDirectory.join(SPEC_DIRECTORY_NAME); } + private get metadataFile(): FilePath { + return new FilePath(this.specDirectory, METADATA_FILE); + } + private get contentDirectory(): DirectoryPath { return this.sourceDirectory.join(CONTENT_DIRECTORY_NAME); } @@ -92,9 +138,9 @@ export class PortalSourceContext { if (discovered.isErr()) { return err(discovered.error); } - const { specs, suggested } = discovered.value; + const { specs, suggestedSite, unmergedSpecs, leftOutSpecs } = discovered.value; - const settings = await this.settingsFrom(document.value, suggested); + const settings = await this.settingsFrom(document.value, suggestedSite); if (settings.isErr()) { return err(settings.error); } @@ -111,8 +157,11 @@ export class PortalSourceContext { return ok({ ...settings.value, - suggestedSite: suggested, + suggestedSite, specs, + unmergedSpecs, + leftOutSpecs, + sharedEndpoints: sharedEndpoints(specs), contentDirectory, staticDirectory, shadowedFiles: staticDirectory === null ? [] : await this.shadowedFiles(staticDirectory), @@ -423,28 +472,134 @@ export class PortalSourceContext { } // With several specifications there is no suggested site: no one of them speaks for the portal. - private async specs(): Promise< - Result<{ specs: PortalSpec[]; suggested: SuggestedSite | null }, PortalSourceProblem> - > { - const specs: PortalSpec[] = []; - // Only the first is kept: it is the one that speaks for the portal when it is alone, and - // specifications run to megabytes. - let first: OpenApiDocument | undefined; + private async specs(): Promise> { + const { fileNames, folders } = await this.specDirectoryListing(); + const merge = await this.mergedFolders(fileNames, folders); + if (merge.isErr()) { + return err(merge.error); + } + const usedSlugs = new Set(RESERVED_SPEC_SLUGS); + const read = + merge.value === null + ? await this.topLevelSpecs(fileNames, folders, usedSlugs) + : await this.mergedSpecs(merge.value, usedSlugs); + if (read.isErr()) { + return err(read.error); + } + const { specs, suggested } = read.value; + const several = specs.length > 1; + return ok({ + specs, + suggestedSite: several ? null : suggested, + unmergedSpecs: several && merge.value === null ? specs.map(({ file }) => file) : [], + leftOutSpecs: merge.value === null ? [] : await this.leftOutSpecs(fileNames) + }); + } - const { fileNames, folders } = await this.specDirectoryListing(); - if (fileNames.length === 0) { + /** The folders `APIMATIC-META.json` merges, or null when it is not there or does not merge. */ + private async mergedFolders( + fileNames: FileName[], + folders: DirectoryPath[] + ): Promise> { + // Found in the listing, so its name matches exactly where the file system ignores case. + if (!fileNames.some((name) => name.isEqual(METADATA_FILE))) { + return ok(null); + } + const metadata = await this.readMetadata(this.metadataFile); + if (metadata === undefined) { + return err({ kind: 'unreadableMetadata', file: this.metadataFile }); + } + if (!metadata.mergesApis()) { + return ok(null); + } + const listed = metadata.mergeOrder()?.map((folder) => this.specDirectory.resolve(folder)); + // A folder the file names twice, as `alpha` and `./alpha`, is still one API. + const once = listed?.filter((folder, index) => index === listed.findIndex((other) => other.isEqual(folder))); + return ok({ metadata: this.metadataFile, folders: once ?? folders }); + } + + /** The documents at the top of `spec/`, each a section named after its file. */ + private async topLevelSpecs( + fileNames: FileName[], + folders: DirectoryPath[], + usedSlugs: Set + ): Promise> { + const names = definitionNames(fileNames); + if (names.length === 0) { return err({ kind: 'emptySpecDirectory', folders }); } + return await this.definitions(this.specDirectory, names, (fileName) => + this.uniqueSlug(fileName.normalize(), usedSlugs) + ); + } + + /** The one document of each merged folder, a section named after the folder, as the platform builds the SDKs. */ + private async mergedSpecs( + { metadata, folders }: MergedFolders, + usedSlugs: Set + ): Promise> { + const listings = await Promise.all( + folders.map(async (folder) => ({ + folder, + // A folder named outside `spec/` is not in the build the platform is sent, so it is read as empty. + fileNames: this.specDirectory.contains(folder) + ? definitionNames(await this.fileService.getFileNames(folder)).sort((left, right) => left.compare(right)) + : [] + })) + ); + const empty = listings.filter(({ fileNames }) => fileNames.length === 0).map(({ folder }) => folder); + if (listings.length === 0 || empty.length > 0) { + return err({ kind: 'emptyMergedFolders', metadata, folders: empty }); + } + + const specs: PortalSpec[] = []; + const sites: SuggestedSite[] = []; + for (const { folder, fileNames } of listings) { + const slug = new FileName(folder.leafName()).slugified(); + const read = await this.definitions(folder, fileNames, () => this.uniqueSlug(slug, usedSlugs)); + if (read.isErr()) { + return err(read.error); + } + if (read.value.specs.length > 1) { + return err({ kind: 'severalSpecsInFolder', files: read.value.specs.map(({ file }) => file) }); + } + specs.push(...read.value.specs); + sites.push(read.value.suggested); + } + return ok({ specs, suggested: sites[0] }); + } + + /** The API definitions at the top of `spec/`, which a merge of its folders leaves out, as the platform does. */ + private async leftOutSpecs(fileNames: FileName[]): Promise { + const files = definitionNames(fileNames) + .filter(isDocumentName) + .map((fileName) => new FilePath(this.specDirectory, fileName)); + const definitions = await Promise.all( + files.map(async (file) => (await this.readDocument(file))?.isApiDefinition() === true) + ); + return files.filter((_, index) => definitions[index]); + } + + /** The OpenAPI 3.x documents at the top of a directory, or how to convert what is there instead. */ + private async definitions( + directory: DirectoryPath, + fileNames: FileName[], + slugOf: (fileName: FileName) => string + ): Promise> { + const specs: PortalSpec[] = []; + // Only the first is kept: it is the one that speaks for the portal when it is alone, and + // specifications run to megabytes. + let first: OpenApiDocument | undefined; // What each one is, for the conversion a directory without an OpenAPI 3.x document is pointed to. const otherFormats: { file: FilePath; format: string }[] = []; - const documentNames = fileNames.filter((name) => SPEC_EXTENSIONS.some((extension) => name.hasExtension(extension))); + const documentNames = fileNames.filter(isDocumentName); for (const fileName of documentNames) { - const file = new FilePath(this.specDirectory, fileName); + const file = new FilePath(directory, fileName); const document = await this.readDocument(file); if (document === undefined) { - return err({ kind: 'unreadableSpec', fileName }); + return err({ kind: 'unreadableSpec', file }); } const format = document.format(); if (!format.supported) { @@ -454,7 +609,7 @@ export class PortalSourceContext { continue; } - specs.push({ slug: this.uniqueSlug(fileName, usedSlugs), file, endpoints: await this.endpoints(document, file) }); + specs.push({ slug: slugOf(fileName), file, endpoints: await this.endpoints(document, file) }); first ??= document; } @@ -463,15 +618,15 @@ export class PortalSourceContext { // No document declares its format: one the portal would read, by extension, is named before any other file. const conversion = convertible === undefined - ? this.conversion(new FilePath(this.specDirectory, documentNames[0] ?? fileNames[0]), null, 0) + ? this.conversion(new FilePath(directory, documentNames[0] ?? fileNames[0]), null, 0) : this.conversion(convertible.file, convertible.format, otherFormats.length - 1); return err({ kind: 'noOpenApiSpec', conversion }); } - return ok({ specs, suggested: specs.length === 1 ? first.suggestedSite() : null }); + return ok({ specs, suggested: first.suggestedSite() }); } private conversion(file: FilePath, format: string | null, others: number): SpecConversion { - const into = this.specDirectory.join(TRANSFORMATIONS_DIRECTORY_NAME); + const into = file.directory().join(TRANSFORMATIONS_DIRECTORY_NAME); const converted = new FilePath(into, transformedFileName(file, ExportFormats.Openapi3Yaml)); return { file, format, converted, others }; } @@ -487,7 +642,10 @@ export class PortalSourceContext { fileNames: directory.items .flatMap((item) => ('fileName' in item ? [item.fileName] : [])) .sort((left, right) => left.compare(right)), - folders: directory.items.flatMap((item) => (item instanceof Directory ? [item.directoryPath] : [])) + // Sorted because folders merged without an order of their own are merged by name. + folders: directory.items + .flatMap((item) => (item instanceof Directory ? [item.directoryPath] : [])) + .sort((left, right) => left.compare(right)) }; } @@ -510,8 +668,16 @@ export class PortalSourceContext { } } - private uniqueSlug(fileName: FileName, used: Set): string { - const base = fileName.normalize().toString() || 'api'; + private async readMetadata(file: FilePath): Promise { + try { + return SpecMetadata.parse(await this.fileService.getContents(file)); + } catch { + return undefined; + } + } + + private uniqueSlug(name: FileName, used: Set): string { + const base = name.toString() || 'api'; let slug = base; let suffix = 2; while (used.has(slug)) { diff --git a/src/types/portal/code-samples.ts b/src/types/portal/code-samples.ts index fbefc3598..cccef9c8a 100644 --- a/src/types/portal/code-samples.ts +++ b/src/types/portal/code-samples.ts @@ -49,6 +49,11 @@ export class CodeSampleCatalog { public endpoints(): Endpoint[] { return [...this.samplesByEndpoint.values()].map(({ endpoint }) => endpoint); } + + public without(endpoints: Endpoint[]): CodeSampleCatalog { + const left = new Set(endpoints.map(String)); + return new CodeSampleCatalog(new Map([...this.samplesByEndpoint].filter(([key]) => !left.has(key)))); + } } export class CodeSampleCatalogs { @@ -62,6 +67,10 @@ export class CodeSampleCatalogs { return this.catalogs.flatMap((catalog) => catalog.sampleFor(endpoint) ?? []); } + public without(endpoints: Endpoint[]): CodeSampleCatalogs { + return new CodeSampleCatalogs(this.catalogs.map((catalog) => catalog.without(endpoints))); + } + public unplacedIn(documented: Endpoint[]): string[] { const names = new Set(documented.map(String)); return [...new Set(this.endpoints().map(String))].filter((endpoint) => !names.has(endpoint)); diff --git a/src/types/portal/content-tree.ts b/src/types/portal/content-tree.ts index 0b545b549..87f4ca326 100644 --- a/src/types/portal/content-tree.ts +++ b/src/types/portal/content-tree.ts @@ -137,9 +137,7 @@ export class ContentTree { /** The `nav.json` files the build reads, which its glob matches by code point. */ public navigationFiles(): FilePath[] { - return this.tree - .getAllFiles() - .filter((file) => file.name().compare(NAVIGATION_FILE) === 0 && !this.isSkipped(file)); + return this.tree.getAllFiles().filter((file) => file.name().isEqual(NAVIGATION_FILE) && !this.isSkipped(file)); } /** Whether the build would read `file` as part of `content/`: below it, and not passed over. */ @@ -537,7 +535,7 @@ class NavigationWalk { } else if (!isSkippedByGlob(item.fileName.toString())) { const file = new FilePath(directory.directoryPath, item.fileName); const page = pageName(item.fileName); - if (item.fileName.compare(NAVIGATION_FILE) === 0) { + if (item.fileName.isEqual(NAVIGATION_FILE)) { listing.navigationFile = file; } else if (item.fileName.is(NAVIGATION_FILE_NAME)) { listing.ignoredFiles.push(file); diff --git a/src/types/portal/openapi-document.ts b/src/types/portal/openapi-document.ts index ec0fffcfd..b5fe1e5b5 100644 --- a/src/types/portal/openapi-document.ts +++ b/src/types/portal/openapi-document.ts @@ -87,6 +87,12 @@ export class OpenApiDocument { return { supported: false, format: null }; } + /** Whether it names the API format it is in, as a definition does and a file of shared parts does not. */ + public isApiDefinition(): boolean { + const format = this.format(); + return format.supported || format.format !== null; + } + /** * Both fields are written into generated files, so each is collapsed to one line. The * description stops at its first blank line: past it a specification's description is diff --git a/src/types/portal/portal-source.ts b/src/types/portal/portal-source.ts index 94813dd27..53b9e1549 100644 --- a/src/types/portal/portal-source.ts +++ b/src/types/portal/portal-source.ts @@ -7,7 +7,7 @@ import { ContentNotices } from './content-notices.js'; import { GeneratedPages } from './generated-pages.js'; import { PortalConfig } from './portal-config.js'; -/** An OpenAPI document found in `src/spec/`, with the slug its section is mounted at. */ +/** An OpenAPI document found in `src/spec/` or in a folder merged there, with the slug its section is mounted at. */ export interface PortalSpec { slug: string; file: FilePath; @@ -15,6 +15,12 @@ export interface PortalSpec { endpoints: Endpoint[]; } +/** An operation more than one specification declares, which one code sample per method and path cannot serve. */ +export interface SharedEndpoint { + endpoint: Endpoint; + files: FilePath[]; +} + /** What `apimatic.json` decides about a portal, which `portal serve` reads again on every edit. */ export interface PortalSettings { config: PortalConfig; @@ -29,6 +35,12 @@ export interface PortalSource extends PortalSettings { */ suggestedSite: SuggestedSite | null; specs: PortalSpec[]; + /** Several specifications at the top of `spec/` that no `APIMATIC-META.json` merges, so the SDKs cover one; else none. */ + 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[]; @@ -97,12 +109,12 @@ export type ContentProblem = | { kind: 'invalidNavigation'; errors: string[] } | { kind: 'missingImages'; images: MissingImage[] }; -/** How `api transform` would turn the first document in `spec/` into one the portal reads. */ +/** How `api transform` would turn the first document in a directory into one the portal reads. */ export interface SpecConversion { file: FilePath; /** What the document is instead, when it says; null for one that names no format. */ format: string | null; - /** Where `api transform --destination` of `spec/` writes it, which is one folder too deep. */ + /** Where `api transform --destination` of the document's directory writes it, which is one folder too deep. */ converted: FilePath; others: number; } @@ -114,8 +126,13 @@ export type PortalSourceProblem = | { kind: 'invalidConfig'; errors: string[]; missingPortal: boolean } // Every problem found in `content/`, so that one run lists all that a build would refuse. | { kind: 'invalidContent'; problems: ContentProblem[] } - | { kind: 'unreadableSpec'; fileName: FileName } - // Its `folders` are not read, and `api transform` writes into one of its own. + | { kind: 'unreadableSpec'; file: FilePath } + | { kind: 'unreadableMetadata'; file: FilePath } + // Its `folders` are not read unless a metadata file merges them, and `api transform` writes into one of its own. | { kind: 'emptySpecDirectory'; folders: DirectoryPath[] } + // The `folders` that `metadata` merges and that hold no file to read; none when it has no folder to merge at all. + | { kind: 'emptyMergedFolders'; metadata: FilePath; folders: DirectoryPath[] } + // Every OpenAPI 3.x document at the top of a merged folder, which holds one API. + | { kind: 'severalSpecsInFolder'; files: FilePath[] } | { kind: 'noOpenApiSpec'; conversion: SpecConversion } | { kind: 'missingStaticFiles'; files: MissingStaticFile[] }; diff --git a/src/types/portal/spec-metadata.ts b/src/types/portal/spec-metadata.ts new file mode 100644 index 000000000..6ee45c488 --- /dev/null +++ b/src/types/portal/spec-metadata.ts @@ -0,0 +1,32 @@ +import { isJsonObject, JsonObject } from '../../utils/json-utils.js'; +import { stripByteOrderMark } from '../../utils/string-utils.js'; + +/** The settings of its `MergeConfiguration` a portal follows, by the names a message gives them. */ +export const MERGE_SETTINGS = { mergeApis: 'MergeApis', order: 'MergeOrderOfDirectories' } as const; + +/** The platform's metadata file beside API definitions, read for the one thing a portal follows: which folders it merges. */ +export class SpecMetadata { + private constructor(private readonly mergeConfiguration: JsonObject) {} + + /** Undefined when the text is not JSON. */ + public static parse(contents: string): SpecMetadata | undefined { + try { + const document: unknown = JSON.parse(stripByteOrderMark(contents)); + const mergeConfiguration = isJsonObject(document) ? document.MergeConfiguration : undefined; + return new SpecMetadata(isJsonObject(mergeConfiguration) ? mergeConfiguration : {}); + } catch { + return undefined; + } + } + + public mergesApis(): boolean { + return this.mergeConfiguration[MERGE_SETTINGS.mergeApis] === true; + } + + /** The folders merged, in order, relative to the file's own; null when it lists none, which merges every folder there by name. */ + public mergeOrder(): string[] | null { + const order = this.mergeConfiguration[MERGE_SETTINGS.order]; + const folders = Array.isArray(order) ? order.filter((folder): folder is string => typeof folder === 'string') : []; + return folders.length > 0 ? folders : null; + } +} diff --git a/src/types/project-layout.ts b/src/types/project-layout.ts index 2f7314bec..a4b6f1dc9 100644 --- a/src/types/project-layout.ts +++ b/src/types/project-layout.ts @@ -2,6 +2,9 @@ export const SOURCE_DIRECTORY_NAME = 'src'; export const SPEC_DIRECTORY_NAME = 'spec'; +// The platform also reads other names that start `APIMATIC-META`, a legacy this CLI does not carry. +export const METADATA_FILE_NAME = 'APIMATIC-META.json'; + export const CONTENT_DIRECTORY_NAME = 'content'; export const STATIC_DIRECTORY_NAME = 'static'; diff --git a/test/actions/portal/generate.test.ts b/test/actions/portal/generate.test.ts index 66e347500..c1e5f15d3 100644 --- a/test/actions/portal/generate.test.ts +++ b/test/actions/portal/generate.test.ts @@ -317,6 +317,15 @@ describe('GenerateAction', () => { expect(shared.prompts.filesShadowedByStatic.firstCall.args[0].map(String)).to.deep.equal(['robots.txt']); }); + it('reports the specs the SDKs do not cover and the operations two specs declare, none in the fixture', async () => { + const result = await execute(); + + expect(result.isSuccess()).to.be.true; + expect(shared.prompts.unmergedSpecs.firstCall.args[0]).to.deep.equal([]); + expect(shared.prompts.leftOutSpecs.firstCall.args[0]).to.deep.equal([]); + 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 ac8011e92..2aea5eae4 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -884,3 +884,60 @@ const stylesheetOf = (output: DirectoryPath) => { expect(locations.filter((location) => !location.startsWith('https://docs.test/api/'))).to.deep.equal([]); }); }); + +/** + * 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 + * file of its own folder. + */ +(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, { + paths: { + '/orders': { GET: sample('client.orders.listOrders()') }, + '/stock': { GET: sample('client.stock.listStock()') }, + '/status': { GET: sample('client.health.getOrdersStatus()') } + } + }) as CodeSampleCatalog + ]) + }); + 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']; + + 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]) { + expect(exists(`${page}/index.html`), page).to.be.true; + } + expect(exists('api/apimatic-meta')).to.be.false; + }); + + it("shows each API's own SDK sample and server on an operation only it declares", () => { + const orders = read(`${LIST_ORDERS}/index.html`); + const stock = read(`${LIST_STOCK}/index.html`); + + expect(orders).to.contain('client.orders.listOrders()'); + expect(orders).to.contain('https://orders.store.test'); + expect(orders).to.not.contain('inventory.store.test'); + expect(stock).to.contain('client.stock.listStock()'); + 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. + 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'); + } + }); + + it('reads a schema kept in another file of the folder', () => { + expect(read(`${LIST_ORDERS}/index.html`)).to.contain('grandTotalCents'); + expect(read(`${LIST_ORDERS}.md`)).to.contain('grandTotalCents'); + }); +}); diff --git a/test/infrastructure/portal-project-service.test.ts b/test/infrastructure/portal-project-service.test.ts index 085e49644..0ec1cd720 100644 --- a/test/infrastructure/portal-project-service.test.ts +++ b/test/infrastructure/portal-project-service.test.ts @@ -16,6 +16,7 @@ import { DirectoryPath } from '../../src/types/file/directoryPath'; import { FileName } from '../../src/types/file/fileName'; import { FilePath } from '../../src/types/file/filePath'; import { CodeSampleCatalog, CodeSampleCatalogs } from '../../src/types/portal/code-samples'; +import { Endpoint } from '../../src/types/portal/endpoint'; import { GENERATED_DIRECTORY_NAME, GeneratedPages } from '../../src/types/portal/generated-pages'; import { PortalConfig, PortalIdentity } from '../../src/types/portal/portal-config'; import { PortalArtifacts } from '../../src/types/portal/portal-artifacts'; @@ -60,6 +61,9 @@ describe('PortalProjectService', () => { endpoints: [] } ], + unmergedSpecs: [], + leftOutSpecs: [], + sharedEndpoints: [], contentDirectory: null, staticDirectory: null, shadowedFiles: [], @@ -298,6 +302,17 @@ describe('PortalProjectService', () => { }); }); + // 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: [{ endpoint: new Endpoint('get', '/pets'), files }] }); + + (await service.prepare(project, source, withCatalogs))._unsafeUnwrap(); + + expect(JSON.parse(fs.readFileSync(readConfig().codeSamples, 'utf8'))).to.deep.equal({}); + }); + it('points each spec at its own file, samples or not', async () => { const source = sourceFor(); diff --git a/test/prompts/portal/serve.test.ts b/test/prompts/portal/serve.test.ts index 422cdf636..7140d40b9 100644 --- a/test/prompts/portal/serve.test.ts +++ b/test/prompts/portal/serve.test.ts @@ -40,7 +40,7 @@ describe('PortalServePrompts', () => { // The artifacts are fetched once and Vite reads its base once, so these wait for a restart. it('says which edits need the preview restarted', () => { expect(printed()).to.match( - /Restart the preview after adding a language or a 'plugin' block \(its SDK or plugin is fetched at startup\), adding or removing a page, creating '.*static', changing which documents are in '.*spec', or changing the path in 'portal\.site\.url'\./ + /Restart the preview after adding a language or a 'plugin' block \(its SDK or plugin is fetched at startup\), adding or removing a page, creating '.*static', changing which documents are in '.*spec' or which folders its 'APIMATIC-META\.json' merges, or changing the path in 'portal\.site\.url'\./ ); }); diff --git a/test/prompts/portal/source.test.ts b/test/prompts/portal/source.test.ts index f6b9b5480..c97bd0625 100644 --- a/test/prompts/portal/source.test.ts +++ b/test/prompts/portal/source.test.ts @@ -4,12 +4,16 @@ import sinon from 'sinon'; import { log } from '@clack/prompts'; import { PortalServePrompts } from '../../../src/prompts/portal/serve.js'; 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 } from '../../../src/types/portal/portal-source.js'; import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; import { FileName } from '../../../src/types/file/fileName.js'; @@ -179,10 +183,110 @@ describe('reportSourceProblem', () => { expect(printed()).to.contain('Convert the other 2 documents the same way.'); }); - it('says a document in a folder of it is not read', () => { - reportSourceProblem({ kind: 'emptySpecDirectory', folders: [spec.join('transformations')] }, source); + // A folder to an API is the layout merging needs, so the message cannot only say to undo it. + it('says a document in a folder of it is not read, and both ways to have it read', () => { + reportSourceProblem({ kind: 'emptySpecDirectory', folders: [spec.join('alpha'), spec.join('beta')] }, source); - expect(printed()).to.contain("such as 'transformations', is not read: move it up."); + expect(printed()).to.contain('has no API definition.'); + expect(printed()).to.contain( + "A document in a folder inside it, such as 'alpha' and 'beta', is not read. Move it up, or to keep one " + + "API to a folder, turn on 'MergeApis' in an 'APIMATIC-META.json' beside the folders: " + + 'https://docs.apimatic.io/manage-apis/api-merging/' + ); + }); + + it('names the folder a conversion is for, and moves the result up into that folder', () => { + const folder = spec.join('orders'); + reportSourceProblem( + { + kind: 'noOpenApiSpec', + conversion: { + file: new FilePath(folder, new FileName('orders.json')), + format: 'Swagger 2.0', + converted: new FilePath(folder.join('transformations'), new FileName('orders_OpenApi3Yaml.yaml')), + others: 0 + } + }, + source + ); + + expect(printed()).to.contain(`No OpenAPI 3.x document found in '${folder}'.`); + expect(printed()).to.contain('--destination=./project/src/spec/orders'); + expect(printed()).to.contain(`up into '${folder}'.`); + expect(printed()).to.not.contain('only folder read'); + }); + + it('names a document it cannot read by its folder', () => { + const file = new FilePath(spec.join('orders'), new FileName('openapi.yaml')); + reportSourceProblem({ kind: 'unreadableSpec', file }, source); + + expect(printed()).to.equal("'spec/orders/openapi.yaml' could not be read as JSON or YAML."); + }); + + it('names a metadata file it cannot read, which is JSON only', () => { + reportSourceProblem( + { kind: 'unreadableMetadata', file: new FilePath(spec, new FileName('APIMATIC-META.json')) }, + source + ); + + expect(printed()).to.equal("'spec/APIMATIC-META.json' could not be read as JSON."); + }); + }); + + describe('folders a metadata file merges that hold nothing to read', () => { + const spec = source.join('spec'); + const metadata = new FilePath(spec, new FileName('APIMATIC-META.json')); + + it('names the file and one folder, and the two ways to put it right', () => { + reportSourceProblem({ kind: 'emptyMergedFolders', metadata, folders: [spec.join('orderz')] }, source); + + expect(printed().split('\n')).to.deep.equal([ + "'spec/APIMATIC-META.json' merges './spec/orderz', which holds no API definition the portal can read.", + `A merged folder sits inside '${spec}', with its OpenAPI 3.x document at its top. Add one to it, or ` + + "list only the folders to merge in 'MergeOrderOfDirectories'." + ]); + }); + + // A folder outside `spec/` may hold a document, which the platform is never sent. + it('names each folder by its path, so one nested or outside the spec directory reads as such', () => { + reportSourceProblem( + { kind: 'emptyMergedFolders', metadata, folders: [spec.join('apis', 'orders'), source.join('content')] }, + source + ); + + expect(printed().split('\n')).to.deep.equal([ + "'spec/APIMATIC-META.json' merges './spec/apis/orders' and './content', which hold no API definition the " + + 'portal can read.', + `A merged folder sits inside '${spec}', with its OpenAPI 3.x document at its top. Add one to each, or ` + + "list only the folders to merge in 'MergeOrderOfDirectories'." + ]); + }); + + it('names each document of a merged folder that holds more than one, and how to split them', () => { + const orders = spec.join('orders'); + reportSourceProblem( + { + kind: 'severalSpecsInFolder', + files: [new FilePath(orders, new FileName('orders.json')), new FilePath(orders, new FileName('returns.json'))] + }, + source + ); + + expect(printed().split('\n')).to.deep.equal([ + "'spec/orders/orders.json' and 'spec/orders/returns.json' are each an OpenAPI document, but a folder " + + "that 'APIMATIC-META.json' merges holds one API definition.", + 'Keep one there and move each of the others into a folder of its own, adding the new folders to ' + + "'MergeOrderOfDirectories' when the file lists folders." + ]); + }); + + it('says so when the file merges and there is no folder at all', () => { + reportSourceProblem({ kind: 'emptyMergedFolders', metadata, folders: [] }, source); + + expect(printed().split('\n')).to.deep.equal([ + `'spec/APIMATIC-META.json' merges the folders of '${spec}', which has none.`, + "Put each API definition in a folder of its own there, or turn off 'MergeApis' in the file." + ]); }); }); @@ -554,3 +658,134 @@ describe('reportUnseenHomeTitle', () => { ]); }); }); + +describe('reportUnmergedSpecs', () => { + const source = new DirectoryPath('project').join('src'); + const spec = source.join('spec'); + let lines: string[]; + + beforeEach(() => { + lines = []; + sinon.stub(log, 'warn').callsFake((text?: string | string[]) => { + lines.push(stripVTControlCharacters(String(text))); + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('says nothing when the specs are merged, or there is one', () => { + reportUnmergedSpecs([], source); + + expect(lines).to.deep.equal([]); + }); + + it('names the specs, says the SDKs cover one of them, and how to cover them all', () => { + reportUnmergedSpecs( + [new FilePath(spec, new FileName('orders.json')), new FilePath(spec, new FileName('items.json'))], + source + ); + + expect(lines).to.deep.equal([ + "'spec/orders.json' and 'spec/items.json' are separate API definitions, and the SDKs are generated from one " + + "of them only. To cover them all, give each its own folder and turn on 'MergeApis' in an " + + "'APIMATIC-META.json' beside the folders: https://docs.apimatic.io/manage-apis/api-merging/" + ]); + }); +}); + +describe('reportLeftOutSpecs', () => { + const source = new DirectoryPath('project').join('src'); + const spec = source.join('spec'); + let lines: string[]; + + beforeEach(() => { + lines = []; + sinon.stub(log, 'warn').callsFake((text?: string | string[]) => { + lines.push(stripVTControlCharacters(String(text))); + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('says nothing when no API definition sits beside the merged folders', () => { + reportLeftOutSpecs([], source); + + expect(lines).to.deep.equal([]); + }); + + it('names the one left out, and how to include it', () => { + reportLeftOutSpecs([new FilePath(spec, new FileName('orders.json'))], source); + + expect(lines).to.deep.equal([ + "'spec/orders.json' is left out of the portal and the SDKs, as 'APIMATIC-META.json' merges only the folders " + + 'beside it. To include it, move it into a folder of its own, adding the folder to ' + + "'MergeOrderOfDirectories' when the file lists folders." + ]); + }); + + it('names several as a sentence would', () => { + reportLeftOutSpecs( + [new FilePath(spec, new FileName('orders.json')), new FilePath(spec, new FileName('items.json'))], + source + ); + + expect(lines).to.deep.equal([ + "'spec/orders.json' and 'spec/items.json' are left out of the portal and the SDKs, as 'APIMATIC-META.json' " + + 'merges only the folders beside it. To include them, move each into a folder of its own, adding the ' + + "folders to 'MergeOrderOfDirectories' when the file lists folders." + ]); + }); +}); + +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( + [ + { endpoint: new Endpoint('get', '/status'), files }, + { endpoint: 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([{ endpoint: 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/resources/portal-inputs/merged/src/apimatic.json b/test/resources/portal-inputs/merged/src/apimatic.json new file mode 100644 index 000000000..d85f46971 --- /dev/null +++ b/test/resources/portal-inputs/merged/src/apimatic.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://cdn.jsdelivr.net/npm/@apimatic/cli@beta/apimatic.schema.json", + "schemaVersion": 1, + "portal": { + "site": { "name": "Store APIs" } + }, + "languages": { + "typescript": {} + } +} diff --git a/test/resources/portal-inputs/merged/src/spec/APIMATIC-META.json b/test/resources/portal-inputs/merged/src/spec/APIMATIC-META.json new file mode 100644 index 000000000..47f45187c --- /dev/null +++ b/test/resources/portal-inputs/merged/src/spec/APIMATIC-META.json @@ -0,0 +1,7 @@ +{ + "MergeConfiguration": { + "MergeApis": true, + "MergedApiName": "Store APIs", + "MergeOrderOfDirectories": ["orders", "inventory"] + } +} diff --git a/test/resources/portal-inputs/merged/src/spec/inventory/openapi.json b/test/resources/portal-inputs/merged/src/spec/inventory/openapi.json new file mode 100644 index 000000000..e8d0bcab8 --- /dev/null +++ b/test/resources/portal-inputs/merged/src/spec/inventory/openapi.json @@ -0,0 +1,24 @@ +{ + "openapi": "3.0.3", + "info": { "title": "Inventory API", "version": "1.0.0" }, + "servers": [{ "url": "https://inventory.store.test" }], + "tags": [{ "name": "Stock" }, { "name": "Health" }], + "paths": { + "/stock": { + "get": { + "operationId": "listStock", + "summary": "List stock levels", + "tags": ["Stock"], + "responses": { "200": { "description": "The stock levels." } } + } + }, + "/status": { + "get": { + "operationId": "getInventoryStatus", + "summary": "Inventory service status", + "tags": ["Health"], + "responses": { "200": { "description": "The service is up." } } + } + } + } +} diff --git a/test/resources/portal-inputs/merged/src/spec/orders/openapi.yaml b/test/resources/portal-inputs/merged/src/spec/orders/openapi.yaml new file mode 100644 index 000000000..210d03bdb --- /dev/null +++ b/test/resources/portal-inputs/merged/src/spec/orders/openapi.yaml @@ -0,0 +1,32 @@ +openapi: 3.0.3 +info: + title: Orders API + version: 1.0.0 +servers: + - url: https://orders.store.test +tags: + - name: Orders + - name: Health +paths: + /orders: + get: + operationId: listOrders + summary: List orders + tags: [Orders] + responses: + '200': + description: The orders. + content: + application/json: + schema: + type: array + items: + $ref: './schemas/order.yaml' + /status: + get: + operationId: getOrdersStatus + summary: Orders service status + tags: [Health] + responses: + '200': + description: The service is up. diff --git a/test/resources/portal-inputs/merged/src/spec/orders/schemas/order.yaml b/test/resources/portal-inputs/merged/src/spec/orders/schemas/order.yaml new file mode 100644 index 000000000..e088b9b72 --- /dev/null +++ b/test/resources/portal-inputs/merged/src/spec/orders/schemas/order.yaml @@ -0,0 +1,8 @@ +type: object +required: [orderId] +properties: + orderId: + type: string + grandTotalCents: + type: integer + example: 1250 diff --git a/test/types/file/directory-path.test.ts b/test/types/file/directory-path.test.ts index 86649c9b3..8dfc2f1a1 100644 --- a/test/types/file/directory-path.test.ts +++ b/test/types/file/directory-path.test.ts @@ -52,3 +52,16 @@ describe('DirectoryPath.fromUserInput', () => { expect(DirectoryPath.fromUserInput(typed).toString()).to.equal(path.resolve(typed)); }); }); + +// What orders the folders of a spec directory that are merged without an order of their own. +describe('DirectoryPath.compare', () => { + const spec = new DirectoryPath(path.join('/', 'projects', 'petstore-api', 'src', 'spec')); + + it('orders sibling directories by name, by code point', () => { + const sorted = [spec.join('beta'), spec.join('alpha'), spec.join('Zeta')].sort((left, right) => + left.compare(right) + ); + + expect(sorted.map((directory) => directory.leafName())).to.deep.equal(['Zeta', 'alpha', 'beta']); + }); +}); diff --git a/test/types/file/fileName.test.ts b/test/types/file/fileName.test.ts new file mode 100644 index 000000000..2f4c1553c --- /dev/null +++ b/test/types/file/fileName.test.ts @@ -0,0 +1,25 @@ +import { expect } from 'chai'; +import { FileName } from '../../../src/types/file/fileName'; + +describe('FileName', () => { + describe('normalize', () => { + it('makes a URL segment of a file name, without its extension', () => { + expect(new FileName('My API.v2.json').normalize().toString()).to.equal('my-api-v2'); + }); + }); + + describe('slugified', () => { + // A folder named `orders.v2` has no extension to lose, and would otherwise share a slug with `orders.v1`. + it('makes a URL segment of the whole name, dots included', () => { + expect(new FileName('orders.v2').slugified().toString()).to.equal('orders-v2'); + expect(new FileName(' Seller Orders ').slugified().toString()).to.equal('seller-orders'); + }); + }); + + describe('isEqual', () => { + it('is the same name only when spelt the same, case included', () => { + expect(new FileName('APIMATIC-META.json').isEqual(new FileName('APIMATIC-META.json'))).to.be.true; + expect(new FileName('apimatic-meta.json').isEqual(new FileName('APIMATIC-META.json'))).to.be.false; + }); + }); +}); diff --git a/test/types/portal-source-context.test.ts b/test/types/portal-source-context.test.ts index e1192651b..f616d30cb 100644 --- a/test/types/portal-source-context.test.ts +++ b/test/types/portal-source-context.test.ts @@ -399,6 +399,9 @@ describe('PortalSourceContext', () => { describe('spec discovery', () => { beforeEach(() => writeConfig({ site: { name: 'Calc' } })); + /** A file as a problem or a warning names it, relative to the source directory. */ + const relative = (file: FilePath) => file.relativeTo(new DirectoryPath(root)); + it('accepts json, yaml and yml documents, ordered by file name', async () => { write('spec/b.json', OPENAPI); write('spec/a.yaml', 'openapi: 3.0.0\ninfo:\n title: A\n version: "1"\npaths: {}\n'); @@ -440,7 +443,6 @@ describe('PortalSourceContext', () => { throw new Error(`expected no OpenAPI 3.x document, got '${problem.kind}'`); } const { file, format, converted, others } = problem.conversion; - const relative = (each: FilePath) => each.relativeTo(new DirectoryPath(root)); return [relative(file), format, relative(converted), others]; }; @@ -483,10 +485,19 @@ describe('PortalSourceContext', () => { ]); }); + /** The document the problem names as unreadable, relative to the source directory. */ + const unreadable = async () => { + const problem = (await resolve())._unsafeUnwrapErr(); + if (problem.kind !== 'unreadableSpec') { + throw new Error(`expected an unreadable document, got '${problem.kind}'`); + } + return relative(problem.file); + }; + it('reports a document it cannot parse', async () => { write('spec/broken.json', '{ not json'); - expect((await resolve())._unsafeUnwrapErr().kind).to.equal('unreadableSpec'); + expect(await unreadable()).to.equal('spec/broken.json'); }); it('reports an empty or absent spec directory', async () => { @@ -545,6 +556,269 @@ describe('PortalSourceContext', () => { expect(spec.endpoints.map(String)).to.deep.equal(['GET /health', 'GET /pets', 'POST /pets', 'DELETE /owners']); }); + + // The layout the platform merges several APIs into one SDK from: a folder to each, and a metadata file above them. + describe('the folders a metadata file merges', () => { + const merging = (settings: object = {}) => + write('spec/APIMATIC-META.json', JSON.stringify({ MergeConfiguration: { MergeApis: true, ...settings } })); + + /** Each spec's slug and the file it was read from, in the order the sections are built. */ + const read = async () => (await resolve())._unsafeUnwrap().specs.map((spec) => [spec.slug, relative(spec.file)]); + + /** The metadata file the problem names, and the folders it merges that hold nothing to read. */ + const emptyFolders = async () => { + const problem = (await resolve())._unsafeUnwrapErr(); + if (problem.kind !== 'emptyMergedFolders') { + throw new Error(`expected merged folders with nothing to read, got '${problem.kind}'`); + } + return [ + relative(problem.metadata), + problem.folders.map((folder) => folder.relativeTo(new DirectoryPath(root))) + ]; + }; + + it('reads the folders the file lists, in its order, and names each section after its folder', async () => { + merging({ MergeOrderOfDirectories: ['beta', 'alpha'] }); + write('spec/alpha/openapi.json', OPENAPI); + write('spec/beta/openapi.json', OPENAPI); + write('spec/gamma/openapi.json', OPENAPI); + + expect(await read()).to.deep.equal([ + ['beta', 'spec/beta/openapi.json'], + ['alpha', 'spec/alpha/openapi.json'] + ]); + }); + + // Read twice, its every operation would count as declared by two specs, and lose its SDK samples. + it('reads a folder the file lists twice once, where it is first listed', async () => { + merging({ MergeOrderOfDirectories: ['beta', 'alpha', './beta/'] }); + write('spec/alpha/openapi.json', OPENAPI); + write('spec/beta/openapi.json', OPENAPI); + + expect(await read()).to.deep.equal([ + ['beta', 'spec/beta/openapi.json'], + ['alpha', 'spec/alpha/openapi.json'] + ]); + }); + + it('reads every folder, by name, when the file lists none', async () => { + merging(); + write('spec/beta/b.json', OPENAPI); + write('spec/alpha/a.json', OPENAPI); + + expect(await read()).to.deep.equal([ + ['alpha', 'spec/alpha/a.json'], + ['beta', 'spec/beta/b.json'] + ]); + + merging({ MergeOrderOfDirectories: [] }); + + expect(await read()).to.deep.equal([ + ['alpha', 'spec/alpha/a.json'], + ['beta', 'spec/beta/b.json'] + ]); + }); + + it("keeps the dots of a folder's name, which end no extension", async () => { + merging(); + write('spec/orders.v2/openapi.json', OPENAPI); + + expect(await read()).to.deep.equal([['orders-v2', 'spec/orders.v2/openapi.json']]); + }); + + it("reads neither a folder's own metadata file nor a document at the top of the spec directory", async () => { + merging(); + write('spec/alpha/openapi.json', OPENAPI); + write('spec/alpha/APIMATIC-META.json', JSON.stringify({ CodeGenSettings: {} })); + write('spec/stray.json', OPENAPI); + + expect(await read()).to.deep.equal([['alpha', 'spec/alpha/openapi.json']]); + }); + + // The platform leaves them out of the SDKs too, and nothing else would say so. + it('names each API definition at the top of the spec directory, in any format, as left out', async () => { + merging(); + write('spec/alpha/openapi.json', OPENAPI); + write('spec/old.json', OPENAPI); + write('spec/older.yaml', JSON.stringify({ swagger: '2.0', info: {}, paths: {} })); + write('spec/shared-schemas.json', JSON.stringify({ components: { schemas: {} } })); + write('spec/broken.json', '{ not json'); + write('spec/notes.md', '# Specs'); + + expect((await resolve())._unsafeUnwrap().leftOutSpecs.map(relative)).to.deep.equal([ + 'spec/old.json', + 'spec/older.yaml' + ]); + }); + + it('names none left out when nothing merges the folders', async () => { + write('spec/a.json', OPENAPI); + write('spec/alpha/openapi.json', OPENAPI); + + expect((await resolve())._unsafeUnwrap().leftOutSpecs).to.be.empty; + }); + + // The platform builds one API from a folder, so a second section would document what the SDK may lack. + it('refuses a folder holding two OpenAPI 3.x documents, naming both', async () => { + merging(); + write('spec/alpha/a.json', OPENAPI); + write('spec/alpha/b.json', OPENAPI); + + const problem = (await resolve())._unsafeUnwrapErr(); + + expect(problem.kind === 'severalSpecsInFolder' && problem.files.map(relative)).to.deep.equal([ + 'spec/alpha/a.json', + 'spec/alpha/b.json' + ]); + }); + + // The platform also reads other names starting `APIMATIC-META`; a project for this CLI uses the one. + it('follows no metadata file under another name, which is read as any other file', async () => { + write('spec/APIMATIC-META-MAIN.json', JSON.stringify({ MergeConfiguration: { MergeApis: true } })); + write('spec/alpha/openapi.json', OPENAPI); + + expect(await conversion()).to.deep.equal([ + 'spec/APIMATIC-META-MAIN.json', + null, + 'spec/transformations/APIMATIC-META-MAIN_OpenApi3Yaml.yaml', + 0 + ]); + }); + + it('reads a folder whose other documents hold only parts of its definition', async () => { + merging(); + write('spec/alpha/openapi.json', OPENAPI); + write('spec/alpha/components.json', JSON.stringify({ components: { schemas: {} } })); + + expect(await read()).to.deep.equal([['alpha', 'spec/alpha/openapi.json']]); + }); + + it('reads a path item kept in another file of the folder', async () => { + merging(); + write('spec/alpha/paths/pets.yaml', JSON.stringify({ get: {} })); + write( + 'spec/alpha/openapi.json', + JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Calc', version: '1' }, + paths: { '/pets': { $ref: './paths/pets.yaml' } } + }) + ); + + const [spec] = (await resolve())._unsafeUnwrap().specs; + + expect(spec.endpoints.map(String)).to.deep.equal(['GET /pets']); + }); + + // The metadata file is there for the SDK's settings alone, and is not offered for conversion. + it('leaves the folders unread when no metadata file merges them', async () => { + write('spec/APIMATIC-META.json', JSON.stringify({ CodeGenSettings: {} })); + write('spec/alpha/openapi.json', OPENAPI); + + const problem = (await resolve())._unsafeUnwrapErr(); + + expect( + problem.kind === 'emptySpecDirectory' && problem.folders.map((folder) => folder.leafName()) + ).to.deep.equal(['alpha']); + }); + + it('reports each merged folder with nothing to read: empty, misnamed, or outside the spec directory', async () => { + merging({ MergeOrderOfDirectories: ['alpha', 'empty', 'misnamed', '../content'] }); + write('spec/alpha/openapi.json', OPENAPI); + fs.mkdirSync(path.join(root, 'spec', 'empty')); + write('content/openapi.json', OPENAPI); + + expect(await emptyFolders()).to.deep.equal([ + 'spec/APIMATIC-META.json', + ['./spec/empty', './spec/misnamed', './content'] + ]); + }); + + it('reports a file that merges where there is no folder to merge', async () => { + merging(); + write('spec/api.json', OPENAPI); + + expect(await emptyFolders()).to.deep.equal(['spec/APIMATIC-META.json', []]); + }); + + it('points a folder with no OpenAPI 3.x document at its conversion, inside that folder', async () => { + merging(); + write('spec/alpha/openapi.json', OPENAPI); + write('spec/beta/swagger.json', JSON.stringify({ swagger: '2.0', info: {}, paths: {} })); + + expect(await conversion()).to.deep.equal([ + 'spec/beta/swagger.json', + 'Swagger 2.0', + 'spec/beta/transformations/swagger_OpenApi3Yaml.yaml', + 0 + ]); + }); + + it('names a document it cannot parse with its folder', async () => { + merging(); + write('spec/alpha/openapi.json', '{ not json'); + + expect(await unreadable()).to.equal('spec/alpha/openapi.json'); + }); + + it('reports a metadata file it cannot parse', async () => { + write('spec/APIMATIC-META.json', '{ not json'); + write('spec/alpha/openapi.json', OPENAPI); + + const problem = (await resolve())._unsafeUnwrapErr(); + + expect(problem.kind === 'unreadableMetadata' && relative(problem.file)).to.equal('spec/APIMATIC-META.json'); + }); + }); + + // The platform builds the SDKs, and so the code samples, from one spec unless the specs are merged. + describe('what the SDKs and their samples cover', () => { + const withPaths = (paths: object) => + JSON.stringify({ openapi: '3.0.0', info: { title: 'Calc', version: '1' }, paths }); + + it('names several specs at the top of the spec directory, which nothing merges', async () => { + write('spec/a.json', OPENAPI); + write('spec/b.json', OPENAPI); + write('spec/APIMATIC-META.json', JSON.stringify({ CodeGenSettings: {} })); + + expect((await resolve())._unsafeUnwrap().unmergedSpecs.map(relative)).to.deep.equal([ + 'spec/a.json', + 'spec/b.json' + ]); + }); + + it('names none when there is one spec, or the specs are merged', async () => { + write('spec/a.json', OPENAPI); + + expect((await resolve())._unsafeUnwrap().unmergedSpecs).to.be.empty; + + fs.rmSync(path.join(root, 'spec'), { recursive: true }); + write('spec/APIMATIC-META.json', JSON.stringify({ MergeConfiguration: { MergeApis: true } })); + write('spec/alpha/openapi.json', OPENAPI); + write('spec/beta/openapi.json', OPENAPI); + + 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(({ endpoint, files }) => [`${endpoint}`, 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; + }); + }); }); describe('optional directories', () => { diff --git a/test/types/portal/code-samples.test.ts b/test/types/portal/code-samples.test.ts index d7ce03c67..2a2729da7 100644 --- a/test/types/portal/code-samples.test.ts +++ b/test/types/portal/code-samples.test.ts @@ -85,6 +85,18 @@ describe('CodeSampleCatalogs', () => { expect(codeSampleCatalogs.unplacedIn([new Endpoint('get', '/pets')])).to.deep.equal(['GET /owners']); }); + 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(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("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' } } }), diff --git a/test/types/portal/openapi-document.test.ts b/test/types/portal/openapi-document.test.ts index fb4a450f7..da527733b 100644 --- a/test/types/portal/openapi-document.test.ts +++ b/test/types/portal/openapi-document.test.ts @@ -66,6 +66,14 @@ describe('OpenApiDocument', () => { }); }); + describe('isApiDefinition', () => { + it('is a definition in any format it names, and not a file of shared parts', () => { + expect(readJson({ openapi: '3.1.0' }).isApiDefinition()).to.be.true; + expect(readJson({ swagger: '2.0' }).isApiDefinition()).to.be.true; + expect(readJson({ components: { schemas: {} } }).isApiDefinition()).to.be.false; + }); + }); + describe('suggestedSite', () => { it('takes the title and description from the document', () => { const site = withInfo({ title: 'Swagger Petstore', version: '1', description: 'Pets.' }).suggestedSite(); diff --git a/test/types/portal/spec-metadata.test.ts b/test/types/portal/spec-metadata.test.ts new file mode 100644 index 000000000..b5fb2430d --- /dev/null +++ b/test/types/portal/spec-metadata.test.ts @@ -0,0 +1,62 @@ +import { expect } from 'chai'; +import { SpecMetadata } from '../../../src/types/portal/spec-metadata'; + +describe('SpecMetadata', () => { + const read = (document: unknown): SpecMetadata => { + const metadata = SpecMetadata.parse(JSON.stringify(document)); + expect(metadata, 'unparsable').to.not.be.undefined; + return metadata as SpecMetadata; + }; + + describe('parse', () => { + it('is undefined for text that is not JSON', () => { + expect(SpecMetadata.parse('{ not json')).to.be.undefined; + }); + + it('reads a file written with a byte-order mark', () => { + const metadata = SpecMetadata.parse('' + JSON.stringify({ MergeConfiguration: { MergeApis: true } })); + + expect(metadata?.mergesApis()).to.be.true; + }); + }); + + describe('mergesApis', () => { + it('merges only when the file turns merging on', () => { + expect(read({ MergeConfiguration: { MergeApis: true } }).mergesApis()).to.be.true; + expect(read({ MergeConfiguration: { MergeApis: false } }).mergesApis()).to.be.false; + expect(read({ MergeConfiguration: {} }).mergesApis()).to.be.false; + }); + + // The file carries the SDK's settings too, and most projects have one for those alone. + it('does not merge for a file with no merge configuration, or one that is not an object', () => { + expect(read({ CodeGenSettings: { Timeout: 30 } }).mergesApis()).to.be.false; + expect(read({ MergeConfiguration: 'yes' }).mergesApis()).to.be.false; + expect(read([1, 2]).mergesApis()).to.be.false; + }); + }); + + describe('mergeOrder', () => { + it('gives the folders in the order the file lists them', () => { + const metadata = read({ MergeConfiguration: { MergeApis: true, MergeOrderOfDirectories: ['beta', 'alpha'] } }); + + expect(metadata.mergeOrder()).to.deep.equal(['beta', 'alpha']); + }); + + // The platform then merges every folder beside the file, by name. + it('is null when the file lists no folders', () => { + const orderOf = (order?: unknown) => + read({ MergeConfiguration: { MergeApis: true, MergeOrderOfDirectories: order } }).mergeOrder(); + + expect(orderOf()).to.be.null; + expect(orderOf('alpha')).to.be.null; + expect(orderOf([])).to.be.null; + expect(orderOf([7, null])).to.be.null; + }); + + it('leaves out an entry that is not a folder name', () => { + const metadata = read({ MergeConfiguration: { MergeApis: true, MergeOrderOfDirectories: ['alpha', 7, null] } }); + + expect(metadata.mergeOrder()).to.deep.equal(['alpha']); + }); + }); +}); From b4f5aba4437c869c963a76bcd915db8081bb29f3 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Wed, 14 Oct 2026 13:08:57 +0500 Subject: [PATCH 2/4] fix(portal): read merged spec folders as the platform does Follows the review of the merged spec folders: - An operation that one spec declares twice, as a path item's own method beside its `$ref` does, no longer counts as shared: a single-spec project kept losing its SDK sample. Pages sharing an address and specs sharing an operation are now one `Shared` type and one grouping. - `APIMATIC-META.json` is read as the platform reads it: setting names in any case, and `MergeApis` as `true` or `"true"`. - A merged folder with more than one OpenAPI document at its top is still refused, now with where to move shared parts: the platform builds the folder from the first file by name, which may be the shared parts. Shared parts at the top of `src/spec` are no longer warned as left out. - Hidden files such as `.gitkeep` are never read as API definitions. - A merged folder the platform would not find is refused: `.` (`src/spec` itself), and a name in another case than the folder's on disk. - `portal serve` reloads on any change in `src/spec`, so shared parts in a folder of their own reload the preview too. - A metadata file that merges where `src/spec` has no folders is its own problem, and the sentence about `MergeOrderOfDirectories` lives once. Co-Authored-By: Claude Opus 5.5 --- portal-template/portal-config.ts | 2 + portal-template/spec-reload.ts | 14 ++- portal-template/vite.config.ts | 2 +- src/infrastructure/file-service.ts | 26 +++-- src/infrastructure/portal-project-service.ts | 4 +- src/prompts/portal/source.ts | 56 ++++++----- src/types/file/directoryPath.ts | 5 + src/types/file/fileName.ts | 5 + src/types/portal-source-context.ts | 94 ++++++++++--------- src/types/portal/content-tree.ts | 10 +- src/types/portal/openapi-document.ts | 4 +- src/types/portal/portal-source.ts | 16 ++-- src/types/portal/shared.ts | 20 ++++ src/types/portal/spec-metadata.ts | 12 ++- test/infrastructure/file-service.test.ts | 10 ++ .../portal-project-service.test.ts | 7 +- test/portal-template/prerender-pages.test.ts | 2 +- test/portal-template/spec-reload.test.ts | 44 +++++++++ test/prompts/portal/source.test.ts | 33 ++++--- test/types/file/directory-path.test.ts | 12 +++ test/types/file/fileName.test.ts | 8 ++ test/types/portal-source-context.test.ts | 92 +++++++++++++----- test/types/portal/openapi-document.test.ts | 9 +- test/types/portal/shared.test.ts | 34 +++++++ test/types/portal/spec-metadata.test.ts | 11 +++ 25 files changed, 384 insertions(+), 148 deletions(-) create mode 100644 src/types/portal/shared.ts create mode 100644 test/portal-template/spec-reload.test.ts create mode 100644 test/types/portal/shared.test.ts diff --git a/portal-template/portal-config.ts b/portal-template/portal-config.ts index 0127eadc0..eaedaf55c 100644 --- a/portal-template/portal-config.ts +++ b/portal-template/portal-config.ts @@ -7,6 +7,8 @@ export interface BuildPaths { base: string; /** Section slug -> absolute path of an OpenAPI document. */ 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. */ codeSamples: string | null; /** Absolute. Always exists, and may be empty. */ diff --git a/portal-template/spec-reload.ts b/portal-template/spec-reload.ts index 6e09c0274..b540581ab 100644 --- a/portal-template/spec-reload.ts +++ b/portal-template/spec-reload.ts @@ -1,20 +1,18 @@ -import path from 'node:path'; import type { Plugin } from 'vite'; /** - * Reloads the preview when a file beside a specification changes. The specifications are read - * at run time rather than imported, so Vite's module graph never learns of them, and the - * reference pages are built once per server module evaluation. + * Reloads the preview when any file in the specification directory changes, as a document may refer + * to parts kept in another folder there. The specifications are read at run time rather than imported, + * so Vite's module graph never learns of them, and the reference pages are built once per evaluation. */ -export function specReload(specs: Record): Plugin { - const directories = [...new Set(Object.values(specs).map((file) => path.posix.dirname(file)))]; - const isInSpecDirectory = (file: string) => directories.some((directory) => file.startsWith(`${directory}/`)); +export function specReload(specDirectory: string): Plugin { + const isInSpecDirectory = (file: string) => file.startsWith(`${specDirectory}/`); return { name: 'apimatic:spec-reload', apply: 'serve', configureServer(server) { - server.watcher.add(directories); + server.watcher.add(specDirectory); }, // Sent to each environment: the server runner re-evaluates every module, the browser reloads. hotUpdate({ file }) { diff --git a/portal-template/vite.config.ts b/portal-template/vite.config.ts index 53a0947ae..27025c9d4 100644 --- a/portal-template/vite.config.ts +++ b/portal-template/vite.config.ts @@ -37,7 +37,7 @@ export default defineConfig(async () => { } } }), - specReload(paths.specs), + specReload(paths.specDir), tailwindcss(), tanstackStart({ // Without a mask path the shell is rendered at "/" and no index.html is written; diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index 5120a9e96..011dfee32 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -165,20 +165,28 @@ export class FileService { // By code point first: Windows and macOS open `logo.png` for `Logo.PNG`, where a web server would not. public async spelledOnDisk(root: DirectoryPath, file: FilePath): Promise { - const names = path.relative(root.toString(), file.toString()).split(path.sep); - const fileName = names.pop() ?? ''; - let directory = root; - for (const name of names) { - const { subDirectories } = await this.listEntries(directory); + const directory = await this.directorySpelledOnDisk(root, file.directory()); + if (directory === null) { + return null; + } + const { fileNames } = await this.listEntries(directory); + const match = spelling(file.name().toString(), fileNames, (candidate) => candidate.toString()); + return match === undefined ? null : new FilePath(directory, match); + } + + /** `directory`, at or below `root`, as each folder below `root` is spelt on disk; null when one is not there. */ + public async directorySpelledOnDisk(root: DirectoryPath, directory: DirectoryPath): Promise { + const relative = path.relative(root.toString(), directory.toString()); + let found = root; + for (const name of relative === '' ? [] : relative.split(path.sep)) { + const { subDirectories } = await this.listEntries(found); const match = spelling(name, subDirectories, (subDirectory) => path.basename(subDirectory.toString())); if (match === undefined) { return null; } - directory = match; + found = match; } - const { fileNames } = await this.listEntries(directory); - const match = spelling(fileName, fileNames, (candidate) => candidate.toString()); - return match === undefined ? null : new FilePath(directory, match); + return found; } // The direct children of `dir`, split into files and directories; both empty when it cannot diff --git a/src/infrastructure/portal-project-service.ts b/src/infrastructure/portal-project-service.ts index b2def4d7f..2dfd9fc12 100644 --- a/src/infrastructure/portal-project-service.ts +++ b/src/infrastructure/portal-project-service.ts @@ -76,6 +76,7 @@ export interface PortalProjectPaths { export interface PortalBuildPaths { base: string; specs: Record; + specDir: string; codeSamples: string | null; contentDir: string; generatedDir: string; @@ -129,7 +130,7 @@ export class PortalProjectService { await this.contentDirectory(projectDirectory, source), await this.writeCodeSamples( projectDirectory, - artifacts.codeSampleCatalogs.without(source.sharedEndpoints.map(({ endpoint }) => endpoint)) + artifacts.codeSampleCatalogs.without(source.sharedEndpoints.map(({ key }) => key)) ), await this.writeDownloads(projectDirectory, artifacts) ); @@ -284,6 +285,7 @@ export class PortalProjectService { const configuration: PortalBuildPaths = { base: source.config.siteAddress()?.path() ?? '/', specs, + specDir: source.specDirectory.toPosix(), codeSamples: codeSamples === null ? null : codeSamples.toPosix(), contentDir: contentDirectory.toPosix(), generatedDir: projectDirectory.join(GENERATED_DIRECTORY_NAME).toPosix(), diff --git a/src/prompts/portal/source.ts b/src/prompts/portal/source.ts index e632613b5..539d3a70b 100644 --- a/src/prompts/portal/source.ts +++ b/src/prompts/portal/source.ts @@ -14,6 +14,7 @@ import { SharedEndpoint, SpecConversion } from '../../types/portal/portal-source.js'; +import { Shared } from '../../types/portal/shared.js'; import { MERGE_SETTINGS } from '../../types/portal/spec-metadata.js'; import { TRANSFORMATIONS_DIRECTORY_NAME } from '../../types/transform-context.js'; import { PortalTab, SharedTabName } from '../../types/portal/portal-tabs.js'; @@ -37,6 +38,9 @@ const mergeTheFolders = (): string => `turn on ${f.var(MERGE_SETTINGS.mergeApis)} in an ${f.var(METADATA_FILE_NAME)} beside the folders: ` + f.link(API_MERGING_URL); +const addingToMergeOrder = (folders: string): string => + `adding ${folders} to ${f.var(MERGE_SETTINGS.order)} when the file lists folders`; + export const specPath = (sourceDirectory: DirectoryPath): string => f.path(sourceDirectory.join(SPEC_DIRECTORY_NAME)); export const contentPath = (sourceDirectory: DirectoryPath): string => @@ -50,6 +54,9 @@ 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 => + claims.map(({ key, files }) => ` • ${f.var(String(key))}: ${listed(files, sourceDirectory)}`).join('\n'); + const spelt = (found: FilePath, sourceDirectory: DirectoryPath): string => `spelt ${relative(found, sourceDirectory)} on disk`; @@ -90,19 +97,16 @@ function reportSpecConversion({ file, format, converted, others }: SpecConversio } function reportEmptyMergedFolders(metadata: FilePath, folders: DirectoryPath[], sourceDirectory: DirectoryPath) { - const file = relative(metadata, sourceDirectory); - if (folders.length === 0) { - log.error(`${file} merges the folders of ${specPath(sourceDirectory)}, which has none.`); - const mergeApis = f.var(MERGE_SETTINGS.mergeApis); - log.message(`Put each API definition in a folder of its own there, or turn off ${mergeApis} in the file.`); - return; - } const one = folders.length === 1; const names = listedInProse(folders.map((folder) => f.var(folder.relativeTo(sourceDirectory)))); - log.error(`${file} merges ${names}, which ${one ? 'holds' : 'hold'} no API definition the portal can read.`); + log.error( + `${relative(metadata, sourceDirectory)} merges ${names}, which ${one ? 'holds' : 'hold'} no API definition ` + + `the portal can read.` + ); log.message( - `A merged folder sits inside ${specPath(sourceDirectory)}, with its OpenAPI 3.x document at its top. Add ` + - `${one ? 'one to it' : 'one to each'}, or list only the folders to merge in ${f.var(MERGE_SETTINGS.order)}.` + `A merged folder sits inside ${specPath(sourceDirectory)}, is named as it is spelt, case included, and holds ` + + `its OpenAPI 3.x document at its top. Add ${one ? 'one to it' : 'one to each'}, or list only the folders ` + + `to merge in ${f.var(MERGE_SETTINGS.order)}.` ); } @@ -168,19 +172,32 @@ export function reportSourceProblem(problem: PortalSourceProblem, sourceDirector } return; } + case 'noFoldersToMerge': { + log.error( + `${relative(problem.metadata, sourceDirectory)} merges the folders of ${specPath(sourceDirectory)}, which ` + + `has none.` + ); + log.message( + `Put each API definition in a folder of its own there, or turn off ${f.var(MERGE_SETTINGS.mergeApis)} in ` + + `the file.` + ); + return; + } case 'emptyMergedFolders': { reportEmptyMergedFolders(problem.metadata, problem.folders, sourceDirectory); return; } case 'severalSpecsInFolder': { - const names = listed(problem.files, sourceDirectory); + const [first] = problem.files; + const shared = f.var(first.directory().join('shared').relativeTo(sourceDirectory)); log.error( - `${names} are each an OpenAPI document, but a folder that ${f.var(METADATA_FILE_NAME)} merges holds ` + - `one API definition.` + `${listed(problem.files, sourceDirectory)} are each an OpenAPI document, but a folder that ` + + `${f.var(METADATA_FILE_NAME)} merges holds one API definition at its top, and the SDKs are built from ` + + `one of them only.` ); log.message( - `Keep one there and move each of the others into a folder of its own, adding the new folders to ` + - `${f.var(MERGE_SETTINGS.order)} when the file lists folders.` + `Keep the definition there and move each of the others: shared parts into a folder inside it, such as ` + + `${shared}, and another API into a folder of its own, ${addingToMergeOrder('the new folders')}.` ); return; } @@ -284,12 +301,11 @@ function reportReservedAddresses(pages: ReservedAddressPage[], sourceDirectory: function reportSharedAddresses(addresses: SharedAddress[], sourceDirectory: DirectoryPath): void { const one = addresses.length === 1; - const lines = addresses.map(({ address, pages }) => ` • ${f.var(address)}: ${listed(pages, sourceDirectory)}`); log.error( `Pages in ${f.path(sourceDirectory)} would share ${one ? 'an address' : 'addresses'}, but only one page ` + `can be served at each:` ); - log.message(lines.join('\n')); + log.message(sharedLines(addresses, sourceDirectory)); log.message( `Rename or move all but one ${one ? 'of them' : 'page at each address'}. A page in a ${f.var( '(group)' @@ -326,7 +342,7 @@ export function reportLeftOutSpecs(files: FilePath[], sourceDirectory: Directory log.warn( `${listed(files, sourceDirectory)} ${verb} left out of the portal and the SDKs, as ` + `${f.var(METADATA_FILE_NAME)} merges only the folders beside it. To include ${pronoun}, move ${moved} into ` + - `a folder of its own, adding the ${folders} to ${f.var(MERGE_SETTINGS.order)} when the file lists folders.` + `a folder of its own, ${addingToMergeOrder(`the ${folders}`)}.` ); } @@ -339,9 +355,7 @@ export function reportSharedEndpoints(shared: SharedEndpoint[], sourceDirectory: `More than one API definition declares ${one ? 'this operation' : 'these operations'}, so the portal shows no ` + `SDK sample on ${one ? 'it' : 'them'}:` ); - log.message( - shared.map(({ endpoint, files }) => ` • ${f.var(`${endpoint}`)}: ${listed(files, sourceDirectory)}`).join('\n') - ); + log.message(sharedLines(shared, sourceDirectory)); } export function reportIgnoredNavigationFiles(files: FilePath[], sourceDirectory: DirectoryPath): void { diff --git a/src/types/file/directoryPath.ts b/src/types/file/directoryPath.ts index 9c70e875a..bd6d126d9 100644 --- a/src/types/file/directoryPath.ts +++ b/src/types/file/directoryPath.ts @@ -77,6 +77,11 @@ export class DirectoryPath { return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); } + /** True when `other` sits anywhere inside this directory, and is not this directory itself. */ + public isAncestorOf(other: DirectoryPath) { + return this.contains(other) && !this.isEqual(other); + } + public leafName() { return path.basename(this.directoryPath); } diff --git a/src/types/file/fileName.ts b/src/types/file/fileName.ts index 32cd64b6e..c54df8cf3 100644 --- a/src/types/file/fileName.ts +++ b/src/types/file/fileName.ts @@ -24,6 +24,11 @@ export class FileName { return this.name === other.name; } + /** Whether the name starts with a dot, as a placeholder or an operating system's own file does. */ + public isHidden(): boolean { + return this.name.startsWith('.'); + } + /** Whether this is `name`, compared without regard to case. */ public is(name: string): boolean { return this.name.toLowerCase() === name.toLowerCase(); diff --git a/src/types/portal-source-context.ts b/src/types/portal-source-context.ts index 449fd8516..f5f85949f 100644 --- a/src/types/portal-source-context.ts +++ b/src/types/portal-source-context.ts @@ -35,9 +35,9 @@ import { PortalSource, PortalSourceProblem, PortalSpec, - SharedEndpoint, SpecConversion } from './portal/portal-source.js'; +import { shared } from './portal/shared.js'; import { SpecMetadata } from './portal/spec-metadata.js'; import { SpecContext } from './spec-context.js'; import { TRANSFORMATIONS_DIRECTORY_NAME, transformedFileName } from './transform-context.js'; @@ -46,28 +46,21 @@ const SPEC_EXTENSIONS = ['.json', '.yaml', '.yml']; const METADATA_FILE = new FileName(METADATA_FILE_NAME); -/** The files of a directory that may be API definitions: all but the platform's metadata. */ -const definitionNames = (fileNames: FileName[]): FileName[] => fileNames.filter((name) => !name.isEqual(METADATA_FILE)); +const definitionNames = (fileNames: FileName[]): FileName[] => + fileNames.filter((name) => !name.isEqual(METADATA_FILE) && !name.isHidden()); -/** Whether the portal would read the file as a document, by its extension. */ const isDocumentName = (name: FileName): boolean => SPEC_EXTENSIONS.some((extension) => name.hasExtension(extension)); -/** The operations more than one specification declares, each with the files that do, in the order first declared. */ -function sharedEndpoints(specs: PortalSpec[]): SharedEndpoint[] { - const declared = new Map(); - for (const { file, endpoints } of specs) { - for (const endpoint of endpoints) { - const files = declared.get(`${endpoint}`)?.files ?? []; - declared.set(`${endpoint}`, { endpoint, files: [...files, file] }); - } - } - return [...declared.values()].filter(({ files }) => files.length > 1); -} - -/** What `spec/` holds, as `resolve` hands it on. */ type DiscoveredSpecs = Pick; -/** The specifications read from a directory, and the site the first of them suggests. */ +/** An OpenAPI 3.x document at the top of a directory, read for its operations and the site it suggests. */ +interface ReadDocument { + file: FilePath; + endpoints: Endpoint[]; + site: SuggestedSite; +} + +/** The specifications read from `spec/`, and the site the first of them suggests. */ interface ReadSpecs { specs: PortalSpec[]; suggested: SuggestedSite; @@ -159,9 +152,10 @@ export class PortalSourceContext { ...settings.value, suggestedSite, specs, + specDirectory: this.specDirectory, unmergedSpecs, leftOutSpecs, - sharedEndpoints: sharedEndpoints(specs), + sharedEndpoints: shared(specs.flatMap(({ file, endpoints }) => endpoints.map((key) => ({ key, file })))), contentDirectory, staticDirectory, shadowedFiles: staticDirectory === null ? [] : await this.shadowedFiles(staticDirectory), @@ -529,47 +523,65 @@ export class PortalSourceContext { if (names.length === 0) { return err({ kind: 'emptySpecDirectory', folders }); } - return await this.definitions(this.specDirectory, names, (fileName) => - this.uniqueSlug(fileName.normalize(), usedSlugs) - ); + return (await this.definitions(this.specDirectory, names)).map((documents) => ({ + specs: documents.map(({ file, endpoints }) => ({ + slug: this.uniqueSlug(file.name().normalize(), usedSlugs), + file, + endpoints + })), + suggested: documents[0].site + })); } - /** The one document of each merged folder, a section named after the folder, as the platform builds the SDKs. */ + /** The one definition of each merged folder, a section named after the folder, as the platform builds the SDKs. */ private async mergedSpecs( { metadata, folders }: MergedFolders, usedSlugs: Set ): Promise> { + if (folders.length === 0) { + return err({ kind: 'noFoldersToMerge', metadata }); + } const listings = await Promise.all( folders.map(async (folder) => ({ folder, - // A folder named outside `spec/` is not in the build the platform is sent, so it is read as empty. - fileNames: this.specDirectory.contains(folder) + fileNames: (await this.isMergeable(folder)) ? definitionNames(await this.fileService.getFileNames(folder)).sort((left, right) => left.compare(right)) : [] })) ); const empty = listings.filter(({ fileNames }) => fileNames.length === 0).map(({ folder }) => folder); - if (listings.length === 0 || empty.length > 0) { + if (empty.length > 0) { return err({ kind: 'emptyMergedFolders', metadata, folders: empty }); } const specs: PortalSpec[] = []; const sites: SuggestedSite[] = []; for (const { folder, fileNames } of listings) { - const slug = new FileName(folder.leafName()).slugified(); - const read = await this.definitions(folder, fileNames, () => this.uniqueSlug(slug, usedSlugs)); + const read = await this.definitions(folder, fileNames); if (read.isErr()) { return err(read.error); } - if (read.value.specs.length > 1) { - return err({ kind: 'severalSpecsInFolder', files: read.value.specs.map(({ file }) => file) }); + // The platform builds the folder from the first of them by name, which may be one of shared parts. + const [definition, ...others] = read.value; + if (others.length > 0) { + return err({ kind: 'severalSpecsInFolder', files: read.value.map(({ file }) => file) }); } - specs.push(...read.value.specs); - sites.push(read.value.suggested); + const slug = this.uniqueSlug(new FileName(folder.leafName()).slugified(), usedSlugs); + specs.push({ slug, file: definition.file, endpoints: definition.endpoints }); + sites.push(definition.site); } return ok({ specs, suggested: sites[0] }); } + /** Whether the platform finds `folder` in the build it is sent: below `spec/`, and spelt on disk as it is named. */ + private async isMergeable(folder: DirectoryPath): Promise { + if (!this.specDirectory.isAncestorOf(folder)) { + return false; + } + const spelt = await this.fileService.directorySpelledOnDisk(this.specDirectory, folder); + return spelt !== null && spelt.isEqual(folder); + } + /** The API definitions at the top of `spec/`, which a merge of its folders leaves out, as the platform does. */ private async leftOutSpecs(fileNames: FileName[]): Promise { const files = definitionNames(fileNames) @@ -584,13 +596,10 @@ export class PortalSourceContext { /** The OpenAPI 3.x documents at the top of a directory, or how to convert what is there instead. */ private async definitions( directory: DirectoryPath, - fileNames: FileName[], - slugOf: (fileName: FileName) => string - ): Promise> { - const specs: PortalSpec[] = []; - // Only the first is kept: it is the one that speaks for the portal when it is alone, and - // specifications run to megabytes. - let first: OpenApiDocument | undefined; + fileNames: FileName[] + ): Promise> { + // The site each suggests rather than the document itself, as specifications run to megabytes. + const documents: ReadDocument[] = []; // What each one is, for the conversion a directory without an OpenAPI 3.x document is pointed to. const otherFormats: { file: FilePath; format: string }[] = []; @@ -609,11 +618,10 @@ export class PortalSourceContext { continue; } - specs.push({ slug: slugOf(fileName), file, endpoints: await this.endpoints(document, file) }); - first ??= document; + documents.push({ file, endpoints: await this.endpoints(document, file), site: document.suggestedSite() }); } - if (first === undefined) { + if (documents.length === 0) { const [convertible] = otherFormats; // No document declares its format: one the portal would read, by extension, is named before any other file. const conversion = @@ -622,7 +630,7 @@ export class PortalSourceContext { : this.conversion(convertible.file, convertible.format, otherFormats.length - 1); return err({ kind: 'noOpenApiSpec', conversion }); } - return ok({ specs, suggested: first.suggestedSite() }); + return ok(documents); } private conversion(file: FilePath, format: string | null, others: number): SpecConversion { diff --git a/src/types/portal/content-tree.ts b/src/types/portal/content-tree.ts index 87f4ca326..358f471fd 100644 --- a/src/types/portal/content-tree.ts +++ b/src/types/portal/content-tree.ts @@ -27,6 +27,7 @@ import { SharedAddress } from './portal-source.js'; import { PortalTab, sharedTabNames, TabOwner, untitledTabName } from './portal-tabs.js'; +import { shared } from './shared.js'; const NAVIGATION_FILE = new FileName(NAVIGATION_FILE_NAME); @@ -337,14 +338,7 @@ export class ContentTree { /** Addresses more than one page would be served at, each with its pages in the order walked. */ private static sharedAddresses(pages: ContentPage[]): SharedAddress[] { - const byAddress = new Map(); - for (const { file, segments } of pages) { - const address = `/${getSlugs(segments.join('/')).join('/')}`; - byAddress.set(address, [...(byAddress.get(address) ?? []), file]); - } - return [...byAddress] - .filter(([, files]) => files.length > 1) - .map(([address, files]) => ({ address, pages: files })); + return shared(pages.map(({ file, segments }) => ({ key: `/${getSlugs(segments.join('/')).join('/')}`, file }))); } /** diff --git a/src/types/portal/openapi-document.ts b/src/types/portal/openapi-document.ts index b5fe1e5b5..caadc726f 100644 --- a/src/types/portal/openapi-document.ts +++ b/src/types/portal/openapi-document.ts @@ -87,10 +87,10 @@ export class OpenApiDocument { return { supported: false, format: null }; } - /** Whether it names the API format it is in, as a definition does and a file of shared parts does not. */ + /** Whether it names the API format it is in and declares paths, as a definition does and a file of shared parts does not. */ public isApiDefinition(): boolean { const format = this.format(); - return format.supported || format.format !== null; + return (format.supported || format.format !== null) && Object.keys(this.paths()).length > 0; } /** diff --git a/src/types/portal/portal-source.ts b/src/types/portal/portal-source.ts index 53b9e1549..efbef8452 100644 --- a/src/types/portal/portal-source.ts +++ b/src/types/portal/portal-source.ts @@ -6,6 +6,7 @@ import { SuggestedSite } from './config/site-config.js'; import { ContentNotices } from './content-notices.js'; import { GeneratedPages } from './generated-pages.js'; import { PortalConfig } from './portal-config.js'; +import { Shared } from './shared.js'; /** An OpenAPI document found in `src/spec/` or in a folder merged there, with the slug its section is mounted at. */ export interface PortalSpec { @@ -16,10 +17,7 @@ export interface PortalSpec { } /** An operation more than one specification declares, which one code sample per method and path cannot serve. */ -export interface SharedEndpoint { - endpoint: Endpoint; - files: FilePath[]; -} +export type SharedEndpoint = Shared; /** What `apimatic.json` decides about a portal, which `portal serve` reads again on every edit. */ export interface PortalSettings { @@ -35,6 +33,8 @@ export interface PortalSource extends PortalSettings { */ suggestedSite: SuggestedSite | null; specs: PortalSpec[]; + /** `spec/`, whose every file the preview reloads on, as a specification may refer to any of them. */ + specDirectory: DirectoryPath; /** Several specifications at the top of `spec/` that no `APIMATIC-META.json` merges, so the SDKs cover one; else none. */ unmergedSpecs: FilePath[]; /** API definitions at the top of `spec/` while its folders are merged, which the portal and the SDKs leave out. */ @@ -85,10 +85,7 @@ export interface ReservedAddressPage { } /** Pages of the user's that would be served at one address, which only one of them can have. */ -export interface SharedAddress { - address: string; - pages: FilePath[]; -} +export type SharedAddress = Shared; /** A page's Markdown image that the build would import and not find, which fails the whole build. */ export interface MissingImage { @@ -130,7 +127,8 @@ export type PortalSourceProblem = | { kind: 'unreadableMetadata'; file: FilePath } // Its `folders` are not read unless a metadata file merges them, and `api transform` writes into one of its own. | { kind: 'emptySpecDirectory'; folders: DirectoryPath[] } - // The `folders` that `metadata` merges and that hold no file to read; none when it has no folder to merge at all. + | { kind: 'noFoldersToMerge'; metadata: FilePath } + // Each of the `folders` that `metadata` merges and the platform would find no definition in. | { kind: 'emptyMergedFolders'; metadata: FilePath; folders: DirectoryPath[] } // Every OpenAPI 3.x document at the top of a merged folder, which holds one API. | { kind: 'severalSpecsInFolder'; files: FilePath[] } diff --git a/src/types/portal/shared.ts b/src/types/portal/shared.ts new file mode 100644 index 000000000..665d17bd3 --- /dev/null +++ b/src/types/portal/shared.ts @@ -0,0 +1,20 @@ +import { FilePath } from '../file/filePath.js'; + +/** What more than one file claims where only one can have it: an address two pages are served at, or an operation two specifications declare. */ +export interface Shared { + key: Key; + files: FilePath[]; +} + +/** The keys more than one file claims, each with its files in the order claimed; a file claiming a key twice counts once. */ +export function shared(claims: { key: Key; file: FilePath }[]): Shared[] { + const byKey = new Map>(); + for (const { key, file } of claims) { + const claimed = byKey.get(String(key)) ?? { key, files: [] }; + byKey.set(String(key), { + key: claimed.key, + files: claimed.files.some((other) => other.isEqual(file)) ? claimed.files : [...claimed.files, file] + }); + } + return [...byKey.values()].filter(({ files }) => files.length > 1); +} diff --git a/src/types/portal/spec-metadata.ts b/src/types/portal/spec-metadata.ts index 6ee45c488..dc1c1d76c 100644 --- a/src/types/portal/spec-metadata.ts +++ b/src/types/portal/spec-metadata.ts @@ -4,6 +4,10 @@ import { stripByteOrderMark } from '../../utils/string-utils.js'; /** The settings of its `MergeConfiguration` a portal follows, by the names a message gives them. */ export const MERGE_SETTINGS = { mergeApis: 'MergeApis', order: 'MergeOrderOfDirectories' } as const; +// The platform matches a setting's name in any case, as its JSON reader does. +const setting = (object: JsonObject, name: string): unknown => + Object.entries(object).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1]; + /** The platform's metadata file beside API definitions, read for the one thing a portal follows: which folders it merges. */ export class SpecMetadata { private constructor(private readonly mergeConfiguration: JsonObject) {} @@ -12,20 +16,22 @@ export class SpecMetadata { public static parse(contents: string): SpecMetadata | undefined { try { const document: unknown = JSON.parse(stripByteOrderMark(contents)); - const mergeConfiguration = isJsonObject(document) ? document.MergeConfiguration : undefined; + const mergeConfiguration = isJsonObject(document) ? setting(document, 'MergeConfiguration') : undefined; return new SpecMetadata(isJsonObject(mergeConfiguration) ? mergeConfiguration : {}); } catch { return undefined; } } + /** On for `true`, and for the text `"true"`, which the platform reads as the same. */ public mergesApis(): boolean { - return this.mergeConfiguration[MERGE_SETTINGS.mergeApis] === true; + const mergeApis = setting(this.mergeConfiguration, MERGE_SETTINGS.mergeApis); + return mergeApis === true || (typeof mergeApis === 'string' && mergeApis.toLowerCase() === 'true'); } /** The folders merged, in order, relative to the file's own; null when it lists none, which merges every folder there by name. */ public mergeOrder(): string[] | null { - const order = this.mergeConfiguration[MERGE_SETTINGS.order]; + const order = setting(this.mergeConfiguration, MERGE_SETTINGS.order); const folders = Array.isArray(order) ? order.filter((folder): folder is string => typeof folder === 'string') : []; return folders.length > 0 ? folders : null; } diff --git a/test/infrastructure/file-service.test.ts b/test/infrastructure/file-service.test.ts index da7f2cbc2..81aa79ec0 100644 --- a/test/infrastructure/file-service.test.ts +++ b/test/infrastructure/file-service.test.ts @@ -141,6 +141,16 @@ describe('FileService', () => { it('does not take a directory for the file', async () => { expect(await fileService.spelledOnDisk(root, fileAt('static', 'images'))).to.be.null; }); + + it('answers with the spelling on disk of a directory, itself included, or null when it is not there', async () => { + const images = root.join('static', 'images'); + + expect((await fileService.directorySpelledOnDisk(root, root.join('Static', 'Images')))?.isEqual(images)).to.be + .true; + expect((await fileService.directorySpelledOnDisk(root, images))?.isEqual(images)).to.be.true; + expect((await fileService.directorySpelledOnDisk(root, root))?.isEqual(root)).to.be.true; + expect(await fileService.directorySpelledOnDisk(root, root.join('static', 'logos'))).to.be.null; + }); }); describe('findSymlinks', () => { diff --git a/test/infrastructure/portal-project-service.test.ts b/test/infrastructure/portal-project-service.test.ts index 0ec1cd720..a01f9de45 100644 --- a/test/infrastructure/portal-project-service.test.ts +++ b/test/infrastructure/portal-project-service.test.ts @@ -61,6 +61,7 @@ describe('PortalProjectService', () => { endpoints: [] } ], + specDirectory: new DirectoryPath(root).join('spec'), unmergedSpecs: [], leftOutSpecs: [], sharedEndpoints: [], @@ -174,11 +175,14 @@ describe('PortalProjectService', () => { 'contentDir', 'downloadsDir', 'generatedDir', + 'specDir', 'specs', 'staticDir' ]); expect(Object.keys(config.specs)).to.deep.equal(['calculator']); expect(config.specs.calculator).to.contain('api.json'); + // A spec in a merged folder may refer to parts in another folder of `spec/`, which the preview reloads on too. + expect(config.specDir).to.equal(new DirectoryPath(root).join('spec').toPosix()); }); it('builds the portal under the path of its address', async () => { @@ -306,7 +310,7 @@ describe('PortalProjectService', () => { 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: [{ endpoint: new Endpoint('get', '/pets'), files }] }); + const source = sourceFor({ sharedEndpoints: [{ key: new Endpoint('get', '/pets'), files }] }); (await service.prepare(project, source, withCatalogs))._unsafeUnwrap(); @@ -320,7 +324,6 @@ describe('PortalProjectService', () => { expect(readConfig().specs.calculator).to.equal(source.specs[0].file.toString().split(path.sep).join('/')); }); - it('names no samples when there are none', async () => { (await service.prepare(project, sourceFor(), NO_ARTIFACTS))._unsafeUnwrap(); diff --git a/test/portal-template/prerender-pages.test.ts b/test/portal-template/prerender-pages.test.ts index 1032d0781..c576287a1 100644 --- a/test/portal-template/prerender-pages.test.ts +++ b/test/portal-template/prerender-pages.test.ts @@ -19,7 +19,7 @@ describe('prerenderPages', () => { const urlsFor = async (siteUrl: string | null = null, base = '/', specs: Record = {}) => { const pages = await prerenderPages( - { base, specs, codeSamples: null, contentDir, generatedDir, staticDir: null, downloadsDir: null }, + { base, specs, specDir: '', codeSamples: null, contentDir, generatedDir, staticDir: null, downloadsDir: null }, siteUrl ); return pages.map((page) => page.path); diff --git a/test/portal-template/spec-reload.test.ts b/test/portal-template/spec-reload.test.ts new file mode 100644 index 000000000..ec959a73e --- /dev/null +++ b/test/portal-template/spec-reload.test.ts @@ -0,0 +1,44 @@ +import { expect } from 'chai'; +import type { ViteDevServer } from 'vite'; +import { specReload } from '../../portal-template/spec-reload'; + +describe('specReload', () => { + const specDirectory = '/project/src/spec'; + + /** The messages the preview's environment is sent when `file` changes. */ + const sentFor = (file: string): unknown[] => { + const sent: unknown[] = []; + const hotUpdate = specReload(specDirectory).hotUpdate as ( + this: { environment: { hot: { send: (message: unknown) => void } } }, + options: { file: string } + ) => unknown; + hotUpdate.call({ environment: { hot: { send: (message) => sent.push(message) } } }, { file }); + return sent; + }; + + it('watches the whole spec directory', () => { + const watched: unknown[] = []; + const configureServer = specReload(specDirectory).configureServer as (server: ViteDevServer) => void; + + configureServer({ watcher: { add: (paths: unknown) => watched.push(paths) } } as unknown as ViteDevServer); + + expect(watched).to.deep.equal([specDirectory]); + }); + + // A merged folder's document may refer to parts in a folder of their own, as `../common/` from it. + it('reloads the preview for a change to any file in it, in a merged folder or beside them', () => { + for (const file of [ + `${specDirectory}/openapi.json`, + `${specDirectory}/orders/openapi.yaml`, + `${specDirectory}/common/schemas/order.yaml` + ]) { + expect(sentFor(file), file).to.deep.equal([{ type: 'full-reload' }]); + } + }); + + it('leaves a change anywhere else to Vite, including beside the directory under a similar name', () => { + for (const file of ['/project/src/content/index.md', '/project/src/spec-backup/openapi.json']) { + expect(sentFor(file), file).to.be.empty; + } + }); +}); diff --git a/test/prompts/portal/source.test.ts b/test/prompts/portal/source.test.ts index c97bd0625..3525fe06f 100644 --- a/test/prompts/portal/source.test.ts +++ b/test/prompts/portal/source.test.ts @@ -242,7 +242,7 @@ describe('reportSourceProblem', () => { expect(printed().split('\n')).to.deep.equal([ "'spec/APIMATIC-META.json' merges './spec/orderz', which holds no API definition the portal can read.", - `A merged folder sits inside '${spec}', with its OpenAPI 3.x document at its top. Add one to it, or ` + + `A merged folder sits inside '${spec}', is named as it is spelt, case included, and holds its OpenAPI 3.x document at its top. Add one to it, or ` + "list only the folders to merge in 'MergeOrderOfDirectories'." ]); }); @@ -257,31 +257,36 @@ describe('reportSourceProblem', () => { expect(printed().split('\n')).to.deep.equal([ "'spec/APIMATIC-META.json' merges './spec/apis/orders' and './content', which hold no API definition the " + 'portal can read.', - `A merged folder sits inside '${spec}', with its OpenAPI 3.x document at its top. Add one to each, or ` + + `A merged folder sits inside '${spec}', is named as it is spelt, case included, and holds its OpenAPI 3.x document at its top. Add one to each, or ` + "list only the folders to merge in 'MergeOrderOfDirectories'." ]); }); - it('names each document of a merged folder that holds more than one, and how to split them', () => { + it('names each document of a merged folder that holds more than one, and where to move the others', () => { const orders = spec.join('orders'); reportSourceProblem( { kind: 'severalSpecsInFolder', - files: [new FilePath(orders, new FileName('orders.json')), new FilePath(orders, new FileName('returns.json'))] + files: [ + new FilePath(orders, new FileName('components.json')), + new FilePath(orders, new FileName('orders.json')) + ] }, source ); expect(printed().split('\n')).to.deep.equal([ - "'spec/orders/orders.json' and 'spec/orders/returns.json' are each an OpenAPI document, but a folder " + - "that 'APIMATIC-META.json' merges holds one API definition.", - 'Keep one there and move each of the others into a folder of its own, adding the new folders to ' + + "'spec/orders/components.json' and 'spec/orders/orders.json' are each an OpenAPI document, but a folder " + + "that 'APIMATIC-META.json' merges holds one API definition at its top, and the SDKs are built from one " + + 'of them only.', + 'Keep the definition there and move each of the others: shared parts into a folder inside it, such as ' + + "'./spec/orders/shared', and another API into a folder of its own, adding the new folders to " + "'MergeOrderOfDirectories' when the file lists folders." ]); }); it('says so when the file merges and there is no folder at all', () => { - reportSourceProblem({ kind: 'emptyMergedFolders', metadata, folders: [] }, source); + reportSourceProblem({ kind: 'noFoldersToMerge', metadata }, source); expect(printed().split('\n')).to.deep.equal([ `'spec/APIMATIC-META.json' merges the folders of '${spec}', which has none.`, @@ -401,8 +406,8 @@ describe('reportSourceProblem', () => { reportContent({ kind: 'sharedAddresses', addresses: [ - { address: '/guides', pages: [page(content, 'guides.md'), page(content.join('guides'), 'index.md')] }, - { address: '/', pages: [page(content, 'index.md'), page(content.join('(start)'), 'index.md')] } + { key: '/guides', files: [page(content, 'guides.md'), page(content.join('guides'), 'index.md')] }, + { key: '/', files: [page(content, 'index.md'), page(content.join('(start)'), 'index.md')] } ] }); @@ -420,7 +425,7 @@ describe('reportSourceProblem', () => { it('speaks of one address when there is one', () => { reportContent({ kind: 'sharedAddresses', - addresses: [{ address: '/faq', pages: [page(content, 'faq.md'), page(content, 'faq.mdx')] }] + addresses: [{ key: '/faq', files: [page(content, 'faq.md'), page(content, 'faq.mdx')] }] }); expect(printed()).to.contain('would share an address, but only one page can be served at each:'); @@ -769,8 +774,8 @@ describe('reportSharedEndpoints', () => { it('names each operation and the specs declaring it, and why it shows no SDK sample', () => { reportSharedEndpoints( [ - { endpoint: new Endpoint('get', '/status'), files }, - { endpoint: new Endpoint('post', '/status'), files } + { key: new Endpoint('get', '/status'), files }, + { key: new Endpoint('post', '/status'), files } ], source ); @@ -783,7 +788,7 @@ describe('reportSharedEndpoints', () => { }); it('speaks of one operation when there is one', () => { - reportSharedEndpoints([{ endpoint: new Endpoint('get', '/status'), files }], source); + 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/file/directory-path.test.ts b/test/types/file/directory-path.test.ts index 8dfc2f1a1..3dbe4600c 100644 --- a/test/types/file/directory-path.test.ts +++ b/test/types/file/directory-path.test.ts @@ -53,6 +53,18 @@ describe('DirectoryPath.fromUserInput', () => { }); }); +describe('DirectoryPath.isAncestorOf', () => { + const spec = new DirectoryPath(path.join('/', 'projects', 'petstore-api', 'src', 'spec')); + + it('holds a directory anywhere below it, but not itself, its parent, or a sibling', () => { + expect(spec.isAncestorOf(spec.join('alpha'))).to.be.true; + expect(spec.isAncestorOf(spec.join('apis', 'alpha'))).to.be.true; + expect(spec.isAncestorOf(spec)).to.be.false; + expect(spec.isAncestorOf(spec.resolve('..'))).to.be.false; + expect(spec.isAncestorOf(spec.resolve('../content'))).to.be.false; + }); +}); + // What orders the folders of a spec directory that are merged without an order of their own. describe('DirectoryPath.compare', () => { const spec = new DirectoryPath(path.join('/', 'projects', 'petstore-api', 'src', 'spec')); diff --git a/test/types/file/fileName.test.ts b/test/types/file/fileName.test.ts index 2f4c1553c..dd03ad59a 100644 --- a/test/types/file/fileName.test.ts +++ b/test/types/file/fileName.test.ts @@ -16,6 +16,14 @@ describe('FileName', () => { }); }); + describe('isHidden', () => { + it('is hidden when the name starts with a dot', () => { + expect(new FileName('.gitkeep').isHidden()).to.be.true; + expect(new FileName('.DS_Store').isHidden()).to.be.true; + expect(new FileName('openapi.json').isHidden()).to.be.false; + }); + }); + describe('isEqual', () => { it('is the same name only when spelt the same, case included', () => { expect(new FileName('APIMATIC-META.json').isEqual(new FileName('APIMATIC-META.json'))).to.be.true; diff --git a/test/types/portal-source-context.test.ts b/test/types/portal-source-context.test.ts index f616d30cb..7d66d5596 100644 --- a/test/types/portal-source-context.test.ts +++ b/test/types/portal-source-context.test.ts @@ -402,6 +402,10 @@ describe('PortalSourceContext', () => { /** A file as a problem or a warning names it, relative to the source directory. */ const relative = (file: FilePath) => file.relativeTo(new DirectoryPath(root)); + /** An OpenAPI 3.x document that declares these paths. */ + const withPaths = (paths: object) => + JSON.stringify({ openapi: '3.0.0', info: { title: 'Calc', version: '1' }, paths }); + it('accepts json, yaml and yml documents, ordered by file name', async () => { write('spec/b.json', OPENAPI); write('spec/a.yaml', 'openapi: 3.0.0\ninfo:\n title: A\n version: "1"\npaths: {}\n'); @@ -507,6 +511,14 @@ describe('PortalSourceContext', () => { expect((await resolve())._unsafeUnwrapErr()).to.deep.equal({ kind: 'emptySpecDirectory', folders: [] }); }); + // A placeholder, or a file the operating system keeps, is never an API definition to convert. + it('reads a spec directory holding only hidden files as empty', async () => { + write('spec/.gitkeep', ''); + write('spec/.DS_Store', 'binary'); + + expect((await resolve())._unsafeUnwrapErr()).to.deep.equal({ kind: 'emptySpecDirectory', folders: [] }); + }); + // Where `api transform --destination=src/spec` puts its result. it('names the folders of a spec directory that holds nothing else, whose documents are not read', async () => { write('spec/transformations/api_OpenApi3Yaml.yaml', 'openapi: 3.0.0'); @@ -635,12 +647,14 @@ describe('PortalSourceContext', () => { expect(await read()).to.deep.equal([['alpha', 'spec/alpha/openapi.json']]); }); - // The platform leaves them out of the SDKs too, and nothing else would say so. + // The platform leaves them out of the SDKs too, and nothing else would say so. Shared parts the + // merged folders refer to may sit there too, with or without a version key. it('names each API definition at the top of the spec directory, in any format, as left out', async () => { merging(); write('spec/alpha/openapi.json', OPENAPI); - write('spec/old.json', OPENAPI); - write('spec/older.yaml', JSON.stringify({ swagger: '2.0', info: {}, paths: {} })); + write('spec/old.json', withPaths({ '/old': { get: {} } })); + write('spec/older.yaml', JSON.stringify({ swagger: '2.0', info: {}, paths: { '/older': { get: {} } } })); + write('spec/common.json', JSON.stringify({ openapi: '3.0.3', info: {}, paths: {}, components: {} })); write('spec/shared-schemas.json', JSON.stringify({ components: { schemas: {} } })); write('spec/broken.json', '{ not json'); write('spec/notes.md', '# Specs'); @@ -658,18 +672,40 @@ describe('PortalSourceContext', () => { expect((await resolve())._unsafeUnwrap().leftOutSpecs).to.be.empty; }); - // The platform builds one API from a folder, so a second section would document what the SDK may lack. + /** The files a refused folder names as its candidate definitions. */ + const severalSpecs = async () => { + const problem = (await resolve())._unsafeUnwrapErr(); + if (problem.kind !== 'severalSpecsInFolder') { + throw new Error(`expected a folder with several definitions, got '${problem.kind}'`); + } + return problem.files.map(relative); + }; + + // The platform builds the folder from one of them and drops the other without a word. it('refuses a folder holding two OpenAPI 3.x documents, naming both', async () => { merging(); - write('spec/alpha/a.json', OPENAPI); - write('spec/alpha/b.json', OPENAPI); + write('spec/alpha/a.json', withPaths({ '/a': { get: {} } })); + write('spec/alpha/b.json', withPaths({ '/b': { get: {} } })); - const problem = (await resolve())._unsafeUnwrapErr(); + expect(await severalSpecs()).to.deep.equal(['spec/alpha/a.json', 'spec/alpha/b.json']); + }); - expect(problem.kind === 'severalSpecsInFolder' && problem.files.map(relative)).to.deep.equal([ - 'spec/alpha/a.json', - 'spec/alpha/b.json' - ]); + // Written as an OpenAPI document so that it passes a linter; the platform builds from it when it sorts first. + it('refuses an OpenAPI document of shared parts beside the definition', async () => { + merging(); + write('spec/alpha/components.json', OPENAPI); + write('spec/alpha/openapi.json', withPaths({ '/pets': { get: {} } })); + + expect(await severalSpecs()).to.deep.equal(['spec/alpha/components.json', 'spec/alpha/openapi.json']); + }); + + // The platform looks for the definition at the top of the folder first, whatever the folder inside it is named. + it('reads a definition whose OpenAPI document of shared parts sits in a folder inside it', async () => { + merging(); + write('spec/alpha/common/components.json', OPENAPI); + write('spec/alpha/openapi.json', withPaths({ '/pets': { get: {} } })); + + expect(await read()).to.deep.equal([['alpha', 'spec/alpha/openapi.json']]); }); // The platform also reads other names starting `APIMATIC-META`; a project for this CLI uses the one. @@ -722,15 +758,19 @@ describe('PortalSourceContext', () => { ).to.deep.equal(['alpha']); }); - it('reports each merged folder with nothing to read: empty, misnamed, or outside the spec directory', async () => { - merging({ MergeOrderOfDirectories: ['alpha', 'empty', 'misnamed', '../content'] }); + // The platform is sent `spec/` alone, and finds a folder only by its exact name. + it('reports each merged folder the platform would find nothing in, wherever and however it is named', async () => { + merging({ MergeOrderOfDirectories: ['alpha', 'empty', 'hidden', 'misnamed', 'Beta', '.', '../content'] }); write('spec/alpha/openapi.json', OPENAPI); fs.mkdirSync(path.join(root, 'spec', 'empty')); + write('spec/hidden/.gitkeep', ''); + write('spec/beta/openapi.json', OPENAPI); + write('spec/top.json', OPENAPI); write('content/openapi.json', OPENAPI); expect(await emptyFolders()).to.deep.equal([ 'spec/APIMATIC-META.json', - ['./spec/empty', './spec/misnamed', './content'] + ['./spec/empty', './spec/hidden', './spec/misnamed', './spec/Beta', './spec', './content'] ]); }); @@ -738,7 +778,9 @@ describe('PortalSourceContext', () => { merging(); write('spec/api.json', OPENAPI); - expect(await emptyFolders()).to.deep.equal(['spec/APIMATIC-META.json', []]); + const problem = (await resolve())._unsafeUnwrapErr(); + + expect(problem.kind === 'noFoldersToMerge' && relative(problem.metadata)).to.equal('spec/APIMATIC-META.json'); }); it('points a folder with no OpenAPI 3.x document at its conversion, inside that folder', async () => { @@ -773,9 +815,6 @@ describe('PortalSourceContext', () => { // The platform builds the SDKs, and so the code samples, from one spec unless the specs are merged. describe('what the SDKs and their samples cover', () => { - const withPaths = (paths: object) => - JSON.stringify({ openapi: '3.0.0', info: { title: 'Calc', version: '1' }, paths }); - it('names several specs at the top of the spec directory, which nothing merges', async () => { write('spec/a.json', OPENAPI); write('spec/b.json', OPENAPI); @@ -807,7 +846,7 @@ describe('PortalSourceContext', () => { const shared = (await resolve())._unsafeUnwrap().sharedEndpoints; - expect(shared.map(({ endpoint, files }) => [`${endpoint}`, files.map(relative)])).to.deep.equal([ + expect(shared.map(({ key, files }) => [`${key}`, files.map(relative)])).to.deep.equal([ ['GET /status', ['spec/alpha/openapi.json', 'spec/beta/openapi.json']] ]); }); @@ -818,6 +857,17 @@ describe('PortalSourceContext', () => { 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('names none for an operation that one spec declares twice', 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', 'GET /pets']); + expect(source.sharedEndpoints).to.be.empty; + }); }); }); @@ -1161,9 +1211,7 @@ describe('PortalSourceContext', () => { throw new Error(`expected a 'sharedAddresses' problem, got '${problem.kind}'`); } return problem.addresses - .map( - ({ address, pages }) => `${address} ${pages.map((page) => page.relativeTo(new DirectoryPath(root))).sort()}` - ) + .map(({ key, files }) => `${key} ${files.map((page) => page.relativeTo(new DirectoryPath(root))).sort()}`) .sort(); }; diff --git a/test/types/portal/openapi-document.test.ts b/test/types/portal/openapi-document.test.ts index da527733b..24289bf9e 100644 --- a/test/types/portal/openapi-document.test.ts +++ b/test/types/portal/openapi-document.test.ts @@ -67,10 +67,11 @@ describe('OpenApiDocument', () => { }); describe('isApiDefinition', () => { - it('is a definition in any format it names, and not a file of shared parts', () => { - expect(readJson({ openapi: '3.1.0' }).isApiDefinition()).to.be.true; - expect(readJson({ swagger: '2.0' }).isApiDefinition()).to.be.true; - expect(readJson({ components: { schemas: {} } }).isApiDefinition()).to.be.false; + it('is a definition in any format it names that declares paths, and not a file of shared parts', () => { + expect(readJson({ openapi: '3.1.0', paths: { '/pets': { get: {} } } }).isApiDefinition()).to.be.true; + expect(readJson({ swagger: '2.0', paths: { '/pets': { $ref: './pets.yaml' } } }).isApiDefinition()).to.be.true; + expect(readJson({ openapi: '3.1.0', paths: {}, components: { schemas: {} } }).isApiDefinition()).to.be.false; + expect(readJson({ components: { schemas: {} }, paths: { '/pets': {} } }).isApiDefinition()).to.be.false; }); }); diff --git a/test/types/portal/shared.test.ts b/test/types/portal/shared.test.ts new file mode 100644 index 000000000..cc9b0b3e0 --- /dev/null +++ b/test/types/portal/shared.test.ts @@ -0,0 +1,34 @@ +import { expect } from 'chai'; +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'; +import { shared } from '../../../src/types/portal/shared'; + +describe('shared', () => { + const spec = new DirectoryPath('project').join('src', 'spec'); + const alpha = new FilePath(spec.join('alpha'), new FileName('openapi.json')); + const beta = new FilePath(spec.join('beta'), new FileName('openapi.json')); + const named = (claims: ReturnType>) => + claims.map(({ key, files }) => [String(key), files.map((file) => file.toString())]); + + it('gives each key more than one file claims, with its files, in the order first claimed', () => { + const claims = shared([ + { key: new Endpoint('get', '/beta'), file: beta }, + { key: new Endpoint('get', '/status'), file: alpha }, + { key: new Endpoint('get', '/alpha'), file: alpha }, + { key: new Endpoint('GET', '/status'), file: beta } + ]); + + expect(named(claims)).to.deep.equal([['GET /status', [alpha.toString(), beta.toString()]]]); + }); + + it('counts a file that claims a key twice once', () => { + const claims = shared([ + { key: new Endpoint('get', '/pets'), file: alpha }, + { key: new Endpoint('get', '/pets'), file: alpha } + ]); + + expect(claims).to.be.empty; + }); +}); diff --git a/test/types/portal/spec-metadata.test.ts b/test/types/portal/spec-metadata.test.ts index b5fb2430d..cd0ad27ee 100644 --- a/test/types/portal/spec-metadata.test.ts +++ b/test/types/portal/spec-metadata.test.ts @@ -27,6 +27,17 @@ describe('SpecMetadata', () => { expect(read({ MergeConfiguration: {} }).mergesApis()).to.be.false; }); + // As the platform reads the file, which merges for each of these. + it('reads the settings in any case, and the text "true" as on', () => { + expect(read({ mergeConfiguration: { mergeapis: true } }).mergesApis()).to.be.true; + expect(read({ MergeConfiguration: { MergeApis: 'true' } }).mergesApis()).to.be.true; + expect(read({ MergeConfiguration: { MergeApis: 'True' } }).mergesApis()).to.be.true; + expect(read({ MergeConfiguration: { MergeApis: 'yes' } }).mergesApis()).to.be.false; + expect( + read({ MERGECONFIGURATION: { MERGEAPIS: true, mergeorderofdirectories: ['beta'] } }).mergeOrder() + ).to.deep.equal(['beta']); + }); + // The file carries the SDK's settings too, and most projects have one for those alone. it('does not merge for a file with no merge configuration, or one that is not an object', () => { expect(read({ CodeGenSettings: { Timeout: 30 } }).mergesApis()).to.be.false; From 0a4c809e9ffe1f19ee422ee36f05d41868857a64 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Wed, 14 Oct 2026 14:05:12 +0500 Subject: [PATCH 3/4] fix(portal): follow up the second review of merged spec folders - A merged folder counts every file with a version key as a candidate definition, as the platform does, so a Swagger or other definition beside the OpenAPI 3.x document is refused too. The conversion advice says to move the converted file up in place of the original, which would otherwise be such a second definition. - Two ideas named apart on `OpenApiDocument`: `namesApiFormat` (what the platform may take as a folder's definition) and `declaresOperations` (paths, webhooks or channels). The left-out warning needs both, so an AsyncAPI or webhooks-only definition at the top of `src/spec` is named again. - Folders `MergeOrderOfDirectories` names that the platform would not find (missing, in another case, outside `src/spec`, or `.`) are their own problem, checked where the order is read; `emptyMergedFolders` keeps the folders that are there and hold nothing. - `APIMATIC-META.json` prefers a setting's exact name over one in another case. - `DirectoryPath.contains` takes a folder named `..v2` for one inside, not a step up. - An operation one spec declares twice is read once where its endpoints are collected, and `shared()` is a plain grouping again. - The preview reloads only for `.json`, `.yaml` and `.yml` files in `src/spec`, by a list a test holds to the CLI's; `SPEC_EXTENSIONS` lives in `src/types/project-layout.ts`. - `severalSpecsInFolder` carries its folder; a read document is a spec yet to be given its slug; one helper gives a page's slugs and address; no restating comments. Co-Authored-By: Claude Opus 5.5 --- portal-template/spec-reload.ts | 15 ++- src/prompts/portal/source.ts | 49 ++++++--- src/types/file/directoryPath.ts | 5 +- src/types/file/fileName.ts | 1 - src/types/portal-source-context.ts | 113 ++++++++++++--------- src/types/portal/content-tree.ts | 17 ++-- src/types/portal/endpoint.ts | 4 + src/types/portal/openapi-document.ts | 14 ++- src/types/portal/portal-source.ts | 7 +- src/types/portal/shared.ts | 9 +- src/types/portal/spec-metadata.ts | 4 +- src/types/project-layout.ts | 3 + test/portal-template/spec-reload.test.ts | 20 +++- test/prompts/portal/source.test.ts | 54 ++++++---- test/types/file/directory-path.test.ts | 5 + test/types/portal-source-context.test.ts | 52 ++++++++-- test/types/portal/openapi-document.test.ts | 23 +++-- test/types/portal/shared.test.ts | 9 -- test/types/portal/spec-metadata.test.ts | 1 + 19 files changed, 272 insertions(+), 133 deletions(-) diff --git a/portal-template/spec-reload.ts b/portal-template/spec-reload.ts index b540581ab..0c20c5754 100644 --- a/portal-template/spec-reload.ts +++ b/portal-template/spec-reload.ts @@ -1,12 +1,17 @@ import type { Plugin } from 'vite'; +/** The extensions of the files a specification is read from and refers to: the CLI's own list, held to it by a test. */ +export const SPEC_FILE_EXTENSIONS = ['.json', '.yaml', '.yml']; + /** - * Reloads the preview when any file in the specification directory changes, as a document may refer - * to parts kept in another folder there. The specifications are read at run time rather than imported, - * so Vite's module graph never learns of them, and the reference pages are built once per evaluation. + * Reloads the preview when a specification file in the specification directory changes, as a document may refer + * to parts kept in another folder there. The specifications are read at run time rather than imported, so Vite's + * module graph never learns of them, and the reference pages are built once per evaluation. */ export function specReload(specDirectory: string): Plugin { - const isInSpecDirectory = (file: string) => file.startsWith(`${specDirectory}/`); + const isSpecFile = (file: string) => + file.startsWith(`${specDirectory}/`) && + SPEC_FILE_EXTENSIONS.some((extension) => file.toLowerCase().endsWith(extension)); return { name: 'apimatic:spec-reload', @@ -16,7 +21,7 @@ export function specReload(specDirectory: string): Plugin { }, // Sent to each environment: the server runner re-evaluates every module, the browser reloads. hotUpdate({ file }) { - if (!isInSpecDirectory(file)) return; + if (!isSpecFile(file)) return; this.environment.hot.send({ type: 'full-reload' }); return []; } diff --git a/src/prompts/portal/source.ts b/src/prompts/portal/source.ts index 539d3a70b..0cb87a2a7 100644 --- a/src/prompts/portal/source.ts +++ b/src/prompts/portal/source.ts @@ -24,6 +24,7 @@ import { CONTENT_DIRECTORY_NAME, METADATA_FILE_NAME, SPEC_DIRECTORY_NAME, + SPEC_EXTENSIONS, STATIC_DIRECTORY_NAME } from '../../types/project-layout.js'; import { format as f } from '../format.js'; @@ -91,23 +92,35 @@ function reportSpecConversion({ file, format, converted, others }: SpecConversio log.message( `${convert} it with:\n` + ` ${command}\n` + - `then move ${f.relativePath(converted)} up into ${directory}.` + + `then move ${f.relativePath(converted)} up into ${directory}, in place of ${name}.` + (others === 0 ? '' : ` Convert the other ${documents} the same way.`) ); } -function reportEmptyMergedFolders(metadata: FilePath, folders: DirectoryPath[], sourceDirectory: DirectoryPath) { - const one = folders.length === 1; - const names = listedInProse(folders.map((folder) => f.var(folder.relativeTo(sourceDirectory)))); +const folderNames = (folders: DirectoryPath[], sourceDirectory: DirectoryPath): string => + listedInProse(folders.map((folder) => f.var(folder.relativeTo(sourceDirectory)))); + +function reportMissingMergedFolders(metadata: FilePath, folders: DirectoryPath[], sourceDirectory: DirectoryPath) { + const [verb, noun] = folders.length === 1 ? ['is', 'a folder'] : ['are', 'folders']; log.error( - `${relative(metadata, sourceDirectory)} merges ${names}, which ${one ? 'holds' : 'hold'} no API definition ` + - `the portal can read.` + `${relative(metadata, sourceDirectory)} merges ${folderNames(folders, sourceDirectory)}, which ${verb} not ` + + `${noun} inside ${specPath(sourceDirectory)}.` ); log.message( - `A merged folder sits inside ${specPath(sourceDirectory)}, is named as it is spelt, case included, and holds ` + - `its OpenAPI 3.x document at its top. Add ${one ? 'one to it' : 'one to each'}, or list only the folders ` + - `to merge in ${f.var(MERGE_SETTINGS.order)}.` + `Name each folder in ${f.var(MERGE_SETTINGS.order)} by its path from the file, spelt as on disk, case included.` + ); +} + +function reportEmptyMergedFolders(metadata: FilePath, folders: DirectoryPath[], sourceDirectory: DirectoryPath) { + const [verb, add] = + folders.length === 1 + ? ['holds', 'its OpenAPI 3.x document to the top of the folder'] + : ['hold', "each one's OpenAPI 3.x document to the top of its folder"]; + log.error( + `${relative(metadata, sourceDirectory)} merges ${folderNames(folders, sourceDirectory)}, which ${verb} no API ` + + `definition.` ); + log.message(`Add ${add}, or list only the folders to merge in ${f.var(MERGE_SETTINGS.order)}.`); } /** @@ -159,9 +172,13 @@ export function reportSourceProblem(problem: PortalSourceProblem, sourceDirector return; } case 'emptySpecDirectory': { + const extensions = listedInProse( + SPEC_EXTENSIONS.map((extension) => f.var(extension)), + 'or' + ); const message = `${specPath(sourceDirectory)} has no API definition. Add your OpenAPI 3.x document to it as a ` + - `${f.var('.json')}, ${f.var('.yaml')} or ${f.var('.yml')} file.`; + `${extensions} file.`; log.error(message); if (problem.folders.length > 0) { const folders = listedInProse(problem.folders.map((folder) => f.var(folder.leafName()))); @@ -183,17 +200,19 @@ export function reportSourceProblem(problem: PortalSourceProblem, sourceDirector ); return; } + case 'missingMergedFolders': { + reportMissingMergedFolders(problem.metadata, problem.folders, sourceDirectory); + return; + } case 'emptyMergedFolders': { reportEmptyMergedFolders(problem.metadata, problem.folders, sourceDirectory); return; } case 'severalSpecsInFolder': { - const [first] = problem.files; - const shared = f.var(first.directory().join('shared').relativeTo(sourceDirectory)); + const shared = f.var(problem.folder.join('shared').relativeTo(sourceDirectory)); log.error( - `${listed(problem.files, sourceDirectory)} are each an OpenAPI document, but a folder that ` + - `${f.var(METADATA_FILE_NAME)} merges holds one API definition at its top, and the SDKs are built from ` + - `one of them only.` + `${listed(problem.files, sourceDirectory)} are each an API definition, but a folder that ` + + `${f.var(METADATA_FILE_NAME)} merges holds one at its top, and the SDKs are built from one of them only.` ); log.message( `Keep the definition there and move each of the others: shared parts into a folder inside it, such as ` + diff --git a/src/types/file/directoryPath.ts b/src/types/file/directoryPath.ts index bd6d126d9..b50822dbe 100644 --- a/src/types/file/directoryPath.ts +++ b/src/types/file/directoryPath.ts @@ -74,10 +74,11 @@ export class DirectoryPath { /** True when `other` is this directory or sits anywhere inside it. */ public contains(other: DirectoryPath) { const relative = path.relative(this.directoryPath, other.directoryPath); - return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); + // A step up is `..` itself; a folder inside may still be named `..v2`. + const up = relative === '..' || relative.startsWith(`..${path.sep}`); + return relative === '' || (!up && !path.isAbsolute(relative)); } - /** True when `other` sits anywhere inside this directory, and is not this directory itself. */ public isAncestorOf(other: DirectoryPath) { return this.contains(other) && !this.isEqual(other); } diff --git a/src/types/file/fileName.ts b/src/types/file/fileName.ts index c54df8cf3..1fe2b6de5 100644 --- a/src/types/file/fileName.ts +++ b/src/types/file/fileName.ts @@ -24,7 +24,6 @@ export class FileName { return this.name === other.name; } - /** Whether the name starts with a dot, as a placeholder or an operating system's own file does. */ public isHidden(): boolean { return this.name.startsWith('.'); } diff --git a/src/types/portal-source-context.ts b/src/types/portal-source-context.ts index f5f85949f..1993cbfa8 100644 --- a/src/types/portal-source-context.ts +++ b/src/types/portal-source-context.ts @@ -14,6 +14,7 @@ import { CONTENT_DIRECTORY_NAME, METADATA_FILE_NAME, SPEC_DIRECTORY_NAME, + SPEC_EXTENSIONS, STATIC_DIRECTORY_NAME } from './project-layout.js'; import { PLACEHOLDER_SITE, SiteConfig, SuggestedSite } from './portal/config/site-config.js'; @@ -42,8 +43,6 @@ import { SpecMetadata } from './portal/spec-metadata.js'; import { SpecContext } from './spec-context.js'; import { TRANSFORMATIONS_DIRECTORY_NAME, transformedFileName } from './transform-context.js'; -const SPEC_EXTENSIONS = ['.json', '.yaml', '.yml']; - const METADATA_FILE = new FileName(METADATA_FILE_NAME); const definitionNames = (fileNames: FileName[]): FileName[] => @@ -53,13 +52,18 @@ const isDocumentName = (name: FileName): boolean => SPEC_EXTENSIONS.some((extens type DiscoveredSpecs = Pick; -/** An OpenAPI 3.x document at the top of a directory, read for its operations and the site it suggests. */ +/** An OpenAPI 3.x document at the top of a directory, read as a spec yet to be given its slug, and the site it suggests. */ interface ReadDocument { - file: FilePath; - endpoints: Endpoint[]; + spec: Omit; site: SuggestedSite; } +/** The top of a directory: its OpenAPI 3.x documents, and every file there that names an API format, in name order. */ +interface ReadDirectory { + documents: ReadDocument[]; + definitions: FilePath[]; +} + /** The specifications read from `spec/`, and the site the first of them suggests. */ interface ReadSpecs { specs: PortalSpec[]; @@ -507,10 +511,31 @@ export class PortalSourceContext { if (!metadata.mergesApis()) { return ok(null); } - const listed = metadata.mergeOrder()?.map((folder) => this.specDirectory.resolve(folder)); + const order = metadata.mergeOrder(); + if (order === null) { + return folders.length === 0 + ? err({ kind: 'noFoldersToMerge', metadata: this.metadataFile }) + : ok({ metadata: this.metadataFile, folders }); + } + + const listed = order.map((folder) => this.specDirectory.resolve(folder)); // A folder the file names twice, as `alpha` and `./alpha`, is still one API. - const once = listed?.filter((folder, index) => index === listed.findIndex((other) => other.isEqual(folder))); - return ok({ metadata: this.metadataFile, folders: once ?? folders }); + const once = listed.filter((folder, index) => index === listed.findIndex((other) => other.isEqual(folder))); + const found = await Promise.all(once.map((folder) => this.isFoundByPlatform(folder))); + const missing = once.filter((_, index) => !found[index]); + if (missing.length > 0) { + return err({ kind: 'missingMergedFolders', metadata: this.metadataFile, folders: missing }); + } + return ok({ metadata: this.metadataFile, folders: once }); + } + + /** Whether the platform finds a folder the metadata file names in the build it is sent: below `spec/`, spelt as named. */ + private async isFoundByPlatform(folder: DirectoryPath): Promise { + if (!this.specDirectory.isAncestorOf(folder)) { + return false; + } + const spelt = await this.fileService.directorySpelledOnDisk(this.specDirectory, folder); + return spelt !== null && spelt.isEqual(folder); } /** The documents at the top of `spec/`, each a section named after its file. */ @@ -523,12 +548,8 @@ export class PortalSourceContext { if (names.length === 0) { return err({ kind: 'emptySpecDirectory', folders }); } - return (await this.definitions(this.specDirectory, names)).map((documents) => ({ - specs: documents.map(({ file, endpoints }) => ({ - slug: this.uniqueSlug(file.name().normalize(), usedSlugs), - file, - endpoints - })), + return (await this.definitions(this.specDirectory, names)).map(({ documents }) => ({ + specs: documents.map(({ spec }) => ({ ...spec, slug: this.uniqueSlug(spec.file.name().normalize(), usedSlugs) })), suggested: documents[0].site })); } @@ -538,16 +559,11 @@ export class PortalSourceContext { { metadata, folders }: MergedFolders, usedSlugs: Set ): Promise> { - if (folders.length === 0) { - return err({ kind: 'noFoldersToMerge', metadata }); - } const listings = await Promise.all( - folders.map(async (folder) => ({ - folder, - fileNames: (await this.isMergeable(folder)) - ? definitionNames(await this.fileService.getFileNames(folder)).sort((left, right) => left.compare(right)) - : [] - })) + folders.map(async (folder) => { + const fileNames = definitionNames(await this.fileService.getFileNames(folder)); + return { folder, fileNames: fileNames.sort((left, right) => left.compare(right)) }; + }) ); const empty = listings.filter(({ fileNames }) => fileNames.length === 0).map(({ folder }) => folder); if (empty.length > 0) { @@ -561,45 +577,40 @@ export class PortalSourceContext { if (read.isErr()) { return err(read.error); } - // The platform builds the folder from the first of them by name, which may be one of shared parts. - const [definition, ...others] = read.value; - if (others.length > 0) { - return err({ kind: 'severalSpecsInFolder', files: read.value.map(({ file }) => file) }); + // The platform builds the folder from the first of them by name, which may be a file of shared parts. + const { documents, definitions } = read.value; + if (definitions.length > 1) { + return err({ kind: 'severalSpecsInFolder', folder, files: definitions }); } - const slug = this.uniqueSlug(new FileName(folder.leafName()).slugified(), usedSlugs); - specs.push({ slug, file: definition.file, endpoints: definition.endpoints }); + const [definition] = documents; + specs.push({ ...definition.spec, slug: this.uniqueSlug(new FileName(folder.leafName()).slugified(), usedSlugs) }); sites.push(definition.site); } return ok({ specs, suggested: sites[0] }); } - /** Whether the platform finds `folder` in the build it is sent: below `spec/`, and spelt on disk as it is named. */ - private async isMergeable(folder: DirectoryPath): Promise { - if (!this.specDirectory.isAncestorOf(folder)) { - return false; - } - const spelt = await this.fileService.directorySpelledOnDisk(this.specDirectory, folder); - return spelt !== null && spelt.isEqual(folder); - } - - /** The API definitions at the top of `spec/`, which a merge of its folders leaves out, as the platform does. */ + /** The APIs at the top of `spec/`, in any format, which a merge of its folders leaves out, as the platform does. */ private async leftOutSpecs(fileNames: FileName[]): Promise { const files = definitionNames(fileNames) .filter(isDocumentName) .map((fileName) => new FilePath(this.specDirectory, fileName)); - const definitions = await Promise.all( - files.map(async (file) => (await this.readDocument(file))?.isApiDefinition() === true) + const apis = await Promise.all( + files.map(async (file) => { + const document = await this.readDocument(file); + return document !== undefined && document.namesApiFormat() && document.declaresOperations(); + }) ); - return files.filter((_, index) => definitions[index]); + return files.filter((_, index) => apis[index]); } - /** The OpenAPI 3.x documents at the top of a directory, or how to convert what is there instead. */ + /** What the top of a directory holds, or how to convert what is there when it has no OpenAPI 3.x document. */ private async definitions( directory: DirectoryPath, fileNames: FileName[] - ): Promise> { + ): Promise> { // The site each suggests rather than the document itself, as specifications run to megabytes. const documents: ReadDocument[] = []; + const definitions: FilePath[] = []; // What each one is, for the conversion a directory without an OpenAPI 3.x document is pointed to. const otherFormats: { file: FilePath; format: string }[] = []; @@ -610,6 +621,9 @@ export class PortalSourceContext { if (document === undefined) { return err({ kind: 'unreadableSpec', file }); } + if (document.namesApiFormat()) { + definitions.push(file); + } const format = document.format(); if (!format.supported) { if (format.format !== null) { @@ -618,7 +632,10 @@ export class PortalSourceContext { continue; } - documents.push({ file, endpoints: await this.endpoints(document, file), site: document.suggestedSite() }); + documents.push({ + spec: { file, endpoints: await this.endpoints(document, file) }, + site: document.suggestedSite() + }); } if (documents.length === 0) { @@ -630,7 +647,7 @@ export class PortalSourceContext { : this.conversion(convertible.file, convertible.format, otherFormats.length - 1); return err({ kind: 'noOpenApiSpec', conversion }); } - return ok(documents); + return ok({ documents, definitions }); } private conversion(file: FilePath, format: string | null, others: number): SpecConversion { @@ -665,7 +682,9 @@ export class PortalSourceContext { return targetDocument?.endpointsAt(path, pointer) ?? []; }) ); - return [...document.endpoints(), ...referenced.flat()]; + // A method beside a path item's `$ref` declares again what the file it refers to declares. + const all = [...document.endpoints(), ...referenced.flat()]; + return all.filter((endpoint, index) => index === all.findIndex((other) => other.isEqual(endpoint))); } private async readDocument(file: FilePath): Promise { diff --git a/src/types/portal/content-tree.ts b/src/types/portal/content-tree.ts index 358f471fd..655c5d950 100644 --- a/src/types/portal/content-tree.ts +++ b/src/types/portal/content-tree.ts @@ -49,6 +49,12 @@ const PAGE_EXTENSIONS = ['.md', '.mdx']; /** Passed over by Vite's `import.meta.glob`, which the build reads the content through. */ export const isSkippedByGlob = (name: string) => name.startsWith('.') || name === 'node_modules'; +/** The slugs the content source gives a page, and the address they make: a `(group)` folder adds none. */ +const servedAt = (segments: string[]): { slugs: string[]; address: string } => { + const slugs = getSlugs(segments.join('/')); + return { slugs, address: `/${slugs.join('/')}` }; +}; + /** Whether the glob passes over `file` below `root`, which it does for a path with any such segment. */ export const isSkippedWithin = (file: FilePath, root: DirectoryPath): boolean => file.relativeTo(root).split('/').some(isSkippedByGlob); @@ -309,11 +315,11 @@ export class ContentTree { } // The build fails on two pages at one address, or moves an index page to `/index`. - const shared = ContentTree.sharedAddresses( + const sharing = ContentTree.sharedAddresses( addressed.filter(({ file }) => !reserved.some((page) => page.file.isEqual(file))) ); - if (shared.length > 0) { - problems.push({ kind: 'sharedAddresses', addresses: shared }); + if (sharing.length > 0) { + problems.push({ kind: 'sharedAddresses', addresses: sharing }); } return problems; } @@ -326,8 +332,7 @@ export class ContentTree { */ private static reservedAddressPages(pages: ContentPage[]): ReservedAddressPage[] { return pages.flatMap(({ file, segments }) => { - const slugs = getSlugs(segments.join('/')); - const address = `/${slugs.join('/')}`; + const { slugs, address } = servedAt(segments); if (address === OAUTH_CALLBACK.address) return [{ file, address, keptFor: OAUTH_CALLBACK }]; const section = GENERATED_SECTIONS.find((candidate) => candidate.folder === slugs[0]); return section === undefined @@ -338,7 +343,7 @@ export class ContentTree { /** Addresses more than one page would be served at, each with its pages in the order walked. */ private static sharedAddresses(pages: ContentPage[]): SharedAddress[] { - return shared(pages.map(({ file, segments }) => ({ key: `/${getSlugs(segments.join('/')).join('/')}`, file }))); + return shared(pages.map(({ file, segments }) => ({ key: servedAt(segments).address, file }))); } /** diff --git a/src/types/portal/endpoint.ts b/src/types/portal/endpoint.ts index e5e9b7cbd..768c945d3 100644 --- a/src/types/portal/endpoint.ts +++ b/src/types/portal/endpoint.ts @@ -5,6 +5,10 @@ export class Endpoint { this.method = method.toUpperCase(); } + public isEqual(other: Endpoint): boolean { + return this.method === other.method && this.path === other.path; + } + public toString(): string { return `${this.method} ${this.path}`; } diff --git a/src/types/portal/openapi-document.ts b/src/types/portal/openapi-document.ts index caadc726f..41f2e4fd6 100644 --- a/src/types/portal/openapi-document.ts +++ b/src/types/portal/openapi-document.ts @@ -87,10 +87,18 @@ export class OpenApiDocument { return { supported: false, format: null }; } - /** Whether it names the API format it is in and declares paths, as a definition does and a file of shared parts does not. */ - public isApiDefinition(): boolean { + /** Whether it carries a version key, which is what the platform takes a file of a merged folder for its definition by. */ + public namesApiFormat(): boolean { const format = this.format(); - return (format.supported || format.format !== null) && Object.keys(this.paths()).length > 0; + return format.supported || format.format !== null; + } + + /** Whether it declares paths, webhooks or channels, as an API does and a file of its shared parts does not. */ + public declaresOperations(): boolean { + return ['paths', 'webhooks', 'channels'].some((key) => { + const operations = this.document[key]; + return isJsonObject(operations) && Object.keys(operations).length > 0; + }); } /** diff --git a/src/types/portal/portal-source.ts b/src/types/portal/portal-source.ts index efbef8452..f7b71d8a7 100644 --- a/src/types/portal/portal-source.ts +++ b/src/types/portal/portal-source.ts @@ -128,9 +128,10 @@ export type PortalSourceProblem = // Its `folders` are not read unless a metadata file merges them, and `api transform` writes into one of its own. | { kind: 'emptySpecDirectory'; folders: DirectoryPath[] } | { kind: 'noFoldersToMerge'; metadata: FilePath } - // Each of the `folders` that `metadata` merges and the platform would find no definition in. + // The `folders` that `metadata` names and the platform would not find: not there as spelt, or not inside `spec/`. + | { kind: 'missingMergedFolders'; metadata: FilePath; folders: DirectoryPath[] } | { kind: 'emptyMergedFolders'; metadata: FilePath; folders: DirectoryPath[] } - // Every OpenAPI 3.x document at the top of a merged folder, which holds one API. - | { kind: 'severalSpecsInFolder'; files: FilePath[] } + // Every file at the top of `folder` that names an API format; the platform builds the folder from one of them. + | { kind: 'severalSpecsInFolder'; folder: DirectoryPath; files: FilePath[] } | { kind: 'noOpenApiSpec'; conversion: SpecConversion } | { kind: 'missingStaticFiles'; files: MissingStaticFile[] }; diff --git a/src/types/portal/shared.ts b/src/types/portal/shared.ts index 665d17bd3..c5bf6a188 100644 --- a/src/types/portal/shared.ts +++ b/src/types/portal/shared.ts @@ -6,15 +6,12 @@ export interface Shared { files: FilePath[]; } -/** The keys more than one file claims, each with its files in the order claimed; a file claiming a key twice counts once. */ +/** The keys more than one file claims, each with its files in the order claimed. */ export function shared(claims: { key: Key; file: FilePath }[]): Shared[] { const byKey = new Map>(); for (const { key, file } of claims) { - const claimed = byKey.get(String(key)) ?? { key, files: [] }; - byKey.set(String(key), { - key: claimed.key, - files: claimed.files.some((other) => other.isEqual(file)) ? claimed.files : [...claimed.files, file] - }); + const claimed = byKey.get(String(key)); + byKey.set(String(key), { key, files: [...(claimed?.files ?? []), file] }); } return [...byKey.values()].filter(({ files }) => files.length > 1); } diff --git a/src/types/portal/spec-metadata.ts b/src/types/portal/spec-metadata.ts index dc1c1d76c..9489a7ff4 100644 --- a/src/types/portal/spec-metadata.ts +++ b/src/types/portal/spec-metadata.ts @@ -4,9 +4,9 @@ import { stripByteOrderMark } from '../../utils/string-utils.js'; /** The settings of its `MergeConfiguration` a portal follows, by the names a message gives them. */ export const MERGE_SETTINGS = { mergeApis: 'MergeApis', order: 'MergeOrderOfDirectories' } as const; -// The platform matches a setting's name in any case, as its JSON reader does. +// The platform matches a setting's name in any case, as its JSON reader does, the exact name first. const setting = (object: JsonObject, name: string): unknown => - Object.entries(object).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1]; + name in object ? object[name] : Object.entries(object).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1]; /** The platform's metadata file beside API definitions, read for the one thing a portal follows: which folders it merges. */ export class SpecMetadata { diff --git a/src/types/project-layout.ts b/src/types/project-layout.ts index a4b6f1dc9..c080a7bdc 100644 --- a/src/types/project-layout.ts +++ b/src/types/project-layout.ts @@ -2,6 +2,9 @@ export const SOURCE_DIRECTORY_NAME = 'src'; export const SPEC_DIRECTORY_NAME = 'spec'; +/** The extensions of the files in `spec/` read as documents, and the preview reloads on. */ +export const SPEC_EXTENSIONS = ['.json', '.yaml', '.yml']; + // The platform also reads other names that start `APIMATIC-META`, a legacy this CLI does not carry. export const METADATA_FILE_NAME = 'APIMATIC-META.json'; diff --git a/test/portal-template/spec-reload.test.ts b/test/portal-template/spec-reload.test.ts index ec959a73e..d9a9d5f97 100644 --- a/test/portal-template/spec-reload.test.ts +++ b/test/portal-template/spec-reload.test.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import type { ViteDevServer } from 'vite'; -import { specReload } from '../../portal-template/spec-reload'; +import { SPEC_FILE_EXTENSIONS, specReload } from '../../portal-template/spec-reload'; +import { SPEC_EXTENSIONS } from '../../src/types/project-layout'; describe('specReload', () => { const specDirectory = '/project/src/spec'; @@ -41,4 +42,21 @@ describe('specReload', () => { expect(sentFor(file), file).to.be.empty; } }); + + // Each reload re-bundles every specification, which a merged project has dozens of. + it('reloads for no file a specification is not read from, such as one the operating system or an editor keeps', () => { + for (const file of [ + `${specDirectory}/.DS_Store`, + `${specDirectory}/orders/.openapi.yaml.swp`, + `${specDirectory}/orders/openapi.yaml___jb_tmp___`, + `${specDirectory}/README.md` + ]) { + expect(sentFor(file), file).to.be.empty; + } + expect(sentFor(`${specDirectory}/orders/OpenAPI.YAML`)).to.deep.equal([{ type: 'full-reload' }]); + }); + + it('reloads for the extensions the CLI reads a specification by', () => { + expect(SPEC_FILE_EXTENSIONS).to.deep.equal(SPEC_EXTENSIONS); + }); }); diff --git a/test/prompts/portal/source.test.ts b/test/prompts/portal/source.test.ts index 3525fe06f..d9b73b96e 100644 --- a/test/prompts/portal/source.test.ts +++ b/test/prompts/portal/source.test.ts @@ -173,6 +173,8 @@ describe('reportSourceProblem', () => { '--destination=./project/src/spec' ); expect(printed()).to.contain("then move './project/src/spec/transformations/petstore_OpenApi3Yaml.yaml' up into"); + // Left beside its conversion, the original would be a second definition of the API. + expect(printed()).to.contain(", in place of 'petstore.json'."); expect(printed()).to.not.contain('the same way'); }); @@ -212,7 +214,7 @@ describe('reportSourceProblem', () => { expect(printed()).to.contain(`No OpenAPI 3.x document found in '${folder}'.`); expect(printed()).to.contain('--destination=./project/src/spec/orders'); - expect(printed()).to.contain(`up into '${folder}'.`); + expect(printed()).to.contain(`up into '${folder}', in place of 'orders.json'.`); expect(printed()).to.not.contain('only folder read'); }); @@ -237,36 +239,55 @@ describe('reportSourceProblem', () => { const spec = source.join('spec'); const metadata = new FilePath(spec, new FileName('APIMATIC-META.json')); - it('names the file and one folder, and the two ways to put it right', () => { - reportSourceProblem({ kind: 'emptyMergedFolders', metadata, folders: [spec.join('orderz')] }, source); + it('names an empty folder, and the two ways to put it right', () => { + reportSourceProblem({ kind: 'emptyMergedFolders', metadata, folders: [spec.join('orders')] }, source); expect(printed().split('\n')).to.deep.equal([ - "'spec/APIMATIC-META.json' merges './spec/orderz', which holds no API definition the portal can read.", - `A merged folder sits inside '${spec}', is named as it is spelt, case included, and holds its OpenAPI 3.x document at its top. Add one to it, or ` + - "list only the folders to merge in 'MergeOrderOfDirectories'." + "'spec/APIMATIC-META.json' merges './spec/orders', which holds no API definition.", + 'Add its OpenAPI 3.x document to the top of the folder, or list only the folders to merge in ' + + "'MergeOrderOfDirectories'." ]); }); - // A folder outside `spec/` may hold a document, which the platform is never sent. - it('names each folder by its path, so one nested or outside the spec directory reads as such', () => { + it('lists several empty folders as a sentence would', () => { reportSourceProblem( - { kind: 'emptyMergedFolders', metadata, folders: [spec.join('apis', 'orders'), source.join('content')] }, + { kind: 'emptyMergedFolders', metadata, folders: [spec.join('orders'), spec.join('apis', 'items')] }, source ); expect(printed().split('\n')).to.deep.equal([ - "'spec/APIMATIC-META.json' merges './spec/apis/orders' and './content', which hold no API definition the " + - 'portal can read.', - `A merged folder sits inside '${spec}', is named as it is spelt, case included, and holds its OpenAPI 3.x document at its top. Add one to each, or ` + - "list only the folders to merge in 'MergeOrderOfDirectories'." + "'spec/APIMATIC-META.json' merges './spec/orders' and './spec/apis/items', which hold no API definition.", + "Add each one's OpenAPI 3.x document to the top of its folder, or list only the folders to merge in " + + "'MergeOrderOfDirectories'." ]); }); - it('names each document of a merged folder that holds more than one, and where to move the others', () => { + // A folder outside `spec/`, or spelt in another case, may well hold a document; the platform still finds none. + it('names each folder the platform would not find by its path, and how folders are named', () => { + reportSourceProblem( + { kind: 'missingMergedFolders', metadata, folders: [spec.join('Orders'), source.join('content'), spec] }, + source + ); + + expect(printed().split('\n')).to.deep.equal([ + `'spec/APIMATIC-META.json' merges './spec/Orders', './content' and './spec', which are not folders inside ` + + `'${spec}'.`, + "Name each folder in 'MergeOrderOfDirectories' by its path from the file, spelt as on disk, case included." + ]); + }); + + it('speaks of one folder the platform would not find when there is one', () => { + reportSourceProblem({ kind: 'missingMergedFolders', metadata, folders: [spec.join('orderz')] }, source); + + expect(printed()).to.contain("merges './spec/orderz', which is not a folder inside"); + }); + + it('names each definition of a merged folder that holds more than one, and where to move the others', () => { const orders = spec.join('orders'); reportSourceProblem( { kind: 'severalSpecsInFolder', + folder: orders, files: [ new FilePath(orders, new FileName('components.json')), new FilePath(orders, new FileName('orders.json')) @@ -276,9 +297,8 @@ describe('reportSourceProblem', () => { ); expect(printed().split('\n')).to.deep.equal([ - "'spec/orders/components.json' and 'spec/orders/orders.json' are each an OpenAPI document, but a folder " + - "that 'APIMATIC-META.json' merges holds one API definition at its top, and the SDKs are built from one " + - 'of them only.', + "'spec/orders/components.json' and 'spec/orders/orders.json' are each an API definition, but a folder " + + "that 'APIMATIC-META.json' merges holds one at its top, and the SDKs are built from one of them only.", 'Keep the definition there and move each of the others: shared parts into a folder inside it, such as ' + "'./spec/orders/shared', and another API into a folder of its own, adding the new folders to " + "'MergeOrderOfDirectories' when the file lists folders." diff --git a/test/types/file/directory-path.test.ts b/test/types/file/directory-path.test.ts index 3dbe4600c..8002e24fa 100644 --- a/test/types/file/directory-path.test.ts +++ b/test/types/file/directory-path.test.ts @@ -63,6 +63,11 @@ describe('DirectoryPath.isAncestorOf', () => { expect(spec.isAncestorOf(spec.resolve('..'))).to.be.false; expect(spec.isAncestorOf(spec.resolve('../content'))).to.be.false; }); + + it('holds a directory whose name starts with two dots, which is no step up', () => { + expect(spec.isAncestorOf(spec.join('..v2'))).to.be.true; + expect(spec.contains(spec.join('..v2'))).to.be.true; + }); }); // What orders the folders of a spec directory that are merged without an order of their own. diff --git a/test/types/portal-source-context.test.ts b/test/types/portal-source-context.test.ts index 7d66d5596..b05b2843b 100644 --- a/test/types/portal-source-context.test.ts +++ b/test/types/portal-source-context.test.ts @@ -654,12 +654,16 @@ describe('PortalSourceContext', () => { write('spec/alpha/openapi.json', OPENAPI); write('spec/old.json', withPaths({ '/old': { get: {} } })); write('spec/older.yaml', JSON.stringify({ swagger: '2.0', info: {}, paths: { '/older': { get: {} } } })); + write('spec/events.yaml', JSON.stringify({ asyncapi: '2.6.0', info: {}, channels: { pets: {} } })); + write('spec/hooks.json', JSON.stringify({ openapi: '3.1.0', info: {}, webhooks: { newPet: { post: {} } } })); write('spec/common.json', JSON.stringify({ openapi: '3.0.3', info: {}, paths: {}, components: {} })); write('spec/shared-schemas.json', JSON.stringify({ components: { schemas: {} } })); write('spec/broken.json', '{ not json'); write('spec/notes.md', '# Specs'); expect((await resolve())._unsafeUnwrap().leftOutSpecs.map(relative)).to.deep.equal([ + 'spec/events.yaml', + 'spec/hooks.json', 'spec/old.json', 'spec/older.yaml' ]); @@ -699,6 +703,15 @@ describe('PortalSourceContext', () => { expect(await severalSpecs()).to.deep.equal(['spec/alpha/components.json', 'spec/alpha/openapi.json']); }); + // The platform takes any file with a version key, so it would build the SDKs from the Swagger file, which sorts first. + it('refuses a definition in another format beside the OpenAPI 3.x document', async () => { + merging(); + write('spec/alpha/a-legacy.json', JSON.stringify({ swagger: '2.0', info: {}, paths: { '/a': { get: {} } } })); + write('spec/alpha/openapi.json', withPaths({ '/pets': { get: {} } })); + + expect(await severalSpecs()).to.deep.equal(['spec/alpha/a-legacy.json', 'spec/alpha/openapi.json']); + }); + // The platform looks for the definition at the top of the folder first, whatever the folder inside it is named. it('reads a definition whose OpenAPI document of shared parts sits in a folder inside it', async () => { merging(); @@ -759,19 +772,38 @@ describe('PortalSourceContext', () => { }); // The platform is sent `spec/` alone, and finds a folder only by its exact name. - it('reports each merged folder the platform would find nothing in, wherever and however it is named', async () => { - merging({ MergeOrderOfDirectories: ['alpha', 'empty', 'hidden', 'misnamed', 'Beta', '.', '../content'] }); + // The platform is sent `spec/` alone, and finds a folder only by its exact name. + it('reports each listed folder the platform would not find, wherever and however it is named', async () => { + merging({ MergeOrderOfDirectories: ['alpha', 'misnamed', 'Beta', '.', '../content', 'empty'] }); write('spec/alpha/openapi.json', OPENAPI); - fs.mkdirSync(path.join(root, 'spec', 'empty')); - write('spec/hidden/.gitkeep', ''); write('spec/beta/openapi.json', OPENAPI); write('spec/top.json', OPENAPI); write('content/openapi.json', OPENAPI); + fs.mkdirSync(path.join(root, 'spec', 'empty')); - expect(await emptyFolders()).to.deep.equal([ - 'spec/APIMATIC-META.json', - ['./spec/empty', './spec/hidden', './spec/misnamed', './spec/Beta', './spec', './content'] - ]); + const problem = (await resolve())._unsafeUnwrapErr(); + + expect( + problem.kind === 'missingMergedFolders' && + problem.folders.map((folder) => folder.relativeTo(new DirectoryPath(root))) + ).to.deep.equal(['./spec/misnamed', './spec/Beta', './spec', './content']); + }); + + it('reports each merged folder with nothing to read, hidden files aside', async () => { + merging({ MergeOrderOfDirectories: ['alpha', 'empty', 'hidden'] }); + write('spec/alpha/openapi.json', OPENAPI); + fs.mkdirSync(path.join(root, 'spec', 'empty')); + write('spec/hidden/.gitkeep', ''); + + expect(await emptyFolders()).to.deep.equal(['spec/APIMATIC-META.json', ['./spec/empty', './spec/hidden']]); + }); + + // Named `..v2`, it sits inside the spec directory, however much it reads like a step up. + it('reads a folder whose name starts with two dots', async () => { + merging({ MergeOrderOfDirectories: ['..v2'] }); + write('spec/..v2/openapi.json', OPENAPI); + + expect(await read()).to.deep.equal([['v2', 'spec/..v2/openapi.json']]); }); it('reports a file that merges where there is no folder to merge', async () => { @@ -859,13 +891,13 @@ describe('PortalSourceContext', () => { }); // A path item's own method beside its `$ref` declares the operation a second time, in the one spec. - it('names none for an operation that one spec declares twice', async () => { + it('reads an operation one spec declares twice once, and names none shared', 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', 'GET /pets']); + expect(source.specs[0].endpoints.map(String)).to.deep.equal(['GET /pets']); expect(source.sharedEndpoints).to.be.empty; }); }); diff --git a/test/types/portal/openapi-document.test.ts b/test/types/portal/openapi-document.test.ts index 24289bf9e..1ad05d94a 100644 --- a/test/types/portal/openapi-document.test.ts +++ b/test/types/portal/openapi-document.test.ts @@ -66,12 +66,23 @@ describe('OpenApiDocument', () => { }); }); - describe('isApiDefinition', () => { - it('is a definition in any format it names that declares paths, and not a file of shared parts', () => { - expect(readJson({ openapi: '3.1.0', paths: { '/pets': { get: {} } } }).isApiDefinition()).to.be.true; - expect(readJson({ swagger: '2.0', paths: { '/pets': { $ref: './pets.yaml' } } }).isApiDefinition()).to.be.true; - expect(readJson({ openapi: '3.1.0', paths: {}, components: { schemas: {} } }).isApiDefinition()).to.be.false; - expect(readJson({ components: { schemas: {} }, paths: { '/pets': {} } }).isApiDefinition()).to.be.false; + describe('namesApiFormat', () => { + // As the platform takes a merged folder's definition, paths or not. + it('names one for any version key, and none for a file without', () => { + expect(readJson({ openapi: '3.1.0', paths: {} }).namesApiFormat()).to.be.true; + expect(readJson({ swagger: '2.0' }).namesApiFormat()).to.be.true; + expect(readJson({ asyncapi: '2.6.0' }).namesApiFormat()).to.be.true; + expect(readJson({ components: { schemas: {} } }).namesApiFormat()).to.be.false; + }); + }); + + describe('declaresOperations', () => { + it('declares them by paths, webhooks or channels, and not by shared parts alone', () => { + expect(readJson({ openapi: '3.1.0', paths: { '/pets': { $ref: './pets.yaml' } } }).declaresOperations()).to.be + .true; + expect(readJson({ openapi: '3.1.0', webhooks: { newPet: { post: {} } } }).declaresOperations()).to.be.true; + expect(readJson({ asyncapi: '2.6.0', channels: { pets: {} } }).declaresOperations()).to.be.true; + expect(readJson({ openapi: '3.1.0', paths: {}, components: { schemas: {} } }).declaresOperations()).to.be.false; }); }); diff --git a/test/types/portal/shared.test.ts b/test/types/portal/shared.test.ts index cc9b0b3e0..1ca114ba6 100644 --- a/test/types/portal/shared.test.ts +++ b/test/types/portal/shared.test.ts @@ -22,13 +22,4 @@ describe('shared', () => { expect(named(claims)).to.deep.equal([['GET /status', [alpha.toString(), beta.toString()]]]); }); - - it('counts a file that claims a key twice once', () => { - const claims = shared([ - { key: new Endpoint('get', '/pets'), file: alpha }, - { key: new Endpoint('get', '/pets'), file: alpha } - ]); - - expect(claims).to.be.empty; - }); }); diff --git a/test/types/portal/spec-metadata.test.ts b/test/types/portal/spec-metadata.test.ts index cd0ad27ee..4491647b3 100644 --- a/test/types/portal/spec-metadata.test.ts +++ b/test/types/portal/spec-metadata.test.ts @@ -33,6 +33,7 @@ describe('SpecMetadata', () => { expect(read({ MergeConfiguration: { MergeApis: 'true' } }).mergesApis()).to.be.true; expect(read({ MergeConfiguration: { MergeApis: 'True' } }).mergesApis()).to.be.true; expect(read({ MergeConfiguration: { MergeApis: 'yes' } }).mergesApis()).to.be.false; + expect(read({ MergeConfiguration: { mergeApis: false, MergeApis: true } }).mergesApis()).to.be.true; expect( read({ MERGECONFIGURATION: { MERGEAPIS: true, mergeorderofdirectories: ['beta'] } }).mergeOrder() ).to.deep.equal(['beta']); From 637a381fd8499b39cdac22f2851f0d34d873369e Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Wed, 14 Oct 2026 17:22:36 +0500 Subject: [PATCH 4/4] fix(portal): say where MergeApis goes instead of linking the merging docs The advice to merge folders, in the error for a `src/spec` with only folders, the warning for several specs nothing merges, and the `portal generate` help, no longer links the docs. It names the setting by its place in `APIMATIC-META.json`, `MergeConfiguration.MergeApis`, which the link was left to say: a `MergeApis` at the top of the file merges nothing. `MERGE_SETTINGS` gains `configuration`, which the file reader uses too. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- src/commands/portal/generate.ts | 2 +- src/prompts/portal/source.ts | 10 ++++------ src/types/portal/spec-metadata.ts | 10 +++++++--- test/prompts/portal/source.test.ts | 7 +++---- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 5f002c784..f04fca6e0 100644 --- a/README.md +++ b/README.md @@ -312,8 +312,8 @@ DESCRIPTION Builds a documentation portal from the OpenAPI documents and Markdown pages in your 'src' directory. Each OpenAPI document at the top of 'src/spec' gets a section of the API reference, and the SDKs are built from one of - them. To build the SDKs from several, put each in a folder of its own and turn on 'MergeApis' in an - 'APIMATIC-META.json' beside the folders; each folder then gets a section. + them. To build the SDKs from several, put each in a folder of its own and set 'MergeConfiguration.MergeApis' to true + in an 'APIMATIC-META.json' beside the folders; each folder then gets a section. The portal is built on your machine and written as static files for any static host, served at its root or, when 'portal.site.url' has a path such as 'https://example.com/docs', at that path. Configure it with 'src/apimatic.json', diff --git a/src/commands/portal/generate.ts b/src/commands/portal/generate.ts index 512151b19..a4f1223cd 100644 --- a/src/commands/portal/generate.ts +++ b/src/commands/portal/generate.ts @@ -11,7 +11,7 @@ export default class PortalGenerate extends Command { static readonly description = `Builds a documentation portal from the OpenAPI documents and Markdown pages in your 'src' directory. -Each OpenAPI document at the top of 'src/spec' gets a section of the API reference, and the SDKs are built from one of them. To build the SDKs from several, put each in a folder of its own and turn on 'MergeApis' in an 'APIMATIC-META.json' beside the folders; each folder then gets a section. +Each OpenAPI document at the top of 'src/spec' gets a section of the API reference, and the SDKs are built from one of them. To build the SDKs from several, put each in a folder of its own and set 'MergeConfiguration.MergeApis' to true in an 'APIMATIC-META.json' beside the folders; each folder then gets a section. The portal is built on your machine and written as static files for any static host, served at its root or, when 'portal.site.url' has a path such as 'https://example.com/docs', at that path. Configure it with 'src/apimatic.json', whose 'languages' block gives the portal a page for each SDK language, and whose 'plugin' block, or a 'pluginUrl' in its 'portal' block for a plugin hosted elsewhere, a page for the context plugin.`; diff --git a/src/prompts/portal/source.ts b/src/prompts/portal/source.ts index 0cb87a2a7..fa72765cd 100644 --- a/src/prompts/portal/source.ts +++ b/src/prompts/portal/source.ts @@ -33,11 +33,9 @@ const TITLE_EXAMPLE = ['---', 'title: Getting started', '---'].join('\n'); const ROOT_NAVIGATION_FILE = `${CONTENT_DIRECTORY_NAME}/${NAVIGATION_FILE_NAME}`; -const API_MERGING_URL = 'https://docs.apimatic.io/manage-apis/api-merging/'; - const mergeTheFolders = (): string => - `turn on ${f.var(MERGE_SETTINGS.mergeApis)} in an ${f.var(METADATA_FILE_NAME)} beside the folders: ` + - f.link(API_MERGING_URL); + `set ${f.var(`${MERGE_SETTINGS.configuration}.${MERGE_SETTINGS.mergeApis}`)} to true in an ` + + `${f.var(METADATA_FILE_NAME)} beside the folders`; const addingToMergeOrder = (folders: string): string => `adding ${folders} to ${f.var(MERGE_SETTINGS.order)} when the file lists folders`; @@ -184,7 +182,7 @@ export function reportSourceProblem(problem: PortalSourceProblem, sourceDirector const folders = listedInProse(problem.folders.map((folder) => f.var(folder.leafName()))); log.message( `A document in a folder inside it, such as ${folders}, is not read. Move it up, or to keep one API ` + - `to a folder, ${mergeTheFolders()}` + `to a folder, ${mergeTheFolders()}.` ); } return; @@ -348,7 +346,7 @@ export function reportUnmergedSpecs(files: FilePath[], sourceDirectory: Director const names = listed(files, sourceDirectory); log.warn( `${names} are separate API definitions, and the SDKs are generated from one of them only. To cover them all, ` + - `give each its own folder and ${mergeTheFolders()}` + `give each its own folder and ${mergeTheFolders()}.` ); } diff --git a/src/types/portal/spec-metadata.ts b/src/types/portal/spec-metadata.ts index 9489a7ff4..4d187d8e9 100644 --- a/src/types/portal/spec-metadata.ts +++ b/src/types/portal/spec-metadata.ts @@ -1,8 +1,12 @@ import { isJsonObject, JsonObject } from '../../utils/json-utils.js'; import { stripByteOrderMark } from '../../utils/string-utils.js'; -/** The settings of its `MergeConfiguration` a portal follows, by the names a message gives them. */ -export const MERGE_SETTINGS = { mergeApis: 'MergeApis', order: 'MergeOrderOfDirectories' } as const; +/** The merge settings a portal follows, as the metadata file names them; `configuration` holds the other two. */ +export const MERGE_SETTINGS = { + configuration: 'MergeConfiguration', + mergeApis: 'MergeApis', + order: 'MergeOrderOfDirectories' +} as const; // The platform matches a setting's name in any case, as its JSON reader does, the exact name first. const setting = (object: JsonObject, name: string): unknown => @@ -16,7 +20,7 @@ export class SpecMetadata { public static parse(contents: string): SpecMetadata | undefined { try { const document: unknown = JSON.parse(stripByteOrderMark(contents)); - const mergeConfiguration = isJsonObject(document) ? setting(document, 'MergeConfiguration') : undefined; + const mergeConfiguration = isJsonObject(document) ? setting(document, MERGE_SETTINGS.configuration) : undefined; return new SpecMetadata(isJsonObject(mergeConfiguration) ? mergeConfiguration : {}); } catch { return undefined; diff --git a/test/prompts/portal/source.test.ts b/test/prompts/portal/source.test.ts index d9b73b96e..2af4be54c 100644 --- a/test/prompts/portal/source.test.ts +++ b/test/prompts/portal/source.test.ts @@ -192,8 +192,7 @@ describe('reportSourceProblem', () => { expect(printed()).to.contain('has no API definition.'); expect(printed()).to.contain( "A document in a folder inside it, such as 'alpha' and 'beta', is not read. Move it up, or to keep one " + - "API to a folder, turn on 'MergeApis' in an 'APIMATIC-META.json' beside the folders: " + - 'https://docs.apimatic.io/manage-apis/api-merging/' + "API to a folder, set 'MergeConfiguration.MergeApis' to true in an 'APIMATIC-META.json' beside the folders." ); }); @@ -714,8 +713,8 @@ describe('reportUnmergedSpecs', () => { expect(lines).to.deep.equal([ "'spec/orders.json' and 'spec/items.json' are separate API definitions, and the SDKs are generated from one " + - "of them only. To cover them all, give each its own folder and turn on 'MergeApis' in an " + - "'APIMATIC-META.json' beside the folders: https://docs.apimatic.io/manage-apis/api-merging/" + "of them only. To cover them all, give each its own folder and set 'MergeConfiguration.MergeApis' to " + + "true in an 'APIMATIC-META.json' beside the folders." ]); }); });