Skip to content

Commit c80b3fd

Browse files
authored
Maintain "behavior" meta-data in specification (#2638)
1 parent 39d43e2 commit c80b3fd

File tree

23 files changed

+477
-263
lines changed

23 files changed

+477
-263
lines changed

compiler/src/model/build-model.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,8 @@ function compileClassOrInterfaceDeclaration (declaration: ClassDeclaration | Int
459459
properties: new Array<model.Property>()
460460
}
461461

462-
hoistTypeAnnotations(type, declaration.getJsDocs())
462+
const jsDocs = declaration.getJsDocs()
463+
hoistTypeAnnotations(type, jsDocs)
463464

464465
const variant = parseVariantNameTag(declaration.getJsDocs())
465466
if (typeof variant === 'string') {
@@ -532,7 +533,7 @@ function compileClassOrInterfaceDeclaration (declaration: ClassDeclaration | Int
532533
if (Node.isClassDeclaration(declaration)) {
533534
for (const implement of declaration.getImplements()) {
534535
if (isKnownBehavior(implement)) {
535-
type.behaviors = (type.behaviors ?? []).concat(modelBehaviors(implement))
536+
type.behaviors = (type.behaviors ?? []).concat(modelBehaviors(implement, jsDocs))
536537
} else {
537538
type.implements = (type.implements ?? []).concat(modelImplements(implement))
538539
}

compiler/src/model/metamodel.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,12 @@ export class Inherits {
215215
generics?: ValueOf[]
216216
}
217217

218+
export class Behavior {
219+
type: TypeName
220+
generics?: ValueOf[]
221+
meta?: { [p: string]: string }
222+
}
223+
218224
/**
219225
* An interface type
220226
*/
@@ -231,7 +237,7 @@ export class Interface extends BaseType {
231237
/**
232238
* Behaviors directly implemented by this interface
233239
*/
234-
behaviors?: Inherits[]
240+
behaviors?: Behavior[]
235241

236242
/**
237243
* Behaviors attached to this interface, coming from the interface itself (see `behaviors`)
@@ -275,7 +281,7 @@ export class Request extends BaseType {
275281
* that don't have a body.
276282
*/
277283
body: Body
278-
behaviors?: Inherits[]
284+
behaviors?: Behavior[]
279285
attachedBehaviors?: string[]
280286
}
281287

@@ -286,7 +292,7 @@ export class Response extends BaseType {
286292
kind: 'response'
287293
generics?: TypeName[]
288294
body: Body
289-
behaviors?: Inherits[]
295+
behaviors?: Behavior[]
290296
attachedBehaviors?: string[]
291297
exceptions?: ResponseException[]
292298
}

compiler/src/model/utils.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -414,14 +414,40 @@ export function modelImplements (node: ExpressionWithTypeArguments): model.Inher
414414
* A class could have multiple behaviors from multiple classes,
415415
* which are defined inside the node typeArguments.
416416
*/
417-
export function modelBehaviors (node: ExpressionWithTypeArguments): model.Inherits {
417+
export function modelBehaviors (node: ExpressionWithTypeArguments, jsDocs: JSDoc[]): model.Behavior {
418+
const behaviorName = node.getExpression().getText()
418419
const generics = node.getTypeArguments().map(node => modelType(node))
420+
421+
let meta: Map<string, string> | undefined
422+
const tags = parseJsDocTagsAllowDuplicates(jsDocs)
423+
if (tags.behavior_meta !== undefined) {
424+
// Extracts whitespace/comma-separated key-value-pairs with a "=" delimiter and handles double-quotes
425+
const re = /(?<key>[^=\s,]+)=(?<value>"([^"]*)"|([^\s,]+))/g
426+
427+
for (const tag of tags.behavior_meta) {
428+
const id = tag.split(' ')
429+
if (id[0].trim() !== behaviorName) {
430+
continue
431+
}
432+
const matches = [...id.slice(1).join(' ').matchAll(re)]
433+
meta = new Map<string, string>()
434+
for (const match of matches) {
435+
if (match.groups == null) {
436+
continue
437+
}
438+
meta.set(match.groups.key, match.groups.value.replace(/^"(.+(?="$))"$/, '$1'))
439+
}
440+
break
441+
}
442+
}
443+
419444
return {
420445
type: {
421-
name: node.getExpression().getText(),
446+
name: behaviorName,
422447
namespace: getNameSpace(node)
423448
},
424-
...(generics.length > 0 && { generics })
449+
...(generics.length > 0 && { generics }),
450+
meta: (meta === undefined) ? undefined : Object.fromEntries(meta)
425451
}
426452
}
427453

@@ -579,7 +605,7 @@ function setTags<TType extends model.BaseType | model.Property | model.EnumMembe
579605
)
580606

581607
for (const tag of validTags) {
582-
if (tag === 'behavior') continue
608+
if (tag === 'behavior' || tag === 'behavior_meta') continue
583609
if (tags[tag] !== undefined) {
584610
setter(tags, tag, tags[tag])
585611
}
@@ -700,7 +726,7 @@ export function hoistTypeAnnotations (type: model.TypeDefinition, jsDocs: JSDoc[
700726
assert(jsDocs, jsDocs.length < 2, 'Use a single multiline jsDoc block instead of multiple single line blocks')
701727

702728
const validTags = ['class_serializer', 'doc_url', 'doc_id', 'behavior', 'variants', 'variant', 'shortcut_property',
703-
'codegen_names', 'non_exhaustive', 'es_quirk']
729+
'codegen_names', 'non_exhaustive', 'es_quirk', 'behavior_meta']
704730
const tags = parseJsDocTags(jsDocs)
705731
if (jsDocs.length === 1) {
706732
const description = jsDocs[0].getDescription()

compiler/src/steps/validate-model.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,13 @@ export default async function validateModel (apiModel: model.Model, restSpec: Ma
643643
context.push('Behaviors')
644644
for (const parent of typeDef.behaviors) {
645645
validateTypeRef(parent.type, parent.generics, openGenerics)
646+
647+
if (parent.type.name === 'AdditionalProperty' && (parent.meta == null || parent.meta.key == null || parent.meta.value == null)) {
648+
modelError(`AdditionalProperty behavior for type '${fqn(typeDef.name)}' requires a 'behavior_meta' decorator with at least 2 arguments (key, value)`)
649+
}
650+
if (parent.type.name === 'AdditionalProperties' && (parent.meta == null || parent.meta.fieldname == null || parent.meta.description == null)) {
651+
modelError(`AdditionalProperties behavior for type '${fqn(typeDef.name)}' requires a 'behavior_meta' decorator with exactly 2 arguments (fieldname, description)`)
652+
}
646653
}
647654
context.pop()
648655
}

docs/behaviors.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,18 @@ This puts it into a bin that needs a client specific solution.
1515
We therefore document the requirement to behave like a dictionary for unknown properties with this interface.
1616

1717
```ts
18+
/**
19+
* @behavior_meta AdditionalProperties fieldname=sub_aggregations
20+
*/
1821
class IpRangeBucket implements AdditionalProperties<AggregateName, Aggregate> {}
1922
```
2023

2124
There are also many places where we expect only one runtime-defined property, such as in field-related queries. To capture that uniqueness constraint, we can use the `AdditionalProperty` (singular) behavior.
2225

2326
```ts
27+
/**
28+
* @behavior_meta AdditionalProperty key=field value=bounding_box
29+
*/
2430
class GeoBoundingBoxQuery extends QueryBase
2531
implements AdditionalProperty<Field, BoundingBox>
2632
```

0 commit comments

Comments
 (0)