@appweaver/core 1.1.6 → 1.2.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.
Files changed (42) hide show
  1. package/cache/eviction/lfu-eviction-index.js +3 -0
  2. package/export/export-service.d.ts +4 -2
  3. package/export/export-service.js +27 -17
  4. package/factory/create-model.js +72 -36
  5. package/factory/create-service.js +1 -1
  6. package/package.json +1 -1
  7. package/resource/index.d.ts +1 -0
  8. package/resource/index.js +1 -0
  9. package/resource/resource-schema.d.ts +0 -5
  10. package/resource/resource-schema.js +25 -10
  11. package/resource/resource-service.d.ts +214 -36
  12. package/resource/resource-service.js +247 -404
  13. package/resource/schemas/index.d.ts +3 -0
  14. package/resource/schemas/index.js +19 -0
  15. package/resource/schemas/resource-aggregate-schema.d.ts +54 -0
  16. package/resource/schemas/resource-aggregate-schema.js +131 -0
  17. package/resource/schemas/resource-filter-schema.d.ts +39 -0
  18. package/resource/schemas/resource-filter-schema.js +153 -0
  19. package/resource/schemas/resource-sort-schema.d.ts +37 -0
  20. package/resource/schemas/resource-sort-schema.js +114 -0
  21. package/resource/utils/aggregate-util.d.ts +113 -0
  22. package/resource/utils/aggregate-util.js +323 -0
  23. package/resource/utils/filter-util.d.ts +24 -0
  24. package/resource/utils/filter-util.js +283 -0
  25. package/resource/utils/index.d.ts +4 -0
  26. package/resource/utils/index.js +20 -0
  27. package/resource/utils/relation-util.d.ts +84 -0
  28. package/resource/utils/relation-util.js +344 -0
  29. package/resource/utils/sort-util.d.ts +20 -0
  30. package/resource/utils/sort-util.js +218 -0
  31. package/security/create-auth-resources.js +2 -2
  32. package/security/helper.js +1 -1
  33. package/security/resources/api-key/model.js +1 -0
  34. package/security/resources/api-key/service.d.ts +1 -1
  35. package/security/resources/role/model.js +1 -1
  36. package/server/create-server.js +4 -1
  37. package/server/register-route.js +1 -0
  38. package/server/swagger.js +87 -21
  39. package/server/virtual-projection.js +10 -8
  40. package/types/generated.d.ts +110 -4
  41. package/utils/schema-util.js +1 -1
  42. package/utils/virtual-util.js +1 -1
package/server/swagger.js CHANGED
@@ -11,7 +11,7 @@ const context_1 = require("../context");
11
11
  exports.default = (0, fastify_plugin_1.default)((server) => {
12
12
  server.register(swagger_1.default, {
13
13
  hideUntagged: common_1.config.SWAGGER_HIDE_UNTAGGED,
14
- transformObject: (document) => addConfig(pruneUnusedSchemas(document)),
14
+ transformObject: (document) => addConfig(normalizeUnionTypes(pruneUnusedSchemas(document))),
15
15
  openapi: {
16
16
  info: {
17
17
  title: common_1.config.APP_NAME,
@@ -64,21 +64,22 @@ exports.default = (0, fastify_plugin_1.default)((server) => {
64
64
  });
65
65
  }
66
66
  });
67
- function pruneUnusedSchemas(document) {
68
- const schemas = document.openapiObject.components?.schemas ?? {};
69
- const used = new Set();
67
+ /**
68
+ * Rewrites the JSON Schema type lists of the document into the equivalent
69
+ * `anyOf` unions. The query filter schemas declare their plain values as a
70
+ * type list, so the request validator leaves the matching values untouched
71
+ * instead of coercing them into the first branch of a typed union, but a type
72
+ * list is only valid from OpenAPI 3.1 onwards, while the emitted document is
73
+ * an OpenAPI 3.0 one.
74
+ *
75
+ * @param {Object} document The OpenAPI document to rewrite in place.
76
+ * @returns {Object} The same document with every type list replaced by an
77
+ * `anyOf` union of the single-type schemas it listed.
78
+ */
79
+ function normalizeUnionTypes(document) {
70
80
  const visit = (node) => {
71
- if (!node || typeof node !== 'object')
81
+ if (!node || typeof node !== 'object') {
72
82
  return;
73
- if (typeof node.$ref === 'string') {
74
- const match = node.$ref.match(/^#\/components\/schemas\/(.+)$/);
75
- if (match) {
76
- const schemaName = match[1];
77
- if (!used.has(schemaName)) {
78
- used.add(schemaName);
79
- visit(schemas[schemaName]);
80
- }
81
- }
82
83
  }
83
84
  if (Array.isArray(node)) {
84
85
  for (const item of node)
@@ -88,15 +89,31 @@ function pruneUnusedSchemas(document) {
88
89
  for (const value of Object.values(node)) {
89
90
  visit(value);
90
91
  }
92
+ if (Array.isArray(node.type)) {
93
+ const branches = node.type.map((type) => ({ type }));
94
+ delete node.type;
95
+ // A node carrying both keywords keeps its own union as one branch, so
96
+ // the two constraints still have to be satisfied together
97
+ node.anyOf = node.anyOf ? [{ anyOf: node.anyOf }, ...branches] : branches;
98
+ }
91
99
  };
92
- visit(document.openapiObject.paths);
93
- if (document.openapiObject.components?.schemas) {
94
- document.openapiObject.components.schemas = Object.fromEntries(Object.entries(document.openapiObject.components.schemas)
95
- .sort(([nameA, objA], [nameB, objB]) => String(objA.title ?? nameA).localeCompare(String(objB.title ?? nameB)))
96
- .filter(([name]) => used.has(name)));
97
- }
98
- return document.openapiObject;
100
+ visit(document.paths);
101
+ visit(document.components?.schemas);
102
+ return document;
99
103
  }
104
+ /**
105
+ * Adds the `x-{CONFIG_NAME}-config` extension to the document, describing where
106
+ * the routes of the application are mounted. The generated client reads it to
107
+ * classify the paths of the specification into their route groups, which the
108
+ * paths alone do not reveal once the prefixes are configurable. It holds the
109
+ * base path of every resource route keyed by its model name, together with the
110
+ * configured prefixes of the api, static, health, auth, account, and file
111
+ * routes. Every path is normalized to start with a slash and to end without
112
+ * one.
113
+ *
114
+ * @param {Object} document The OpenAPI document to extend in place.
115
+ * @returns {Object} The same document, carrying the configuration extension.
116
+ */
100
117
  function addConfig(document) {
101
118
  const normalizePath = (path) => {
102
119
  const withLeadingSlash = path.startsWith('/') ? path : `/${path}`;
@@ -120,3 +137,52 @@ function addConfig(document) {
120
137
  };
121
138
  return document;
122
139
  }
140
+ /**
141
+ * Removes the schemas no route refers to from the document and sorts the ones
142
+ * that remain by name. Every model registered on the server is added to the
143
+ * schema registry, including the variants no route uses, so the document is
144
+ * pruned down to the schemas actually reachable from a path. The reachable set
145
+ * is collected by walking the paths for `#/components/schemas/` references and
146
+ * following each one into the schema it points at, so the schemas referenced
147
+ * only by another schema (i.e. a nested relation model, or a query filter
148
+ * referring to itself) are kept as well.
149
+ *
150
+ * @param {Object} document The transform argument of the Swagger plugin,
151
+ * wrapping the OpenAPI document in its `openapiObject` property.
152
+ * @returns {Object} The unwrapped OpenAPI document, with the unreferenced
153
+ * schemas removed and the remaining ones ordered by their title, falling back
154
+ * to the name they are registered under.
155
+ */
156
+ function pruneUnusedSchemas(document) {
157
+ const schemas = document.openapiObject.components?.schemas ?? {};
158
+ const used = new Set();
159
+ const visit = (node) => {
160
+ if (!node || typeof node !== 'object')
161
+ return;
162
+ if (typeof node.$ref === 'string') {
163
+ const match = node.$ref.match(/^#\/components\/schemas\/(.+)$/);
164
+ if (match) {
165
+ const schemaName = match[1];
166
+ if (!used.has(schemaName)) {
167
+ used.add(schemaName);
168
+ visit(schemas[schemaName]);
169
+ }
170
+ }
171
+ }
172
+ if (Array.isArray(node)) {
173
+ for (const item of node)
174
+ visit(item);
175
+ return;
176
+ }
177
+ for (const value of Object.values(node)) {
178
+ visit(value);
179
+ }
180
+ };
181
+ visit(document.openapiObject.paths);
182
+ if (document.openapiObject.components?.schemas) {
183
+ document.openapiObject.components.schemas = Object.fromEntries(Object.entries(document.openapiObject.components.schemas)
184
+ .sort(([nameA, objA], [nameB, objB]) => String(objA.title ?? nameA).localeCompare(String(objB.title ?? nameB)))
185
+ .filter(([name]) => used.has(name)));
186
+ }
187
+ return document.openapiObject;
188
+ }
@@ -47,7 +47,7 @@ function buildVirtualProjectionPlan(responseSchemas, getSchema) {
47
47
  function createVirtualProjectionHook(plan) {
48
48
  return async (_request, reply, payload) => {
49
49
  const entries = plan[String(reply.statusCode)] ?? plan['2xx'];
50
- if (!entries?.length || (!(0, common_1.isObject)(payload) && !(0, common_1.isArray)(payload))) {
50
+ if (!entries?.length || (!(0, common_1.isPlainObject)(payload) && !(0, common_1.isArray)(payload))) {
51
51
  return payload;
52
52
  }
53
53
  try {
@@ -77,7 +77,7 @@ function createVirtualProjectionHook(plan) {
77
77
  * @return {void}
78
78
  */
79
79
  function collectProjectionEntries(schema, path, entries, getSchema, visitedRefs) {
80
- if (!(0, common_1.isObject)(schema)) {
80
+ if (!(0, common_1.isPlainObject)(schema)) {
81
81
  return;
82
82
  }
83
83
  if ((0, common_1.isString)(schema['$ref'])) {
@@ -97,7 +97,7 @@ function collectProjectionEntries(schema, path, entries, getSchema, visitedRefs)
97
97
  return;
98
98
  }
99
99
  // OpenAPI-style response objects wrap the schema in media type content
100
- if ((0, common_1.isObject)(schema['content'])) {
100
+ if ((0, common_1.isPlainObject)(schema['content'])) {
101
101
  for (const media of Object.values(schema['content'])) {
102
102
  collectProjectionEntries(media?.schema, path, entries, getSchema, visitedRefs);
103
103
  }
@@ -110,10 +110,10 @@ function collectProjectionEntries(schema, path, entries, getSchema, visitedRefs)
110
110
  }
111
111
  }
112
112
  }
113
- if ((0, common_1.isObject)(schema['items'])) {
113
+ if ((0, common_1.isPlainObject)(schema['items'])) {
114
114
  collectProjectionEntries(schema['items'], [...path, ARRAY_SEGMENT], entries, getSchema, visitedRefs);
115
115
  }
116
- if ((0, common_1.isObject)(schema['properties'])) {
116
+ if ((0, common_1.isPlainObject)(schema['properties'])) {
117
117
  for (const [key, property] of Object.entries(schema['properties'])) {
118
118
  collectProjectionEntries(property, [...path, key], entries, getSchema, visitedRefs);
119
119
  }
@@ -213,9 +213,11 @@ function applyProjection(value, path, index, resourceName) {
213
213
  }
214
214
  if (index === path.length) {
215
215
  if ((0, common_1.isArray)(value)) {
216
- return value.map((item) => (0, common_1.isObject)(item) ? (0, utils_1.projectVirtualFields)(item, resourceName) : item);
216
+ return value.map((item) => (0, common_1.isPlainObject)(item) ? (0, utils_1.projectVirtualFields)(item, resourceName) : item);
217
217
  }
218
- return (0, common_1.isObject)(value) ? (0, utils_1.projectVirtualFields)(value, resourceName) : value;
218
+ return (0, common_1.isPlainObject)(value)
219
+ ? (0, utils_1.projectVirtualFields)(value, resourceName)
220
+ : value;
219
221
  }
220
222
  const segment = path[index];
221
223
  if (segment === ARRAY_SEGMENT) {
@@ -223,7 +225,7 @@ function applyProjection(value, path, index, resourceName) {
223
225
  ? value.map((item) => applyProjection(item, path, index + 1, resourceName))
224
226
  : value;
225
227
  }
226
- if (!(0, common_1.isObject)(value) || value[segment] === undefined) {
228
+ if (!(0, common_1.isPlainObject)(value) || value[segment] === undefined) {
227
229
  return value;
228
230
  }
229
231
  value[segment] = applyProjection(value[segment], path, index + 1, resourceName);
@@ -1,3 +1,4 @@
1
+ import { AggregateSelect, QueryFilter, QuerySort } from '@appweaver/common';
1
2
  export type ApiKey = {
2
3
  id: number;
3
4
  key: string;
@@ -43,6 +44,28 @@ export type ApiKeyUpdate = {
43
44
  description?: string | null;
44
45
  enabled?: boolean;
45
46
  };
47
+ export type ApiKeyRelationCreate = {
48
+ name?: string | null;
49
+ description?: string | null;
50
+ enabled?: boolean;
51
+ expiresAt?: Date | null;
52
+ };
53
+ export type ApiKeyRelationUpdate = {
54
+ id: number;
55
+ name?: string | null;
56
+ description?: string | null;
57
+ enabled?: boolean;
58
+ };
59
+ export type ApiKeyRelationInput = {
60
+ id?: number;
61
+ name?: string | null;
62
+ description?: string | null;
63
+ enabled?: boolean;
64
+ expiresAt?: Date | null;
65
+ };
66
+ export type ApiKeyQuery = QueryFilter<ApiKey>;
67
+ export type ApiKeySort = QuerySort<ApiKeyMultiple>;
68
+ export type ApiKeyAggregate = AggregateSelect<ApiKey>;
46
69
  export type OneTimeToken = {
47
70
  id: number;
48
71
  tokenHash: string;
@@ -82,6 +105,29 @@ export type OneTimeTokenUpdate = {
82
105
  expiresAt?: Date;
83
106
  data?: any;
84
107
  };
108
+ export type OneTimeTokenRelationCreate = {
109
+ tokenHash: string;
110
+ purpose: string;
111
+ expiresAt: Date;
112
+ data: any;
113
+ };
114
+ export type OneTimeTokenRelationUpdate = {
115
+ id: number;
116
+ tokenHash?: string;
117
+ purpose?: string;
118
+ expiresAt?: Date;
119
+ data?: any;
120
+ };
121
+ export type OneTimeTokenRelationInput = {
122
+ id?: number;
123
+ tokenHash?: string;
124
+ purpose?: string;
125
+ expiresAt?: Date;
126
+ data?: any;
127
+ };
128
+ export type OneTimeTokenQuery = QueryFilter<OneTimeToken>;
129
+ export type OneTimeTokenSort = QuerySort<OneTimeTokenMultiple>;
130
+ export type OneTimeTokenAggregate = AggregateSelect<OneTimeToken>;
85
131
  export type Permission = {
86
132
  id: number;
87
133
  name: string;
@@ -103,6 +149,20 @@ export type PermissionCreate = {
103
149
  export type PermissionUpdate = {
104
150
  name?: string;
105
151
  };
152
+ export type PermissionRelationCreate = {
153
+ name: string;
154
+ };
155
+ export type PermissionRelationUpdate = {
156
+ id: number;
157
+ name?: string;
158
+ };
159
+ export type PermissionRelationInput = {
160
+ id?: number;
161
+ name?: string;
162
+ };
163
+ export type PermissionQuery = QueryFilter<Permission>;
164
+ export type PermissionSort = QuerySort<PermissionMultiple>;
165
+ export type PermissionAggregate = AggregateSelect<Permission>;
106
166
  export type Role = {
107
167
  id: number;
108
168
  name: string;
@@ -114,25 +174,39 @@ export type Role = {
114
174
  export type RoleSingle = {
115
175
  id: number;
116
176
  name: string;
117
- permissions: Array<PermissionSingle>;
177
+ permissions?: Array<PermissionSingle>;
118
178
  };
119
179
  export type RoleMultiple = {
120
180
  id: number;
121
181
  name: string;
122
- permissions: Array<PermissionSingle>;
182
+ permissions?: Array<PermissionSingle>;
123
183
  };
124
184
  export type RoleCreate = {
125
185
  name: string;
126
186
  permissions: Array<{
127
187
  id: number;
128
- }> | Array<number>;
188
+ } | number>;
129
189
  };
130
190
  export type RoleUpdate = {
131
191
  name?: string;
132
192
  permissions?: Array<{
133
193
  id: number;
134
- }> | Array<number>;
194
+ } | number>;
195
+ };
196
+ export type RoleRelationCreate = {
197
+ name: string;
198
+ };
199
+ export type RoleRelationUpdate = {
200
+ id: number;
201
+ name?: string;
135
202
  };
203
+ export type RoleRelationInput = {
204
+ id?: number;
205
+ name?: string;
206
+ };
207
+ export type RoleQuery = QueryFilter<Role>;
208
+ export type RoleSort = QuerySort<RoleMultiple>;
209
+ export type RoleAggregate = AggregateSelect<Role>;
136
210
  export type File = {
137
211
  id: number;
138
212
  name: string;
@@ -196,3 +270,35 @@ export type FileUpdate = {
196
270
  title?: string | null;
197
271
  description?: string | null;
198
272
  };
273
+ export type FileRelationCreate = {
274
+ name: string;
275
+ originalName: string;
276
+ mimeType: string;
277
+ sizeBytes: number;
278
+ checksum: string;
279
+ title?: string | null;
280
+ description?: string | null;
281
+ };
282
+ export type FileRelationUpdate = {
283
+ id: number;
284
+ name?: string;
285
+ originalName?: string;
286
+ mimeType?: string;
287
+ sizeBytes?: number;
288
+ checksum?: string;
289
+ title?: string | null;
290
+ description?: string | null;
291
+ };
292
+ export type FileRelationInput = {
293
+ id?: number;
294
+ name?: string;
295
+ originalName?: string;
296
+ mimeType?: string;
297
+ sizeBytes?: number;
298
+ checksum?: string;
299
+ title?: string | null;
300
+ description?: string | null;
301
+ };
302
+ export type FileQuery = QueryFilter<File>;
303
+ export type FileSort = QuerySort<FileMultiple>;
304
+ export type FileAggregate = AggregateSelect<File>;
@@ -25,7 +25,7 @@ function createSchemaModel(schema, config = {}) {
25
25
  schema.title ??
26
26
  `Object${existingModels.length}`;
27
27
  const reference = typebox_1.Type.Ref(name);
28
- if (existingModels.find((m) => m.name === name) &&
28
+ if (existingModels.find((m) => m.value?.name === name) &&
29
29
  config.skipExisting !== false) {
30
30
  return reference;
31
31
  }
@@ -40,7 +40,7 @@ function projectVirtualFields(resource, resourceName) {
40
40
  const value = projectedVirtual[key];
41
41
  const relationSchema = (0, common_1.extractSchemaProperties)(relationsModel, key);
42
42
  const fileSchema = (0, common_1.extractSchemaProperties)(filesModel, key);
43
- if ((0, common_1.isObject)(value) || ((0, common_1.isArray)(value) && (0, common_1.isObject)(value[0]))) {
43
+ if ((0, common_1.isPlainObject)(value) || ((0, common_1.isArray)(value) && (0, common_1.isPlainObject)(value[0]))) {
44
44
  const nestedResourceName = (0, common_1.extractResourceName)(relationSchema ?? fileSchema);
45
45
  if (nestedResourceName) {
46
46
  projectedVirtual[key] = (0, common_1.isArray)(value)