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
136 changes: 135 additions & 1 deletion packages/plugins/openapi/src/sdk/google-discovery.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -11,24 +14,44 @@ 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),
}),
),
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),
});

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<string, unknown>;
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({
Expand Down Expand Up @@ -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",
Expand All @@ -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" },
},
},
},
},
Expand All @@ -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",
Expand All @@ -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"]);
}),
);
Loading
Loading