Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[api_generator] API path builder update #913

Merged
merged 2 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 25 additions & 17 deletions api_generator/src/renderers/render_code/FunctionFileRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<never>|*}',
Expand Down Expand Up @@ -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'"
Expand All @@ -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<Record<string, string>> {
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
}
}
11 changes: 10 additions & 1 deletion api_generator/src/renderers/templates/function.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '';
Expand Down
5 changes: 3 additions & 2 deletions api_generator/src/spec_parser/ApiFunction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string>
readonly description: string
readonly api_reference: string | undefined
Expand All @@ -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()))
Expand Down
86 changes: 86 additions & 0 deletions api_generator/src/spec_parser/ApiPath.ts
Original file line number Diff line number Diff line change
@@ -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 !== "''")
}
}
Loading