@api-client/core 0.18.27 → 0.18.28

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@api-client/core",
3
3
  "description": "The API Client's core client library. Works in NodeJS and in a ES enabled browser.",
4
- "version": "0.18.27",
4
+ "version": "0.18.28",
5
5
  "license": "Apache-2.0",
6
6
  "exports": {
7
7
  "./browser.js": {
@@ -87,6 +87,7 @@
87
87
  "@api-client/graph": "^0.3.5",
88
88
  "@api-client/json": "^0.2.0",
89
89
  "@esm-bundle/chai": "^4.3.4-fix.0",
90
+ "@jarrodek/pluralize": "^1.0.2",
90
91
  "@metrichor/jmespath": "^0.3.1",
91
92
  "@pawel-up/csv": "^0.2.0",
92
93
  "@pawel-up/data-mock": "^0.4.0",
@@ -11,10 +11,13 @@ import type {
11
11
  RolesBasedAccessControl,
12
12
  SessionConfiguration,
13
13
  UsernamePasswordConfiguration,
14
+ ExposeOptions,
14
15
  } from './types.js'
15
16
  import { DataDomain } from './DataDomain.js'
16
17
  import { DependentModel, type DependentModelSchema, type DomainDependency } from './DependentModel.js'
17
18
  import { observed, toRaw } from '../decorators/observed.js'
19
+ import pluralize from '@jarrodek/pluralize'
20
+ import { createDomainKey } from './helpers/keying.js'
18
21
 
19
22
  /**
20
23
  * Contact information for the exposed API.
@@ -54,12 +57,12 @@ export interface ApiModelSchema extends DependentModelSchema {
54
57
  */
55
58
  kind: typeof ApiModelKind
56
59
  /**
57
- * The unique key of the data domain schema.
60
+ * The unique key of the API model schema.
58
61
  * This is a stable identifier that does not change across versions.
59
62
  */
60
63
  key: string
61
64
  /**
62
- * Contains the name, display name, description, and the version of the data domain schema.
65
+ * Contains the name, display name, description, and the version of the API model schema.
63
66
  */
64
67
  info: IThing
65
68
 
@@ -376,41 +379,182 @@ export class ApiModel extends DependentModel {
376
379
  /**
377
380
  * Exposes a new entity in the API model.
378
381
  * If the entity already exists, it returns the existing one.
379
- * @param entityKey The key of the entity to expose.
382
+ * @param entity The entity key and domain to expose.
380
383
  * @returns The exposed entity.
381
384
  */
382
- exposeEntity(entityKey: string): ExposedEntity {
383
- const existing = this.exposes.find((e) => e.key === entityKey)
385
+ exposeEntity(entity: AssociationTarget, options?: ExposeOptions): ExposedEntity {
386
+ const domain = this.domain
387
+ if (!domain) {
388
+ throw new Error(`No domain attached to API model`)
389
+ }
390
+ // checks whether the entity is already exposed as a root exposure.
391
+ const existing = this.exposes.find(
392
+ (e) => e.isRoot && e.entity.key === entity.key && e.entity.domain === entity.domain
393
+ )
384
394
  if (existing) {
395
+ // quietly return the existing exposure
396
+ // TBD: should we throw an error here?
385
397
  return existing
386
398
  }
399
+ const domainEntity = domain.findEntity(entity.key, entity.domain)
400
+ if (!domainEntity) {
401
+ throw new Error(`Entity not found in domain: ${entity.key}`)
402
+ }
403
+ const name = domainEntity.info.name || ''
387
404
  const newEntity: ExposedEntity = {
388
- key: entityKey,
405
+ key: nanoid(),
406
+ entity: { ...entity },
389
407
  actions: [],
408
+ isRoot: true,
409
+ path: pluralize(name.toLocaleLowerCase()),
410
+ }
411
+ if (options) {
412
+ newEntity.exposeOptions = { ...options }
390
413
  }
391
414
  this.exposes.push(newEntity)
415
+
416
+ // Follow associations if requested
417
+ if (options?.followAssociations) {
418
+ if (options?.maxDepth === undefined || options.maxDepth > 0) {
419
+ this.followEntityAssociations(newEntity, options)
420
+ }
421
+ }
392
422
  this.notifyChange()
393
423
  return newEntity
394
424
  }
395
425
 
396
426
  /**
397
- * Removes an entity from the API model.
398
- * @param entityKey The key of the entity to remove.
427
+ * Follows associations for a newly exposed entity if configured to do so.
428
+ * This creates nested exposures based on the entity's associations.
429
+ *
430
+ * @param parentExposure The root exposure to follow associations from
431
+ * @param options The expose options containing follow configuration
399
432
  */
400
- removeEntity(entityKey: string): void {
401
- const index = this.exposes.findIndex((e) => e.key === entityKey)
402
- if (index !== -1) {
403
- this.exposes.splice(index, 1)
404
- this.notifyChange()
433
+ private followEntityAssociations(parentExposure: ExposedEntity, options: ExposeOptions): void {
434
+ const domain = this.domain
435
+ if (!domain) {
436
+ return
437
+ }
438
+ const maxDepth = options.maxDepth ?? 6
439
+ const visited = new Set<string>()
440
+ // Add parent entity's key to the visited set so we won't skip it when traversing
441
+ // associations.
442
+ visited.add(createDomainKey(parentExposure.entity))
443
+
444
+ const follow = (currentEntity: AssociationTarget, parentKey: string, depth: number) => {
445
+ // Find the domain entity
446
+ const domainEntity = domain.findEntity(currentEntity.key, currentEntity.domain)
447
+ if (!domainEntity) return
448
+
449
+ // Iterate through associations
450
+ for (const association of domainEntity.listAssociations()) {
451
+ for (const target of association.targets) {
452
+ // Skip self-referencing associations
453
+ if (target.key === currentEntity.key && target.domain === currentEntity.domain) {
454
+ continue
455
+ }
456
+
457
+ // Create unique identifier for circular detection
458
+ const visitKey = createDomainKey(target)
459
+ if (visited.has(visitKey)) {
460
+ continue // Skip circular references
461
+ }
462
+ visited.add(visitKey)
463
+
464
+ // Check if this nested exposure already exists
465
+ const existingNested = this.exposes.find(
466
+ (e) =>
467
+ !e.isRoot &&
468
+ e.entity.key === target.key &&
469
+ e.entity.domain === target.domain &&
470
+ e.parent?.key === parentKey
471
+ )
472
+
473
+ if (existingNested) {
474
+ continue // Already exposed under this parent
475
+ }
476
+
477
+ // Find the target domain entity for path generation
478
+ const targetDomainEntity = domain.findEntity(target.key, target.domain)
479
+ if (!targetDomainEntity) continue
480
+
481
+ const name = association.info.name || ''
482
+ // Create nested exposure
483
+ const nestedExposure: ExposedEntity = {
484
+ key: nanoid(),
485
+ entity: { ...target },
486
+ actions: [],
487
+ isRoot: false,
488
+ path: pluralize(name.toLocaleLowerCase()),
489
+ parent: {
490
+ key: parentKey,
491
+ association: {
492
+ key: association.key,
493
+ domain: currentEntity.domain,
494
+ },
495
+ depth: depth + 1,
496
+ },
497
+ }
498
+
499
+ this.exposes.push(nestedExposure)
500
+ if (depth + 1 >= maxDepth) {
501
+ nestedExposure.truncated = true
502
+ } else {
503
+ // Recursively follow associations
504
+ follow(target, nestedExposure.key, depth + 1)
505
+ }
506
+ }
507
+ }
508
+ }
509
+
510
+ // Start following from the root exposure
511
+ follow(parentExposure.entity, parentExposure.key, 0)
512
+ }
513
+
514
+ /**
515
+ * Removes an exposed entity from the API model.
516
+ * @param entity The entity to remove.
517
+ */
518
+ removeEntity(entity: AssociationTarget): void {
519
+ const current = this.exposes.find((e) => e.entity.key === entity.key && e.entity.domain === entity.domain)
520
+ if (!current) {
521
+ return
522
+ }
523
+ this.removeWithChildren(current.key)
524
+ this.notifyChange()
525
+ }
526
+
527
+ private removeWithChildren(key: string): void {
528
+ const index = this.exposes.findIndex((e) => e.key === key)
529
+ if (index < 0) {
530
+ return
405
531
  }
532
+ // Remove the parent itself
533
+ this.exposes.splice(index, 1)
534
+ // Remove all children recursively
535
+ const removeChildren = (parentKey: string) => {
536
+ // Find all exposures whose parent.key matches parentKey
537
+ const children = this.exposes.filter((e) => e.parent?.key === parentKey)
538
+ for (const child of children) {
539
+ removeChildren(child.key)
540
+ const childIndex = this.exposes.findIndex((e) => e.key === child.key)
541
+ if (childIndex >= 0) {
542
+ this.exposes.splice(childIndex, 1)
543
+ }
544
+ }
545
+ }
546
+ // Then also remove children
547
+ removeChildren(key)
548
+ this.notifyChange()
406
549
  }
550
+
407
551
  /**
408
552
  * Returns the exposed entity by its key.
409
553
  * @param entityKey The key of the entity to find.
410
554
  * @returns The exposed entity or undefined if not found.
411
555
  */
412
- getExposedEntity(entityKey: string): ExposedEntity | undefined {
413
- return this.exposes.find((e) => e.key === entityKey)
556
+ getExposedEntity(entity: AssociationTarget): ExposedEntity | undefined {
557
+ return this.exposes.find((e) => e.entity.key === entity.key && e.entity.domain === entity.domain)
414
558
  }
415
559
 
416
560
  /**
@@ -0,0 +1,5 @@
1
+ export function paramNameFor(entityKeyLocal: string): string {
2
+ const parts = entityKeyLocal.split(':')
3
+ const key = parts[parts.length - 1]
4
+ return `${key}Id`
5
+ }
@@ -0,0 +1,11 @@
1
+ import { AssociationTarget } from '../types.js'
2
+
3
+ /**
4
+ * Creates a consistent key for a given association target within its domain.
5
+ *
6
+ * @param target The association target to create a key for.
7
+ * @returns A unique key for the association target.
8
+ */
9
+ export function createDomainKey(target: AssociationTarget): string {
10
+ return target.domain ? `${target.domain}:${target.key}` : target.key
11
+ }
@@ -122,13 +122,159 @@ Let's imagine a simple e-commerce domain:
122
122
  - **Entity-Parent**: A `DomainEntity` can have `DomainEntity` instances as parents.
123
123
  - **Namespace-Foreign**: A `DomainNamespace` can have references to `DomainNamespace` instances.
124
124
 
125
+ ## API Modeling
126
+
127
+ Beyond data modeling, the system also provides **API Modeling** capabilities that allow you to define how your data domain is exposed as a secure, production-ready API.
128
+
129
+ ### ApiModel
130
+
131
+ The `ApiModel` class extends the data modeling system to define how Data Entities from a `DataDomain` are exposed via HTTP APIs. It provides configuration for:
132
+
133
+ **Core API Configuration:**
134
+
135
+ - **Exposed Entities**: Select which Data Entities from your Data Domain should be accessible via the API
136
+ - **API Actions**: Define what operations (List, Read, Create, Update, Delete, Search) are available for each entity
137
+ - **User Entity**: Designate which Data Entity represents a "User" for authentication purposes
138
+
139
+ **Security & Authentication:**
140
+
141
+ - **Authentication**: Configure how users prove their identity (e.g., username/password)
142
+ - **Authorization**: Define what authenticated users are allowed to do (e.g., Role-Based Access Control)
143
+ - **Session Management**: Configure transport and payload for user sessions (JWT, cookies)
144
+ - **Access Rules**: Define fine-grained access control policies for entities and actions
145
+
146
+ **API Metadata:**
147
+
148
+ - **Contact Information**: API maintainer details
149
+ - **License Information**: Legal information about API usage
150
+ - **Terms of Service**: Link to API usage terms
151
+ - **Rate Limiting**: Protect the API from overuse and abuse
152
+
153
+ ### How API Modeling Works with Data Modeling
154
+
155
+ The API modeling system builds on top of the data modeling foundation:
156
+
157
+ 1. **Start with a Data Domain**: Create your `DataDomain` with entities, properties, and associations
158
+ 2. **Create an API Model**: Instantiate an `ApiModel` and attach your `DataDomain`
159
+ 3. **Expose Entities**: Select which entities should be available via the API using `exposeEntity()`
160
+ 4. **Configure Security**: Set up authentication, authorization, and session management
161
+ 5. **Define Actions**: Configure what operations are available for each exposed entity
162
+ 6. **Set API Metadata**: Add contact info, license, terms of service
163
+
164
+ ### Example: E-Commerce API
165
+
166
+ Building on the e-commerce data domain example:
167
+
168
+ ```typescript
169
+ // 1. Create the data domain (as shown in previous example)
170
+ const domain = new DataDomain({ key: 'ecommerce', info: { name: 'E-Commerce Platform' } })
171
+ const userModel = domain.addModel({ info: { name: 'User Management' } })
172
+ const userEntity = userModel.addEntity({
173
+ info: { name: 'user' },
174
+ semantics: [{ type: SemanticType.User }] // Mark as User entity
175
+ })
176
+
177
+ // 2. Create an API model
178
+ const apiModel = new ApiModel({
179
+ info: { name: 'E-Commerce API', version: '1.0.0' },
180
+ termsOfService: 'https://example.com/terms',
181
+ contact: { name: 'API Team', email: 'api@example.com' },
182
+ license: { name: 'MIT', url: 'https://opensource.org/licenses/MIT' }
183
+ })
184
+
185
+ // 3. Attach the data domain
186
+ apiModel.attachDataDomain(domain)
187
+
188
+ // 4. Configure security
189
+ apiModel.user = { key: userEntity.key }
190
+ apiModel.authentication = { strategy: 'UsernamePassword', passwordKey: 'password' }
191
+ apiModel.authorization = { strategy: 'RBAC', roleKey: 'role' }
192
+ apiModel.session = {
193
+ secret: 'your-secure-secret',
194
+ properties: ['email', 'role'],
195
+ cookie: { enabled: true, kind: 'cookie', lifetime: '7d' }
196
+ }
197
+
198
+ // 5. Expose entities and configure actions
199
+ const exposedUser = apiModel.exposeEntity(userEntity.key)
200
+ exposedUser.actions = [
201
+ { type: 'Read', enabled: true },
202
+ { type: 'Update', enabled: true },
203
+ { type: 'Create', enabled: true }
204
+ ]
205
+ ```
206
+
207
+ ### Key API Modeling Concepts
208
+
209
+ #### ExposedEntity
210
+
211
+ Represents a Data Entity that is exposed via the API. Contains:
212
+
213
+ - **Key**: Reference to the Data Entity
214
+ - **Actions**: List of available API operations (CRUD, Search)
215
+ - **Access Rules**: Entity-specific access control
216
+ - **Rate Limiting**: Entity-specific rate limiting rules
217
+
218
+ #### API Actions
219
+
220
+ Standard RESTful operations that can be enabled for each entity:
221
+
222
+ - **List**: Retrieve collections of entities with filtering and pagination
223
+ - **Read**: Retrieve a single entity by ID
224
+ - **Create**: Create new entities
225
+ - **Update**: Modify existing entities
226
+ - **Delete**: Remove entities
227
+ - **Search**: Full-text or advanced search across entities
228
+
229
+ #### Authentication Configuration
230
+
231
+ Defines how users prove their identity:
232
+
233
+ - **UsernamePassword**: Traditional email/password authentication
234
+ - Extensible for future strategies (SSO, OAuth, etc.)
235
+
236
+ #### Authorization Configuration
237
+
238
+ Defines what authenticated users can do:
239
+
240
+ - **RBAC (Role-Based Access Control)**: Users have roles, permissions granted to roles
241
+ - Extensible for future strategies (PBAC - Permission-Based Access Control)
242
+
243
+ #### Session Configuration
244
+
245
+ Manages user session data:
246
+
247
+ - **Properties**: Which User entity properties to include in session
248
+ - **Transport**: How session data is transmitted (JWT tokens, cookies)
249
+ - **Security**: Encryption, lifetime, and security settings
250
+
251
+ ### Business-First API Design
252
+
253
+ The API modeling system follows the same business-first philosophy as data modeling:
254
+
255
+ - **Semantic Awareness**: Uses semantic types to automatically configure security (e.g., Password properties are write-only)
256
+ - **Standard Compliance**: Generates OpenAPI-compliant specifications
257
+ - **Security by Default**: Requires explicit configuration for authentication, authorization, and sessions
258
+ - **User-Friendly**: Clear error messages and validation feedback
259
+ - **Production Ready**: Built-in support for rate limiting, access control, and monitoring
260
+
125
261
  ## Summary
126
262
 
127
- This data modeling system provides a flexible and powerful way to define complex data domains. It allows you to:
263
+ This comprehensive modeling system provides both **Data Modeling** and **API Modeling** capabilities:
264
+
265
+ **Data Modeling** allows you to:
266
+
267
+ - **Organize**: Group related data into namespaces and models
268
+ - **Structure**: Define entities with properties and relationships
269
+ - **Reuse**: Inherit from other entities and reference foreign namespaces
270
+ - **Translate**: Define bindings to map the model to different formats
271
+ - **Validate**: Validate the data model definition
272
+ - **Generate**: Generate AMF shapes and examples
273
+
274
+ **API Modeling** extends this to:
128
275
 
129
- - **Organize**: Group related data into namespaces and models.
130
- - **Structure**: Define entities with properties and relationships.
131
- - **Reuse**: Inherit from other entities and reference foreign namespaces.
132
- - **Translate**: Define bindings to map the model to different formats.
133
- - **Validate**: Validate the data model definition.
134
- - **Generate**: Generate AMF shapes and examples.
276
+ - **Expose**: Selectively expose Data Entities as API resources
277
+ - **Secure**: Configure authentication, authorization, and session management
278
+ - **Control**: Define fine-grained access rules and rate limiting
279
+ - **Document**: Generate complete API documentation with business context
280
+ - **Deploy**: Create production-ready APIs with proper security and monitoring
@@ -428,25 +428,101 @@ export interface UsernamePasswordConfiguration extends AuthenticationConfigurati
428
428
  */
429
429
  export interface ExposedEntity {
430
430
  /**
431
- * The key of the Data Entity from the Data Domain.
431
+ * The unique identifier for this exposure instance.
432
+ * In the exposure model, we need to uniquely identify each exposure instance, because
433
+ * an entity can be exposed multiple times in different contexts. Consider the following structure:
434
+ *
435
+ * ```
436
+ * /categories/{categoryId}
437
+ * /products/{productId}/categories
438
+ * /products/{productId}/categories/{categoryId}
439
+ * /promotions/{promotionId}/categories
440
+ * /promotions/{promotionId}/categories/{categoryId}
441
+ * ```
442
+ *
443
+ * The `category` entity would be exposed multiple times (as root and nested under products and promotions).
444
+ * We need a way to distinguish between these different exposure instances.
432
445
  */
433
446
  key: string
447
+ /**
448
+ * A pointer to a Data Entity from the Data Domain.
449
+ */
450
+ entity: AssociationTarget
451
+ /**
452
+ * The path segment for this exposure.
453
+ */
454
+ path: string
434
455
 
435
456
  /**
436
- * Optional configuration for resource-wide rate limiting and throttling.
437
- * Defines rules to protect the resource from overuse.
457
+ * Whether this exposure is a root exposure (top-level collection).
458
+ * If this is set then the `parent` reference must be populated.
438
459
  */
439
- rateLimiting?: RateLimitingConfiguration
460
+ isRoot?: boolean
461
+
440
462
  /**
441
- * Access control rules defining who can perform actions on this resource or collection.
442
- * It override the top-level access rules defined in the API model.
463
+ * Parent reference when this exposure was created via following an association.
443
464
  */
444
- accessRule?: AccessRule[]
465
+ parent?: ExposeParentRef
445
466
 
446
467
  /**
447
- * The collection of API actions (e.g., List, Read, Create) enabled for this entity.
468
+ * Expose-time config used to create this exposure (persisted for auditing/UI).
469
+ * This is only populated for the root exposure. All children exposures inherit this config.
470
+ */
471
+ exposeOptions?: ExposeOptions
472
+
473
+ /**
474
+ * The list of enabled API actions for this exposure (List/Read/Create/etc.)
448
475
  */
449
476
  actions: ApiAction[]
477
+
478
+ /**
479
+ * Optional array of access rules that define the access control policies for this exposure.
480
+ */
481
+ accessRule?: AccessRule[]
482
+
483
+ /**
484
+ * Optional configuration for rate limiting for this exposure.
485
+ */
486
+ rateLimiting?: RateLimitingConfiguration
487
+
488
+ /**
489
+ * When true, generation for this exposure hit configured limits
490
+ */
491
+ truncated?: boolean
492
+ }
493
+
494
+ /**
495
+ * Parent reference stored on a nested exposure
496
+ */
497
+ export interface ExposeParentRef {
498
+ /**
499
+ * The key of the parent exposed entity. This references the `ExposedEntity.key` property.
500
+ */
501
+ key: string
502
+ /**
503
+ * The association from the parent that produced this exposure.
504
+ * A sub-entity must always have a parent association.
505
+ */
506
+ association: AssociationTarget
507
+ /**
508
+ * The numeric depth from the root exposure (root = 0)
509
+ */
510
+ depth?: number
511
+ }
512
+
513
+ /**
514
+ * Options passed when creating a new exposure
515
+ */
516
+ export interface ExposeOptions {
517
+ /**
518
+ * Whether to follow associations when creating the exposure.
519
+ * When not set, it only exposes the passed entity.
520
+ */
521
+ followAssociations?: boolean
522
+ /**
523
+ * The maximum depth to follow associations when creating the exposure.
524
+ */
525
+ maxDepth?: number
450
526
  }
451
527
 
452
528
  /**