@api-client/core 0.20.7 → 0.20.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/src/modeling/ApiModel.d.ts.map +1 -1
- package/build/src/modeling/ApiModel.js +9 -5
- package/build/src/modeling/ApiModel.js.map +1 -1
- package/build/src/modeling/RuntimeApiModel.d.ts +43 -0
- package/build/src/modeling/RuntimeApiModel.d.ts.map +1 -1
- package/build/src/modeling/RuntimeApiModel.js +1 -0
- package/build/src/modeling/RuntimeApiModel.js.map +1 -1
- package/build/src/modeling/generators/RuntimeModelGenerator.d.ts.map +1 -1
- package/build/src/modeling/generators/RuntimeModelGenerator.js +58 -8
- package/build/src/modeling/generators/RuntimeModelGenerator.js.map +1 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/modeling/ApiModel.ts +9 -5
- package/src/modeling/RuntimeApiModel.ts +45 -0
- package/src/modeling/generators/RuntimeModelGenerator.ts +61 -9
- package/tests/fixtures/modeling/runtime/domains.ts +270 -0
- package/tests/unit/modeling/api_model_expose_entity.spec.ts +2 -1
- package/tests/unit/modeling/generators/OasGenerator.spec.ts +8 -8
- package/tests/unit/modeling/generators/RuntimeModelGenerator.spec.ts +352 -114
package/package.json
CHANGED
package/src/modeling/ApiModel.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { DependentModel, type DependentModelSchema, type DomainDependency } from
|
|
|
16
16
|
import { observed, toRaw } from '../decorators/observed.js'
|
|
17
17
|
import pluralize from '@jarrodek/pluralize'
|
|
18
18
|
import { createDomainKey } from './helpers/keying.js'
|
|
19
|
+
import { paramNameFor } from './helpers/endpointHelpers.js'
|
|
19
20
|
import { ExposedEntity } from './ExposedEntity.js'
|
|
20
21
|
import { AccessRule, AccessRuleSchema } from './rules/AccessRule.js'
|
|
21
22
|
import { RateLimitingConfiguration, RateLimitingConfigurationSchema } from './rules/RateLimitingConfiguration.js'
|
|
@@ -462,15 +463,16 @@ export class ApiModel extends DependentModel {
|
|
|
462
463
|
}
|
|
463
464
|
const name = domainEntity.info.name || ''
|
|
464
465
|
const segment = pluralize(name.toLocaleLowerCase())
|
|
466
|
+
const paramName = paramNameFor(name)
|
|
465
467
|
let relativeCollectionPath = `/${segment}`
|
|
466
|
-
let relativeResourcePath = `/${segment}/{
|
|
468
|
+
let relativeResourcePath = `/${segment}/{${paramName}}`
|
|
467
469
|
|
|
468
470
|
// Check for root path collision and resolve by appending a number
|
|
469
471
|
let counter = 1
|
|
470
472
|
const originalCollectionPath = relativeCollectionPath
|
|
471
473
|
while (this.findCollectionPathCollision(relativeCollectionPath)) {
|
|
472
474
|
relativeCollectionPath = `${originalCollectionPath}-${counter}`
|
|
473
|
-
relativeResourcePath = `${relativeCollectionPath}/{
|
|
475
|
+
relativeResourcePath = `${relativeCollectionPath}/{${paramName}}`
|
|
474
476
|
counter++
|
|
475
477
|
}
|
|
476
478
|
|
|
@@ -552,14 +554,15 @@ export class ApiModel extends DependentModel {
|
|
|
552
554
|
if (targetDomEntity) {
|
|
553
555
|
const name = targetDomEntity.info.name || ''
|
|
554
556
|
const segment = pluralize(name.toLocaleLowerCase())
|
|
557
|
+
const paramName = paramNameFor(name)
|
|
555
558
|
let relativeCollectionPath = `/${segment}`
|
|
556
|
-
let relativeResourcePath = `/${segment}/{
|
|
559
|
+
let relativeResourcePath = `/${segment}/{${paramName}}`
|
|
557
560
|
|
|
558
561
|
let counter = 1
|
|
559
562
|
const originalCollectionPath = relativeCollectionPath
|
|
560
563
|
while (this.findResourcePathCollision(relativeCollectionPath)) {
|
|
561
564
|
relativeCollectionPath = `${originalCollectionPath}-${counter}`
|
|
562
|
-
relativeResourcePath = `${relativeCollectionPath}/{
|
|
565
|
+
relativeResourcePath = `${relativeCollectionPath}/{${paramName}}`
|
|
563
566
|
counter++
|
|
564
567
|
}
|
|
565
568
|
|
|
@@ -582,8 +585,9 @@ export class ApiModel extends DependentModel {
|
|
|
582
585
|
const name = association.info.name || ''
|
|
583
586
|
const segment = pluralize(name.toLocaleLowerCase())
|
|
584
587
|
const isCollection = association.multiple !== false
|
|
588
|
+
const paramName = paramNameFor(targetDomainEntity.info.name || '')
|
|
585
589
|
const relativeCollectionPath = isCollection ? `/${segment}` : undefined
|
|
586
|
-
const relativeResourcePath = isCollection ? `/${segment}/{
|
|
590
|
+
const relativeResourcePath = isCollection ? `/${segment}/{${paramName}}` : `/${segment}`
|
|
587
591
|
// Create nested exposure
|
|
588
592
|
const nestedExposure: ExposedEntitySchema = {
|
|
589
593
|
kind: ExposedEntityKind,
|
|
@@ -11,12 +11,55 @@ import { SemanticType } from './Semantics.js'
|
|
|
11
11
|
import { AccessRule, AccessRuleExecutionPhase } from './rules/AccessRule.js'
|
|
12
12
|
import { Exception } from '../exceptions/exception.js'
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Describes a mapped parameter for a route.
|
|
16
|
+
*/
|
|
17
|
+
export interface RouteParam {
|
|
18
|
+
/**
|
|
19
|
+
* The name of the parameter in the path (e.g. "userId")
|
|
20
|
+
* This can be anything as the user can freely change the param names.
|
|
21
|
+
*/
|
|
22
|
+
paramName: string
|
|
23
|
+
/**
|
|
24
|
+
* The "key" of the exposed entity this parameter belongs to.
|
|
25
|
+
*/
|
|
26
|
+
exposedEntityKey: string
|
|
27
|
+
/**
|
|
28
|
+
* The "name" of the exposed entity this parameter belongs to.
|
|
29
|
+
*/
|
|
30
|
+
exposedEntityName: string
|
|
31
|
+
/**
|
|
32
|
+
* The primary key's property name of the exposed entity (e.g., "id").
|
|
33
|
+
*/
|
|
34
|
+
propertyName: string
|
|
35
|
+
/**
|
|
36
|
+
* The name of the association that connects the parent entity to its child (e.g., "comments")
|
|
37
|
+
* If its missing it means that there's no association, so there's no sub-endpoint in the path
|
|
38
|
+
* (e.g., `/users/123` - It's a single root endpoint - it doesn't have any sub-endpoints.)
|
|
39
|
+
*/
|
|
40
|
+
associationName?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
14
43
|
/**
|
|
15
44
|
* Identifies a specific exposed entity and its action kind.
|
|
16
45
|
*/
|
|
17
46
|
export interface RouteLookup {
|
|
47
|
+
/**
|
|
48
|
+
* The "key" of the exposed entity that is being accessed.
|
|
49
|
+
*/
|
|
18
50
|
exposedEntityKey: string
|
|
51
|
+
/**
|
|
52
|
+
* The "name" of the exposed entity that is being accessed.
|
|
53
|
+
*/
|
|
54
|
+
exposedEntityName: string
|
|
55
|
+
/**
|
|
56
|
+
* The "kind" of the action that is being performed.
|
|
57
|
+
*/
|
|
19
58
|
actionKind: ActionKind
|
|
59
|
+
/**
|
|
60
|
+
* Information about each path parameter in this route.
|
|
61
|
+
*/
|
|
62
|
+
params: RouteParam[]
|
|
20
63
|
}
|
|
21
64
|
|
|
22
65
|
/**
|
|
@@ -44,6 +87,7 @@ export interface RuntimeResolvedAction {
|
|
|
44
87
|
entity: DomainEntity
|
|
45
88
|
action: Action
|
|
46
89
|
params: Record<string, string>
|
|
90
|
+
lookup: RouteLookup
|
|
47
91
|
}
|
|
48
92
|
|
|
49
93
|
export type RuleEvaluator = (rule: AccessRule) => Promise<boolean | undefined> | boolean | undefined
|
|
@@ -429,6 +473,7 @@ export class RuntimeApiModel extends ApiModel {
|
|
|
429
473
|
entity,
|
|
430
474
|
action,
|
|
431
475
|
params,
|
|
476
|
+
lookup: def.lookup,
|
|
432
477
|
}
|
|
433
478
|
}
|
|
434
479
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ActionKind, type UpdateAction } from '../actions/index.js'
|
|
2
2
|
import { ApiModel, type ApiModelSchema } from '../ApiModel.js'
|
|
3
|
-
import {
|
|
3
|
+
import { ExposedEntity } from '../ExposedEntity.js'
|
|
4
|
+
import { RouteParam, type RouteDefinition, type RuntimeApiModelSchema } from '../RuntimeApiModel.js'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* A class that takes the API model and generates a runtime-optimized API model.
|
|
@@ -34,27 +35,27 @@ export class RuntimeModelGenerator {
|
|
|
34
35
|
for (const action of expose.actions) {
|
|
35
36
|
switch (action.kind) {
|
|
36
37
|
case 'list':
|
|
37
|
-
if (colPath) this.addRoute('GET', colPath, expose
|
|
38
|
+
if (colPath) this.addRoute('GET', colPath, expose, action.kind)
|
|
38
39
|
break
|
|
39
40
|
case 'create':
|
|
40
|
-
if (colPath) this.addRoute('POST', colPath, expose
|
|
41
|
+
if (colPath) this.addRoute('POST', colPath, expose, action.kind)
|
|
41
42
|
break
|
|
42
43
|
case 'search':
|
|
43
|
-
if (colPath) this.addRoute('POST', `${colPath}/search`, expose
|
|
44
|
+
if (colPath) this.addRoute('POST', `${colPath}/search`, expose, action.kind)
|
|
44
45
|
break
|
|
45
46
|
case 'read':
|
|
46
|
-
if (resPath) this.addRoute('GET', resPath, expose
|
|
47
|
+
if (resPath) this.addRoute('GET', resPath, expose, action.kind)
|
|
47
48
|
break
|
|
48
49
|
case 'update':
|
|
49
50
|
if (resPath) {
|
|
50
51
|
const updateAction = action as UpdateAction
|
|
51
52
|
for (const method of updateAction.allowedMethods) {
|
|
52
|
-
this.addRoute(method, resPath, expose
|
|
53
|
+
this.addRoute(method, resPath, expose, action.kind)
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
break
|
|
56
57
|
case 'delete':
|
|
57
|
-
if (resPath) this.addRoute('DELETE', resPath, expose
|
|
58
|
+
if (resPath) this.addRoute('DELETE', resPath, expose, action.kind)
|
|
58
59
|
break
|
|
59
60
|
}
|
|
60
61
|
}
|
|
@@ -66,14 +67,65 @@ export class RuntimeModelGenerator {
|
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
private addRoute(method: string, path: string,
|
|
70
|
+
private addRoute(method: string, path: string, expose: ExposedEntity, actionKind: ActionKind): void {
|
|
70
71
|
const upperMethod = method.toUpperCase()
|
|
71
72
|
if (!this.#routingMap[upperMethod]) {
|
|
72
73
|
this.#routingMap[upperMethod] = []
|
|
73
74
|
}
|
|
75
|
+
|
|
76
|
+
const domainEntity = this.#apiModel.domain?.findEntity(expose.entity.key, expose.entity.domain)
|
|
77
|
+
if (!domainEntity) throw new Error(`Entity ${expose.key} not found, this should never happen`)
|
|
78
|
+
|
|
79
|
+
const params: RouteParam[] = []
|
|
80
|
+
let current: ExposedEntity | undefined
|
|
81
|
+
let previous: ExposedEntity | undefined = undefined
|
|
82
|
+
if (['list', 'create', 'search'].includes(actionKind)) {
|
|
83
|
+
// when starting with a collection, ignore self and start with parent (if any).
|
|
84
|
+
// A collection won't have any path params anyway, so we can skip it.
|
|
85
|
+
current = expose.parent ? this.#apiModel.exposes.get(expose.parent.key) : undefined
|
|
86
|
+
// setting the previous to the current so the associations are captured.
|
|
87
|
+
previous = expose
|
|
88
|
+
} else {
|
|
89
|
+
current = expose
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
while (current) {
|
|
93
|
+
const match = current.resourcePath.match(/\{([^}]+)\}/)
|
|
94
|
+
if (match) {
|
|
95
|
+
const entity = this.#apiModel.domain?.findEntity(current.entity.key, current.entity.domain)
|
|
96
|
+
if (!entity) throw new Error(`Entity ${current.entity.key} not found, this should never happen`)
|
|
97
|
+
const pkName = entity.primaryKey()?.info.name
|
|
98
|
+
if (!pkName) throw new Error(`Entity ${current.entity.key} has no primary key, this should never happen`)
|
|
99
|
+
|
|
100
|
+
let associationName: string | undefined
|
|
101
|
+
if (previous && previous.parent && previous.parent.key === current.key) {
|
|
102
|
+
for (const assoc of entity.withInheritedAssociations()) {
|
|
103
|
+
if (previous.parent?.association.key === assoc.key) {
|
|
104
|
+
associationName = assoc.info.name
|
|
105
|
+
break
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
params.push({
|
|
111
|
+
paramName: match[1],
|
|
112
|
+
exposedEntityKey: current.key,
|
|
113
|
+
exposedEntityName: entity.info.name as string,
|
|
114
|
+
propertyName: pkName,
|
|
115
|
+
...(associationName ? { associationName } : {}),
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
previous = current
|
|
119
|
+
current = current.parent ? this.#apiModel.exposes.get(current.parent.key) : undefined
|
|
120
|
+
}
|
|
74
121
|
this.#routingMap[upperMethod].push({
|
|
75
122
|
path,
|
|
76
|
-
lookup: {
|
|
123
|
+
lookup: {
|
|
124
|
+
exposedEntityKey: expose.key,
|
|
125
|
+
exposedEntityName: domainEntity.info.name as string,
|
|
126
|
+
actionKind,
|
|
127
|
+
params,
|
|
128
|
+
},
|
|
77
129
|
})
|
|
78
130
|
}
|
|
79
131
|
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { DataDomain } from '../../../../src/modeling/DataDomain.js'
|
|
2
|
+
import type { DomainEntity } from '../../../../src/modeling/DomainEntity.js'
|
|
3
|
+
import type { DomainModel } from '../../../../src/modeling/DomainModel.js'
|
|
4
|
+
import { SemanticType } from '../../../../src/modeling/Semantics.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates the simplest data domain with a single entity user.
|
|
8
|
+
*/
|
|
9
|
+
export function userOnlyDomain(): { domain: DataDomain; model: DomainModel; user: DomainEntity } {
|
|
10
|
+
const domain = new DataDomain({ info: { name: 'Testing Domain', version: '1.0.0' } })
|
|
11
|
+
const model = domain.addModel({ info: { name: 'Container model' } })
|
|
12
|
+
const user = model.addEntity({ info: { name: 'user' } })
|
|
13
|
+
user.addProperty({ info: { name: 'id' }, primary: true })
|
|
14
|
+
user.addProperty({ info: { name: 'username' }, semantics: [{ id: SemanticType.Username }] })
|
|
15
|
+
user.addProperty({ info: { name: 'password' }, semantics: [{ id: SemanticType.Password }] })
|
|
16
|
+
user.addProperty({ info: { name: 'email' }, semantics: [{ id: SemanticType.Email }] })
|
|
17
|
+
user.addProperty({
|
|
18
|
+
info: { name: 'role' },
|
|
19
|
+
semantics: [{ id: SemanticType.UserRole }],
|
|
20
|
+
schema: {
|
|
21
|
+
enum: ['admin', 'member', 'guest'],
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
return { domain, model, user }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns a domain that has 1:1 association between user and config.
|
|
29
|
+
*/
|
|
30
|
+
export function singletonDomain(): {
|
|
31
|
+
domain: DataDomain
|
|
32
|
+
model: DomainModel
|
|
33
|
+
user: DomainEntity
|
|
34
|
+
config: DomainEntity
|
|
35
|
+
} {
|
|
36
|
+
const { domain, model, user } = userOnlyDomain()
|
|
37
|
+
const config = model.addEntity({ info: { name: 'config' } })
|
|
38
|
+
config.addProperty({ info: { name: 'id' }, primary: true })
|
|
39
|
+
// user has 1 config
|
|
40
|
+
user.addAssociation({
|
|
41
|
+
info: { name: 'config' },
|
|
42
|
+
targets: [{ key: config.key }],
|
|
43
|
+
multiple: false,
|
|
44
|
+
required: false,
|
|
45
|
+
})
|
|
46
|
+
config.addAssociation({
|
|
47
|
+
info: { name: 'owner' },
|
|
48
|
+
targets: [{ key: user.key }],
|
|
49
|
+
multiple: false,
|
|
50
|
+
required: true,
|
|
51
|
+
semantics: [{ id: SemanticType.ResourceOwnerIdentifier }],
|
|
52
|
+
})
|
|
53
|
+
return { domain, model, user, config }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Returns a blog domain with four entities: user, blog, post, comment.
|
|
58
|
+
*/
|
|
59
|
+
export function blogDomain(): {
|
|
60
|
+
domain: DataDomain
|
|
61
|
+
user: DomainEntity
|
|
62
|
+
blog: DomainEntity
|
|
63
|
+
post: DomainEntity
|
|
64
|
+
comment: DomainEntity
|
|
65
|
+
} {
|
|
66
|
+
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
67
|
+
const model = domain.addModel({ info: { name: 'Container Model' } })
|
|
68
|
+
|
|
69
|
+
const user = model.addEntity({ info: { name: 'user' }, semantics: [{ id: SemanticType.User }] })
|
|
70
|
+
user.addProperty({
|
|
71
|
+
info: { name: 'id' },
|
|
72
|
+
type: 'string',
|
|
73
|
+
primary: true,
|
|
74
|
+
required: true,
|
|
75
|
+
schema: { defaultValue: { type: 'function', value: 'random' } },
|
|
76
|
+
})
|
|
77
|
+
user.addProperty({
|
|
78
|
+
info: { name: 'name' },
|
|
79
|
+
type: 'string',
|
|
80
|
+
required: true,
|
|
81
|
+
semantics: [{ id: SemanticType.Username }],
|
|
82
|
+
})
|
|
83
|
+
user.addProperty({
|
|
84
|
+
info: { name: 'password' },
|
|
85
|
+
type: 'string',
|
|
86
|
+
required: true,
|
|
87
|
+
semantics: [{ id: SemanticType.Password }],
|
|
88
|
+
})
|
|
89
|
+
user.addProperty({
|
|
90
|
+
info: { name: 'role' },
|
|
91
|
+
type: 'string',
|
|
92
|
+
required: true,
|
|
93
|
+
semantics: [{ id: SemanticType.UserRole }],
|
|
94
|
+
schema: {
|
|
95
|
+
defaultValue: { type: 'literal', value: 'user' },
|
|
96
|
+
enum: ['user', 'admin', 'moderator'],
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const blog = model.addEntity({ info: { name: 'blog' } })
|
|
101
|
+
blog.addProperty({
|
|
102
|
+
info: { name: 'id' },
|
|
103
|
+
type: 'string',
|
|
104
|
+
primary: true,
|
|
105
|
+
required: true,
|
|
106
|
+
schema: { defaultValue: { type: 'function', value: 'random' } },
|
|
107
|
+
})
|
|
108
|
+
blog.addProperty({
|
|
109
|
+
info: { name: 'title' },
|
|
110
|
+
type: 'string',
|
|
111
|
+
required: true,
|
|
112
|
+
semantics: [{ id: SemanticType.Title }],
|
|
113
|
+
})
|
|
114
|
+
blog.addProperty({
|
|
115
|
+
info: { name: 'content' },
|
|
116
|
+
type: 'string',
|
|
117
|
+
required: true,
|
|
118
|
+
semantics: [{ id: SemanticType.Description }, { id: SemanticType.Markdown }],
|
|
119
|
+
})
|
|
120
|
+
blog.addProperty({
|
|
121
|
+
info: { name: 'status' },
|
|
122
|
+
type: 'string',
|
|
123
|
+
required: true,
|
|
124
|
+
semantics: [{ id: SemanticType.Status }],
|
|
125
|
+
schema: {
|
|
126
|
+
defaultValue: { type: 'literal', value: 'draft' },
|
|
127
|
+
enum: ['draft', 'published'],
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
blog.addProperty({
|
|
131
|
+
info: { name: 'created_at' },
|
|
132
|
+
type: 'datetime',
|
|
133
|
+
readOnly: true,
|
|
134
|
+
schema: { defaultValue: { type: 'function', value: 'now' } },
|
|
135
|
+
})
|
|
136
|
+
blog.addProperty({
|
|
137
|
+
info: { name: 'updated_at' },
|
|
138
|
+
type: 'datetime',
|
|
139
|
+
readOnly: true,
|
|
140
|
+
schema: { defaultValue: { type: 'function', value: 'now' } },
|
|
141
|
+
})
|
|
142
|
+
blog.addAssociation({
|
|
143
|
+
info: { name: 'author' },
|
|
144
|
+
targets: [{ key: user.key }],
|
|
145
|
+
multiple: false,
|
|
146
|
+
required: true,
|
|
147
|
+
semantics: [{ id: SemanticType.ResourceOwnerIdentifier }],
|
|
148
|
+
})
|
|
149
|
+
user.addAssociation({
|
|
150
|
+
info: { name: 'blogs' },
|
|
151
|
+
targets: [{ key: blog.key }],
|
|
152
|
+
multiple: true,
|
|
153
|
+
required: false,
|
|
154
|
+
onDelete: 'cascade',
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const post = model.addEntity({ info: { name: 'posts' } })
|
|
158
|
+
post.addProperty({
|
|
159
|
+
info: { name: 'id' },
|
|
160
|
+
type: 'string',
|
|
161
|
+
primary: true,
|
|
162
|
+
required: true,
|
|
163
|
+
schema: { defaultValue: { type: 'function', value: 'random' } },
|
|
164
|
+
})
|
|
165
|
+
post.addProperty({
|
|
166
|
+
info: { name: 'title' },
|
|
167
|
+
type: 'string',
|
|
168
|
+
required: true,
|
|
169
|
+
semantics: [{ id: SemanticType.Title }],
|
|
170
|
+
})
|
|
171
|
+
post.addProperty({
|
|
172
|
+
info: { name: 'content' },
|
|
173
|
+
type: 'string',
|
|
174
|
+
required: true,
|
|
175
|
+
semantics: [{ id: SemanticType.Description }, { id: SemanticType.Markdown }],
|
|
176
|
+
})
|
|
177
|
+
post.addProperty({
|
|
178
|
+
info: { name: 'status' },
|
|
179
|
+
type: 'string',
|
|
180
|
+
required: true,
|
|
181
|
+
semantics: [{ id: SemanticType.Status }],
|
|
182
|
+
schema: {
|
|
183
|
+
defaultValue: { type: 'literal', value: 'draft' },
|
|
184
|
+
enum: ['draft', 'published'],
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
post.addProperty({
|
|
188
|
+
info: { name: 'created_at' },
|
|
189
|
+
type: 'datetime',
|
|
190
|
+
readOnly: true,
|
|
191
|
+
schema: { defaultValue: { type: 'function', value: 'now' } },
|
|
192
|
+
})
|
|
193
|
+
post.addProperty({
|
|
194
|
+
info: { name: 'updated_at' },
|
|
195
|
+
type: 'datetime',
|
|
196
|
+
readOnly: true,
|
|
197
|
+
schema: { defaultValue: { type: 'function', value: 'now' } },
|
|
198
|
+
})
|
|
199
|
+
post.addAssociation({
|
|
200
|
+
info: { name: 'author' },
|
|
201
|
+
targets: [{ key: user.key }],
|
|
202
|
+
multiple: false,
|
|
203
|
+
required: true,
|
|
204
|
+
semantics: [{ id: SemanticType.ResourceOwnerIdentifier }],
|
|
205
|
+
})
|
|
206
|
+
post.addAssociation({
|
|
207
|
+
info: { name: 'blog' },
|
|
208
|
+
targets: [{ key: blog.key }],
|
|
209
|
+
multiple: false,
|
|
210
|
+
required: true,
|
|
211
|
+
})
|
|
212
|
+
user.addAssociation({
|
|
213
|
+
info: { name: 'posts' },
|
|
214
|
+
targets: [{ key: post.key }],
|
|
215
|
+
multiple: true,
|
|
216
|
+
required: false,
|
|
217
|
+
onDelete: 'cascade',
|
|
218
|
+
})
|
|
219
|
+
blog.addAssociation({
|
|
220
|
+
info: { name: 'posts' },
|
|
221
|
+
targets: [{ key: post.key }],
|
|
222
|
+
multiple: true,
|
|
223
|
+
required: false,
|
|
224
|
+
onDelete: 'cascade',
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
const comment = model.addEntity({ info: { name: 'comment' } })
|
|
228
|
+
comment.addProperty({
|
|
229
|
+
info: { name: 'id' },
|
|
230
|
+
type: 'string',
|
|
231
|
+
primary: true,
|
|
232
|
+
required: true,
|
|
233
|
+
schema: { defaultValue: { type: 'function', value: 'random' } },
|
|
234
|
+
})
|
|
235
|
+
comment.addProperty({
|
|
236
|
+
info: { name: 'content' },
|
|
237
|
+
type: 'string',
|
|
238
|
+
required: true,
|
|
239
|
+
semantics: [{ id: SemanticType.Markdown }],
|
|
240
|
+
})
|
|
241
|
+
comment.addAssociation({
|
|
242
|
+
info: { name: 'author' },
|
|
243
|
+
targets: [{ key: user.key }],
|
|
244
|
+
multiple: false,
|
|
245
|
+
required: true,
|
|
246
|
+
semantics: [{ id: SemanticType.ResourceOwnerIdentifier }],
|
|
247
|
+
})
|
|
248
|
+
comment.addAssociation({
|
|
249
|
+
info: { name: 'post' },
|
|
250
|
+
targets: [{ key: post.key }],
|
|
251
|
+
multiple: false,
|
|
252
|
+
required: true,
|
|
253
|
+
})
|
|
254
|
+
user.addAssociation({
|
|
255
|
+
info: { name: 'comments' },
|
|
256
|
+
targets: [{ key: comment.key }],
|
|
257
|
+
multiple: true,
|
|
258
|
+
required: false,
|
|
259
|
+
onDelete: 'cascade',
|
|
260
|
+
})
|
|
261
|
+
post.addAssociation({
|
|
262
|
+
info: { name: 'comments' },
|
|
263
|
+
targets: [{ key: comment.key }],
|
|
264
|
+
multiple: true,
|
|
265
|
+
required: false,
|
|
266
|
+
onDelete: 'cascade',
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
return { domain, user, blog, post, comment }
|
|
270
|
+
}
|
|
@@ -87,6 +87,7 @@ test.group('ApiModel.exposeEntity()', () => {
|
|
|
87
87
|
assert.isDefined(nestedB)
|
|
88
88
|
assert.deepEqual(nestedB?.parent?.key, exposedA.key)
|
|
89
89
|
assert.strictEqual(nestedB?.collectionPath, '/entitybs')
|
|
90
|
+
assert.strictEqual(nestedB?.resourcePath, '/entitybs/{bId}')
|
|
90
91
|
})
|
|
91
92
|
|
|
92
93
|
test('does not infinitely expose circular associations', ({ assert }) => {
|
|
@@ -216,7 +217,7 @@ test.group('ApiModel.exposeEntity()', () => {
|
|
|
216
217
|
// Expose second entity -> should resolve collision to /items-1
|
|
217
218
|
const exp2 = model.exposeEntity({ key: e2.key })
|
|
218
219
|
assert.equal(exp2.collectionPath, '/items-1')
|
|
219
|
-
assert.equal(exp2.resourcePath, '/items-1/{
|
|
220
|
+
assert.equal(exp2.resourcePath, '/items-1/{itemId}')
|
|
220
221
|
|
|
221
222
|
// Expose third entity -> /items-2
|
|
222
223
|
const e3 = dm.addEntity({ info: { name: 'Item' } })
|
|
@@ -10,7 +10,7 @@ test.group('OasGenerator', (group) => {
|
|
|
10
10
|
let api: ApiModel
|
|
11
11
|
|
|
12
12
|
group.each.setup(() => {
|
|
13
|
-
domain = new DataDomain({ info: { name: 'Test Domain', version: '1.0.0' } })
|
|
13
|
+
domain = new DataDomain({ info: { name: 'Test Domain', version: '1.0.0' } }, undefined, { readOnly: true })
|
|
14
14
|
const model = domain.addModel({ info: { name: 'model1' } })
|
|
15
15
|
const user = model.addEntity({ info: { name: 'user' } })
|
|
16
16
|
user.addSemantic({ id: SemanticType.User })
|
|
@@ -232,17 +232,17 @@ test.group('OasGenerator', (group) => {
|
|
|
232
232
|
// User endpoints (CRUD)
|
|
233
233
|
assert.isObject(paths['/users'].get, 'User list')
|
|
234
234
|
assert.isObject(paths['/users'].post, 'User create')
|
|
235
|
-
assert.isObject(paths['/users/{
|
|
236
|
-
assert.isObject(paths['/users/{
|
|
237
|
-
assert.isObject(paths['/users/{
|
|
235
|
+
assert.isObject(paths['/users/{userId}'].get, 'User read')
|
|
236
|
+
assert.isObject(paths['/users/{userId}'].put, 'User put')
|
|
237
|
+
assert.isObject(paths['/users/{userId}'].delete, 'User delete')
|
|
238
238
|
|
|
239
239
|
// Post endpoints (list, update, delete)
|
|
240
240
|
assert.isObject(paths['/posts'].get)
|
|
241
241
|
assert.isUndefined(paths['/posts'].post) // no create Action
|
|
242
|
-
assert.isUndefined(paths['/posts/{
|
|
243
|
-
assert.isObject(paths['/posts/{
|
|
244
|
-
assert.isObject(paths['/posts/{
|
|
245
|
-
assert.isObject(paths['/posts/{
|
|
242
|
+
assert.isUndefined(paths['/posts/{postId}'].get) // no read action
|
|
243
|
+
assert.isObject(paths['/posts/{postId}'].patch)
|
|
244
|
+
assert.isObject(paths['/posts/{postId}'].put)
|
|
245
|
+
assert.isObject(paths['/posts/{postId}'].delete)
|
|
246
246
|
})
|
|
247
247
|
|
|
248
248
|
test('generates standard error responses', ({ assert }) => {
|