json-rest-api 1.0.28 → 2.0.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.
- package/LICENSE +10 -670
- package/LICENSE-GPL-3.0 +674 -0
- package/LICENSE-MIT +21 -0
- package/README.md +39 -30
- package/docs/API.md +746 -1273
- package/docs/GUIDE/01-getting-started.md +338 -0
- package/docs/GUIDE/02-resources-and-relationships.md +33 -0
- package/docs/GUIDE/03-running-example.md +87 -0
- package/docs/GUIDE/04-creating-and-querying.md +181 -0
- package/docs/GUIDE/05-belongs-to.md +177 -0
- package/docs/GUIDE/06-has-many.md +184 -0
- package/docs/GUIDE/07-polymorphic-relationships.md +191 -0
- package/docs/GUIDE/08-many-to-many.md +184 -0
- package/docs/GUIDE/09-pagination-and-sorting.md +149 -0
- package/docs/GUIDE/10-put-and-patch.md +174 -0
- package/docs/GUIDE/11-relationship-endpoints.md +174 -0
- package/docs/GUIDE/12-field-transformations.md +287 -0
- package/docs/GUIDE/13-hooks-and-lifecycle.md +479 -0
- package/docs/GUIDE/14-custom-resource-methods.md +125 -0
- package/docs/GUIDE/15-query-projections.md +266 -0
- package/docs/GUIDE/{GUIDE_X_Autofiltering.md → 16-autofiltering.md} +77 -15
- package/docs/GUIDE/{GUIDE_X_Row_Policies.md → 17-row-policies.md} +137 -44
- package/docs/GUIDE/18-labels.md +86 -0
- package/docs/GUIDE/19-managed-transactions.md +179 -0
- package/docs/GUIDE/20-transaction-outcomes.md +207 -0
- package/docs/GUIDE/21-schema-and-migrations.md +634 -0
- package/docs/GUIDE/22-version-field-migrations.md +208 -0
- package/docs/GUIDE/23-fastify.md +179 -0
- package/docs/GUIDE/24-cors.md +172 -0
- package/docs/GUIDE/{GUIDE_X_URL_Management.md → 25-url-management.md} +7 -1
- package/docs/GUIDE/26-file-uploads.md +278 -0
- package/docs/GUIDE/27-bulk-operations.md +277 -0
- package/docs/GUIDE/28-socketio.md +325 -0
- package/docs/GUIDE/29-writing-plugins.md +278 -0
- package/docs/GUIDE/30-backend-capabilities.md +100 -0
- package/docs/GUIDE/31-positioning.md +181 -0
- package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +90 -31
- package/docs/GUIDE/33-migrating-to-v2.md +3827 -0
- package/docs/GUIDE/index.md +51 -59
- package/docs/QUICKSTART.md +120 -287
- package/docs/architecture.md +75 -0
- package/docs/contributing.md +178 -0
- package/index.d.ts +36 -0
- package/index.js +1 -22
- package/lib/enhanced-logger.js +91 -85
- package/lib/error-context.js +445 -5
- package/lib/error-formatter.js +219 -59
- package/lib/knex-transaction.js +64 -0
- package/lib/logger-types.d.ts +22 -0
- package/lib/missing-package.js +4 -0
- package/lib/rest-api-errors.js +103 -8
- package/lib/runtime/json-rest-api.js +172 -0
- package/lib/transaction-types.d.ts +59 -0
- package/package.json +97 -27
- package/plugins/core/bulk-operations-plugin.js +191 -184
- package/plugins/core/connectors/express-plugin.js +148 -219
- package/plugins/core/connectors/fastify-plugin.js +125 -82
- package/plugins/core/connectors/lib/busboy-detector.js +74 -156
- package/plugins/core/connectors/lib/connector-core.js +144 -20
- package/plugins/core/connectors/lib/formidable-detector.js +63 -120
- package/plugins/core/connectors/lib/http-validators.js +77 -0
- package/plugins/core/connectors/lib/multipart-helpers.js +18 -0
- package/plugins/core/connectors/lib/request-helpers.js +10 -1
- package/plugins/core/connectors/lib/transport-http-helpers.js +89 -8
- package/plugins/core/connectors/lib/transport-route-schemas.js +7 -4
- package/plugins/core/file-handling-plugin.js +87 -174
- package/plugins/core/lib/anyapi/anyapi-registry.js +213 -164
- package/plugins/core/lib/anyapi/canonical-link-store.js +507 -0
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +598 -0
- package/plugins/core/lib/anyapi/schema-utils.js +25 -16
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
- package/plugins/core/lib/dbIntrospection.js +228 -21
- package/plugins/core/lib/dbTablesOperations.js +409 -92
- package/plugins/core/lib/fractional-positioning.js +12 -48
- package/plugins/core/lib/querying/identifier-query.js +19 -0
- package/plugins/core/lib/querying/include-query-helpers.js +364 -0
- package/plugins/core/lib/querying/include-to-many.js +428 -0
- package/plugins/core/lib/querying/include-to-one.js +579 -0
- package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
- package/plugins/core/lib/querying/knex-pagination-helpers.js +67 -330
- package/plugins/core/lib/querying/knex-process-includes.js +5 -110
- package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
- package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
- package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
- package/plugins/core/lib/querying/knex-window-queries.js +62 -253
- package/plugins/core/lib/querying/query-builder-utils.js +44 -4
- package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
- package/plugins/core/lib/querying/query-constraint.js +27 -0
- package/plugins/core/lib/querying/query-field-sort-helpers.js +205 -30
- package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
- package/plugins/core/lib/querying/sort-helpers.js +20 -1
- package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
- package/plugins/core/lib/querying/url-helpers.js +11 -4
- package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
- package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +238 -187
- package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
- package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
- package/plugins/core/lib/querying-writing/field-utils.js +113 -92
- package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
- package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +111 -287
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
- package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
- package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
- package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
- package/plugins/core/lib/querying-writing/request-contracts.js +109 -44
- package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
- package/plugins/core/lib/querying-writing/response-options.js +35 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
- package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
- package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
- package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
- package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
- package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
- package/plugins/core/lib/storage/storage-adapter.js +142 -62
- package/plugins/core/lib/storage/storage-mapping.js +78 -28
- package/plugins/core/lib/storage/storage-types.d.ts +502 -0
- package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
- package/plugins/core/lib/writing/relationship-processor.js +94 -158
- package/plugins/core/lib/writing/resource-version.js +172 -0
- package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +116 -2509
- package/plugins/core/rest-api-autofilter-plugin.js +17 -19
- package/plugins/core/rest-api-cors-plugin.js +52 -205
- package/plugins/core/rest-api-knex-plugin.js +45 -1069
- package/plugins/core/rest-api-label-plugin.js +25 -38
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
- package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
- package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
- package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
- package/plugins/core/rest-api-plugin-methods/common.js +291 -385
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
- package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -145
- package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
- package/plugins/core/rest-api-plugin-methods/get.js +38 -69
- package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
- package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
- package/plugins/core/rest-api-plugin-methods/post.js +51 -51
- package/plugins/core/rest-api-plugin-methods/put.js +69 -148
- package/plugins/core/rest-api-plugin-methods/query.js +39 -82
- package/plugins/core/rest-api-plugin-methods/release.js +2 -1
- package/plugins/core/rest-api-plugin.js +32 -46
- package/plugins/core/rest-api-positioning-plugin.js +183 -628
- package/plugins/core/rest-api-query-projections-plugin.js +19 -102
- package/plugins/core/rest-api-row-policy-plugin.js +3 -3
- package/plugins/core/socketio-plugin.js +364 -344
- package/plugins/storage/local-storage.js +40 -36
- package/types/autofilter.d.ts +57 -0
- package/types/bulk-methods.d.ts +63 -0
- package/types/errors.d.ts +118 -0
- package/types/fastify-options.d.ts +19 -0
- package/types/file-detectors.d.ts +33 -0
- package/types/file-storage.d.ts +48 -0
- package/types/hook-context.d.ts +180 -0
- package/types/plugin-options.d.ts +53 -0
- package/types/relationship-methods.d.ts +74 -0
- package/types/representations.d.ts +50 -0
- package/types/resource-methods.d.ts +95 -0
- package/types/resource-schema.d.ts +157 -0
- package/types/row-policy.d.ts +31 -0
- package/types/runtime.d.ts +132 -0
- package/types/transactions.d.ts +21 -0
- package/.claude/agents/library-guide-expert.md +0 -30
- package/.claude/agents/rest-api-internals-expert.md +0 -54
- package/.claude/settings.local.json +0 -70
- package/AGENTS.md +0 -34
- package/CLAUDE.md +0 -160
- package/CRUSH.md +0 -55
- package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
- package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
- package/TODO.todo +0 -13
- package/connectors_ideas/fastify.js +0 -217
- package/connectors_ideas/koa-plugin.js +0 -112
- package/docs/.bundle/config +0 -2
- package/docs/COMPARISON.md +0 -433
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
- package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
- package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
- package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
- package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1238
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
- package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
- package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
- package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
- package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
- package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
- package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
- package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
- package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
- package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
- package/docs/Gemfile +0 -6
- package/docs/Gemfile.lock +0 -308
- package/docs/ONBOARDING.md +0 -315
- package/docs/_config.yml +0 -36
- package/docs/_layouts/default.html +0 -123
- package/docs/apple-touch-icon.svg +0 -4
- package/docs/assets/css/style.scss +0 -392
- package/docs/favicon.svg +0 -4
- package/docs/index.md +0 -164
- package/docs/mini-guides/file-uploads-complete.md +0 -161
- package/eslint.config.js +0 -13
- package/examples/url-override-example.js +0 -137
- package/fixing_plan.md +0 -112
- package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
- package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
- package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
- package/plugins/storage/s3-storage.js +0 -93
- package/quickTest.js +0 -136
- package/scripts/debug-anyapi-review-filter.mjs +0 -63
- package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
- package/scripts/inspect-anyapi-descriptor.mjs +0 -19
- package/scripts/run-docs.js +0 -133
- package/test-debug.js +0 -59
- package/tests/README.md +0 -202
- package/tests/TEST_TEMPLATE.test.js +0 -178
- package/tests/anyapi-basic.test.js +0 -105
- package/tests/anyapi-cursor-pagination.test.js +0 -150
- package/tests/anyapi-custom-idproperty.test.js +0 -163
- package/tests/autofilter.test.js +0 -463
- package/tests/bulk-operations.test.js +0 -657
- package/tests/computed-fields.test.js +0 -586
- package/tests/cors.test.js +0 -834
- package/tests/curl.test.js +0 -467
- package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
- package/tests/custom-id-simplified-post.test.js +0 -75
- package/tests/custom-idproperty-relationships.test.js +0 -932
- package/tests/db-introspection.test.js +0 -521
- package/tests/db-table-operations.test.js +0 -798
- package/tests/fastify-plugin.test.js +0 -184
- package/tests/field-getters.test.js +0 -370
- package/tests/field-setters.test.js +0 -306
- package/tests/file-handling.test.js +0 -118
- package/tests/fixtures/api-configs.js +0 -2509
- package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
- package/tests/helpers/fake-fastify.js +0 -99
- package/tests/helpers/storage-mode.js +0 -65
- package/tests/helpers/test-utils.js +0 -203
- package/tests/http-connectors-parity.test.js +0 -395
- package/tests/id-normalization.test.js +0 -444
- package/tests/include-depth-validation.test.js +0 -331
- package/tests/includes.test.js +0 -427
- package/tests/local-storage.test.js +0 -96
- package/tests/logical-id-write-validation.test.js +0 -151
- package/tests/nested-includes.test.js +0 -617
- package/tests/pagination-cursor-multifield.test.js +0 -643
- package/tests/pagination-enhanced.test.js +0 -563
- package/tests/polymorphic-relationships.test.js +0 -347
- package/tests/polymorphic-simple.test.js +0 -148
- package/tests/positioning-advanced.test.js +0 -692
- package/tests/positioning-extended.test.js +0 -915
- package/tests/positioning-multifilter.test.js +0 -699
- package/tests/positioning.test.js +0 -560
- package/tests/post-commit-hooks.test.js +0 -120
- package/tests/projected-fields.test.js +0 -319
- package/tests/public-exports.test.js +0 -15
- package/tests/put-create-parity.test.js +0 -216
- package/tests/queries.test.js +0 -521
- package/tests/query-limits.test.js +0 -584
- package/tests/relationship-endpoints.test.js +0 -620
- package/tests/relationship-include-storage-adapter.test.js +0 -56
- package/tests/relationships.test.js +0 -681
- package/tests/rest-api.test.js +0 -337
- package/tests/return-record-settings.test.js +0 -184
- package/tests/row-policy.test.js +0 -489
- package/tests/s3-storage.test.js +0 -38
- package/tests/searchschema-knex-behavior.test.js +0 -187
- package/tests/searchschema-merge.test.js +0 -151
- package/tests/socketio.test.js +0 -860
- package/tests/storage-mapping.test.js +0 -362
- package/tests/test-development-guide.md +0 -370
- package/tests/virtual-fields.test.js +0 -404
|
@@ -1,7 +1,31 @@
|
|
|
1
1
|
import {
|
|
2
|
+
RestApiWriteError,
|
|
2
3
|
REST_API_FIELDSET_ERROR_CODE,
|
|
4
|
+
REST_API_INCLUDE_ERROR_CODE,
|
|
3
5
|
REST_API_TEMPORAL_DATA_ERROR_CODE
|
|
4
6
|
} from '../../../../lib/rest-api-errors.js'
|
|
7
|
+
import { isRestApiError } from '../../../../lib/error-context.js'
|
|
8
|
+
import { parse as parseContentType } from 'content-type'
|
|
9
|
+
import vary from 'vary'
|
|
10
|
+
|
|
11
|
+
export function mergeResponseHeaders (...sources) {
|
|
12
|
+
const headers = new Map()
|
|
13
|
+
for (const source of sources) {
|
|
14
|
+
for (const [name, value] of Object.entries(source || {})) {
|
|
15
|
+
if (value === undefined || value === null) continue
|
|
16
|
+
const key = name.toLowerCase()
|
|
17
|
+
headers.set(key, key === 'vary' ? vary.append(headers.get(key) || '', value) : value)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return Object.fromEntries(headers)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getHttpDiagnosticError (error) {
|
|
24
|
+
// Parser messages and stacks can quote raw input before a resource is known.
|
|
25
|
+
// Keep the original error for hooks/responses, but log only this safe preview.
|
|
26
|
+
if (error?.type !== 'entity.parse.failed') return error
|
|
27
|
+
return { name: 'SyntaxError', message: 'Invalid JSON request body', type: 'entity.parse.failed', status: 400 }
|
|
28
|
+
}
|
|
5
29
|
|
|
6
30
|
const JSON_API_WRITE_CONTENT_TYPES = [
|
|
7
31
|
'application/vnd.api+json',
|
|
@@ -12,6 +36,10 @@ export function isWriteMethod (method) {
|
|
|
12
36
|
return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(String(method || '').toUpperCase())
|
|
13
37
|
}
|
|
14
38
|
|
|
39
|
+
export function isMultipartContentType (contentType) {
|
|
40
|
+
try { return parseContentType(contentType).type === 'multipart/form-data' } catch { return false }
|
|
41
|
+
}
|
|
42
|
+
|
|
15
43
|
export function getAllowedWriteContentTypes ({ allowMultipart = false } = {}) {
|
|
16
44
|
return allowMultipart
|
|
17
45
|
? [...JSON_API_WRITE_CONTENT_TYPES, 'multipart/form-data']
|
|
@@ -20,9 +48,40 @@ export function getAllowedWriteContentTypes ({ allowMultipart = false } = {}) {
|
|
|
20
48
|
|
|
21
49
|
export function isAllowedWriteContentType (contentType, { allowMultipart = false } = {}) {
|
|
22
50
|
if (!contentType) return true
|
|
51
|
+
try {
|
|
52
|
+
const media = parseContentType(contentType)
|
|
53
|
+
return getAllowedWriteContentTypes({ allowMultipart }).includes(media.type) &&
|
|
54
|
+
(media.type !== JSON_API_WRITE_CONTENT_TYPES[0] || Object.keys(media.parameters).every(key => key === 'profile'))
|
|
55
|
+
} catch { return false }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function acceptsJsonApi (accept) {
|
|
59
|
+
if (accept === undefined) return true
|
|
60
|
+
// Split only between media ranges; quoted profile URIs can contain commas.
|
|
61
|
+
const ranges = (String(accept).match(/(?:[^,"]|"(?:\\.|[^"\\])*")+/g) || []).flatMap(value => {
|
|
62
|
+
try { return [parseContentType(value.trim())] } catch { return [] }
|
|
63
|
+
})
|
|
64
|
+
const vendorType = JSON_API_WRITE_CONTENT_TYPES[0]
|
|
65
|
+
const validParameters = media => Object.keys(media.parameters).every(key => ['profile', 'q'].includes(key))
|
|
66
|
+
const vendorRanges = ranges.filter(media => media.type === vendorType)
|
|
67
|
+
if (vendorRanges.length && !vendorRanges.some(validParameters)) return false
|
|
68
|
+
const matches = ranges.flatMap(media => {
|
|
69
|
+
const specificity = [vendorType, 'application/*', '*/*'].indexOf(media.type)
|
|
70
|
+
if (specificity < 0 || !validParameters(media)) return []
|
|
71
|
+
const q = media.parameters.q ?? '1'
|
|
72
|
+
const quality = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/.test(q) ? Number(q) : 0
|
|
73
|
+
return [{ specificity, quality }]
|
|
74
|
+
})
|
|
75
|
+
matches.sort((a, b) => a.specificity - b.specificity || b.quality - a.quality)
|
|
76
|
+
return (matches[0]?.quality || 0) > 0
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getNotAcceptableErrorBody () {
|
|
80
|
+
return { errors: [{ status: '406', title: 'Not Acceptable', detail: 'Accept must allow application/vnd.api+json without unsupported parameters or extensions' }] }
|
|
81
|
+
}
|
|
23
82
|
|
|
24
|
-
|
|
25
|
-
|
|
83
|
+
export function getNotFoundErrorBody (method, url) {
|
|
84
|
+
return { errors: [{ status: '404', title: 'Not Found', detail: `The requested endpoint ${method} ${url} does not exist` }] }
|
|
26
85
|
}
|
|
27
86
|
|
|
28
87
|
export function getUnsupportedMediaTypeErrorBody ({ allowMultipart = false } = {}) {
|
|
@@ -53,6 +112,9 @@ export function determineResponseStatus (method, result) {
|
|
|
53
112
|
}
|
|
54
113
|
|
|
55
114
|
export function mapRestApiErrorToHttp (error) {
|
|
115
|
+
// Read hooks can reject with null or undefined.
|
|
116
|
+
error ??= {}
|
|
117
|
+
const statusHint = error.statusCode ?? error.status
|
|
56
118
|
let status = 500
|
|
57
119
|
let errors = [{
|
|
58
120
|
status: '500',
|
|
@@ -76,6 +138,15 @@ export function mapRestApiErrorToHttp (error) {
|
|
|
76
138
|
title: 'Invalid Sparse Fieldset',
|
|
77
139
|
detail: error.message
|
|
78
140
|
}]
|
|
141
|
+
} else if (error.code === REST_API_INCLUDE_ERROR_CODE) {
|
|
142
|
+
status = 400
|
|
143
|
+
errors = [{
|
|
144
|
+
status: '400',
|
|
145
|
+
code: error.code,
|
|
146
|
+
title: 'Invalid Include Path',
|
|
147
|
+
detail: error.message,
|
|
148
|
+
source: { parameter: 'include' }
|
|
149
|
+
}]
|
|
79
150
|
} else if (error.code === REST_API_TEMPORAL_DATA_ERROR_CODE) {
|
|
80
151
|
status = 500
|
|
81
152
|
errors = [{
|
|
@@ -101,6 +172,12 @@ export function mapRestApiErrorToHttp (error) {
|
|
|
101
172
|
...(error.details ? { source: error.details } : {})
|
|
102
173
|
}]
|
|
103
174
|
}
|
|
175
|
+
} else if (error.code === 'REST_API_PRECONDITION_FAILED') {
|
|
176
|
+
status = 412
|
|
177
|
+
errors = [{ status: '412', code: error.code, title: 'Precondition Failed', detail: error.message }]
|
|
178
|
+
} else if (error.code === 'REST_API_VERSION_CONFLICT') {
|
|
179
|
+
status = 409
|
|
180
|
+
errors = [{ status: '409', code: error.code, title: 'Version Conflict', detail: error.message }]
|
|
104
181
|
} else if (error.code === 'REST_API_RESOURCE') {
|
|
105
182
|
switch (error.subtype) {
|
|
106
183
|
case 'not_found':
|
|
@@ -120,15 +197,15 @@ export function mapRestApiErrorToHttp (error) {
|
|
|
120
197
|
errors = [{ status: '400', title: 'Bad Request', detail: error.message }]
|
|
121
198
|
}
|
|
122
199
|
} else if (error.code === 'REST_API_PAYLOAD') {
|
|
123
|
-
status = 400
|
|
200
|
+
status = error.statusCode === 413 ? 413 : 400
|
|
124
201
|
errors = [{
|
|
125
|
-
status:
|
|
126
|
-
title: 'Bad Request',
|
|
202
|
+
status: String(status),
|
|
203
|
+
title: status === 413 ? 'Content Too Large' : 'Bad Request',
|
|
127
204
|
detail: error.message,
|
|
128
|
-
...(error.path ? { source: { pointer: error.path } } : {})
|
|
205
|
+
...(error.parameter ? { source: { parameter: error.parameter } } : error.path ? { source: { pointer: error.path } } : {})
|
|
129
206
|
}]
|
|
130
|
-
} else if (typeof
|
|
131
|
-
status =
|
|
207
|
+
} else if (typeof statusHint === 'number' && statusHint >= 400 && statusHint < 500) {
|
|
208
|
+
status = statusHint
|
|
132
209
|
errors = [{
|
|
133
210
|
status: String(status),
|
|
134
211
|
title: status === 415 ? 'Unsupported Media Type' : 'Bad Request',
|
|
@@ -136,6 +213,10 @@ export function mapRestApiErrorToHttp (error) {
|
|
|
136
213
|
}]
|
|
137
214
|
}
|
|
138
215
|
|
|
216
|
+
if (isRestApiError(error) && error instanceof RestApiWriteError) {
|
|
217
|
+
errors = errors.map(item => ({ ...item, meta: { ...item.meta, transactionOutcome: error.transactionOutcome } }))
|
|
218
|
+
}
|
|
219
|
+
|
|
139
220
|
return {
|
|
140
221
|
status,
|
|
141
222
|
body: { errors }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getRequestContracts } from '../../lib/querying-writing/request-contracts.js'
|
|
2
2
|
|
|
3
|
-
export function
|
|
3
|
+
export function getTransportRouteContract ({ routeMeta, api }) {
|
|
4
4
|
if (!routeMeta) {
|
|
5
5
|
return null
|
|
6
6
|
}
|
|
@@ -38,7 +38,10 @@ export function buildTransportRouteSchema ({ routeMeta, api }) {
|
|
|
38
38
|
return null
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
return
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
return contract
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildTransportRouteSchema (options) {
|
|
45
|
+
const contract = getTransportRouteContract(options)
|
|
46
|
+
return contract ? { body: contract.schema.toJsonSchema({ mode: contract.mode, additionalProperties: false }) } : null
|
|
44
47
|
}
|
|
@@ -1,48 +1,17 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* File Handling Plugin for JSON REST API
|
|
3
|
-
*
|
|
4
|
-
* This plugin provides automatic file upload handling based on schema definitions.
|
|
5
|
-
* It works with any protocol connector (HTTP, Express, WebSocket, etc.) by using
|
|
6
|
-
* a detector registry pattern.
|
|
7
|
-
*
|
|
8
|
-
* Features:
|
|
9
|
-
* - Schema-driven: Detects file fields from type: 'file' in schemas
|
|
10
|
-
* - Protocol-agnostic: Works with any connector that registers a detector
|
|
11
|
-
* - Storage pluggable: Different fields can use different storage backends
|
|
12
|
-
* - Zero configuration: Just define file fields in your schema
|
|
13
|
-
*
|
|
14
|
-
* Usage:
|
|
15
|
-
* ```javascript
|
|
16
|
-
* // 1. Define schema with file fields
|
|
17
|
-
* const imageSchema = {
|
|
18
|
-
* title: { type: 'string' },
|
|
19
|
-
* uploadedImage: {
|
|
20
|
-
* type: 'file',
|
|
21
|
-
* storage: S3Storage,
|
|
22
|
-
* accepts: ['image/jpeg', 'image/png']
|
|
23
|
-
* }
|
|
24
|
-
* };
|
|
25
|
-
*
|
|
26
|
-
* // 2. Use plugins (order matters - file-handling depends on rest-api)
|
|
27
|
-
* api.use(RestApiPlugin);
|
|
28
|
-
* api.use(FileHandlingPlugin);
|
|
29
|
-
* api.use(ExpressPlugin); // Or any other connector
|
|
30
|
-
*
|
|
31
|
-
* // 3. Files are automatically handled!
|
|
32
|
-
* ```
|
|
33
|
-
*/
|
|
1
|
+
/** File detection, uploads and cleanup tied to resource writes. */
|
|
34
2
|
|
|
35
3
|
import { RestApiValidationError } from '../../lib/rest-api-errors.js'
|
|
4
|
+
import { getOperationDiagnosticContext, wrapUnexpectedError } from '../../lib/error-context.js'
|
|
5
|
+
import { createEnhancedLogger } from '../../lib/enhanced-logger.js'
|
|
36
6
|
|
|
37
7
|
export const FileHandlingPlugin = {
|
|
38
8
|
name: 'file-handling',
|
|
39
9
|
dependencies: ['rest-api'],
|
|
40
10
|
|
|
41
|
-
install ({ addHook,
|
|
42
|
-
|
|
43
|
-
const
|
|
11
|
+
install ({ addHook, scopes, log, api }) {
|
|
12
|
+
const boundedLog = createEnhancedLogger(log)
|
|
13
|
+
const fileFieldsBySchema = new WeakMap()
|
|
44
14
|
|
|
45
|
-
// Registry of file detectors from various protocols
|
|
46
15
|
const detectorRegistry = []
|
|
47
16
|
|
|
48
17
|
/**
|
|
@@ -59,27 +28,31 @@ export const FileHandlingPlugin = {
|
|
|
59
28
|
}
|
|
60
29
|
|
|
61
30
|
detectorRegistry.push(detector)
|
|
62
|
-
|
|
31
|
+
boundedLog.debug(`Registered file detector: ${detector.name}`)
|
|
63
32
|
}
|
|
64
33
|
|
|
65
|
-
// Store detectors array for inspection
|
|
66
34
|
api.rest.fileDetectors = detectorRegistry
|
|
67
35
|
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
36
|
+
const recordCleanupFailure = async (context, phase, field, error) => {
|
|
37
|
+
const errors = context.cleanupErrors ||= []
|
|
38
|
+
errors.push({ phase, field, error })
|
|
39
|
+
try {
|
|
40
|
+
await createEnhancedLogger(log, { schemaInfo: context.schemaInfo }).warn('File cleanup failed', {
|
|
41
|
+
...getOperationDiagnosticContext(context, { phase }), field, error
|
|
42
|
+
})
|
|
43
|
+
} catch (error) {
|
|
44
|
+
errors.push({ phase: 'logging', during: phase, field, error })
|
|
71
45
|
}
|
|
72
|
-
context.fileHandlingUploads.push(upload)
|
|
73
46
|
}
|
|
74
47
|
|
|
75
|
-
const cleanupParsedFiles = async (files = {}) => {
|
|
76
|
-
for (const file of Object.
|
|
48
|
+
const cleanupParsedFiles = async (files = {}, context) => {
|
|
49
|
+
for (const [field, file] of Object.entries(files)) {
|
|
77
50
|
if (!file?.cleanup) continue
|
|
78
51
|
|
|
79
52
|
try {
|
|
80
53
|
await file.cleanup()
|
|
81
54
|
} catch (error) {
|
|
82
|
-
|
|
55
|
+
await recordCleanupFailure(context, 'temporaryFileCleanup', field, error)
|
|
83
56
|
}
|
|
84
57
|
}
|
|
85
58
|
}
|
|
@@ -88,129 +61,89 @@ export const FileHandlingPlugin = {
|
|
|
88
61
|
const uploads = context.fileHandlingUploads
|
|
89
62
|
if (!uploads || uploads.length === 0) return
|
|
90
63
|
|
|
91
|
-
|
|
64
|
+
const remaining = []
|
|
92
65
|
for (const upload of uploads) {
|
|
93
|
-
if (!upload
|
|
66
|
+
if (upload.transaction !== context.transaction || !upload.url || typeof upload.storage?.delete !== 'function') {
|
|
67
|
+
remaining.push(upload)
|
|
94
68
|
continue
|
|
95
69
|
}
|
|
96
70
|
|
|
97
71
|
try {
|
|
98
72
|
await upload.storage.delete(upload.url)
|
|
99
73
|
} catch (error) {
|
|
100
|
-
|
|
74
|
+
remaining.push(upload)
|
|
75
|
+
await recordCleanupFailure(context, 'uploadedFileCleanup', upload.field, error)
|
|
101
76
|
}
|
|
102
77
|
}
|
|
78
|
+
context.fileHandlingUploads = remaining
|
|
103
79
|
}
|
|
104
80
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (!schema) return
|
|
81
|
+
const getFileFields = scopeName => {
|
|
82
|
+
const schemaInfo = scopes[scopeName]?.vars?.schemaInfo
|
|
83
|
+
if (!schemaInfo) return []
|
|
84
|
+
const cached = fileFieldsBySchema.get(schemaInfo)
|
|
85
|
+
if (cached) return cached
|
|
111
86
|
|
|
112
87
|
const fileFields = []
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
for (const [fieldName, fieldConfig] of Object.entries(schema)) {
|
|
116
|
-
if (fieldConfig && fieldConfig.type === 'file') {
|
|
88
|
+
for (const [fieldName, fieldConfig] of Object.entries(schemaInfo.schemaStructure)) {
|
|
89
|
+
if (fieldConfig?.type === 'file') {
|
|
117
90
|
fileFields.push({
|
|
118
91
|
field: fieldName,
|
|
119
92
|
storage: fieldConfig.storage,
|
|
120
93
|
accepts: fieldConfig.accepts || ['*'],
|
|
121
|
-
maxSize: fieldConfig.maxSize
|
|
122
|
-
required: fieldConfig.required || false
|
|
94
|
+
maxSize: fieldConfig.maxSize
|
|
123
95
|
})
|
|
124
96
|
}
|
|
125
97
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
fileScopes.set(scopeName, fileFields)
|
|
129
|
-
log.info(`Scope '${scopeName}' has ${fileFields.length} file field(s): ${fileFields.map(f => f.field).join(', ')}`)
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Analyze existing scopes
|
|
134
|
-
for (const [scopeName, scope] of Object.entries(scopes)) {
|
|
135
|
-
if (scope._scopeOptions) {
|
|
136
|
-
analyzeScopeSchema(scopeName, scope._scopeOptions)
|
|
137
|
-
}
|
|
98
|
+
fileFieldsBySchema.set(schemaInfo, fileFields)
|
|
99
|
+
return fileFields
|
|
138
100
|
}
|
|
139
101
|
|
|
140
|
-
// Listen for new scopes being added
|
|
141
|
-
addHook('scope:added', 'analyzeFileFields', {}, ({ context }) => {
|
|
142
|
-
const { scopeName, scopeOptions } = context
|
|
143
|
-
analyzeScopeSchema(scopeName, scopeOptions)
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Process files for a scope if it has file fields
|
|
148
|
-
*/
|
|
149
102
|
const processFiles = async (scopeName, params, context) => {
|
|
150
|
-
const fileFields =
|
|
151
|
-
if (
|
|
152
|
-
return // This scope doesn't have file fields
|
|
153
|
-
}
|
|
103
|
+
const fileFields = getFileFields(scopeName)
|
|
104
|
+
if (fileFields.length === 0) return
|
|
154
105
|
|
|
155
|
-
// Try each detector to see if we have files
|
|
156
106
|
let parsed = null
|
|
157
107
|
let detectorUsed = null
|
|
158
108
|
|
|
159
109
|
for (const detector of detectorRegistry) {
|
|
110
|
+
let matched
|
|
160
111
|
try {
|
|
161
|
-
|
|
162
|
-
log.debug(`Detector '${detector.name}' matched for scope '${scopeName}'`)
|
|
163
|
-
parsed = await detector.parse(params, context)
|
|
164
|
-
detectorUsed = detector.name
|
|
165
|
-
break
|
|
166
|
-
}
|
|
112
|
+
matched = await detector.detect(params, context)
|
|
167
113
|
} catch (error) {
|
|
168
|
-
|
|
114
|
+
throw wrapUnexpectedError(error, {
|
|
115
|
+
message: `File detector '${detector.name}' failed`,
|
|
116
|
+
context: { scopeName, detector: detector.name, phase: 'fileDetection' }
|
|
117
|
+
})
|
|
169
118
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
fields: [fieldConfig.field],
|
|
180
|
-
violations: [{
|
|
181
|
-
field: fieldConfig.field,
|
|
182
|
-
message: 'This field is required'
|
|
183
|
-
}]
|
|
184
|
-
}
|
|
185
|
-
)
|
|
186
|
-
}
|
|
119
|
+
if (!matched) continue
|
|
120
|
+
boundedLog.debug(`Detector '${detector.name}' matched for scope '${scopeName}'`)
|
|
121
|
+
try {
|
|
122
|
+
parsed = await detector.parse(params, context)
|
|
123
|
+
} catch (error) {
|
|
124
|
+
throw wrapUnexpectedError(error, {
|
|
125
|
+
message: `File parser '${detector.name}' failed`,
|
|
126
|
+
context: { scopeName, detector: detector.name, phase: 'fileParsing' }
|
|
127
|
+
})
|
|
187
128
|
}
|
|
188
|
-
|
|
129
|
+
detectorUsed = detector.name
|
|
130
|
+
break
|
|
189
131
|
}
|
|
190
132
|
|
|
191
|
-
|
|
133
|
+
if (!parsed) return
|
|
134
|
+
|
|
192
135
|
const { fields = {}, files = {} } = parsed
|
|
193
136
|
|
|
194
137
|
try {
|
|
195
|
-
|
|
138
|
+
boundedLog.debug(`Processing files with detector '${detectorUsed}'`)
|
|
139
|
+
const fileNames = new Set(fileFields.map(config => config.field))
|
|
140
|
+
for (const name of Object.keys(files)) {
|
|
141
|
+
if (!fileNames.has(name)) throw new RestApiValidationError(`Unknown file field '${name}'`, { fields: [name] })
|
|
142
|
+
}
|
|
196
143
|
for (const fieldConfig of fileFields) {
|
|
197
144
|
const file = files[fieldConfig.field]
|
|
198
145
|
|
|
199
|
-
if (!file)
|
|
200
|
-
if (fieldConfig.required) {
|
|
201
|
-
throw new RestApiValidationError(
|
|
202
|
-
`Required file field '${fieldConfig.field}' is missing`,
|
|
203
|
-
{
|
|
204
|
-
fields: [fieldConfig.field],
|
|
205
|
-
violations: [{
|
|
206
|
-
field: fieldConfig.field,
|
|
207
|
-
message: 'This field is required'
|
|
208
|
-
}]
|
|
209
|
-
}
|
|
210
|
-
)
|
|
211
|
-
}
|
|
212
|
-
continue
|
|
213
|
-
}
|
|
146
|
+
if (!file) continue
|
|
214
147
|
|
|
215
148
|
// Validate mime type
|
|
216
149
|
if (fieldConfig.accepts[0] !== '*') {
|
|
@@ -238,7 +171,7 @@ export const FileHandlingPlugin = {
|
|
|
238
171
|
}
|
|
239
172
|
|
|
240
173
|
// Validate file size
|
|
241
|
-
if (fieldConfig.maxSize) {
|
|
174
|
+
if (fieldConfig.maxSize !== undefined) {
|
|
242
175
|
const maxBytes = parseSize(fieldConfig.maxSize)
|
|
243
176
|
if (file.size > maxBytes) {
|
|
244
177
|
throw new RestApiValidationError(
|
|
@@ -254,74 +187,53 @@ export const FileHandlingPlugin = {
|
|
|
254
187
|
}
|
|
255
188
|
}
|
|
256
189
|
|
|
257
|
-
// Upload to storage
|
|
258
190
|
if (!fieldConfig.storage) {
|
|
259
191
|
throw new Error(`No storage configured for file field '${fieldConfig.field}'`)
|
|
260
192
|
}
|
|
261
193
|
|
|
262
194
|
try {
|
|
263
195
|
const storedUrl = await fieldConfig.storage.upload(file)
|
|
264
|
-
fields
|
|
265
|
-
|
|
196
|
+
Object.defineProperty(fields, fieldConfig.field, { value: storedUrl, enumerable: true, writable: true, configurable: true })
|
|
197
|
+
const uploads = context.fileHandlingUploads ||= []
|
|
198
|
+
uploads.push({
|
|
266
199
|
field: fieldConfig.field,
|
|
267
200
|
storage: fieldConfig.storage,
|
|
268
|
-
url: storedUrl
|
|
201
|
+
url: storedUrl,
|
|
202
|
+
transaction: context.transaction
|
|
269
203
|
})
|
|
270
|
-
|
|
204
|
+
boundedLog.debug(`Uploaded file for field '${fieldConfig.field}'`)
|
|
271
205
|
} catch (error) {
|
|
272
|
-
throw
|
|
273
|
-
`Failed to upload file for field '${fieldConfig.field}'
|
|
274
|
-
{
|
|
275
|
-
|
|
276
|
-
violations: [{
|
|
277
|
-
field: fieldConfig.field,
|
|
278
|
-
message: error.message
|
|
279
|
-
}]
|
|
280
|
-
}
|
|
281
|
-
)
|
|
206
|
+
throw wrapUnexpectedError(error, {
|
|
207
|
+
message: `Failed to upload file for field '${fieldConfig.field}'`,
|
|
208
|
+
context: { scopeName, field: fieldConfig.field, phase: 'fileUpload' }
|
|
209
|
+
})
|
|
282
210
|
}
|
|
283
211
|
}
|
|
284
212
|
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
if (!params.inputRecord.data) {
|
|
290
|
-
params.inputRecord.data = { attributes: {} }
|
|
291
|
-
}
|
|
292
|
-
if (!params.inputRecord.data.attributes) {
|
|
293
|
-
params.inputRecord.data.attributes = {}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// Merge fields into attributes
|
|
297
|
-
Object.assign(params.inputRecord.data.attributes, fields)
|
|
213
|
+
// Work on the canonical document, including calls using the plain format.
|
|
214
|
+
context.inputRecord.data ||= { attributes: {} }
|
|
215
|
+
context.inputRecord.data.attributes = { ...context.inputRecord.data.attributes, ...fields }
|
|
298
216
|
} finally {
|
|
299
|
-
await cleanupParsedFiles(files)
|
|
217
|
+
await cleanupParsedFiles(files, context)
|
|
300
218
|
}
|
|
301
219
|
}
|
|
302
220
|
|
|
303
|
-
/**
|
|
304
|
-
* Hook into REST API methods to process files
|
|
305
|
-
*/
|
|
306
221
|
addHook('beforeProcessing', 'processFiles', {}, async ({ context }) => {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const params = context.params
|
|
310
|
-
|
|
311
|
-
// Only process for mutation methods
|
|
312
|
-
if (!['post', 'put', 'patch'].includes(method)) {
|
|
313
|
-
return
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// Process files if this scope has file fields
|
|
317
|
-
await processFiles(scopeName, params, context)
|
|
222
|
+
if (!['post', 'put', 'patch'].includes(context.method)) return
|
|
223
|
+
await processFiles(context.scopeName, context.params, context)
|
|
318
224
|
})
|
|
319
225
|
|
|
320
226
|
addHook('afterRollback', 'cleanupUploadedFiles', {}, async ({ context }) => {
|
|
321
227
|
await cleanupUploadedFiles(context)
|
|
322
228
|
})
|
|
323
229
|
|
|
324
|
-
|
|
230
|
+
addHook('afterCommit', 'releaseUploadedFiles', {}, ({ context }) => {
|
|
231
|
+
if (context.fileHandlingUploads) {
|
|
232
|
+
context.fileHandlingUploads = context.fileHandlingUploads.filter(upload => upload.transaction !== context.transaction)
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
boundedLog.info('File handling plugin initialized successfully')
|
|
325
237
|
}
|
|
326
238
|
}
|
|
327
239
|
|
|
@@ -331,6 +243,7 @@ export const FileHandlingPlugin = {
|
|
|
331
243
|
* @returns {number} Size in bytes
|
|
332
244
|
*/
|
|
333
245
|
function parseSize (size) {
|
|
246
|
+
if (typeof size === 'number' && Number.isFinite(size) && size >= 0) return size
|
|
334
247
|
const units = {
|
|
335
248
|
b: 1,
|
|
336
249
|
kb: 1024,
|