diff --git a/packages/kbn-openapi-generator/src/parser/lib/get_circular_refs.ts b/packages/kbn-openapi-generator/src/parser/lib/get_circular_refs.ts index da9649d5f0c6d..5c1e71170d8c5 100644 --- a/packages/kbn-openapi-generator/src/parser/lib/get_circular_refs.ts +++ b/packages/kbn-openapi-generator/src/parser/lib/get_circular_refs.ts @@ -9,7 +9,8 @@ import type { OpenApiDocument } from '../openapi_types'; import type { PlainObject } from './helpers/plain_object'; import { extractByJsonPointer } from './helpers/extract_by_json_pointer'; -import { findRefs } from './find_refs'; +import { findLocalRefs } from './helpers/find_local_refs'; +import { parseRef } from './helpers/parse_ref'; /** * Extracts circular references from a provided document. @@ -19,7 +20,7 @@ export function getCircularRefs(document: OpenApiDocument): Set { const localRefs = findLocalRefs(document); const circularRefs = new Set(); const resolveLocalRef = (localRef: string): PlainObject => - extractByJsonPointer(document, extractJsonPointer(localRef)); + extractByJsonPointer(document, parseRef(localRef).pointer); // In general references represent a disconnected graph. To find // all references cycles we need to check each reference. @@ -80,26 +81,3 @@ function findCycleHeadRef( return result; } - -/** - * Finds local references - */ -function findLocalRefs(obj: unknown): string[] { - return findRefs(obj).filter((ref) => isLocalRef(ref)); -} - -/** - * Checks whether the provided ref is local. - * Local references start with `#/` - */ -function isLocalRef(ref: string): boolean { - return ref.startsWith('#/'); -} - -/** - * Extracts a JSON Pointer from a local reference - * by getting rid of the leading slash - */ -function extractJsonPointer(ref: string): string { - return ref.substring(1); -} diff --git a/packages/kbn-openapi-generator/src/parser/lib/get_components.ts b/packages/kbn-openapi-generator/src/parser/lib/get_components.ts index 6e98793de1afb..ff2f77aced821 100644 --- a/packages/kbn-openapi-generator/src/parser/lib/get_components.ts +++ b/packages/kbn-openapi-generator/src/parser/lib/get_components.ts @@ -6,12 +6,124 @@ * Side Public License, v 1. */ -import type { OpenApiDocument } from '../openapi_types'; +import type { + OpenApiDocument, + OpenApiComponentsObject, + OpenApiSchemasObject, +} from '../openapi_types'; +import { extractByJsonPointer } from './helpers/extract_by_json_pointer'; +import { findLocalRefs } from './helpers/find_local_refs'; +import { parseRef } from './helpers/parse_ref'; -export function getComponents(parsedSchema: OpenApiDocument) { - if (parsedSchema.components?.['x-codegen-enabled'] === false) { +/** + * Returns document components. + * + * It performs topological sorting of component schemas to enable arbitrary + * schemas definition order. + */ +export function getComponents(document: OpenApiDocument): OpenApiComponentsObject | undefined { + if (document.components?.['x-codegen-enabled'] === false) { return undefined; } - return parsedSchema.components; + if (!document.components) { + return; + } + + const refsAdjList = buildLocalRefsAdjacencyList(document.components); + const sortedSchemaRefs = sortTopologically( + refsAdjList, + Array.from(Object.keys(document.components?.schemas ?? {})) + ); + // Starting from ES2020 functions returning or traversing object properties + // make it in ascending chronological order of property creation. It makes + // it possible to assemble schemas object which will be traversed in + // the right order preserving topological sorting. + const sortedSchemas: OpenApiSchemasObject = {}; + + for (const schemaName of sortedSchemaRefs) { + sortedSchemas[schemaName] = extractByJsonPointer(document, `/components/schemas/${schemaName}`); + } + + return { + ...document.components, + schemas: sortedSchemas, + }; +} + +/** + * References adjacency list with keys as schema name and value + * as a set of schemas the key references to. + */ +type ReferencesAdjacencyList = Map>; + +/** + * Builds a references adjacency list. An adjacency list allow to apply + * any graph algorithms working with adjacency lists. + * See https://en.wikipedia.org/wiki/Adjacency_list + */ +function buildLocalRefsAdjacencyList( + componentsObj: OpenApiComponentsObject +): ReferencesAdjacencyList { + if (!componentsObj.schemas) { + return new Map(); + } + + const adjacencyList: ReferencesAdjacencyList = new Map(); + + for (const [schemaName, schema] of Object.entries(componentsObj.schemas)) { + const dependencies = adjacencyList.get(schemaName); + const dependencySchemaNames = findLocalRefs(schema).map((ref) => parseRef(ref).schemaName); + + if (!dependencies) { + adjacencyList.set(schemaName, new Set(dependencySchemaNames)); + } else { + for (const dependencySchemaName of dependencySchemaNames) { + dependencies.add(dependencySchemaName); + } + } + } + + return adjacencyList; +} + +/** + * Sorts dependent references in topological order. Local dependencies are placed + * before dependent schemas. External references aren't involved. + * See https://en.wikipedia.org/wiki/Topological_sorting + * + * It uses Depth First Search (DFS) variant of topological sort to preserve schemas + * definition order in OpenAPI specification document. Topological sorting doesn't + * define any order for non dependent schemas. Preserving original ordering looks + * like a good option to minimize diffs and have higher result predictability. + * + * @param adjacencyList An adjacency list, e.g. built via buildLocalRefsAdjacencyList + * @param originalOrder A string array having schema names sorted in OpenAPI spec order + * @returns A string array sorting in topological way + */ +function sortTopologically( + adjacencyList: ReferencesAdjacencyList, + originalOrder: string[] +): string[] { + const sortedSchemas: string[] = []; + const visited = new Set(); + const addToSorted = (schemaName: string): void => { + if (visited.has(schemaName)) { + return; + } + + visited.add(schemaName); + + for (const dependencySchemaName of adjacencyList.get(schemaName) ?? []) { + addToSorted(dependencySchemaName); + } + + sortedSchemas.push(schemaName); + }; + + for (const schemaName of originalOrder) { + addToSorted(schemaName); + } + + return sortedSchemas; } diff --git a/packages/kbn-openapi-generator/src/parser/lib/get_imports_map.ts b/packages/kbn-openapi-generator/src/parser/lib/get_imports_map.ts index 634ac82aaae8c..d843bef8456cc 100644 --- a/packages/kbn-openapi-generator/src/parser/lib/get_imports_map.ts +++ b/packages/kbn-openapi-generator/src/parser/lib/get_imports_map.ts @@ -8,7 +8,7 @@ import { uniq } from 'lodash'; import type { OpenApiDocument } from '../openapi_types'; -import { findRefs } from './find_refs'; +import { findRefs } from './helpers/find_refs'; export interface ImportsMap { [importPath: string]: string[]; diff --git a/packages/kbn-openapi-generator/src/parser/lib/helpers/find_local_refs.ts b/packages/kbn-openapi-generator/src/parser/lib/helpers/find_local_refs.ts new file mode 100644 index 0000000000000..8dc9c5a5d7b1e --- /dev/null +++ b/packages/kbn-openapi-generator/src/parser/lib/helpers/find_local_refs.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { findRefs } from './find_refs'; +import { isLocalRef } from './is_local_ref'; + +/** + * Finds local references + */ +export function findLocalRefs(obj: unknown): string[] { + return findRefs(obj).filter((ref) => isLocalRef(ref)); +} diff --git a/packages/kbn-openapi-generator/src/parser/lib/find_refs.ts b/packages/kbn-openapi-generator/src/parser/lib/helpers/find_refs.ts similarity index 87% rename from packages/kbn-openapi-generator/src/parser/lib/find_refs.ts rename to packages/kbn-openapi-generator/src/parser/lib/helpers/find_refs.ts index 1829c0a5e7ee2..49965fde840ab 100644 --- a/packages/kbn-openapi-generator/src/parser/lib/find_refs.ts +++ b/packages/kbn-openapi-generator/src/parser/lib/helpers/find_refs.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import { hasRef } from './helpers/has_ref'; -import { traverseObject } from './helpers/traverse_object'; +import { hasRef } from './has_ref'; +import { traverseObject } from './traverse_object'; /** * Traverse the OpenAPI document recursively and find all references diff --git a/packages/kbn-openapi-generator/src/parser/lib/helpers/is_local_ref.ts b/packages/kbn-openapi-generator/src/parser/lib/helpers/is_local_ref.ts new file mode 100644 index 0000000000000..40b1973330236 --- /dev/null +++ b/packages/kbn-openapi-generator/src/parser/lib/helpers/is_local_ref.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * Checks whether the provided ref is local. + * Local references start with `#/` + */ +export function isLocalRef(ref: string): boolean { + return ref.startsWith('#/'); +} diff --git a/packages/kbn-openapi-generator/src/parser/lib/helpers/parse_ref.ts b/packages/kbn-openapi-generator/src/parser/lib/helpers/parse_ref.ts new file mode 100644 index 0000000000000..75ee17951cba2 --- /dev/null +++ b/packages/kbn-openapi-generator/src/parser/lib/helpers/parse_ref.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +interface ParsedRef { + uri: string; + pointer: string; + schemaName: string; +} + +/** + * Parses an OpenAPI reference a.k.a JSON reference + * See https://datatracker.ietf.org/doc/html/draft-pbryan-zyp-json-ref-03 + * + * JSON reference consists of an optional uri and required JSON pointer + * looking like `uri#pointer`. While RFC implies URI usage mostly relative + * paths are used. + * + * An example looks like + * + * ``` + * ../path/to/my/file.schema.yaml#/components/schemas/MySchema + * ``` + * + * This function returns `uri`, JSON `pointer` and + * `schemaName` which is the last part of the JSON pointer. In the example + * above `schemaName` is `MySchema`. + */ +export function parseRef(ref: string): ParsedRef { + if (!ref.includes('#')) { + throw new Error(`Reference parse error: provided ref is not valid "${ref}"`); + } + + const [uri, pointer] = ref.split('#'); + const schemaName = pointer.split('/').at(-1)!; + + return { + uri, + pointer, + schemaName, + }; +} diff --git a/packages/kbn-openapi-generator/src/parser/openapi_types.ts b/packages/kbn-openapi-generator/src/parser/openapi_types.ts index c8b1e4f715345..669d1d9b68764 100644 --- a/packages/kbn-openapi-generator/src/parser/openapi_types.ts +++ b/packages/kbn-openapi-generator/src/parser/openapi_types.ts @@ -16,6 +16,11 @@ interface AdditionalProperties { } export type OpenApiDocument = OpenAPIV3.Document; +export type OpenApiComponentsObject = OpenAPIV3.ComponentsObject; +export type OpenApiSchemasObject = Record< + string, + OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject +>; // Override the OpenAPI types to add the x-codegen-enabled property to the // components object. diff --git a/packages/kbn-openapi-generator/src/template_service/register_helpers.ts b/packages/kbn-openapi-generator/src/template_service/register_helpers.ts index 55f2d9d60f37a..f02f295954465 100644 --- a/packages/kbn-openapi-generator/src/template_service/register_helpers.ts +++ b/packages/kbn-openapi-generator/src/template_service/register_helpers.ts @@ -71,7 +71,7 @@ export function registerHelpers(handlebarsInstance: typeof Handlebars) { }); /** - * Checks whether provided schema is circular or a part of the circular chain. + * Checks whether provided schema is circular or a part of a circular chain. * * It's expected that `context.circularRefs` has been filled by the parser. */