diff --git a/.github/workflows/portal_tests.yml b/.github/workflows/portal_tests.yml index a88a5e28f..59f23579b 100644 --- a/.github/workflows/portal_tests.yml +++ b/.github/workflows/portal_tests.yml @@ -82,7 +82,6 @@ jobs: uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: apimatic/v4-test-automation - ref: feat/functional-tests path: tests - name: install test dependencies diff --git a/README.md b/README.md index d2b246749..f04fca6e0 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 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 @@ -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/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..0c20c5754 100644 --- a/portal-template/spec-reload.ts +++ b/portal-template/spec-reload.ts @@ -1,24 +1,27 @@ -import path from 'node:path'; 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 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 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(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 isSpecFile = (file: string) => + file.startsWith(`${specDirectory}/`) && + SPEC_FILE_EXTENSIONS.some((extension) => file.toLowerCase().endsWith(extension)); 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 }) { - if (!isInSpecDirectory(file)) return; + if (!isSpecFile(file)) return; this.environment.hot.send({ type: 'full-reload' }); return []; } 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/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..a4f1223cd 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 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.`; 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/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 6a53f3b0f..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; @@ -127,7 +128,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(({ key }) => key)) + ), await this.writeDownloads(projectDirectory, artifacts) ); @@ -281,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/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..fa72765cd 100644 --- a/src/prompts/portal/source.ts +++ b/src/prompts/portal/source.ts @@ -11,19 +11,35 @@ import { PortalSourceProblem, ReservedAddressPage, SharedAddress, + 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'; 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, + SPEC_EXTENSIONS, + 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 mergeTheFolders = (): string => + `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`; + export const specPath = (sourceDirectory: DirectoryPath): string => f.path(sourceDirectory.join(SPEC_DIRECTORY_NAME)); export const contentPath = (sourceDirectory: DirectoryPath): string => @@ -34,6 +50,12 @@ 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 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`; @@ -55,11 +77,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 +90,37 @@ 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}, in place of ${name}.` + (others === 0 ? '' : ` Convert the other ${documents} the same way.`) ); } +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 ${folderNames(folders, sourceDirectory)}, which ${verb} not ` + + `${noun} inside ${specPath(sourceDirectory)}.` + ); + log.message( + `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)}.`); +} + /** * 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 +141,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': { @@ -119,18 +170,56 @@ 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 files. Add your OpenAPI 3.x document to it as a ` + - `${f.var('.json')}, ${f.var('.yaml')} or ${f.var('.yml')} file.`; + `${specPath(sourceDirectory)} has no API definition. Add your OpenAPI 3.x document to it as a ` + + `${extensions} 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 '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 'missingMergedFolders': { + reportMissingMergedFolders(problem.metadata, problem.folders, sourceDirectory); + return; + } + case 'emptyMergedFolders': { + reportEmptyMergedFolders(problem.metadata, problem.folders, sourceDirectory); + return; + } + case 'severalSpecsInFolder': { + const shared = f.var(problem.folder.join('shared').relativeTo(sourceDirectory)); + log.error( + `${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 ` + + `${shared}, and another API into a folder of its own, ${addingToMergeOrder('the new folders')}.` + ); + return; + } case 'noOpenApiSpec': { - reportSpecConversion(problem.conversion, sourceDirectory); + reportSpecConversion(problem.conversion); return; } } @@ -153,7 +242,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,15 +318,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)}: ${listedInProse(pages.map((page) => relative(page, 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)' @@ -254,11 +339,47 @@ 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, ${addingToMergeOrder(`the ${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(sharedLines(shared, sourceDirectory)); +} + 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 +476,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..b50822dbe 100644 --- a/src/types/file/directoryPath.ts +++ b/src/types/file/directoryPath.ts @@ -66,10 +66,21 @@ 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); - 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)); + } + + public isAncestorOf(other: DirectoryPath) { + return this.contains(other) && !this.isEqual(other); } public leafName() { diff --git a/src/types/file/fileName.ts b/src/types/file/fileName.ts index e08ab449e..1fe2b6de5 100644 --- a/src/types/file/fileName.ts +++ b/src/types/file/fileName.ts @@ -19,6 +19,15 @@ 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; + } + + 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(); @@ -36,13 +45,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..1993cbfa8 100644 --- a/src/types/portal-source-context.ts +++ b/src/types/portal-source-context.ts @@ -10,7 +10,13 @@ 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, + SPEC_EXTENSIONS, + 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'; @@ -32,10 +38,43 @@ import { PortalSpec, 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'; -const SPEC_EXTENSIONS = ['.json', '.yaml', '.yml']; +const METADATA_FILE = new FileName(METADATA_FILE_NAME); + +const definitionNames = (fileNames: FileName[]): FileName[] => + fileNames.filter((name) => !name.isEqual(METADATA_FILE) && !name.isHidden()); + +const isDocumentName = (name: FileName): boolean => SPEC_EXTENSIONS.some((extension) => name.hasExtension(extension)); + +type DiscoveredSpecs = Pick; + +/** 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 { + 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[]; + 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. @@ -67,6 +106,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 +135,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 +154,12 @@ export class PortalSourceContext { return ok({ ...settings.value, - suggestedSite: suggested, + suggestedSite, specs, + specDirectory: this.specDirectory, + unmergedSpecs, + leftOutSpecs, + sharedEndpoints: shared(specs.flatMap(({ file, endpoints }) => endpoints.map((key) => ({ key, file })))), contentDirectory, staticDirectory, shadowedFiles: staticDirectory === null ? [] : await this.shadowedFiles(staticDirectory), @@ -423,28 +470,159 @@ 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 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))); + 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. */ + 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)).map(({ documents }) => ({ + specs: documents.map(({ spec }) => ({ ...spec, slug: this.uniqueSlug(spec.file.name().normalize(), usedSlugs) })), + suggested: documents[0].site + })); + } + + /** 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> { + const listings = await Promise.all( + 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) { + return err({ kind: 'emptyMergedFolders', metadata, folders: empty }); + } + + const specs: PortalSpec[] = []; + const sites: SuggestedSite[] = []; + for (const { folder, fileNames } of listings) { + const read = await this.definitions(folder, fileNames); + if (read.isErr()) { + return err(read.error); + } + // 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 [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] }); + } + + /** 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 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) => apis[index]); + } + + /** 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> { + // 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 }[] = []; - 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 }); + } + if (document.namesApiFormat()) { + definitions.push(file); } const format = document.format(); if (!format.supported) { @@ -454,24 +632,26 @@ export class PortalSourceContext { continue; } - specs.push({ slug: this.uniqueSlug(fileName, usedSlugs), file, endpoints: await this.endpoints(document, file) }); - first ??= document; + documents.push({ + spec: { 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 = 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({ documents, definitions }); } 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 +667,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)) }; } @@ -499,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 { @@ -510,8 +695,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..655c5d950 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); @@ -48,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); @@ -137,9 +144,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. */ @@ -310,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; } @@ -327,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 @@ -339,14 +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[] { - 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: servedAt(segments).address, file }))); } /** @@ -537,7 +534,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/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 ec0fffcfd..41f2e4fd6 100644 --- a/src/types/portal/openapi-document.ts +++ b/src/types/portal/openapi-document.ts @@ -87,6 +87,20 @@ export class OpenApiDocument { return { supported: false, format: null }; } + /** 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; + } + + /** 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; + }); + } + /** * 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..f7b71d8a7 100644 --- a/src/types/portal/portal-source.ts +++ b/src/types/portal/portal-source.ts @@ -6,8 +6,9 @@ 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/`, 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 +16,9 @@ export interface PortalSpec { endpoints: Endpoint[]; } +/** An operation more than one specification declares, which one code sample per method and path cannot serve. */ +export type SharedEndpoint = Shared; + /** What `apimatic.json` decides about a portal, which `portal serve` reads again on every edit. */ export interface PortalSettings { config: PortalConfig; @@ -29,6 +33,14 @@ 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. */ + 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[]; @@ -73,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 { @@ -97,12 +106,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 +123,15 @@ 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[] } + | { kind: 'noFoldersToMerge'; metadata: FilePath } + // 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 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 new file mode 100644 index 000000000..c5bf6a188 --- /dev/null +++ b/src/types/portal/shared.ts @@ -0,0 +1,17 @@ +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. */ +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)); + 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 new file mode 100644 index 000000000..4d187d8e9 --- /dev/null +++ b/src/types/portal/spec-metadata.ts @@ -0,0 +1,42 @@ +import { isJsonObject, JsonObject } from '../../utils/json-utils.js'; +import { stripByteOrderMark } from '../../utils/string-utils.js'; + +/** 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 => + 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 { + 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) ? setting(document, MERGE_SETTINGS.configuration) : 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 { + 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 = 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/src/types/project-layout.ts b/src/types/project-layout.ts index 2f7314bec..c080a7bdc 100644 --- a/src/types/project-layout.ts +++ b/src/types/project-layout.ts @@ -2,6 +2,12 @@ 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'; + 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/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 085e49644..a01f9de45 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,10 @@ describe('PortalProjectService', () => { endpoints: [] } ], + specDirectory: new DirectoryPath(root).join('spec'), + unmergedSpecs: [], + leftOutSpecs: [], + sharedEndpoints: [], contentDirectory: null, staticDirectory: null, shadowedFiles: [], @@ -170,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 () => { @@ -298,6 +306,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: [{ key: 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(); @@ -305,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..d9a9d5f97 --- /dev/null +++ b/test/portal-template/spec-reload.test.ts @@ -0,0 +1,62 @@ +import { expect } from 'chai'; +import type { ViteDevServer } from 'vite'; +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'; + + /** 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; + } + }); + + // 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/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..2af4be54c 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'; @@ -169,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'); }); @@ -179,10 +185,132 @@ 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, set 'MergeConfiguration.MergeApis' to true in an 'APIMATIC-META.json' beside the folders." + ); + }); + + 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}', in place of 'orders.json'.`); + 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 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/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'." + ]); + }); + + it('lists several empty folders as a sentence would', () => { + reportSourceProblem( + { 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/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'." + ]); + }); + + // 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')) + ] + }, + source + ); + + expect(printed().split('\n')).to.deep.equal([ + "'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." + ]); + }); + + it('says so when the file merges and there is no folder at all', () => { + reportSourceProblem({ kind: 'noFoldersToMerge', metadata }, 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." + ]); }); }); @@ -297,8 +425,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')] } ] }); @@ -316,7 +444,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:'); @@ -554,3 +682,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 set 'MergeConfiguration.MergeApis' to " + + "true in an 'APIMATIC-META.json' beside the folders." + ]); + }); +}); + +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( + [ + { key: new Endpoint('get', '/status'), files }, + { key: new Endpoint('post', '/status'), files } + ], + source + ); + + expect(lines.join('\n').split('\n')).to.deep.equal([ + 'More than one API definition declares these operations, so the portal shows no SDK sample on them:', + " • 'GET /status': 'spec/alpha/openapi.json' and 'spec/beta/openapi.json'", + " • 'POST /status': 'spec/alpha/openapi.json' and 'spec/beta/openapi.json'" + ]); + }); + + it('speaks of one operation when there is one', () => { + reportSharedEndpoints([{ key: new Endpoint('get', '/status'), files }], source); + + expect(lines[0]).to.contain('declares this operation,'); + expect(lines[0]).to.contain('no SDK sample on it:'); + }); +}); diff --git a/test/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..8002e24fa 100644 --- a/test/types/file/directory-path.test.ts +++ b/test/types/file/directory-path.test.ts @@ -52,3 +52,33 @@ describe('DirectoryPath.fromUserInput', () => { expect(DirectoryPath.fromUserInput(typed).toString()).to.equal(path.resolve(typed)); }); }); + +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; + }); + + 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. +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..dd03ad59a --- /dev/null +++ b/test/types/file/fileName.test.ts @@ -0,0 +1,33 @@ +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('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; + 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..b05b2843b 100644 --- a/test/types/portal-source-context.test.ts +++ b/test/types/portal-source-context.test.ts @@ -399,6 +399,13 @@ 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)); + + /** 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'); @@ -440,7 +447,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 +489,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 () => { @@ -496,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'); @@ -545,6 +568,339 @@ 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. 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', 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' + ]); + }); + + 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 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', withPaths({ '/a': { get: {} } })); + write('spec/alpha/b.json', withPaths({ '/b': { get: {} } })); + + expect(await severalSpecs()).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 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(); + 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. + 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']); + }); + + // The platform is sent `spec/` alone, and finds a folder only by its exact name. + // 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); + write('spec/beta/openapi.json', OPENAPI); + write('spec/top.json', OPENAPI); + write('content/openapi.json', OPENAPI); + fs.mkdirSync(path.join(root, 'spec', 'empty')); + + 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 () => { + merging(); + write('spec/api.json', OPENAPI); + + 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 () => { + 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', () => { + 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(({ key, files }) => [`${key}`, files.map(relative)])).to.deep.equal([ + ['GET /status', ['spec/alpha/openapi.json', 'spec/beta/openapi.json']] + ]); + }); + + it('names none when no two specs declare the same operation', async () => { + write('spec/a.json', withPaths({ '/a': { get: {} } })); + write('spec/b.json', withPaths({ '/b': { get: {} } })); + + expect((await resolve())._unsafeUnwrap().sharedEndpoints).to.be.empty; + }); + + // A path item's own method beside its `$ref` declares the operation a second time, in the one spec. + it('reads an operation one spec declares twice once, and names none shared', async () => { + write('spec/paths/pets.json', JSON.stringify({ get: {} })); + write('spec/api.json', withPaths({ '/pets': { $ref: './paths/pets.json', get: {} } })); + + const source = (await resolve())._unsafeUnwrap(); + + expect(source.specs[0].endpoints.map(String)).to.deep.equal(['GET /pets']); + expect(source.sharedEndpoints).to.be.empty; + }); + }); }); describe('optional directories', () => { @@ -887,9 +1243,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/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..1ad05d94a 100644 --- a/test/types/portal/openapi-document.test.ts +++ b/test/types/portal/openapi-document.test.ts @@ -66,6 +66,26 @@ describe('OpenApiDocument', () => { }); }); + 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; + }); + }); + 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/shared.test.ts b/test/types/portal/shared.test.ts new file mode 100644 index 000000000..1ca114ba6 --- /dev/null +++ b/test/types/portal/shared.test.ts @@ -0,0 +1,25 @@ +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()]]]); + }); +}); diff --git a/test/types/portal/spec-metadata.test.ts b/test/types/portal/spec-metadata.test.ts new file mode 100644 index 000000000..4491647b3 --- /dev/null +++ b/test/types/portal/spec-metadata.test.ts @@ -0,0 +1,74 @@ +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; + }); + + // 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: false, MergeApis: true } }).mergesApis()).to.be.true; + 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; + 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']); + }); + }); +});