diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aae2429c2..5e2c341dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,11 @@ jobs: cp .env.test.sample .env.test - name: Unit tests pass run: npm run test:unit:coverage + - name: Validate generated OpenAPI spec + run: npm run docs:validate + env: + VECTOR_ENABLED: true + ICEBERG_ENABLED: true - name: Upload coverage results to Coveralls uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2.3.7 with: diff --git a/package-lock.json b/package-lock.json index 3bc5008a0..b0d96d80b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,6 +77,7 @@ "devDependencies": { "@aws-sdk/s3-presigned-post": "^3.1023.0", "@biomejs/biome": "2.5.1", + "@hyperjump/json-schema": "^1.17.7", "@platformatic/runtime": "^3.63.0", "@types/js-yaml": "^4.0.5", "@types/json-bigint": "^1.0.4", @@ -3601,6 +3602,99 @@ "node": ">=6" } }, + "node_modules/@hyperjump/browser": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@hyperjump/browser/-/browser-1.4.0.tgz", + "integrity": "sha512-AbtynPyALR2wkN3ngYIiMEc21cZhP170zb5y3YIje4kTZ0J515GDgxeCK8D+PjyMhER7idU2Q3d0Zk0z/W1QLQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@hyperjump/json-pointer": "^1.1.0", + "@hyperjump/uri": "^1.2.0", + "content-type": "^1.0.5", + "just-curry-it": "^5.3.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + } + }, + "node_modules/@hyperjump/json-pointer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@hyperjump/json-pointer/-/json-pointer-1.1.2.tgz", + "integrity": "sha512-zPNgu1zdhtjQHFNLGzvEsLDsLOEvhRj6u6ktIQmlz7YPESv5uF8SnAe3Dq0oL6gZ6OGWSLq2n7pphRNF6Hpg6w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + } + }, + "node_modules/@hyperjump/json-schema": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-1.17.7.tgz", + "integrity": "sha512-CP4OTm4y5U200z3Ir6SAQk9aGM61m1LZpd4TMNXTZbOgHg02TvYFhtQLQcG2WPx6nmmM6DPS9ha+N7poY6e+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hyperjump/json-pointer": "^1.1.0", + "@hyperjump/json-schema-formats": "^1.0.0", + "@hyperjump/pact": "^1.2.0", + "@hyperjump/uri": "^1.2.0", + "content-type": "^1.0.4", + "json-stringify-deterministic": "^1.0.12", + "just-curry-it": "^5.3.0", + "uuid": "^14.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + }, + "peerDependencies": { + "@hyperjump/browser": "^1.1.0" + } + }, + "node_modules/@hyperjump/json-schema-formats": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@hyperjump/json-schema-formats/-/json-schema-formats-1.0.2.tgz", + "integrity": "sha512-LwV7YMBst77SCvZJS/Fq5od/2dWvefU+a7RF9/MOo9mVXEcA7PJ0Ax6zgoL2x69f6PmEfFXTp3l1vGdtyziW6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hyperjump/uri": "^1.3.2", + "idn-hostname": "^15.1.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/hyperjump-io" + } + }, + "node_modules/@hyperjump/pact": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@hyperjump/pact/-/pact-1.4.0.tgz", + "integrity": "sha512-01Q7VY6BcAkp9W31Fv+ciiZycxZHGlR2N6ba9BifgyclHYHdbaZgITo0U6QMhYRlem4k8pf8J31/tApxvqAz8A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + } + }, + "node_modules/@hyperjump/uri": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@hyperjump/uri/-/uri-1.3.4.tgz", + "integrity": "sha512-aUVWwu2GtC/cU4DwdTM+b+5jjboM6wft6vNg1+7pUTk/nNRNaBYcYhaEnCir9eRLBbkcGPkiEO7XRUlcDkxj4Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + } + }, "node_modules/@iarna/toml": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", @@ -12512,6 +12606,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/conventional-changelog-conventionalcommits": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-5.0.0.tgz", @@ -13702,6 +13806,16 @@ "ieee754": "^1.2.1" } }, + "node_modules/idn-hostname": { + "version": "15.1.10", + "resolved": "https://registry.npmjs.org/idn-hostname/-/idn-hostname-15.1.10.tgz", + "integrity": "sha512-/mSXWRhVasTJ7Z4z18523rTA6CmStYN29yDt+oXi9fe1/M0SO2Un1BgUr3v28aAZG5hWicyUtIamC/juXt3nZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -14062,6 +14176,16 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, + "node_modules/json-stringify-deterministic": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/json-stringify-deterministic/-/json-stringify-deterministic-1.0.14.tgz", + "integrity": "sha512-aP0bu09AgPQ0siVLg+64SVR9jXRCxwM+fW8pheDCoMKc3fipsVojQtsKyuUdoy1319v/Bgm978q2Il6brzPTFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -14091,6 +14215,13 @@ "node": ">=18.0.0" } }, + "node_modules/just-curry-it": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/just-curry-it/-/just-curry-it-5.3.0.tgz", + "integrity": "sha512-silMIRiFjUWlfaDhkgSzpuAyQ6EX/o09Eu8ZBfmFwQMbax7+LQzeIU2CBrICT6Ne4l86ITCGvUCBpCubWYy0Yw==", + "dev": true, + "license": "MIT" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -15474,6 +15605,16 @@ "pump": "^3.0.0" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/q": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", @@ -16611,6 +16752,20 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vitest": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", @@ -19374,6 +19529,63 @@ "yargs": "^17.7.2" } }, + "@hyperjump/browser": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@hyperjump/browser/-/browser-1.4.0.tgz", + "integrity": "sha512-AbtynPyALR2wkN3ngYIiMEc21cZhP170zb5y3YIje4kTZ0J515GDgxeCK8D+PjyMhER7idU2Q3d0Zk0z/W1QLQ==", + "dev": true, + "peer": true, + "requires": { + "@hyperjump/json-pointer": "^1.1.0", + "@hyperjump/uri": "^1.2.0", + "content-type": "^1.0.5", + "just-curry-it": "^5.3.0" + } + }, + "@hyperjump/json-pointer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@hyperjump/json-pointer/-/json-pointer-1.1.2.tgz", + "integrity": "sha512-zPNgu1zdhtjQHFNLGzvEsLDsLOEvhRj6u6ktIQmlz7YPESv5uF8SnAe3Dq0oL6gZ6OGWSLq2n7pphRNF6Hpg6w==", + "dev": true + }, + "@hyperjump/json-schema": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-1.17.7.tgz", + "integrity": "sha512-CP4OTm4y5U200z3Ir6SAQk9aGM61m1LZpd4TMNXTZbOgHg02TvYFhtQLQcG2WPx6nmmM6DPS9ha+N7poY6e+uA==", + "dev": true, + "requires": { + "@hyperjump/json-pointer": "^1.1.0", + "@hyperjump/json-schema-formats": "^1.0.0", + "@hyperjump/pact": "^1.2.0", + "@hyperjump/uri": "^1.2.0", + "content-type": "^1.0.4", + "json-stringify-deterministic": "^1.0.12", + "just-curry-it": "^5.3.0", + "uuid": "^14.0.0" + } + }, + "@hyperjump/json-schema-formats": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@hyperjump/json-schema-formats/-/json-schema-formats-1.0.2.tgz", + "integrity": "sha512-LwV7YMBst77SCvZJS/Fq5od/2dWvefU+a7RF9/MOo9mVXEcA7PJ0Ax6zgoL2x69f6PmEfFXTp3l1vGdtyziW6w==", + "dev": true, + "requires": { + "@hyperjump/uri": "^1.3.2", + "idn-hostname": "^15.1.2" + } + }, + "@hyperjump/pact": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@hyperjump/pact/-/pact-1.4.0.tgz", + "integrity": "sha512-01Q7VY6BcAkp9W31Fv+ciiZycxZHGlR2N6ba9BifgyclHYHdbaZgITo0U6QMhYRlem4k8pf8J31/tApxvqAz8A==", + "dev": true + }, + "@hyperjump/uri": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@hyperjump/uri/-/uri-1.3.4.tgz", + "integrity": "sha512-aUVWwu2GtC/cU4DwdTM+b+5jjboM6wft6vNg1+7pUTk/nNRNaBYcYhaEnCir9eRLBbkcGPkiEO7XRUlcDkxj4Q==", + "dev": true + }, "@iarna/toml": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", @@ -25484,6 +25696,12 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==" }, + "content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true + }, "conventional-changelog-conventionalcommits": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-5.0.0.tgz", @@ -26257,6 +26475,15 @@ } } }, + "idn-hostname": { + "version": "15.1.10", + "resolved": "https://registry.npmjs.org/idn-hostname/-/idn-hostname-15.1.10.tgz", + "integrity": "sha512-/mSXWRhVasTJ7Z4z18523rTA6CmStYN29yDt+oXi9fe1/M0SO2Un1BgUr3v28aAZG5hWicyUtIamC/juXt3nZQ==", + "dev": true, + "requires": { + "punycode": "^2.3.1" + } + }, "ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -26484,6 +26711,12 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, + "json-stringify-deterministic": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/json-stringify-deterministic/-/json-stringify-deterministic-1.0.14.tgz", + "integrity": "sha512-aP0bu09AgPQ0siVLg+64SVR9jXRCxwM+fW8pheDCoMKc3fipsVojQtsKyuUdoy1319v/Bgm978q2Il6brzPTFg==", + "dev": true + }, "json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -26499,6 +26732,12 @@ "jsep": "^1.4.0" } }, + "just-curry-it": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/just-curry-it/-/just-curry-it-5.3.0.tgz", + "integrity": "sha512-silMIRiFjUWlfaDhkgSzpuAyQ6EX/o09Eu8ZBfmFwQMbax7+LQzeIU2CBrICT6Ne4l86ITCGvUCBpCubWYy0Yw==", + "dev": true + }, "leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -27352,6 +27591,12 @@ "pump": "^3.0.0" } }, + "punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true + }, "q": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", @@ -28095,6 +28340,12 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, + "uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "dev": true + }, "vitest": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", diff --git a/package.json b/package.json index 02543ab67..c5282d378 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "migrations:types": "tsx src/scripts/migrations-types.ts", "docs:export": "tsx ./src/scripts/export-docs.ts", "docs:export-ui": "tsx ./src/scripts/export-docs-ui.ts", + "docs:validate": "tsx ./src/scripts/validate-openapi.ts", "pprof:capture": "tsx src/scripts/pprof-client.ts", "test:dummy-data": "tsx -r dotenv/config ./src/test/db/import-dummy-data.ts", "test:unit": "vitest run --config vitest.unit.config.ts", @@ -117,6 +118,7 @@ "devDependencies": { "@aws-sdk/s3-presigned-post": "^3.1023.0", "@biomejs/biome": "2.5.1", + "@hyperjump/json-schema": "^1.17.7", "@platformatic/runtime": "^3.63.0", "@types/js-yaml": "^4.0.5", "@types/json-bigint": "^1.0.4", diff --git a/src/admin-app.test.ts b/src/admin-app.test.ts index 572e63ce1..b9dc13482 100644 --- a/src/admin-app.test.ts +++ b/src/admin-app.test.ts @@ -257,4 +257,17 @@ describe('admin app', () => { await app.close() } }) + + it('declares OpenAPI 3.1.0, matching the JSON Schema nullable idioms route schemas use', async () => { + getGlobal.mockReturnValue(undefined) + + const app = await buildAdminApp({ exposeDocs: true }) + + try { + const spec = app.swagger() as { openapi: string } + expect(spec.openapi).toBe('3.1.0') + } finally { + await app.close() + } + }) }) diff --git a/src/admin-app.ts b/src/admin-app.ts index 6d4a303c2..6850250b9 100644 --- a/src/admin-app.ts +++ b/src/admin-app.ts @@ -23,6 +23,7 @@ const build = (opts: buildOpts = {}): FastifyInstance => { exposeHeadRoutes: true, transform: finiteSwaggerTransform, openapi: { + openapi: '3.1.0', info: { title: 'Supabase Storage Admin API', description: 'Admin API documentation for Supabase Storage', diff --git a/src/app.test.ts b/src/app.test.ts index 6e3416288..491c3f612 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -38,4 +38,17 @@ describe('public app', () => { await app.close() } }) + + it('declares OpenAPI 3.1.0, matching the JSON Schema nullable idioms route schemas use', async () => { + const app = buildApp({ exposeDocs: true }) + + try { + await app.ready() + + const spec = app.swagger() as { openapi: string } + expect(spec.openapi).toBe('3.1.0') + } finally { + await app.close() + } + }) }) diff --git a/src/app.ts b/src/app.ts index e00520721..a11fff8e0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -29,6 +29,7 @@ const build = (opts: buildOpts = {}): FastifyInstance => { exposeHeadRoutes: true, transform: finiteSwaggerTransform, openapi: { + openapi: '3.1.0', info: { title: 'Supabase Storage API', description: 'API documentation for Supabase Storage', diff --git a/src/http/routes/admin/tenants.ts b/src/http/routes/admin/tenants.ts index 004656a02..56933cdeb 100644 --- a/src/http/routes/admin/tenants.ts +++ b/src/http/routes/admin/tenants.ts @@ -36,15 +36,15 @@ const patchSchema = { properties: { anonKey: { type: 'string' }, databaseUrl: { type: 'string' }, - databasePoolUrl: { type: 'string', nullable: true }, + databasePoolUrl: { type: ['string', 'null'] }, maxConnections: { type: 'number', finite: true }, - jwks: { type: 'object', nullable: true }, + jwks: { type: ['object', 'null'] }, fileSizeLimit: { type: 'number', finite: true }, - deleteObjectsLimit: { type: 'integer', finite: true, minimum: 1, nullable: true }, + deleteObjectsLimit: { type: ['integer', 'null'], finite: true, minimum: 1 }, jwtSecret: { type: 'string' }, serviceKey: { type: 'string' }, tracingMode: { type: 'string' }, - disableEvents: { type: 'array', items: { type: 'string' }, nullable: true }, + disableEvents: { type: ['array', 'null'], items: { type: 'string' } }, features: { type: 'object', properties: { @@ -52,7 +52,7 @@ const patchSchema = { type: 'object', properties: { enabled: { type: 'boolean' }, - maxResolution: { type: 'number', finite: true, nullable: true }, + maxResolution: { type: ['number', 'null'], finite: true }, }, }, purgeCache: { diff --git a/src/http/routes/bucket/createBucket.ts b/src/http/routes/bucket/createBucket.ts index 1d1006a83..c6550b0ef 100644 --- a/src/http/routes/bucket/createBucket.ts +++ b/src/http/routes/bucket/createBucket.ts @@ -14,8 +14,7 @@ const createBucketBodySchema = { type: { type: 'string', enum: ['STANDARD', 'ANALYTICS'] }, file_size_limit: fileSizeLimitSchema, allowed_mime_types: { - type: 'array', - nullable: true, + type: ['array', 'null'], examples: [['image/png', 'image/jpg']], items: { type: 'string' }, }, diff --git a/src/http/routes/bucket/updateBucket.ts b/src/http/routes/bucket/updateBucket.ts index 8d65e0bec..e95afc8a2 100644 --- a/src/http/routes/bucket/updateBucket.ts +++ b/src/http/routes/bucket/updateBucket.ts @@ -12,8 +12,7 @@ const updateBucketBodySchema = { public: { type: 'boolean', examples: [false] }, file_size_limit: fileSizeLimitSchema, allowed_mime_types: { - type: 'array', - nullable: true, + type: ['array', 'null'], items: { type: 'string', examples: [['image/png', 'image/jpg']] }, }, }, diff --git a/src/http/routes/iceberg/table.ts b/src/http/routes/iceberg/table.ts index 6b1bd94e0..6a4c06d1b 100644 --- a/src/http/routes/iceberg/table.ts +++ b/src/http/routes/iceberg/table.ts @@ -12,7 +12,7 @@ const createTableSchema = { required: ['name', 'schema'], properties: { name: { type: 'string' }, - location: { type: 'string', format: 'uri', nullable: true }, + location: { type: ['string', 'null'], format: 'uri' }, schema: { allOf: [ @@ -121,8 +121,7 @@ const createTableSchema = { }, 'stage-create': { type: 'boolean', default: false }, 'write-order': { - type: 'object', - nullable: true, + type: ['object', 'null'], required: ['fields'], properties: { 'order-id': { type: 'integer', finite: true, readOnly: true }, diff --git a/src/http/routes/s3/commands/complete-multipart-upload.ts b/src/http/routes/s3/commands/complete-multipart-upload.ts index 19313bebd..f70aad782 100644 --- a/src/http/routes/s3/commands/complete-multipart-upload.ts +++ b/src/http/routes/s3/commands/complete-multipart-upload.ts @@ -29,8 +29,7 @@ const CompletedMultipartUpload = { required: ['authorization'], }, Body: { - nullable: true, - type: 'object', + type: ['object', 'null'], properties: { CompleteMultipartUpload: { type: 'object', diff --git a/src/http/schemas/file-size-limit.ts b/src/http/schemas/file-size-limit.ts index b82d74196..86e0e4db9 100644 --- a/src/http/schemas/file-size-limit.ts +++ b/src/http/schemas/file-size-limit.ts @@ -1,11 +1,10 @@ export const fileSizeLimitSchema = { anyOf: [ - { type: 'integer', finite: true, examples: [1000], nullable: true, minimum: 0 }, + { type: ['integer', 'null'], finite: true, examples: [1000], minimum: 0 }, { - type: 'string', + type: ['string', 'null'], pattern: '^[0-9]+(?:\\.[0-9]+)?(?:[gG][bB]|[mM][bB]|[kK][bB]|[bB])$', examples: ['100MB'], - nullable: true, }, ], } as const diff --git a/src/scripts/validate-openapi.ts b/src/scripts/validate-openapi.ts new file mode 100644 index 000000000..03587ab1c --- /dev/null +++ b/src/scripts/validate-openapi.ts @@ -0,0 +1,131 @@ +import type { Json } from '@hyperjump/json-pointer' +import { + registerSchema, + setMetaSchemaOutputFormat, + validate, +} from '@hyperjump/json-schema/openapi-3-1' +import { FastifyInstance } from 'fastify' +import buildAdmin from '../admin-app' +import buildApp from '../app' + +const BASIC = 'BASIC' + +setMetaSchemaOutputFormat(BASIC) + +// The official OAS dialect allows unknown keywords in Schema Objects (e.g. a +// leftover OAS 3.0 `nullable: true`), so it can't catch a regression back to +// the 3.0 idiom. This strict dialect adds `unevaluatedProperties: false`, +// which - via the dialect's `$dynamicAnchor: "meta"` - rejects unknown +// keywords at every nesting level (properties, items, allOf, ...). +const STRICT_DIALECT = 'https://storage.supabase.com/oas/3.1/dialect/strict' + +registerSchema( + { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $vocabulary: { + 'https://json-schema.org/draft/2020-12/vocab/core': true, + 'https://json-schema.org/draft/2020-12/vocab/applicator': true, + 'https://json-schema.org/draft/2020-12/vocab/unevaluated': true, + 'https://json-schema.org/draft/2020-12/vocab/validation': true, + 'https://json-schema.org/draft/2020-12/vocab/meta-data': true, + 'https://json-schema.org/draft/2020-12/vocab/format-annotation': true, + 'https://json-schema.org/draft/2020-12/vocab/content': true, + 'https://spec.openapis.org/oas/3.1/vocab/base': false, + }, + $dynamicAnchor: 'meta', + $ref: 'https://spec.openapis.org/oas/3.1/dialect/base', + unevaluatedProperties: false, + }, + STRICT_DIALECT +) + +async function getSpec(instance: FastifyInstance) { + await instance.ready() + const response = await instance.inject({ method: 'GET', url: '/documentation/json' }) + if (response.statusCode !== 200) { + throw new Error('Unable to get api spec: ' + response.statusCode + ' ' + response.statusMessage) + } + return JSON.parse(response.body) +} + +// Schema Objects show up under a `schema` property (Parameter, Header, Media +// Type Objects) or as values of `components.schemas`. Nested subschemas +// (`properties`, `items`, `allOf`, ...) don't need separate entry points - +// the dialect's dynamic-scoped `meta` anchor validates them recursively as +// part of validating their containing Schema Object. +function collectSchemaObjects( + node: Json, + path: string, + parentKey: string | undefined, + out: { path: string; schema: Json }[] +) { + if (Array.isArray(node)) { + node.forEach((item, i) => collectSchemaObjects(item, `${path}[${i}]`, undefined, out)) + return + } + + if (typeof node !== 'object' || node === null) { + return + } + + if (parentKey === 'schema') { + out.push({ path, schema: node }) + return + } + + if (parentKey === 'schemas') { + for (const [name, schema] of Object.entries(node)) { + out.push({ path: `${path}.${name}`, schema }) + } + return + } + + for (const [key, value] of Object.entries(node)) { + collectSchemaObjects(value, `${path}.${key}`, key, out) + } +} + +;(async () => { + const validateOpenApi = await validate('https://spec.openapis.org/oas/3.1/schema-base') + const validateSchemaObjectStrict = await validate(STRICT_DIALECT) + + const specs = [ + { name: 'public API', instance: buildApp({ exposeDocs: true }) }, + { name: 'admin API', instance: buildAdmin({ exposeDocs: true }) }, + ] + + let hasErrors = false + + for (const { name, instance } of specs) { + const spec = await getSpec(instance) + await instance.close() + + const result = validateOpenApi(spec, BASIC) + const specErrors: unknown[] = result.valid ? [] : (result.errors ?? []) + + const schemaObjects: { path: string; schema: Json }[] = [] + collectSchemaObjects(spec, '#', undefined, schemaObjects) + + for (const { path, schema } of schemaObjects) { + const strictResult = validateSchemaObjectStrict(schema, BASIC) + if (!strictResult.valid) { + specErrors.push({ schemaObject: path, errors: strictResult.errors }) + } + } + + if (specErrors.length > 0) { + hasErrors = true + console.error(`✗ ${name} OpenAPI spec is not valid OpenAPI 3.1:`) + console.error(JSON.stringify(specErrors, null, 2)) + } else { + console.log(`✓ ${name} OpenAPI spec is valid OpenAPI 3.1`) + } + } + + if (hasErrors) { + process.exit(1) + } +})().catch((e) => { + console.error(e) + process.exit(1) +})