Skip to content

Commit 0ba9e9d

Browse files
authored
Add endpoint URLs to the API specification (phase 1) (#3469) (#3524)
1 parent 7175268 commit 0ba9e9d

File tree

12 files changed

+322
-27
lines changed

12 files changed

+322
-27
lines changed

compiler-rs/Cargo.lock

Lines changed: 17 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

compiler-rs/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ derive_more = "1.0.0-beta.6"
1717
either_n = "0.2"
1818
icu_segmenter = "1"
1919
indexmap = "2"
20+
itertools = "0.14"
2021
maplit = "1"
2122
once_cell = "1.16"
2223
openapiv3 = "2"

compiler-rs/clients_schema/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ serde_json = { workspace = true }
1111
once_cell = { workspace = true }
1212
anyhow = { workspace = true }
1313
indexmap = { workspace = true, features = ["serde"] }
14+
itertools = { workspace = true }
15+
1416

1517
arcstr = { workspace = true, features = ["serde", "substr"] }
1618
clap = { workspace = true, features = ["derive"] }
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
use std::collections::HashMap;
2+
use std::path::{Path, PathBuf};
3+
use clap::Parser;
4+
use itertools::Itertools;
5+
6+
7+
fn main() -> anyhow::Result<()> {
8+
let cli = Cli::parse();
9+
cli.run()?;
10+
Ok(())
11+
}
12+
13+
// Example usage:
14+
// (cd compiler-rs; find ../specification -name '*Request.ts' | cargo run --bin add_url_paths ../output/schema/schema.json | sh)
15+
16+
/// Adds url paths to request definitions. Stdin must be a list of files, one per line.
17+
/// Outputs a shell script that uses ast-grep.
18+
#[derive(Debug, Parser)]
19+
#[command(author, version, about, long_about)]
20+
pub struct Cli {
21+
/// input schema file, eg: ../output/schema/schema-no-generics.json
22+
schema: PathBuf,
23+
}
24+
25+
impl Cli {
26+
pub fn run(&self) -> anyhow::Result<()> {
27+
28+
// Canonicalize all file names, so that we can do some suffix mapping from the schema locations.
29+
let files: Vec<PathBuf> = std::io::read_to_string(std::io::stdin())?
30+
.lines()
31+
.flat_map(|line| std::fs::canonicalize(line)
32+
.map_err(|e| {
33+
eprintln!("File {} not found", line);
34+
Result::<PathBuf, _>::Err(e)
35+
})) // Remove errors
36+
.collect();
37+
38+
let json = std::fs::read_to_string(&self.schema)?;
39+
let schema = clients_schema::IndexedModel::from_reader(json.as_bytes())?;
40+
41+
let mut location_to_request = HashMap::<&Path, &clients_schema::Endpoint>::new();
42+
for ep in &schema.endpoints {
43+
let Some(req_name) = ep.request.as_ref() else {
44+
//eprintln!("Skipping endpoint {} with no request", ep.name);
45+
continue;
46+
};
47+
48+
let type_def = schema.types.get(req_name).unwrap();
49+
let location = type_def.base().spec_location.as_ref().unwrap();
50+
let location = Path::new(location.split_once('#').unwrap().0);
51+
52+
location_to_request.insert(location, ep);
53+
};
54+
55+
for file in files {
56+
if let Some((_, endpoint)) = location_to_request.iter().find(|(location, _)| file.ends_with(location)) {
57+
generate_astgrep_command(&file, endpoint);
58+
} else {
59+
eprintln!("No request found for {:?}", file);
60+
}
61+
}
62+
63+
Ok(())
64+
}
65+
}
66+
67+
fn generate_astgrep_command(file: &Path, endpoint: &clients_schema::Endpoint) {
68+
69+
let text = std::fs::read_to_string(file).unwrap();
70+
if text.contains("urls:") {
71+
eprintln!("Found an existing 'url' property. Skipping {file:?}");
72+
return;
73+
}
74+
75+
// We cannot express conditional parts in the source form of patterns.
76+
77+
// Requests with generic parameters
78+
let request_expr = if text.contains("Request<") {
79+
"Request<$$$PARAM>"
80+
} else {
81+
"Request"
82+
};
83+
84+
// A handful of requests don't have an extends clause
85+
let extends_expr = if text.contains(" extends ") {
86+
"extends $REQBASE"
87+
} else {
88+
""
89+
};
90+
91+
let urls: String = endpoint.urls.iter().map(|url| {
92+
let path = &url.path;
93+
let methods = url.methods.iter().map(|method| format!("\"{}\"", method)).join(", ");
94+
let deprecation = match &url.deprecation {
95+
Some(deprecation) => format!("/** @deprecated {} {} */\n ", deprecation.version, deprecation.description),
96+
None => "".to_string(),
97+
};
98+
99+
format!(r#" {{
100+
{deprecation}path: "{path}",
101+
methods: [{methods}]
102+
}}"#)
103+
}).join(",\n");
104+
105+
let pattern = format!(r#"interface {request_expr} {extends_expr} {{
106+
$$$PROPS
107+
}}"#);
108+
109+
let fix = format!(r#"interface {request_expr} {extends_expr} {{
110+
urls: [
111+
{urls}
112+
],
113+
$$$PROPS
114+
}}"#);
115+
116+
let file = file.to_str().unwrap();
117+
println!("#----- {file}");
118+
println!(r#"ast-grep --update-all --lang ts --pattern '{pattern}' --rewrite '{fix}' "{file}""#);
119+
120+
println!();
121+
}

compiler/src/model/build-model.ts

Lines changed: 107 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import {
5353
verifyUniqueness,
5454
parseJsDocTags,
5555
deepEqual,
56-
sourceLocation, sortTypeDefinitions
56+
sourceLocation, sortTypeDefinitions, parseDeprecation
5757
} from './utils'
5858

5959
const jsonSpec = buildJsonSpec()
@@ -210,14 +210,6 @@ function compileClassOrInterfaceDeclaration (declaration: ClassDeclaration | Int
210210
if (mapping == null) {
211211
throw new Error(`Cannot find url template for ${namespace}, very likely the specification folder does not follow the rest-api-spec`)
212212
}
213-
// list of unique dynamic parameters
214-
const urlTemplateParams = [...new Set(
215-
mapping.urls.flatMap(url => url.path.split('/')
216-
.filter(part => part.includes('{'))
217-
.map(part => part.slice(1, -1))
218-
)
219-
)]
220-
const methods = [...new Set(mapping.urls.flatMap(url => url.methods))]
221213

222214
let pathMember: Node | null = null
223215
let bodyProperties: model.Property[] = []
@@ -226,39 +218,50 @@ function compileClassOrInterfaceDeclaration (declaration: ClassDeclaration | Int
226218

227219
// collect path/query/body properties
228220
for (const member of declaration.getMembers()) {
229-
// we are visiting `path_parts, `query_parameters` or `body`
221+
// we are visiting `urls`, `path_parts, `query_parameters` or `body`
230222
assert(
231223
member,
232224
Node.isPropertyDeclaration(member) || Node.isPropertySignature(member),
233225
'Class and interfaces can only have property declarations or signatures'
234226
)
235-
const property = visitRequestOrResponseProperty(member)
236-
if (property.name === 'path_parts') {
227+
const name = member.getName()
228+
if (name === 'urls') {
229+
// Overwrite the endpoint urls read from the json-rest-spec
230+
// TODO: once all spec files are using it, make it mandatory.
231+
mapping.urls = visitUrls(member)
232+
} else if (name === 'path_parts') {
233+
const property = visitRequestOrResponseProperty(member)
237234
assert(member, property.properties.length > 0, 'There is no need to declare an empty object path_parts, just remove the path_parts declaration.')
238235
pathMember = member
239236
type.path = property.properties
240-
} else if (property.name === 'query_parameters') {
237+
} else if (name === 'query_parameters') {
238+
const property = visitRequestOrResponseProperty(member)
241239
assert(member, property.properties.length > 0, 'There is no need to declare an empty object query_parameters, just remove the query_parameters declaration.')
242240
type.query = property.properties
243-
} else if (property.name === 'body') {
241+
} else if (name === 'body') {
242+
const property = visitRequestOrResponseProperty(member)
244243
bodyMember = member
245-
assert(
246-
member,
247-
methods.some(method => ['POST', 'PUT', 'DELETE'].includes(method)),
248-
`${namespace}.${name} can't have a body, allowed methods: ${methods.join(', ')}`
249-
)
250244
if (property.valueOf != null) {
251245
bodyValue = property.valueOf
252246
} else {
253247
assert(member, property.properties.length > 0, 'There is no need to declare an empty object body, just remove the body declaration.')
254248
bodyProperties = property.properties
255249
}
256250
} else {
257-
assert(member, false, `Unknown request property: ${property.name}`)
251+
assert(member, false, `Unknown request property: ${name}`)
258252
}
259253
}
260254

261255
// validate path properties
256+
// list of unique dynamic parameters
257+
const urlTemplateParams = [...new Set(
258+
mapping.urls.flatMap(url => url.path.split('/')
259+
.filter(part => part.includes('{'))
260+
.map(part => part.slice(1, -1))
261+
)
262+
)]
263+
const methods = [...new Set(mapping.urls.flatMap(url => url.methods))]
264+
262265
for (const part of type.path) {
263266
assert(
264267
pathMember as Node,
@@ -282,6 +285,13 @@ function compileClassOrInterfaceDeclaration (declaration: ClassDeclaration | Int
282285
}
283286

284287
// validate body
288+
if (bodyMember != null) {
289+
assert(
290+
bodyMember,
291+
methods.some(method => ['POST', 'PUT', 'DELETE'].includes(method)),
292+
`${namespace}.${name} can't have a body, allowed methods: ${methods.join(', ')}`
293+
)
294+
}
285295
// the body can either be a value (eg Array<string> or an object with properties)
286296
if (bodyValue != null) {
287297
// Propagate required body value nature based on TS question token being present.
@@ -587,3 +597,80 @@ function visitRequestOrResponseProperty (member: PropertyDeclaration | PropertyS
587597

588598
return { name, properties, valueOf }
589599
}
600+
601+
/**
602+
* Parse the 'urls' property of a request definition. Format is:
603+
* ```
604+
* urls: [
605+
* {
606+
* /** @deprecated 1.2.3 Use something else
607+
* path: '/some/path',
608+
* methods: ["GET", "POST"]
609+
* }
610+
* ]
611+
* ```
612+
*/
613+
function visitUrls (member: PropertyDeclaration | PropertySignature): model.UrlTemplate[] {
614+
const value = member.getTypeNode()
615+
616+
// Literal arrays are exposed as tuples by ts-morph
617+
assert(value, Node.isTupleTypeNode(value), '"urls" should be an array')
618+
619+
const result: model.UrlTemplate[] = []
620+
621+
value.forEachChild(urlNode => {
622+
assert(urlNode, Node.isTypeLiteral(urlNode), '"urls" members should be objects')
623+
624+
const urlTemplate: any = {}
625+
626+
urlNode.forEachChild(node => {
627+
assert(node, Node.isPropertySignature(node), "Expecting 'path' and 'methods' properties")
628+
629+
const name = node.getName()
630+
const propValue = node.getTypeNode()
631+
632+
if (name === 'path') {
633+
assert(propValue, Node.isLiteralTypeNode(propValue), '"path" should be a string')
634+
635+
const pathLit = propValue.getLiteral()
636+
assert(pathLit, Node.isStringLiteral(pathLit), '"path" should be a string')
637+
638+
urlTemplate.path = pathLit.getLiteralValue()
639+
640+
// Deprecation
641+
const jsDoc = node.getJsDocs()
642+
const tags = parseJsDocTags(jsDoc)
643+
const deprecation = parseDeprecation(tags, jsDoc)
644+
if (deprecation != null) {
645+
urlTemplate.deprecation = deprecation
646+
}
647+
if (Object.keys(tags).length > 0) {
648+
assert(jsDoc, false, `Unknown annotations: ${Object.keys(tags).join(', ')}`)
649+
}
650+
} else if (name === 'methods') {
651+
assert(propValue, Node.isTupleTypeNode(propValue), '"methods" should be an array')
652+
653+
const methods: string[] = []
654+
propValue.forEachChild(node => {
655+
assert(node, Node.isLiteralTypeNode(node), '"methods" should contain strings')
656+
657+
const nodeLit = node.getLiteral()
658+
assert(nodeLit, Node.isStringLiteral(nodeLit), '"methods" should contain strings')
659+
660+
methods.push(nodeLit.getLiteralValue())
661+
})
662+
assert(node, methods.length > 0, "'methods' should not be empty")
663+
urlTemplate.methods = methods
664+
} else {
665+
assert(node, false, "Expecting 'path' or 'methods'")
666+
}
667+
})
668+
669+
assert(urlTemplate, urlTemplate.path, "Missing required property 'path'")
670+
assert(urlTemplate, urlTemplate.methods, "Missing required property 'methods'")
671+
672+
result.push(urlTemplate)
673+
})
674+
675+
return result
676+
}

compiler/src/model/utils.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -576,12 +576,21 @@ export function modelProperty (declaration: PropertySignature | PropertyDeclarat
576576
* Pulls @deprecated from types and properties
577577
*/
578578
function setDeprecated (type: model.BaseType | model.Property | model.EnumMember, tags: Record<string, string>, jsDocs: JSDoc[]): void {
579+
const deprecation = parseDeprecation(tags, jsDocs)
580+
if (deprecation != null) {
581+
type.deprecation = deprecation
582+
}
583+
}
584+
585+
export function parseDeprecation (tags: Record<string, string>, jsDocs: JSDoc[]): model.Deprecation | undefined {
579586
if (tags.deprecated !== undefined) {
580587
const [version, ...description] = tags.deprecated.split(' ')
581588
assert(jsDocs, semver.valid(version), 'Invalid semver value')
582-
type.deprecation = { version, description: description.join(' ') }
589+
delete tags.deprecated
590+
return { version, description: description.join(' ') }
591+
} else {
592+
return undefined
583593
}
584-
delete tags.deprecated
585594
}
586595

587596
/**

0 commit comments

Comments
 (0)