Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/cli/src/legacy/commands/sso/list/list.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ const PROVIDER_ITEM = {
updated_at: "2023-03-28T13:50:14.464Z",
};

const PROVIDER_ITEM_WITHOUT_SAML_ID = {
...PROVIDER_ITEM,
saml: {
entity_id: "https://example.com",
metadata_url: "https://example.com",
metadata_xml: '<?xml version="2.0"?>',
attribute_mapping: { keys: { a: { name: "xyz", default: 3 } } },
},
};

const tempRoot = useLegacyTempWorkdir("supabase-sso-list-int-");

interface SetupOpts {
Expand Down Expand Up @@ -151,6 +161,15 @@ describe("legacy sso list integration", () => {
}).pipe(Effect.provide(layer));
});

it.live("lists providers when the API omits items[].saml.id", () => {
const { layer, out } = setup({ body: { items: [PROVIDER_ITEM_WITHOUT_SAML_ID] } });
return Effect.gen(function* () {
yield* legacySsoList({ projectRef: Option.none() });
expect(out.stdoutText).toContain("0b0d48f6-878b-4190-88d7-2ca33ed800bc");
expect(out.stdoutText).toContain("example.com");
}).pipe(Effect.provide(layer));
});

it.live("emits a success payload via --output-format=json", () => {
const { layer, out } = setup({ format: "json" });
return Effect.gen(function* () {
Expand Down
124 changes: 119 additions & 5 deletions packages/api/scripts/download-openapi.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,134 @@
#!/usr/bin/env bun
import { writeFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

const DEFAULT_SUPABASE_API_URL = "https://api.supabase.com";
const OPENAPI_SPEC_PATH = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"../src/generated/openapi.json",
);
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const OPENAPI_SPEC_PATH = path.join(scriptDir, "../src/generated/openapi.json");
const OPENAPI_OVERRIDES_PATH = path.join(scriptDir, "openapi-overrides.json");

type OpenApiDocument = {
readonly [key: string]: unknown;
readonly paths: Record<string, unknown>;
};

type JsonPatchOperation = {
readonly op: "test" | "replace";
readonly path: string;
readonly value: unknown;
};

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function unescapeJsonPointerSegment(segment: string): string {
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
}

function jsonPointerSegments(pointer: string): ReadonlyArray<string> {
if (pointer === "") {
return [];
}
if (!pointer.startsWith("/")) {
throw new Error(`Invalid JSON pointer ${JSON.stringify(pointer)}.`);
}
return pointer.slice(1).split("/").map(unescapeJsonPointerSegment);
}

function getJsonPointerValue(document: unknown, pointer: string): unknown {
let current = document;
for (const segment of jsonPointerSegments(pointer)) {
if (Array.isArray(current)) {
const index = Number(segment);
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
throw new Error(`JSON pointer ${JSON.stringify(pointer)} does not exist.`);
}
current = current[index];
} else if (isRecord(current) && segment in current) {
current = current[segment];
} else {
throw new Error(`JSON pointer ${JSON.stringify(pointer)} does not exist.`);
}
}
return current;
}

function replaceJsonPointerValue(document: unknown, pointer: string, value: unknown): void {
const segments = jsonPointerSegments(pointer);
if (segments.length === 0) {
throw new Error("Replacing the document root is not supported.");
}

let parent = document;
for (const segment of segments.slice(0, -1)) {
parent = getJsonPointerValue(parent, `/${segment.replace(/~/g, "~0").replace(/\//g, "~1")}`);
}

const key = segments[segments.length - 1]!;
if (Array.isArray(parent)) {
const index = Number(key);
if (!Number.isInteger(index) || index < 0 || index >= parent.length) {
throw new Error(`JSON pointer ${JSON.stringify(pointer)} does not exist.`);
}
parent[index] = value;
return;
}

if (!isRecord(parent) || !(key in parent)) {
throw new Error(`JSON pointer ${JSON.stringify(pointer)} does not exist.`);
}
parent[key] = value;
}

function assertJsonPatchOperation(value: unknown): asserts value is JsonPatchOperation {
if (!isRecord(value)) {
throw new Error("OpenAPI override entry must be an object.");
}
if (value.op !== "test" && value.op !== "replace") {
throw new Error("OpenAPI overrides only support test and replace operations.");
}
if (typeof value.path !== "string") {
throw new Error("OpenAPI override path must be a string.");
}
if (!("value" in value)) {
throw new Error("OpenAPI override value is required.");
}
}

function valuesEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}

export function applyOpenApiOverrides(
document: OpenApiDocument,
overrides: ReadonlyArray<unknown>,
): OpenApiDocument {
for (const override of overrides) {
assertJsonPatchOperation(override);
if (override.op === "test") {
const actual = getJsonPointerValue(document, override.path);
if (!valuesEqual(actual, override.value)) {
throw new Error(
`OpenAPI override test failed at ${override.path}: expected ${JSON.stringify(override.value)}, got ${JSON.stringify(actual)}.`,
);
}
continue;
}
replaceJsonPointerValue(document, override.path, override.value);
}
return document;
}

async function loadOpenApiOverrides(): Promise<ReadonlyArray<unknown>> {
const parsed = JSON.parse(await readFile(OPENAPI_OVERRIDES_PATH, "utf8"));
if (!Array.isArray(parsed)) {
throw new Error("OpenAPI overrides file must contain a JSON Patch array.");
}
return parsed;
}

export function resolveOpenApiSpecUrl(baseUrl = process.env.SUPABASE_API_URL): string {
const normalizedBaseUrl = (baseUrl ?? DEFAULT_SUPABASE_API_URL).replace(/\/+$/, "");
return `${normalizedBaseUrl}/api/v1-json`;
Expand All @@ -38,6 +150,8 @@ export async function downloadOpenApiSpec(specUrl = resolveOpenApiSpecUrl()): Pr
const document = await response.json();
assertOpenApiDocument(document);

applyOpenApiOverrides(document, await loadOpenApiOverrides());

await writeFile(OPENAPI_SPEC_PATH, `${JSON.stringify(document, null, 2)}\n`);
}

Expand Down
62 changes: 61 additions & 1 deletion packages/api/scripts/download-openapi.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest";

import { assertOpenApiDocument, resolveOpenApiSpecUrl } from "./download-openapi.ts";
import {
applyOpenApiOverrides,
assertOpenApiDocument,
resolveOpenApiSpecUrl,
} from "./download-openapi.ts";

describe("download-openapi", () => {
test("defaults to the production API spec URL", () => {
Expand All @@ -25,4 +29,60 @@ describe("download-openapi", () => {
"Downloaded spec is not a valid OpenAPI document with a paths object.",
);
});

test("applies OpenAPI JSON Patch overrides", () => {
const document = {
paths: {},
components: {
schemas: {
ListProvidersResponse: {
properties: {
items: {
items: {
properties: {
saml: {
required: ["id", "entity_id"],
},
},
},
},
},
},
},
},
};

applyOpenApiOverrides(document, [
{
op: "test",
path: "/components/schemas/ListProvidersResponse/properties/items/items/properties/saml/required",
value: ["id", "entity_id"],
},
{
op: "replace",
path: "/components/schemas/ListProvidersResponse/properties/items/items/properties/saml/required",
value: ["entity_id"],
},
]);

expect(
document.components.schemas.ListProvidersResponse.properties.items.items.properties.saml
.required,
).toEqual(["entity_id"]);
});

test("fails when an OpenAPI override test no longer matches", () => {
expect(() =>
applyOpenApiOverrides(
{ paths: {}, components: { schemas: { ListProvidersResponse: { required: [] } } } },
[
{
op: "test",
path: "/components/schemas/ListProvidersResponse/required",
value: ["items"],
},
],
),
).toThrow("OpenAPI override test failed");
});
});
12 changes: 12 additions & 0 deletions packages/api/scripts/openapi-overrides.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[
{
"op": "test",
"path": "/components/schemas/ListProvidersResponse/properties/items/items/properties/saml/required",
"value": ["id", "entity_id"]
},
{
"op": "replace",
"path": "/components/schemas/ListProvidersResponse/properties/items/items/properties/saml/required",
"value": ["entity_id"]
}
]
70 changes: 69 additions & 1 deletion packages/api/src/generated/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2584,6 +2584,25 @@ export const V1GetPostgresConfigInput = Schema.Struct({
export const V1GetPostgresConfigOutput = Schema.Struct({
effective_cache_size: Schema.optionalKey(Schema.String),
logical_decoding_work_mem: Schema.optionalKey(Schema.String),
"cron.log_statement": Schema.optionalKey(Schema.Boolean),
log_autovacuum_min_duration: Schema.optionalKey(
Schema.String.annotate({ description: "Default unit: ms" }).check(
Schema.isPattern(new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$")),
),
),
log_checkpoints: Schema.optionalKey(Schema.Boolean),
log_connections: Schema.optionalKey(Schema.Boolean),
log_disconnections: Schema.optionalKey(Schema.Boolean),
log_duration: Schema.optionalKey(Schema.Boolean),
log_lock_waits: Schema.optionalKey(Schema.Boolean),
log_recovery_conflict_waits: Schema.optionalKey(Schema.Boolean),
log_replication_commands: Schema.optionalKey(Schema.Boolean),
log_startup_progress_interval: Schema.optionalKey(
Schema.String.annotate({ description: "Default unit: ms" }).check(
Schema.isPattern(new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$")),
),
),
log_temp_files: Schema.optionalKey(Schema.String),
maintenance_work_mem: Schema.optionalKey(Schema.String),
track_activity_query_size: Schema.optionalKey(Schema.String),
max_connections: Schema.optionalKey(
Expand Down Expand Up @@ -3555,7 +3574,7 @@ export const V1ListAllSsoProviderOutput = Schema.Struct({
id: Schema.String,
saml: Schema.optionalKey(
Schema.Struct({
id: Schema.String,
id: Schema.optionalKey(Schema.String),
entity_id: Schema.String,
metadata_url: Schema.optionalKey(Schema.String),
metadata_xml: Schema.optionalKey(Schema.String),
Expand Down Expand Up @@ -5353,6 +5372,25 @@ export const V1UpdatePostgresConfigInput = Schema.Struct({
.check(Schema.isPattern(new RegExp("^[a-z]+$"))),
effective_cache_size: Schema.optionalKey(Schema.String),
logical_decoding_work_mem: Schema.optionalKey(Schema.String),
"cron.log_statement": Schema.optionalKey(Schema.Boolean),
log_autovacuum_min_duration: Schema.optionalKey(
Schema.String.annotate({ description: "Default unit: ms" }).check(
Schema.isPattern(new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$")),
),
),
log_checkpoints: Schema.optionalKey(Schema.Boolean),
log_connections: Schema.optionalKey(Schema.Boolean),
log_disconnections: Schema.optionalKey(Schema.Boolean),
log_duration: Schema.optionalKey(Schema.Boolean),
log_lock_waits: Schema.optionalKey(Schema.Boolean),
log_recovery_conflict_waits: Schema.optionalKey(Schema.Boolean),
log_replication_commands: Schema.optionalKey(Schema.Boolean),
log_startup_progress_interval: Schema.optionalKey(
Schema.String.annotate({ description: "Default unit: ms" }).check(
Schema.isPattern(new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$")),
),
),
log_temp_files: Schema.optionalKey(Schema.String),
maintenance_work_mem: Schema.optionalKey(Schema.String),
track_activity_query_size: Schema.optionalKey(Schema.String),
max_connections: Schema.optionalKey(
Expand Down Expand Up @@ -5417,6 +5455,25 @@ export const V1UpdatePostgresConfigInput = Schema.Struct({
export const V1UpdatePostgresConfigOutput = Schema.Struct({
effective_cache_size: Schema.optionalKey(Schema.String),
logical_decoding_work_mem: Schema.optionalKey(Schema.String),
"cron.log_statement": Schema.optionalKey(Schema.Boolean),
log_autovacuum_min_duration: Schema.optionalKey(
Schema.String.annotate({ description: "Default unit: ms" }).check(
Schema.isPattern(new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$")),
),
),
log_checkpoints: Schema.optionalKey(Schema.Boolean),
log_connections: Schema.optionalKey(Schema.Boolean),
log_disconnections: Schema.optionalKey(Schema.Boolean),
log_duration: Schema.optionalKey(Schema.Boolean),
log_lock_waits: Schema.optionalKey(Schema.Boolean),
log_recovery_conflict_waits: Schema.optionalKey(Schema.Boolean),
log_replication_commands: Schema.optionalKey(Schema.Boolean),
log_startup_progress_interval: Schema.optionalKey(
Schema.String.annotate({ description: "Default unit: ms" }).check(
Schema.isPattern(new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$")),
),
),
log_temp_files: Schema.optionalKey(Schema.String),
maintenance_work_mem: Schema.optionalKey(Schema.String),
track_activity_query_size: Schema.optionalKey(Schema.String),
max_connections: Schema.optionalKey(
Expand Down Expand Up @@ -8359,6 +8416,17 @@ export const operationDefinitions = {
fields: [
"effective_cache_size",
"logical_decoding_work_mem",
"cron.log_statement",
"log_autovacuum_min_duration",
"log_checkpoints",
"log_connections",
"log_disconnections",
"log_duration",
"log_lock_waits",
"log_recovery_conflict_waits",
"log_replication_commands",
"log_startup_progress_interval",
"log_temp_files",
"maintenance_work_mem",
"track_activity_query_size",
"max_connections",
Expand Down
Loading
Loading