Skip to content

Commit 148c2df

Browse files
swallezflobernd
authored andcommitted
add handling of urls definitions to the compiler
1 parent a308593 commit 148c2df

File tree

3 files changed

+143
-22
lines changed

3 files changed

+143
-22
lines changed

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
/**

compiler/src/steps/validate-rest-spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import assert from 'assert'
2121
import * as model from '../model/metamodel'
2222
import { JsonSpec } from '../model/json-spec'
2323
import { ValidationErrors } from '../validation-errors'
24+
import { deepEqual } from '../model/utils'
2425

2526
// This code can be simplified once https://github.com/tc39/proposal-set-methods is available
2627

@@ -46,6 +47,30 @@ export default async function validateRestSpec (model: model.Model, jsonSpec: Ma
4647
const spec = jsonSpec.get(endpoint.name)
4748
assert(spec, `Can't find the json spec for ${endpoint.name}`)
4849

50+
// Check URL paths and methods
51+
if (spec.url.paths.length !== endpoint.urls.length) {
52+
errors.addEndpointError(endpoint.name, 'request', `${endpoint.request.name}: different number of urls in the json spec`)
53+
} else {
54+
for (const modelUrl of endpoint.urls) {
55+
// URL path
56+
const restSpecUrl = spec.url.paths.find(path => path.path === modelUrl.path)
57+
if (restSpecUrl == null) {
58+
errors.addEndpointError(endpoint.name, 'request', `${endpoint.request.name}: url path '${modelUrl.path}' not found in the json spec`)
59+
} else {
60+
// URL methods
61+
if (!deepEqual([...restSpecUrl.methods].sort(), [...modelUrl.methods].sort())) {
62+
errors.addEndpointError(endpoint.name, 'request', `${modelUrl.path}: different http methods in the json spec`)
63+
}
64+
65+
// Deprecation.
66+
if ((restSpecUrl.deprecated != null) !== (modelUrl.deprecation != null)) {
67+
errors.addEndpointError(endpoint.name, 'request', `${endpoint.request.name}: different deprecation in the json spec`)
68+
}
69+
}
70+
}
71+
}
72+
73+
// Check url parts
4974
const urlParts = Array.from(new Set(spec.url.paths
5075
.filter(path => path.parts != null)
5176
.flatMap(path => {

0 commit comments

Comments
 (0)