qdadm 0.28.0 → 0.30.0

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.
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Unified Schema Types
3
+ *
4
+ * Core contracts that abstract differences between OpenAPI, Pydantic, and manual schema sources.
5
+ * These types provide a common format for entity and field definitions that can be used
6
+ * by connectors (schema sources) and generators (code output).
7
+ *
8
+ * @module gen/schema
9
+ */
10
+
11
+ /**
12
+ * Field Type Enumeration
13
+ *
14
+ * Unified field types that abstract source-specific types (OpenAPI string/integer,
15
+ * Pydantic str/int, etc.) into a common set.
16
+ *
17
+ * @typedef {'text' | 'number' | 'boolean' | 'date' | 'datetime' | 'email' | 'url' | 'uuid' | 'array' | 'object'} UnifiedFieldType
18
+ */
19
+
20
+ /**
21
+ * Reference Definition
22
+ *
23
+ * Describes a relation to another entity for foreign key fields.
24
+ *
25
+ * @typedef {object} UnifiedFieldReference
26
+ * @property {string} entity - Target entity name (e.g., 'users', 'categories')
27
+ * @property {string} [labelField] - Field to display as label (e.g., 'name', 'title')
28
+ */
29
+
30
+ /**
31
+ * Unified Field Schema
32
+ *
33
+ * Common field definition that abstracts OpenAPI property, Pydantic field, or manual definition.
34
+ * Connectors transform their source-specific formats into this common shape.
35
+ *
36
+ * @typedef {object} UnifiedFieldSchema
37
+ * @property {string} name - Field name (e.g., 'email', 'created_at')
38
+ * @property {UnifiedFieldType} type - Unified field type
39
+ * @property {string} [label] - Human-readable label (e.g., 'Email Address')
40
+ * @property {boolean} [required] - Whether field is required (default: false)
41
+ * @property {boolean} [readOnly] - Whether field is read-only (default: false)
42
+ * @property {boolean} [hidden] - Whether field should be hidden in UI (default: false)
43
+ * @property {string} [format] - Original format hint from source (e.g., 'date-time', 'uri', 'email')
44
+ * @property {string[]} [enum] - Allowed values for enumeration fields
45
+ * @property {*} [default] - Default value for the field
46
+ * @property {UnifiedFieldReference} [reference] - Relation to another entity
47
+ * @property {Record<string, *>} [extensions] - Project-specific extensions
48
+ *
49
+ * @example
50
+ * // Simple text field
51
+ * const nameField = {
52
+ * name: 'name',
53
+ * type: 'text',
54
+ * label: 'Full Name',
55
+ * required: true
56
+ * }
57
+ *
58
+ * @example
59
+ * // Enum field with options
60
+ * const statusField = {
61
+ * name: 'status',
62
+ * type: 'text',
63
+ * label: 'Status',
64
+ * enum: ['draft', 'published', 'archived'],
65
+ * default: 'draft'
66
+ * }
67
+ *
68
+ * @example
69
+ * // Foreign key with reference
70
+ * const authorField = {
71
+ * name: 'author_id',
72
+ * type: 'number',
73
+ * label: 'Author',
74
+ * reference: {
75
+ * entity: 'users',
76
+ * labelField: 'username'
77
+ * }
78
+ * }
79
+ */
80
+
81
+ /**
82
+ * Unified Entity Schema
83
+ *
84
+ * Common entity definition that abstracts OpenAPI path/schema, Pydantic model, or manual definition.
85
+ * This is the primary contract between schema sources (connectors) and consumers (generators, runtime).
86
+ *
87
+ * @typedef {object} UnifiedEntitySchema
88
+ * @property {string} name - Entity name, typically plural lowercase (e.g., 'users', 'blog_posts')
89
+ * @property {string} endpoint - API endpoint path (e.g., '/users', '/api/v1/posts')
90
+ * @property {string} [label] - Human-readable singular label (e.g., 'User', 'Blog Post')
91
+ * @property {string} [labelPlural] - Human-readable plural label (e.g., 'Users', 'Blog Posts')
92
+ * @property {string} [labelField] - Field used as display label (e.g., 'name', 'title')
93
+ * @property {string} [routePrefix] - Route prefix for admin UI (e.g., 'user', 'blog-post')
94
+ * @property {string} [idField] - Primary key field name (default: 'id')
95
+ * @property {boolean} [readOnly] - Whether entity is read-only (no create/update/delete)
96
+ * @property {Record<string, UnifiedFieldSchema>} fields - Field definitions keyed by field name
97
+ * @property {Record<string, *>} [extensions] - Project-specific extensions
98
+ *
99
+ * @example
100
+ * // Complete entity schema
101
+ * const usersSchema = {
102
+ * name: 'users',
103
+ * endpoint: '/api/users',
104
+ * label: 'User',
105
+ * labelPlural: 'Users',
106
+ * labelField: 'username',
107
+ * routePrefix: 'user',
108
+ * idField: 'id',
109
+ * readOnly: false,
110
+ * fields: {
111
+ * id: { name: 'id', type: 'number', readOnly: true },
112
+ * username: { name: 'username', type: 'text', required: true },
113
+ * email: { name: 'email', type: 'email', required: true },
114
+ * created_at: { name: 'created_at', type: 'datetime', readOnly: true }
115
+ * }
116
+ * }
117
+ *
118
+ * @example
119
+ * // Read-only entity (external API)
120
+ * const countriesSchema = {
121
+ * name: 'countries',
122
+ * endpoint: 'https://restcountries.com/v3.1/all',
123
+ * label: 'Country',
124
+ * labelPlural: 'Countries',
125
+ * labelField: 'name',
126
+ * idField: 'cca3',
127
+ * readOnly: true,
128
+ * fields: {
129
+ * cca3: { name: 'cca3', type: 'text', label: 'Code' },
130
+ * name: { name: 'name', type: 'text', label: 'Name' }
131
+ * }
132
+ * }
133
+ */
134
+
135
+ /**
136
+ * Valid field types for UnifiedFieldSchema
137
+ *
138
+ * Used for validation and documentation. Maps to common UI input types:
139
+ * - text: Single-line text input
140
+ * - number: Numeric input
141
+ * - boolean: Checkbox/toggle
142
+ * - date: Date picker (date only)
143
+ * - datetime: Date-time picker
144
+ * - email: Email input with validation
145
+ * - url: URL input with validation
146
+ * - uuid: UUID text input
147
+ * - array: Multi-value field (tags, list)
148
+ * - object: Nested object (JSON editor or subform)
149
+ *
150
+ * @type {readonly UnifiedFieldType[]}
151
+ */
152
+ export const UNIFIED_FIELD_TYPES = Object.freeze([
153
+ 'text',
154
+ 'number',
155
+ 'boolean',
156
+ 'date',
157
+ 'datetime',
158
+ 'email',
159
+ 'url',
160
+ 'uuid',
161
+ 'array',
162
+ 'object'
163
+ ])
164
+
165
+ /**
166
+ * Check if a type is a valid UnifiedFieldType
167
+ *
168
+ * @param {string} type - Type to validate
169
+ * @returns {type is UnifiedFieldType} - True if valid
170
+ *
171
+ * @example
172
+ * isValidFieldType('text') // true
173
+ * isValidFieldType('string') // false (OpenAPI type, not unified)
174
+ */
175
+ export function isValidFieldType(type) {
176
+ return UNIFIED_FIELD_TYPES.includes(type)
177
+ }
178
+
179
+ /**
180
+ * Create a minimal field schema with defaults
181
+ *
182
+ * @param {string} name - Field name
183
+ * @param {UnifiedFieldType} type - Field type
184
+ * @param {Partial<UnifiedFieldSchema>} [overrides] - Additional field properties
185
+ * @returns {UnifiedFieldSchema} - Complete field schema
186
+ *
187
+ * @example
188
+ * const field = createFieldSchema('email', 'email', { required: true })
189
+ * // { name: 'email', type: 'email', required: true }
190
+ */
191
+ export function createFieldSchema(name, type, overrides = {}) {
192
+ return {
193
+ name,
194
+ type,
195
+ ...overrides
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Create a minimal entity schema with defaults
201
+ *
202
+ * @param {string} name - Entity name
203
+ * @param {string} endpoint - API endpoint
204
+ * @param {Record<string, UnifiedFieldSchema>} fields - Field definitions
205
+ * @param {Partial<UnifiedEntitySchema>} [overrides] - Additional entity properties
206
+ * @returns {UnifiedEntitySchema} - Complete entity schema
207
+ *
208
+ * @example
209
+ * const schema = createEntitySchema('users', '/api/users', {
210
+ * id: createFieldSchema('id', 'number', { readOnly: true }),
211
+ * name: createFieldSchema('name', 'text', { required: true })
212
+ * })
213
+ */
214
+ export function createEntitySchema(name, endpoint, fields, overrides = {}) {
215
+ return {
216
+ name,
217
+ endpoint,
218
+ fields,
219
+ ...overrides
220
+ }
221
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Vite Plugin for qdadm EntityManager Generation
3
+ *
4
+ * Integrates generateManagers with Vite's build pipeline, triggering
5
+ * entity manager file generation during the buildStart hook.
6
+ *
7
+ * @module gen/vite-plugin
8
+ */
9
+
10
+ import { generateManagers } from './generateManagers.js'
11
+ import { pathToFileURL } from 'node:url'
12
+ import { resolve } from 'node:path'
13
+
14
+ /**
15
+ * Plugin options for qdadmGen
16
+ *
17
+ * @typedef {object} QdadmGenOptions
18
+ * @property {string} [config] - Path to qdadm config file (default: 'qdadm.config.js')
19
+ * @property {string} [output] - Override output directory for generated files
20
+ */
21
+
22
+ /**
23
+ * Vite plugin for generating EntityManager files at build time
24
+ *
25
+ * Loads the qdadm configuration file and calls generateManagers during
26
+ * Vite's buildStart hook. Supports both ESM and CommonJS config files.
27
+ *
28
+ * @param {QdadmGenOptions} [options={}] - Plugin options
29
+ * @returns {import('vite').Plugin} Vite plugin object
30
+ *
31
+ * @example
32
+ * // vite.config.js
33
+ * import { defineConfig } from 'vite'
34
+ * import { qdadmGen } from 'qdadm/gen/vite-plugin'
35
+ *
36
+ * export default defineConfig({
37
+ * plugins: [
38
+ * qdadmGen({
39
+ * config: './qdadm.config.js',
40
+ * output: 'src/generated/managers/'
41
+ * })
42
+ * ]
43
+ * })
44
+ *
45
+ * @example
46
+ * // qdadm.config.js
47
+ * export default {
48
+ * output: 'src/generated/managers/',
49
+ * entities: {
50
+ * users: {
51
+ * schema: { name: 'users', fields: { id: { type: 'integer' } } },
52
+ * endpoint: '/api/users',
53
+ * storageImport: 'qdadm',
54
+ * storageClass: 'ApiStorage'
55
+ * }
56
+ * }
57
+ * }
58
+ */
59
+ export function qdadmGen(options = {}) {
60
+ const configPath = options.config || 'qdadm.config.js'
61
+
62
+ return {
63
+ name: 'qdadm-gen',
64
+
65
+ async buildStart() {
66
+ try {
67
+ // Resolve config path relative to cwd
68
+ const resolvedConfigPath = resolve(process.cwd(), configPath)
69
+
70
+ // Load config file dynamically
71
+ const configUrl = pathToFileURL(resolvedConfigPath).href
72
+ const configModule = await import(configUrl)
73
+ const loadedConfig = configModule.default || configModule
74
+
75
+ // Validate loaded config
76
+ if (!loadedConfig || typeof loadedConfig !== 'object') {
77
+ throw new Error(`Invalid qdadm config at '${configPath}': must export an object`)
78
+ }
79
+
80
+ if (!loadedConfig.entities) {
81
+ throw new Error(`Invalid qdadm config at '${configPath}': missing 'entities' property`)
82
+ }
83
+
84
+ // Merge plugin options with config (plugin options take precedence)
85
+ const mergedConfig = {
86
+ ...loadedConfig,
87
+ ...(options.output && { output: options.output })
88
+ }
89
+
90
+ // Generate managers
91
+ const generatedFiles = await generateManagers(mergedConfig)
92
+
93
+ // Log results
94
+ console.log(`[qdadm-gen] Generated ${generatedFiles.length} manager file(s)`)
95
+ for (const file of generatedFiles) {
96
+ console.log(` - ${file}`)
97
+ }
98
+ } catch (error) {
99
+ // Wrap error with plugin context for clearer messages
100
+ const message = error instanceof Error ? error.message : String(error)
101
+ throw new Error(`[qdadm-gen] Failed to generate managers: ${message}`)
102
+ }
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * TestManager - Auto-generated EntityManager
3
+ *
4
+ * Generated by qdadm/gen/generateManagers
5
+ * DO NOT EDIT MANUALLY - Changes will be overwritten
6
+ *
7
+ * Entity: test
8
+ * Endpoint: /test
9
+ */
10
+
11
+ import { EntityManager } from 'qdadm'
12
+ import { ApiStorage } from 'qdadm'
13
+
14
+ /**
15
+ * Schema definition for test
16
+ * @type {import('qdadm/gen').UnifiedEntitySchema}
17
+ */
18
+ export const testSchema = {
19
+ name: "test",
20
+ endpoint: "/test",
21
+ fields: {}
22
+ }
23
+
24
+ /**
25
+ * Storage options for test
26
+ */
27
+ const storageOptions = {
28
+ endpoint: "/test"
29
+ }
30
+
31
+ /**
32
+ * TestManager instance
33
+ *
34
+ * Provides CRUD operations for test entity.
35
+ *
36
+ * @type {EntityManager}
37
+ */
38
+ export const testManager = new EntityManager({
39
+ ...{
40
+ name: "test",
41
+ idField: "id",
42
+ fields: {}
43
+ },
44
+ storage: new ApiStorage(storageOptions)
45
+ })
@@ -56,6 +56,7 @@ import { createManagers } from '../entity/factory.js'
56
56
  import { defaultStorageResolver } from '../entity/storage/factory.js'
57
57
  import { createDeferredRegistry } from '../deferred/DeferredRegistry.js'
58
58
  import { createEventRouter } from './EventRouter.js'
59
+ import { createSSEBridge } from './SSEBridge.js'
59
60
 
60
61
  export class Kernel {
61
62
  /**
@@ -82,6 +83,7 @@ export class Kernel {
82
83
  * @param {object} options.security - Security config { role_hierarchy, role_permissions, entity_permissions }
83
84
  * @param {boolean} options.warmup - Enable warmup at boot (default: true)
84
85
  * @param {object} options.eventRouter - EventRouter config { 'source:signal': ['target:signal', ...] }
86
+ * @param {object} options.sse - SSEBridge config { url, reconnectDelay, signalPrefix, autoConnect, events }
85
87
  */
86
88
  constructor(options) {
87
89
  this.options = options
@@ -93,6 +95,7 @@ export class Kernel {
93
95
  this.hookRegistry = null
94
96
  this.deferred = null
95
97
  this.eventRouter = null
98
+ this.sseBridge = null
96
99
  this.layoutComponents = null
97
100
  this.securityChecker = null
98
101
  }
@@ -113,15 +116,19 @@ export class Kernel {
113
116
  this._initModules()
114
117
  // 4. Create router (needs routes from modules)
115
118
  this._createRouter()
116
- // 5. Create orchestrator and remaining components
119
+ // 5. Setup auth:expired handler (needs router + authAdapter)
120
+ this._setupAuthExpiredHandler()
121
+ // 6. Create orchestrator and remaining components
117
122
  this._createOrchestrator()
118
- // 6. Create EventRouter (needs signals + orchestrator)
123
+ // 7. Create EventRouter (needs signals + orchestrator)
119
124
  this._createEventRouter()
125
+ // 8. Create SSEBridge (needs signals + authAdapter for token)
126
+ this._createSSEBridge()
120
127
  this._setupSecurity()
121
128
  this._createLayoutComponents()
122
129
  this._createVueApp()
123
130
  this._installPlugins()
124
- // 6. Fire warmups (fire-and-forget, pages await via DeferredRegistry)
131
+ // 9. Fire warmups (fire-and-forget, pages await via DeferredRegistry)
125
132
  this._fireWarmups()
126
133
  return this.vueApp
127
134
  }
@@ -144,6 +151,58 @@ export class Kernel {
144
151
  })
145
152
  }
146
153
 
154
+ /**
155
+ * Setup handler for auth:expired signal
156
+ *
157
+ * When auth:expired is emitted (e.g., from API 401/403 response),
158
+ * this handler:
159
+ * 1. Calls authAdapter.logout() to clear tokens
160
+ * 2. Redirects to login page
161
+ * 3. Optionally calls onAuthExpired callback
162
+ *
163
+ * To emit auth:expired from your API client:
164
+ * ```js
165
+ * axios.interceptors.response.use(
166
+ * response => response,
167
+ * error => {
168
+ * if (error.response?.status === 401 || error.response?.status === 403) {
169
+ * signals.emit('auth:expired', { status: error.response.status })
170
+ * }
171
+ * return Promise.reject(error)
172
+ * }
173
+ * )
174
+ * ```
175
+ */
176
+ _setupAuthExpiredHandler() {
177
+ const { authAdapter, onAuthExpired } = this.options
178
+ if (!authAdapter) return
179
+
180
+ this.signals.on('auth:expired', async (payload) => {
181
+ const debug = this.options.debug ?? false
182
+ if (debug) {
183
+ console.warn('[Kernel] auth:expired received:', payload)
184
+ }
185
+
186
+ // 1. Logout (clear tokens)
187
+ if (authAdapter.logout) {
188
+ authAdapter.logout()
189
+ }
190
+
191
+ // 2. Emit auth:logout signal
192
+ await this.signals.emit('auth:logout', { reason: 'expired', ...payload })
193
+
194
+ // 3. Redirect to login (if not already there)
195
+ if (this.router.currentRoute.value.name !== 'login') {
196
+ this.router.push({ name: 'login', query: { expired: '1' } })
197
+ }
198
+
199
+ // 4. Optional callback
200
+ if (onAuthExpired) {
201
+ onAuthExpired(payload)
202
+ }
203
+ })
204
+ }
205
+
147
206
  /**
148
207
  * Fire entity cache warmups
149
208
  * Fire-and-forget: pages that need cache will await via DeferredRegistry.
@@ -368,6 +427,52 @@ export class Kernel {
368
427
  })
369
428
  }
370
429
 
430
+ /**
431
+ * Create SSEBridge for Server-Sent Events to SignalBus integration
432
+ *
433
+ * SSE config:
434
+ * ```js
435
+ * sse: {
436
+ * url: '/api/events', // SSE endpoint
437
+ * reconnectDelay: 5000, // Reconnect delay (ms), 0 to disable
438
+ * signalPrefix: 'sse', // Signal prefix (default: 'sse')
439
+ * autoConnect: false, // Connect immediately vs wait for auth:login
440
+ * events: ['task:completed', 'bot:status'] // Event names to register
441
+ * }
442
+ * ```
443
+ */
444
+ _createSSEBridge() {
445
+ const { sse, authAdapter } = this.options
446
+ if (!sse?.url) return
447
+
448
+ const debug = this.options.debug ?? false
449
+
450
+ // Build getToken from authAdapter
451
+ const getToken = authAdapter?.getToken
452
+ ? () => authAdapter.getToken()
453
+ : () => localStorage.getItem('auth_token')
454
+
455
+ this.sseBridge = createSSEBridge({
456
+ signals: this.signals,
457
+ url: sse.url,
458
+ reconnectDelay: sse.reconnectDelay ?? 5000,
459
+ signalPrefix: sse.signalPrefix ?? 'sse',
460
+ autoConnect: sse.autoConnect ?? false,
461
+ withCredentials: sse.withCredentials ?? false,
462
+ tokenParam: sse.tokenParam ?? 'token',
463
+ getToken,
464
+ debug
465
+ })
466
+
467
+ // Register known event names if provided
468
+ if (sse.events?.length) {
469
+ // Wait for connection before registering
470
+ this.signals.once('sse:connected').then(() => {
471
+ this.sseBridge.registerEvents(sse.events)
472
+ })
473
+ }
474
+ }
475
+
371
476
  /**
372
477
  * Create layout components map for useLayoutResolver
373
478
  * Maps layout types to their Vue components.
@@ -438,6 +543,11 @@ export class Kernel {
438
543
  // Signal bus injection
439
544
  app.provide('qdadmSignals', this.signals)
440
545
 
546
+ // SSEBridge injection (if configured)
547
+ if (this.sseBridge) {
548
+ app.provide('qdadmSSEBridge', this.sseBridge)
549
+ }
550
+
441
551
  // Hook registry injection
442
552
  app.provide('qdadmHooks', this.hookRegistry)
443
553
 
@@ -538,6 +648,22 @@ export class Kernel {
538
648
  return this.eventRouter
539
649
  }
540
650
 
651
+ /**
652
+ * Get the SSEBridge instance
653
+ * @returns {import('./SSEBridge.js').SSEBridge|null}
654
+ */
655
+ getSSEBridge() {
656
+ return this.sseBridge
657
+ }
658
+
659
+ /**
660
+ * Shorthand accessor for SSE bridge
661
+ * @returns {import('./SSEBridge.js').SSEBridge|null}
662
+ */
663
+ get sse() {
664
+ return this.sseBridge
665
+ }
666
+
541
667
  /**
542
668
  * Get the layout components map
543
669
  * @returns {object} Layout components by type
@@ -570,4 +696,103 @@ export class Kernel {
570
696
  get security() {
571
697
  return this.securityChecker
572
698
  }
699
+
700
+ /**
701
+ * Setup an axios client with automatic auth and error handling
702
+ *
703
+ * Adds interceptors that:
704
+ * - Add Authorization header with token from authAdapter
705
+ * - Emit auth:expired on 401/403 responses (triggers auto-logout)
706
+ * - Emit api:error on all errors for centralized handling
707
+ *
708
+ * Usage:
709
+ * ```js
710
+ * import axios from 'axios'
711
+ *
712
+ * const apiClient = axios.create({ baseURL: '/api' })
713
+ * kernel.setupApiClient(apiClient)
714
+ *
715
+ * // Now 401/403 errors automatically trigger logout
716
+ * const storage = new ApiStorage({ endpoint: '/users', client: apiClient })
717
+ * ```
718
+ *
719
+ * Or let Kernel create the client:
720
+ * ```js
721
+ * const kernel = new Kernel({
722
+ * ...options,
723
+ * apiClient: { baseURL: '/api' } // axios.create() options
724
+ * })
725
+ * const apiClient = kernel.getApiClient()
726
+ * ```
727
+ *
728
+ * @param {object} client - Axios instance to configure
729
+ * @returns {object} The configured axios instance
730
+ */
731
+ setupApiClient(client) {
732
+ const { authAdapter } = this.options
733
+ const signals = this.signals
734
+ const debug = this.options.debug ?? false
735
+
736
+ // Request interceptor: add Authorization header
737
+ client.interceptors.request.use(
738
+ (config) => {
739
+ if (authAdapter?.getToken) {
740
+ const token = authAdapter.getToken()
741
+ if (token) {
742
+ config.headers = config.headers || {}
743
+ config.headers.Authorization = `Bearer ${token}`
744
+ }
745
+ }
746
+ return config
747
+ },
748
+ (error) => Promise.reject(error)
749
+ )
750
+
751
+ // Response interceptor: handle auth errors
752
+ client.interceptors.response.use(
753
+ (response) => response,
754
+ async (error) => {
755
+ const status = error.response?.status
756
+ const url = error.config?.url
757
+
758
+ // Emit api:error for all errors
759
+ await signals.emit('api:error', {
760
+ status,
761
+ message: error.message,
762
+ url,
763
+ error
764
+ })
765
+
766
+ // Emit auth:expired for 401/403
767
+ if (status === 401 || status === 403) {
768
+ if (debug) {
769
+ console.warn(`[Kernel] API ${status} error on ${url}, emitting auth:expired`)
770
+ }
771
+ await signals.emit('auth:expired', { status, url })
772
+ }
773
+
774
+ return Promise.reject(error)
775
+ }
776
+ )
777
+
778
+ // Store reference
779
+ this._apiClient = client
780
+ return client
781
+ }
782
+
783
+ /**
784
+ * Get the configured API client
785
+ * @returns {object|null} Axios instance or null if not configured
786
+ */
787
+ getApiClient() {
788
+ return this._apiClient
789
+ }
790
+
791
+ /**
792
+ * Shorthand accessor for API client
793
+ * @returns {object|null}
794
+ */
795
+ get api() {
796
+ return this._apiClient
797
+ }
573
798
  }