import { Effect, Option, Schema, SchemaGetter } from "effect"; import { GoogleDiscoveryParseError } from "./errors"; import { GoogleDiscoveryHttpMethod, GoogleDiscoveryManifest, GoogleDiscoveryManifestMethod, GoogleDiscoveryMethodBinding, GoogleDiscoveryParameter, GoogleDiscoveryParameterLocation, } from "./types"; type JsonObject = Record; const TrimmedString = Schema.Trim; const Text = Schema.Trim.pipe(Schema.decodeTo(Schema.NonEmptyString)); const LowercaseText = Schema.String.pipe( Schema.decode({ decode: SchemaGetter.transform((value) => value.trim().toLowerCase()), encode: SchemaGetter.transform((value) => value.trim().toLowerCase()), }), ); const TextOption = Schema.OptionFromOptional(TrimmedString).pipe( Schema.decode({ decode: SchemaGetter.transform((value) => Option.filter(value, (text) => text.length > 0)), encode: SchemaGetter.transform((value) => value), }), Schema.withDecodingDefaultType(Effect.succeed(Option.none())), ); const TextArray = Schema.optional(Schema.Array(Text)).pipe( Schema.withDecodingDefaultType(Effect.succeed([] as string[])), ); const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown); const UnknownRecordWithDefault = Schema.optional(UnknownRecord).pipe( Schema.withDecodingDefaultType(Effect.succeed({})), ); const DiscoveryHttpMethodInput = Schema.optional( LowercaseText.pipe(Schema.decodeTo(GoogleDiscoveryHttpMethod)), ); const DiscoveryParameterLocationInput = Schema.optional( LowercaseText.pipe(Schema.decodeTo(GoogleDiscoveryParameterLocation)), ); const DiscoveryDefaultValue = Schema.Union([Schema.String, Schema.Number, Schema.Boolean]); const DiscoverySchemaModel = Schema.Struct({ type: Schema.optional(LowercaseText), description: TextOption, properties: UnknownRecordWithDefault, items: Schema.optional(Schema.Unknown), additionalProperties: Schema.optional(Schema.Union([Schema.Boolean, Schema.Unknown])), enum: TextArray, format: Schema.optional(Text), readOnly: Schema.optional(Schema.Boolean), default: Schema.optional(DiscoveryDefaultValue), $ref: Schema.optional(Text), required: TextArray, }); type DiscoverySchema = typeof DiscoverySchemaModel.Type; const DiscoveryParameterModel = Schema.Struct({ type: Schema.optional(LowercaseText), description: TextOption, properties: UnknownRecordWithDefault, items: Schema.optional(Schema.Unknown), additionalProperties: Schema.optional(Schema.Union([Schema.Boolean, Schema.Unknown])), enum: TextArray, format: Schema.optional(Text), readOnly: Schema.optional(Schema.Boolean), default: Schema.optional(DiscoveryDefaultValue), $ref: Schema.optional(Text), location: DiscoveryParameterLocationInput, required: Schema.optional(Schema.Boolean), repeated: Schema.optional(Schema.Boolean), }); type DiscoveryParameter = typeof DiscoveryParameterModel.Type; const DiscoveryRefModel = Schema.Struct({ $ref: Schema.optional(Text), }); const DiscoveryMethodModel = Schema.Struct({ id: TextOption, description: TextOption, httpMethod: DiscoveryHttpMethodInput, path: TextOption, parameters: UnknownRecordWithDefault, request: Schema.optional(DiscoveryRefModel), response: Schema.optional(DiscoveryRefModel), scopes: TextArray, }); type DiscoveryMethod = typeof DiscoveryMethodModel.Type; const DiscoveryResourceModel = Schema.Struct({ methods: UnknownRecordWithDefault, resources: UnknownRecordWithDefault, }); const DiscoveryDocumentModel = Schema.Struct({ name: TextOption, version: TextOption, title: TextOption, rootUrl: TextOption, servicePath: Schema.optional(TrimmedString).pipe( Schema.withDecodingDefaultType(Effect.succeed("")), ), parameters: UnknownRecordWithDefault, methods: UnknownRecordWithDefault, resources: UnknownRecordWithDefault, schemas: UnknownRecordWithDefault, auth: Schema.optional( Schema.Struct({ oauth2: Schema.optional( Schema.Struct({ scopes: Schema.optional( Schema.Record( Schema.String, Schema.Struct({ description: TextOption, }), ), ).pipe(Schema.withDecodingDefaultType(Effect.succeed({}))), }), ), }), ), }); type DiscoveryDocument = typeof DiscoveryDocumentModel.Type; const decodeUnknownWith = ( message: string, decode: (value: unknown) => A, ): ((value: unknown) => Effect.Effect) => (value) => Effect.try({ try: () => decode(value), // The Schema.TaggedError version of GoogleDiscoveryParseError no // longer carries a `cause` field because the client only sees the // user-facing message. catch: () => new GoogleDiscoveryParseError({ message }), }); const decodeDiscoveryDocument = decodeUnknownWith( "Failed to decode Google Discovery document", Schema.decodeUnknownSync(DiscoveryDocumentModel), ); const decodeDiscoveryDocumentJson = decodeUnknownWith( "Failed to parse Google Discovery document", Schema.decodeUnknownSync(Schema.fromJsonString(DiscoveryDocumentModel)), ); const decodeDiscoverySchema = decodeUnknownWith( "Failed to decode Google Discovery schema", Schema.decodeUnknownSync(DiscoverySchemaModel), ); const decodeDiscoveryParameter = decodeUnknownWith( "Failed to decode Google Discovery parameter", Schema.decodeUnknownSync(DiscoveryParameterModel), ); const decodeDiscoveryMethod = decodeUnknownWith( "Failed to decode Google Discovery method", Schema.decodeUnknownSync(DiscoveryMethodModel), ); const decodeDiscoveryResource = decodeUnknownWith( "Failed to decode Google Discovery resource", Schema.decodeUnknownSync(DiscoveryResourceModel), ); const schemaRef = (name: string) => `#/$defs/${name}`; const toJsonSchemaSeed = (input: { type: DiscoverySchema["type"]; description: DiscoverySchema["description"]; properties: DiscoverySchema["properties"]; items: DiscoverySchema["items"]; additionalProperties: DiscoverySchema["additionalProperties"]; enum: DiscoverySchema["enum"]; format: DiscoverySchema["format"]; readOnly: DiscoverySchema["readOnly"]; default: DiscoverySchema["default"]; $ref: DiscoverySchema["$ref"]; required: DiscoverySchema["required"]; }): DiscoverySchema => ({ type: input.type, description: input.description, properties: input.properties ?? {}, items: input.items, additionalProperties: input.additionalProperties, enum: input.enum ?? [], format: input.format, readOnly: input.readOnly, default: input.default, $ref: input.$ref, required: input.required ?? [], }); const discoverySchemaToJsonSchema = ( schema: DiscoverySchema, ): Effect.Effect => Effect.gen(function* () { const ref = schema.$ref; if (ref) { return { $ref: schemaRef(ref) }; } const description = Option.getOrUndefined(schema.description); const enumValues = schema.enum ?? []; const required = schema.required ?? []; const base: Record = { ...(description ? { description } : {}), ...(schema.format ? { format: schema.format } : {}), ...(enumValues.length > 0 ? { enum: enumValues } : {}), ...(schema.readOnly === true ? { readOnly: true } : {}), ...(schema.default !== undefined ? { default: schema.default } : {}), }; if (schema.type === "array") { return { ...base, type: "array", items: yield* googleSchemaToJsonSchema(schema.items), }; } const properties = schema.properties ?? {}; const additionalProperties = schema.additionalProperties; if ( schema.type === "object" || Object.keys(properties).length > 0 || additionalProperties !== undefined ) { const convertedProperties = Object.fromEntries( yield* Effect.forEach(Object.entries(properties), ([name, property]) => googleSchemaToJsonSchema(property).pipe( Effect.map((jsonSchema) => [name, jsonSchema] as const), ), ), ); const convertedAdditionalProperties = additionalProperties === undefined ? undefined : additionalProperties === true ? true : yield* googleSchemaToJsonSchema(additionalProperties); return { ...base, type: "object", ...(Object.keys(convertedProperties).length > 0 ? { properties: convertedProperties } : {}), ...(required.length > 0 ? { required } : {}), ...(convertedAdditionalProperties !== undefined ? { additionalProperties: convertedAdditionalProperties } : {}), }; } if ( schema.type === "boolean" || schema.type === "integer" || schema.type === "number" || schema.type === "string" ) { return { ...base, type: schema.type }; } if (schema.type === "any") return base; return Object.keys(base).length > 0 ? base : {}; }); const googleSchemaToJsonSchema = ( rawSchema: unknown, ): Effect.Effect => rawSchema === undefined ? Effect.succeed({}) : decodeDiscoverySchema(rawSchema).pipe(Effect.flatMap(discoverySchemaToJsonSchema)); const parameterToJsonSchema = ( parameter: DiscoveryParameter, ): Effect.Effect => parameter.repeated === true ? discoverySchemaToJsonSchema( toJsonSchemaSeed({ type: parameter.type, description: parameter.description, properties: parameter.properties, items: parameter.items, additionalProperties: parameter.additionalProperties, enum: parameter.enum, format: parameter.format, readOnly: parameter.readOnly, default: parameter.default, $ref: parameter.$ref, required: [], }), ).pipe( Effect.map((items) => ({ type: "array", items, })), ) : discoverySchemaToJsonSchema( toJsonSchemaSeed({ type: parameter.type, description: parameter.description, properties: parameter.properties, items: parameter.items, additionalProperties: parameter.additionalProperties, enum: parameter.enum, format: parameter.format, readOnly: parameter.readOnly, default: parameter.default, $ref: parameter.$ref, required: [], }), ); const toToolPath = (service: string, methodId: string): string => { const withoutPrefix = methodId.startsWith(`${service}.`) ? methodId.slice(service.length + 1) : methodId; return withoutPrefix.trim(); }; const toParameter = ( name: string, rawParameter: unknown, ): Effect.Effect => Effect.gen(function* () { const parameter = yield* decodeDiscoveryParameter(rawParameter); if (parameter.location === undefined) return null; return new GoogleDiscoveryParameter({ name, location: parameter.location, required: parameter.required === true, repeated: parameter.repeated === true, description: parameter.description, schema: Option.some(yield* parameterToJsonSchema(parameter)), }); }); const mergeParameters = (input: { globalParameters: Readonly>; method: DiscoveryMethod; }): Effect.Effect => Effect.gen(function* () { const merged = new Map(); for (const [name, parameter] of Object.entries(input.globalParameters)) { const converted = yield* toParameter(name, parameter); if (converted) merged.set(name, converted); } for (const [name, parameter] of Object.entries(input.method.parameters ?? {})) { const converted = yield* toParameter(name, parameter); if (converted) merged.set(name, converted); } return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name)); }); const buildInputSchema = (input: { parameters: readonly GoogleDiscoveryParameter[]; requestRef: string | undefined; }): unknown | undefined => { const properties: Record = {}; const required: string[] = []; for (const parameter of input.parameters) { properties[parameter.name] = Option.getOrElse(parameter.schema, () => ({ type: "string", })); if (parameter.required) required.push(parameter.name); } if (input.requestRef) { properties.body = { $ref: schemaRef(input.requestRef) }; } if (Object.keys(properties).length === 0) return undefined; return { type: "object", properties, ...(required.length > 0 ? { required } : {}), additionalProperties: false, }; }; const extractScopes = (document: DiscoveryDocument): Record | undefined => { const scopes = document.auth?.oauth2?.scopes ?? {}; const normalized = Object.fromEntries( Object.entries(scopes).map(([scope, value]) => [ scope, Option.getOrElse(value.description, () => ""), ]), ); return Object.keys(normalized).length > 0 ? normalized : undefined; }; const manifestMethodFromMethod = (input: { service: string; rawMethod: unknown; globalParameters: Readonly>; }): Effect.Effect => Effect.gen(function* () { const method = yield* decodeDiscoveryMethod(input.rawMethod); const methodId = Option.getOrUndefined(method.id); const path = Option.getOrUndefined(method.path); if (!methodId || !path) return null; if (!method.httpMethod) { return yield* new GoogleDiscoveryParseError({ message: `Google Discovery method '${methodId}' is missing httpMethod`, }); } const requestRef = method.request?.$ref; const responseRef = method.response?.$ref; const parameters = yield* mergeParameters({ globalParameters: input.globalParameters, method, }); return new GoogleDiscoveryManifestMethod({ toolPath: toToolPath(input.service, methodId), description: method.description, binding: new GoogleDiscoveryMethodBinding({ method: method.httpMethod, pathTemplate: path, parameters, hasBody: requestRef !== undefined, }), inputSchema: Option.fromNullishOr(buildInputSchema({ parameters, requestRef })), outputSchema: Option.fromNullishOr( responseRef ? { $ref: schemaRef(responseRef) } : undefined, ), scopes: method.scopes ?? [], }); }); const collectMethods = (input: { service: string; rawResource: unknown; globalParameters: Readonly>; }): Effect.Effect => Effect.gen(function* () { const resource = yield* decodeDiscoveryResource(input.rawResource); const methods = yield* Effect.forEach(Object.values(resource.methods ?? {}), (rawMethod) => manifestMethodFromMethod({ service: input.service, rawMethod, globalParameters: input.globalParameters, }), ); const nested = yield* Effect.forEach(Object.values(resource.resources ?? {}), (rawResource) => collectMethods({ ...input, rawResource, }), ); return [...methods.flatMap((method) => (method ? [method] : [])), ...nested.flat()]; }); export const extractGoogleDiscoveryManifest = Effect.fn("GoogleDiscovery.extractManifest")( function* (discoveryDocument: string | JsonObject) { const document = typeof discoveryDocument === "string" ? yield* decodeDiscoveryDocumentJson(discoveryDocument) : yield* decodeDiscoveryDocument(discoveryDocument); const service = Option.getOrUndefined(document.name); const version = Option.getOrUndefined(document.version); const rootUrl = Option.getOrUndefined(document.rootUrl); if (!service || !version || !rootUrl) { return yield* new GoogleDiscoveryParseError({ message: "Google Discovery document is missing one of: name, version, rootUrl", }); } const schemaDefinitions = Object.fromEntries( yield* Effect.forEach(Object.entries(document.schemas ?? {}), ([name, rawSchema]) => googleSchemaToJsonSchema(rawSchema).pipe(Effect.map((schema) => [name, schema] as const)), ), ); const topLevelMethods = yield* Effect.forEach( Object.values(document.methods ?? {}), (rawMethod) => manifestMethodFromMethod({ service, rawMethod, globalParameters: document.parameters ?? {}, }), ); const nestedMethods = yield* Effect.forEach( Object.values(document.resources ?? {}), (rawResource) => collectMethods({ service, rawResource, globalParameters: document.parameters ?? {}, }), ); return new GoogleDiscoveryManifest({ title: document.title, service, version, rootUrl, servicePath: document.servicePath ?? "", oauthScopes: Option.fromNullishOr(extractScopes(document)), schemaDefinitions, methods: [ ...topLevelMethods.flatMap((method) => (method ? [method] : [])), ...nestedMethods.flat(), ].sort((a, b) => a.toolPath.localeCompare(b.toolPath)), }); }, );