Skip to content

Commit

Permalink
[api_generator] API path builder update (#913)
Browse files Browse the repository at this point in the history
* [api_generator] API path builder
Updated URL path building strategy to accommodate for more complex set of URLs.

Signed-off-by: Theo Truong <[email protected]>
  • Loading branch information
nhtruong authored Nov 18, 2024
1 parent a4a2736 commit ba69002
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 20 deletions.
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 !== "''")
}
}

0 comments on commit ba69002

Please sign in to comment.