diff --git a/api_generator/src/renderers/render_code/FunctionFileRenderer.ts b/api_generator/src/renderers/render_code/FunctionFileRenderer.ts index 0b7f0dec3..5022e8f09 100644 --- a/api_generator/src/renderers/render_code/FunctionFileRenderer.ts +++ b/api_generator/src/renderers/render_code/FunctionFileRenderer.ts @@ -12,6 +12,7 @@ import BaseRenderer from '../BaseRenderer' import _ from 'lodash' import type ApiFunction from '../../spec_parser/ApiFunction' import type Namespace from '../../spec_parser/Namespace' +import ApiPath from '../../spec_parser/ApiPath' export default class FunctionFileRenderer extends BaseRenderer { protected template_file = 'function.mustache' @@ -35,8 +36,9 @@ export default class FunctionFileRenderer extends BaseRenderer { params_container_description: _.values(this.func.params).length === 0 ? ' - (Unused)' : undefined, parameter_descriptions: this.#parameter_descriptions(), function_name: this.func.function_name, - path_components: this.#path_components(), - path: this.#path(), + paths_are_uniform: ApiPath.statically_uniform(this.func.paths), + uniform_path: this.#uniform_path(), + diverged_paths: this.#diverged_paths(), http_verb: this.#http_verb(), body_required: this.func.request_body?.required, return_type: '{{abort: function(), then: function(), catch: function()}|Promise|*}', @@ -64,21 +66,6 @@ export default class FunctionFileRenderer extends BaseRenderer { return type } - #path (): string { - const path_params = _.values(this.func.path_params) - if (path_params.length === 0) return `'${this.func.url}'` - if (path_params.every((p) => p.required)) return `${this.#path_components().join(' + ')}` - return `[${this.#path_components().join(', ')}].filter(c => c).join('').replace('//', '/')` - } - - #path_components (): string[] { - return this.func.url - .split('{') - .flatMap(x => x.split('}')) - .map(x => x.includes('/') ? `'${x}'` : x) - .filter(x => x !== '') - } - #http_verb (): string { const verbs = Array.from(this.func.http_verbs).sort() if (_.isEqual(verbs, ['GET', 'POST'])) return "body ? 'POST' : 'GET'" @@ -89,4 +76,25 @@ export default class FunctionFileRenderer extends BaseRenderer { } return `'${verbs[0]}'` } + + #uniform_path (): string { + const path = _.maxBy(this.func.paths, (path) => path.params.length) + const path_params = _.values(this.func.path_params) + return path?.build(path_params.every((p) => p.required)) ?? 'UNKNOWN PATH' + } + + #diverged_paths (): Array> { + const paths = this.func.paths.sort((a, b) => b.params.length - a.params.length) + const diverged_paths = paths.map((path) => { + return { + guard: 'else if', + condition: ` (${path.params.map((p) => `${p} != null`).join(' && ')})`, + path: path.build(true) + } + }) + diverged_paths[0].guard = 'if' + diverged_paths[diverged_paths.length - 1].guard = 'else' + diverged_paths[diverged_paths.length - 1].condition = '' + return diverged_paths + } } diff --git a/api_generator/src/renderers/templates/function.mustache b/api_generator/src/renderers/templates/function.mustache index f82e485db..5ab814fa4 100644 --- a/api_generator/src/renderers/templates/function.mustache +++ b/api_generator/src/renderers/templates/function.mustache @@ -34,7 +34,16 @@ function {{{function_name}}}(params, options, callback) { {{{.}}} = parsePathParam({{{.}}}); {{/path_params}} - const path = {{{path}}}; + {{#paths_are_uniform}} + const path = {{{uniform_path}}}; + {{/paths_are_uniform}} + {{^paths_are_uniform}} + let path; + {{#diverged_paths}} + {{{guard}}}{{{condition}}} { + path = {{{path}}}; + }{{/diverged_paths}} + {{/paths_are_uniform}} const method = {{{http_verb}}}; {{^body_required}} body = body || ''; diff --git a/api_generator/src/spec_parser/ApiFunction.ts b/api_generator/src/spec_parser/ApiFunction.ts index 3b3eda61a..0a1cb2ae2 100644 --- a/api_generator/src/spec_parser/ApiFunction.ts +++ b/api_generator/src/spec_parser/ApiFunction.ts @@ -11,6 +11,7 @@ import _ from 'lodash' import type { Parameter, RequestBody, ResponseBody, Operation } from './types' import { to_pascal_case } from '../helpers' +import ApiPath from './ApiPath' export interface ApiFunctionTyping { request: string @@ -25,7 +26,7 @@ export default class ApiFunction { readonly ns_prototype: string readonly name: string readonly full_name: string - readonly url: string + readonly paths: ApiPath[] readonly http_verbs: Set readonly description: string readonly api_reference: string | undefined @@ -44,7 +45,7 @@ export default class ApiFunction { this.name = operations[0].group this.full_name = operations[0].full_name this.ns_prototype = ns_prototype - this.url = _.maxBy(operations, (o) => o.url.split('/').length)?.url ?? '' + this.paths = ApiPath.from_operations(operations) this.path_params = this.#path_params(operations) this.query_params = this.#query_params(operations) this.http_verbs = new Set(operations.map((o) => o.http_verb.toUpperCase())) diff --git a/api_generator/src/spec_parser/ApiPath.ts b/api_generator/src/spec_parser/ApiPath.ts new file mode 100644 index 000000000..308d3ca3c --- /dev/null +++ b/api_generator/src/spec_parser/ApiPath.ts @@ -0,0 +1,86 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + */ + +import _ from 'lodash' +import { type Operation } from './types' + +export default class ApiPath { + readonly url: string + readonly components: string[] + readonly params: string[] + readonly param_signature: string + readonly static_signature: string + + constructor (url: string) { + this.url = url + this.components = this.#components() + this.params = this.components.filter(x => !x.startsWith("'")) + this.param_signature = _.clone(this.params).sort().join() + this.static_signature = this.components.filter(x => x.startsWith("'")) + .map(x => x.replaceAll("'", '')) + .join('/') + } + + static from_operations (operations: Operation[]): ApiPath[] { + const paths = operations.map(o => new ApiPath(o.url)) + return _.uniqBy(paths, 'param_signature') + } + + // Operations with statically uniform paths can be grouped together in a simple one-line path constructor + // Operations with diverged paths will require a more complex path constructor with multiple if-else branches + static statically_uniform (paths: ApiPath[]): boolean { + return _.uniqBy(paths, 'static_signature').length === 1 + } + + // Generate a path constructor + // @param required - whether all path parameters are required + build (required: boolean): string { + if (this.components.length === 0) return "'/'" + return required ? this.#build_required() : this.#build_optional() + } + + // turn ['one', a, b, 'two/three', c] into '/one' + a + '/' + b + '/two/three/' + c + // turn [a, b, 'one', c] into '/' + a + '/' + b + '/one/' + c + #build_required (): string { + const components = this.components.map(x => !x.startsWith("'") ? x : `'/${x.slice(1, -1)}/'`) + if (!components[0].startsWith("'")) components.unshift("'/'") + const next = _.clone(components) + next.shift() + next.push("'^.^'") // sentinel value to mark the end of the array + return Array.from({ length: components.length }, (_, i) => [components[i], next[i]]) + .flatMap(([com, nxt]) => { + if (!com.startsWith("'") && !nxt.startsWith("'")) return [com, "'/'"] // insert '/' between param components + if (com.startsWith("'") && nxt === "'^.^'") return `${com.slice(0, -2)}'` // remove trailing '/' from last component + return com + }).join(' + ') + } + + // turn ['one', a, b, 'two/three', c] into `['/one', a, b, 'two/three', c].filter(c => c).join('/')` + // turn [a, b, 'one', c] into `['', a, b, 'one', c].filter(c => c).join('/')` + #build_optional (): string { + const components = _.clone(this.components) + if (components[0].startsWith("'")) components[0] = `'/${components[0].slice(1)}` + else components.unshift("''") + return `[${components.join(', ')}].filter(c => c).join('/')` + } + + // turn '/one/{a}/{b}/two/three/{c}' into ['one', a, b, 'two/three', c] + #components (): string[] { + return this.url + .split('{').flatMap(x => x.split('}')) + .map(x => { + if (!x.includes('/')) return x // path parameter + if (x.startsWith('/')) x = x.slice(1) // remove leading '/' of static component + if (x.endsWith('/')) x = x.slice(0, -1) // remove trailing '/' of static component + return `'${x}'` // static component + }) + .filter(x => x !== '' && x !== "''") + } +}