From 1b5901e28eca19a3d610fce3235698d663da577b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 28 May 2026 23:43:30 -0700 Subject: [PATCH 1/3] Emit valid schemas for Google Discovery specs --- .../openapi/src/sdk/google-discovery.test.ts | 136 ++++++++- .../openapi/src/sdk/google-discovery.ts | 278 ++++++++++++++---- packages/plugins/openapi/src/sdk/plugin.ts | 17 +- .../react/src/components/schema-explorer.tsx | 22 +- 4 files changed, 384 insertions(+), 69 deletions(-) diff --git a/packages/plugins/openapi/src/sdk/google-discovery.test.ts b/packages/plugins/openapi/src/sdk/google-discovery.test.ts index 526b7386e..68933a57c 100644 --- a/packages/plugins/openapi/src/sdk/google-discovery.test.ts +++ b/packages/plugins/openapi/src/sdk/google-discovery.test.ts @@ -1,7 +1,10 @@ import { expect, it } from "@effect/vitest"; -import { Effect, Schema } from "effect"; +import { Effect, Option, Schema } from "effect"; +import { buildToolTypeScriptPreview } from "@executor-js/sdk/core"; import { convertGoogleDiscoveryToOpenApi } from "./google-discovery"; +import { extract } from "./extract"; +import { parse } from "./parse"; const ConvertedOperation = Schema.Struct({ operationId: Schema.String, @@ -11,6 +14,8 @@ const ConvertedOperation = Schema.Struct({ name: Schema.String, in: Schema.String, required: Schema.Boolean, + description: Schema.optional(Schema.String), + schema: Schema.Unknown, style: Schema.optional(Schema.String), explode: Schema.optional(Schema.Boolean), }), @@ -18,6 +23,8 @@ const ConvertedOperation = Schema.Struct({ security: Schema.optional( Schema.Array(Schema.Record(Schema.String, Schema.Array(Schema.String))), ), + requestBody: Schema.optional(Schema.Unknown), + responses: Schema.Unknown, "x-google-scopes": Schema.Array(Schema.String), }); @@ -25,10 +32,26 @@ const ConvertedSpec = Schema.Struct({ openapi: Schema.String, servers: Schema.Array(Schema.Struct({ url: Schema.String })), paths: Schema.Record(Schema.String, Schema.Record(Schema.String, ConvertedOperation)), + components: Schema.Struct({ + schemas: Schema.Record(Schema.String, Schema.Unknown), + }), }); const decodeConvertedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(ConvertedSpec)); +const normalizeOpenApiRefsForPreview = (node: unknown): unknown => { + if (node == null || typeof node !== "object") return node; + if (Array.isArray(node)) return node.map(normalizeOpenApiRefsForPreview); + const obj = node as Record; + if (typeof obj.$ref === "string") { + const match = obj.$ref.match(/^#\/components\/schemas\/(.+)$/); + return match ? { ...obj, $ref: `#/$defs/${match[1]}` } : obj; + } + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => [key, normalizeOpenApiRefsForPreview(value)]), + ); +}; + it.effect("converts Google Discovery documents into Executor-preserving OpenAPI 3 specs", () => Effect.gen(function* () { const result = yield* convertGoogleDiscoveryToOpenApi({ @@ -64,6 +87,7 @@ it.effect("converts Google Discovery documents into Executor-preserving OpenAPI location: "path", required: true, type: "string", + description: "The user's email address. The special value me can be used.", }, metadataHeaders: { location: "query", @@ -74,6 +98,54 @@ it.effect("converts Google Discovery documents into Executor-preserving OpenAPI }, }, }, + drafts: { + methods: { + create: { + id: "gmail.users.drafts.create", + httpMethod: "POST", + path: "gmail/v1/users/{userId}/drafts", + request: { $ref: "Draft" }, + response: { $ref: "Draft" }, + scopes: ["https://www.googleapis.com/auth/gmail.metadata"], + parameters: { + userId: { + location: "path", + required: true, + type: "string", + }, + }, + }, + }, + }, + }, + }, + }, + schemas: { + Draft: { + id: "Draft", + type: "object", + description: "A draft email.", + properties: { + id: { + type: "string", + description: "The immutable ID of the draft.", + }, + message: { + $ref: "Message", + }, + }, + }, + Message: { + id: "Message", + type: "object", + properties: { + id: { + type: "string", + }, + labelIds: { + type: "array", + items: { type: "string" }, + }, }, }, }, @@ -82,8 +154,10 @@ it.effect("converts Google Discovery documents into Executor-preserving OpenAPI const spec = decodeConvertedSpec(result.specText); const operation = spec.paths["/gmail/v1/users/{userId}/messages"]?.get; + const createDraft = spec.paths["/gmail/v1/users/{userId}/drafts"]?.post; expect(spec.openapi).toBe("3.1.0"); expect(spec.servers).toEqual([{ url: "https://gmail.googleapis.com/" }]); + expect(result.specText).not.toContain("_tag"); expect(operation).toMatchObject({ operationId: "users.messages.list", "x-executor-toolPath": "users.messages.list", @@ -100,5 +174,65 @@ it.effect("converts Google Discovery documents into Executor-preserving OpenAPI explode: true, }), ); + expect(operation?.parameters).toContainEqual( + expect.objectContaining({ + name: "userId", + description: "The user's email address. The special value me can be used.", + schema: expect.objectContaining({ type: "string" }), + }), + ); + expect(createDraft).toMatchObject({ + operationId: "users.drafts.create", + "x-executor-toolPath": "users.drafts.create", + }); + expect(createDraft).toMatchObject({ + requestBody: { + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Draft" }, + }, + }, + }, + }); + expect(createDraft?.parameters).toContainEqual( + expect.objectContaining({ + name: "userId", + schema: expect.objectContaining({ type: "string" }), + }), + ); + + const parsed = yield* parse(result.specText); + const extracted = yield* extract(parsed); + const extractedDraftCreate = extracted.operations.find( + (candidate) => candidate.operationId === "users.drafts.create", + ); + expect(extractedDraftCreate?.operationId).toBe("users.drafts.create"); + const preview = yield* Effect.promise(() => + buildToolTypeScriptPreview({ + inputSchema: normalizeOpenApiRefsForPreview( + extractedDraftCreate + ? Option.getOrUndefined(extractedDraftCreate.inputSchema) + : undefined, + ), + outputSchema: normalizeOpenApiRefsForPreview( + extractedDraftCreate + ? Option.getOrUndefined(extractedDraftCreate.outputSchema) + : undefined, + ), + defs: new Map( + Object.entries(spec.components.schemas).map(([name, schema]) => [ + name, + normalizeOpenApiRefsForPreview(schema), + ]), + ), + }), + ); + expect(preview.inputTypeScript).toBe("{ userId: string; body?: Draft; }"); + expect(preview.outputTypeScript).toBe("Draft"); + expect(preview.typeScriptDefinitions).toMatchObject({ + Draft: "{ id?: string; message?: Message; }", + Message: "{ id?: string; labelIds?: string[]; }", + }); + expect(result.oauth2?.identityScopes).toEqual(["openid", "email", "profile"]); }), ); diff --git a/packages/plugins/openapi/src/sdk/google-discovery.ts b/packages/plugins/openapi/src/sdk/google-discovery.ts index dd1331420..4f8d2b0b4 100644 --- a/packages/plugins/openapi/src/sdk/google-discovery.ts +++ b/packages/plugins/openapi/src/sdk/google-discovery.ts @@ -2,7 +2,7 @@ // Discovery converters currently target Swagger 2.0 or a broad conversion // pipeline; this adapter emits the shape Executor parses while preserving // Executor-specific tool ids and query semantics. -import { Effect, Option, Schema, SchemaGetter } from "effect"; +import { Effect, Option, Predicate, Schema, SchemaGetter } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { OpenApiParseError } from "./errors"; @@ -15,6 +15,106 @@ import type { OAuth2SourceConfig } from "./types"; import type { SpecFetchCredentials } from "./parse"; const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis"; +const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const GOOGLE_OAUTH_ISSUER_URL = "https://accounts.google.com"; +const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const OPENAPI_SCHEMA_TYPES = new Set([ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string", +]); + +type JsonPrimitive = string | number | boolean | null; +type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue }; + +type OpenApiSchemaObject = { + readonly $ref?: string; + readonly type?: "array" | "boolean" | "integer" | "null" | "number" | "object" | "string"; + readonly description?: string; + readonly title?: string; + readonly format?: string; + readonly readOnly?: boolean; + readonly default?: JsonValue; + readonly enum?: readonly JsonValue[]; + readonly items?: OpenApiSchemaObject; + readonly properties?: Record; + readonly required?: readonly string[]; + readonly additionalProperties?: boolean | OpenApiSchemaObject; +}; + +type OpenApiParameterObject = { + readonly name: string; + readonly in: "path" | "query" | "header"; + readonly required: boolean; + readonly description?: string; + readonly schema: OpenApiSchemaObject; + readonly style?: "form"; + readonly explode?: boolean; +}; + +type OpenApiOperationObject = { + readonly operationId: string; + readonly "x-executor-toolPath": string; + readonly description?: string; + readonly parameters: readonly OpenApiParameterObject[]; + readonly requestBody?: { + readonly required: false; + readonly content: { + readonly "application/json": { + readonly schema: OpenApiSchemaObject; + }; + }; + }; + readonly responses: { + readonly "200": { + readonly description: "Successful response"; + readonly content: { + readonly "application/json": { + readonly schema: OpenApiSchemaObject; + }; + }; + }; + }; + readonly security?: readonly Record[]; + readonly "x-google-scopes": readonly string[]; +}; + +type OpenApiDocument = { + readonly openapi: "3.1.0"; + readonly info: { + readonly title: string; + readonly version: string; + }; + readonly servers: readonly { readonly url: string }[]; + readonly paths: Record>; + readonly components: { + readonly schemas: Record; + readonly securitySchemes?: Record< + string, + { + readonly type: "oauth2"; + readonly flows: { + readonly authorizationCode: { + readonly authorizationUrl: string; + readonly tokenUrl: string; + readonly scopes: Record; + }; + }; + } + >; + }; + readonly security?: readonly Record[]; + readonly "x-executor-origin": { + readonly kind: "googleDiscovery"; + readonly discoveryUrl: string; + readonly service: string; + readonly version: string; + }; +}; const TextOption = Schema.OptionFromOptional(Schema.Trim).pipe( Schema.decode({ @@ -180,64 +280,117 @@ export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscov }, ); -const schemaRef = (name: string) => `#/$defs/${name}`; +const schemaRef = (name: string) => `#/components/schemas/${name}`; -const discoverySchemaToJsonSchema = (raw: unknown): unknown => { - if (!raw || typeof raw !== "object") return {}; - const schema = raw as Record; - if (typeof schema.$ref === "string") return { $ref: schemaRef(schema.$ref) }; +const discoveryDescription = (value: unknown): string | undefined => + typeof value === "string" + ? value + : Option.isOption(value) && Option.isSome(value) + ? typeof value.value === "string" + ? value.value + : undefined + : undefined; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); - const out: Record = {}; - for (const key of ["description", "format", "readOnly", "default", "enum"]) { - if (schema[key] !== undefined) out[key] = schema[key]; +const jsonValue = (value: unknown): JsonValue | undefined => { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; } + if (Array.isArray(value)) { + const values = value.map(jsonValue); + return values.every(Predicate.isNotUndefined) ? values : undefined; + } + if (!isRecord(value)) return undefined; + const entries = Object.entries(value).flatMap(([key, item]) => { + const converted = jsonValue(item); + return converted === undefined ? [] : [[key, converted] as const]; + }); + return Object.fromEntries(entries); +}; + +const stringArray = (value: unknown): readonly string[] | undefined => { + if (!Array.isArray(value)) return undefined; + const strings = value.filter((item): item is string => typeof item === "string"); + return strings.length === value.length ? strings : undefined; +}; - if (schema.type === "array") { - return { ...out, type: "array", items: discoverySchemaToJsonSchema(schema.items) }; +const schemaType = (value: unknown): OpenApiSchemaObject["type"] | undefined => + typeof value === "string" && OPENAPI_SCHEMA_TYPES.has(value) + ? (value as OpenApiSchemaObject["type"]) + : undefined; + +const discoverySchemaToOpenApiSchema = (raw: unknown): OpenApiSchemaObject => { + if (!isRecord(raw)) return {}; + const schema = raw; + if (typeof schema.$ref === "string") return { $ref: schemaRef(schema.$ref) }; + + const description = discoveryDescription(schema.description); + const title = discoveryDescription(schema.title); + const defaultValue = jsonValue(schema.default); + const enumValues = Array.isArray(schema.enum) + ? schema.enum.map(jsonValue).filter(Predicate.isNotUndefined) + : []; + const format = typeof schema.format === "string" ? schema.format : undefined; + const readOnly = typeof schema.readOnly === "boolean" ? schema.readOnly : undefined; + const type = schemaType(schema.type); + + const base = { + ...(description !== undefined ? { description } : {}), + ...(title !== undefined ? { title } : {}), + ...(format !== undefined ? { format } : {}), + ...(readOnly !== undefined ? { readOnly } : {}), + ...(defaultValue !== undefined ? { default: defaultValue } : {}), + ...(enumValues.length > 0 ? { enum: enumValues } : {}), + } satisfies OpenApiSchemaObject; + + if (type === "array") { + return { ...base, type: "array", items: discoverySchemaToOpenApiSchema(schema.items) }; } const properties = schema.properties; if ( - schema.type === "object" || - (properties && typeof properties === "object" && !Array.isArray(properties)) || + type === "object" || + (isRecord(properties) && Object.keys(properties).length > 0) || schema.additionalProperties !== undefined ) { - const convertedProperties = - properties && typeof properties === "object" && !Array.isArray(properties) - ? Object.fromEntries( - Object.entries(properties).map(([name, value]) => [ - name, - discoverySchemaToJsonSchema(value), - ]), - ) - : undefined; + const convertedProperties = isRecord(properties) + ? Object.fromEntries( + Object.entries(properties).map(([name, value]) => [ + name, + discoverySchemaToOpenApiSchema(value), + ]), + ) + : undefined; + const required = stringArray(schema.required); + const additionalProperties = + schema.additionalProperties === undefined + ? undefined + : typeof schema.additionalProperties === "boolean" + ? schema.additionalProperties + : discoverySchemaToOpenApiSchema(schema.additionalProperties); return { - ...out, + ...base, type: "object", ...(convertedProperties && Object.keys(convertedProperties).length > 0 ? { properties: convertedProperties } : {}), - ...(Array.isArray(schema.required) && schema.required.length > 0 - ? { required: schema.required } - : {}), - ...(schema.additionalProperties === undefined - ? {} - : { - additionalProperties: - typeof schema.additionalProperties === "boolean" - ? schema.additionalProperties - : discoverySchemaToJsonSchema(schema.additionalProperties), - }), + ...(required && required.length > 0 ? { required } : {}), + ...(additionalProperties !== undefined ? { additionalProperties } : {}), }; } - return typeof schema.type === "string" && schema.type !== "any" - ? { ...out, type: schema.type } - : out; + return type !== undefined ? { ...base, type } : base; }; -const parameterSchema = (parameter: DiscoveryParameter): unknown => { - const base = discoverySchemaToJsonSchema(parameter); +const parameterSchema = (parameter: DiscoveryParameter): OpenApiSchemaObject => { + const base = discoverySchemaToOpenApiSchema(parameter); return parameter.repeated ? { type: "array", @@ -293,7 +446,7 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD const baseUrl = new URL(document.servicePath || "", rootUrl).toString(); const title = Option.getOrElse(document.title, () => `${service} ${version}`); - const paths: Record> = {}; + const paths: Record> = {}; const allMethods = [ ...Object.values(document.methods ?? {}).map((raw) => decodeDiscoveryMethod(raw)), ...Object.values(document.resources ?? {}).flatMap(collectMethods), @@ -316,22 +469,30 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD if (parameter.location) mergedParameters.set(name, parameter); } const methodScopes = method.scopes ?? []; + const methodDescription = Option.getOrUndefined(method.description); paths[path] ??= {}; paths[path]![method.httpMethod.toLowerCase()] = { operationId: toolPath, "x-executor-toolPath": toolPath, - description: Option.getOrUndefined(method.description), - parameters: [...mergedParameters.entries()].map(([name, parameter]) => ({ - name, - in: parameter.location, - required: parameter.location === "path" ? true : parameter.required === true, - description: Option.getOrUndefined(parameter.description), - schema: parameterSchema(parameter), - ...(parameter.location === "query" - ? { style: "form", explode: parameter.repeated === true } - : {}), - })), + ...(methodDescription !== undefined ? { description: methodDescription } : {}), + parameters: [...mergedParameters.entries()].flatMap(([name, parameter]) => { + const location = parameter.location; + if (!location) return []; + const description = Option.getOrUndefined(parameter.description); + return [ + { + name, + in: location, + required: location === "path" ? true : parameter.required === true, + ...(description !== undefined ? { description } : {}), + schema: parameterSchema(parameter), + ...(location === "query" + ? { style: "form" as const, explode: parameter.repeated === true } + : {}), + }, + ]; + }), ...(method.request?.$ref ? { requestBody: { @@ -367,17 +528,18 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD kind: "oauth2", securitySchemeName, flow: "authorizationCode", - authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", - issuerUrl: "https://accounts.google.com", - tokenUrl: "https://oauth2.googleapis.com/token", + authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL, + issuerUrl: GOOGLE_OAUTH_ISSUER_URL, + tokenUrl: GOOGLE_OAUTH_TOKEN_URL, clientIdSlot: oauth2ClientIdSlot(securitySchemeName), clientSecretSlot: oauth2ClientSecretSlot(securitySchemeName), connectionSlot: oauth2ConnectionSlot(securitySchemeName), scopes: Object.keys(scopes), + identityScopes: ["openid", "email", "profile"], } : undefined; - const spec = { + const spec: OpenApiDocument = { openapi: "3.1.0", info: { title, @@ -389,7 +551,7 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD schemas: Object.fromEntries( Object.entries(document.schemas ?? {}).map(([name, schema]) => [ name, - discoverySchemaToJsonSchema(schema), + discoverySchemaToOpenApiSchema(schema), ]), ), ...(oauth2 @@ -399,8 +561,8 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD type: "oauth2", flows: { authorizationCode: { - authorizationUrl: oauth2.authorizationUrl, - tokenUrl: oauth2.tokenUrl, + authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL, + tokenUrl: GOOGLE_OAUTH_TOKEN_URL, scopes, }, }, diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index c7b39e4dc..afaf43c41 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -1347,9 +1347,15 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { sourceScope: effective.specFetchCredentialsSource.scope, credentials: resolvedConfig.specFetchCredentials, }); - const specText = yield* resolveSpecText(sourceUrl, credentials).pipe( - Effect.provide(httpClientLayer), - ); + const specText = isGoogleDiscoveryUrl(sourceUrl) + ? yield* fetchGoogleDiscoveryDocument(sourceUrl, credentials).pipe( + Effect.provide(httpClientLayer), + Effect.flatMap((documentText) => + convertGoogleDiscoveryToOpenApi({ discoveryUrl: sourceUrl, documentText }), + ), + Effect.map((conversion) => conversion.specText), + ) + : yield* resolveSpecText(sourceUrl, credentials).pipe(Effect.provide(httpClientLayer)); yield* rebuildSource(ctx, { specText, scope, @@ -1401,7 +1407,10 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { return yield* rebuildSource(ctx, { specText: resolvedSpec.specText, scope: config.scope, - sourceUrl: config.spec.kind === "url" ? config.spec.url : undefined, + sourceUrl: + config.spec.kind === "url" || config.spec.kind === "googleDiscovery" + ? config.spec.url + : undefined, name: config.name, baseUrl: resolvedSpec.baseUrl || config.baseUrl, namespace: config.namespace, diff --git a/packages/react/src/components/schema-explorer.tsx b/packages/react/src/components/schema-explorer.tsx index bf6af73d8..9661f30ec 100644 --- a/packages/react/src/components/schema-explorer.tsx +++ b/packages/react/src/components/schema-explorer.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useMemo } from "react"; import { ChevronRight } from "lucide-react"; +import * as Option from "effect/Option"; import { CardStack, CardStackHeader, CardStackContent } from "./card-stack"; // --------------------------------------------------------------------------- @@ -19,8 +20,8 @@ type JsonSchema = { const?: unknown; $ref?: string; $defs?: Record; - description?: string; - title?: string; + description?: unknown; + title?: unknown; default?: unknown; nullable?: boolean; format?: string; @@ -37,6 +38,14 @@ export const safeSchemaValueLabel = (value: unknown): string => { } }; +const schemaText = (value: unknown): string | undefined => { + if (typeof value === "string") return value; + if (Option.isOption(value) && Option.isSome(value) && typeof value.value === "string") { + return value.value; + } + return undefined; +}; + // --------------------------------------------------------------------------- // Ref resolution — lazy, only on expand // --------------------------------------------------------------------------- @@ -181,8 +190,8 @@ const mergeAllOf = (schemas: JsonSchema[], root: JsonSchema): JsonSchema => { if (resolved.required) { merged.required = [...(merged.required ?? []), ...resolved.required]; } - if (resolved.description && !merged.description) { - merged.description = resolved.description; + if (schemaText(resolved.description) && !schemaText(merged.description)) { + merged.description = schemaText(resolved.description); } } return merged; @@ -215,7 +224,8 @@ function PropertyRow(props: { const expandable = isExpandable(schema, root); const typeLabel = getTypeLabel(schema, root); const description = - schema.description ?? (schema.$ref ? resolveRef(schema.$ref, root)?.description : undefined); + schemaText(schema.description) ?? + (schema.$ref ? schemaText(resolveRef(schema.$ref, root)?.description) : undefined); const handleToggle = useCallback(() => { if (!open && !resolved && schema.$ref) { @@ -373,7 +383,7 @@ function PropertyChildren(props: { schema: JsonSchema; root: JsonSchema; depth: {variants.map((variant, i) => ( Date: Fri, 29 May 2026 10:21:02 -0700 Subject: [PATCH 2/3] Resolve Google API preset icons from base URLs --- .../src/components/source-favicon.test.tsx | 30 +++++++++++++++++ .../react/src/components/source-favicon.tsx | 32 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/react/src/components/source-favicon.test.tsx b/packages/react/src/components/source-favicon.test.tsx index 1a812462e..170ce65fb 100644 --- a/packages/react/src/components/source-favicon.test.tsx +++ b/packages/react/src/components/source-favicon.test.tsx @@ -55,6 +55,36 @@ describe("SourceFavicon", () => { ).toBe("https://example.com/sheets.svg"); }); + it("finds Google preset icons from generated API base URLs", () => { + expect( + sourcePresetIconUrl( + { + id: "calendar_api", + kind: "openapi", + name: "Calendar API", + url: "https://www.googleapis.com/calendar/v3/", + }, + [ + { + key: "openapi", + label: "OpenAPI", + add: () => null, + edit: () => null, + presets: [ + { + id: "google-calendar", + name: "Google Calendar", + summary: "Calendars.", + url: "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", + icon: "https://example.com/calendar.svg", + }, + ], + }, + ], + ), + ).toBe("https://example.com/calendar.svg"); + }); + it("finds preset icons from display names with suffixes", () => { expect( sourcePresetIconUrl( diff --git a/packages/react/src/components/source-favicon.tsx b/packages/react/src/components/source-favicon.tsx index aff17b4fd..cbb2cd8c7 100644 --- a/packages/react/src/components/source-favicon.tsx +++ b/packages/react/src/components/source-favicon.tsx @@ -39,6 +39,35 @@ const normalizeUrl = (url: string | undefined): string | null => { } }; +const googleApiServiceFromUrl = (url: string | undefined): string | null => { + if (!url) return null; + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + const segments = parsed.pathname.split("/").filter(Boolean); + + if ( + hostname === "www.googleapis.com" && + segments[0] === "discovery" && + segments[2] === "apis" && + segments[3] + ) { + return segments[3]; + } + + if (hostname === "www.googleapis.com") return segments[0] ?? null; + + const suffix = ".googleapis.com"; + if (hostname.endsWith(suffix)) { + const service = hostname.slice(0, -suffix.length); + return service.length > 0 ? service : null; + } + } catch { + return null; + } + return null; +}; + const normalizeToken = (value: string | undefined): string => value?.toLowerCase().replace(/[^a-z0-9]+/g, "") ?? ""; @@ -62,15 +91,18 @@ export function sourcePresetIconUrl( const plugin = sourcePlugins.find((p) => p.key === pluginKey); const presets = plugin?.presets ?? []; const sourceUrl = normalizeUrl(source.url); + const sourceGoogleService = googleApiServiceFromUrl(source.url); const sourceId = normalizeToken(source.id); const sourceName = normalizeToken(source.name); const preset = presets.find((p) => { const presetUrl = normalizeUrl(p.url); + const presetGoogleService = googleApiServiceFromUrl(p.url); const presetId = normalizeToken(p.id); const presetName = normalizeToken(p.name); return ( (sourceUrl !== null && presetUrl === sourceUrl) || + (sourceGoogleService !== null && presetGoogleService === sourceGoogleService) || tokenMatches(sourceId, presetId) || tokenMatches(sourceName, presetName) ); From 9f195675260dbbb3caac8a1e8794d644fc40f313 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 30 May 2026 10:07:32 -0700 Subject: [PATCH 3/3] Type schema text fields explicitly --- packages/react/src/components/schema-explorer.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/react/src/components/schema-explorer.tsx b/packages/react/src/components/schema-explorer.tsx index 9661f30ec..d0c95bd92 100644 --- a/packages/react/src/components/schema-explorer.tsx +++ b/packages/react/src/components/schema-explorer.tsx @@ -20,8 +20,8 @@ type JsonSchema = { const?: unknown; $ref?: string; $defs?: Record; - description?: unknown; - title?: unknown; + description?: SchemaText; + title?: SchemaText; default?: unknown; nullable?: boolean; format?: string; @@ -38,9 +38,11 @@ export const safeSchemaValueLabel = (value: unknown): string => { } }; -const schemaText = (value: unknown): string | undefined => { +type SchemaText = string | Option.Option; + +const schemaText = (value: SchemaText | undefined): string | undefined => { if (typeof value === "string") return value; - if (Option.isOption(value) && Option.isSome(value) && typeof value.value === "string") { + if (Option.isOption(value) && Option.isSome(value)) { return value.value; } return undefined;