Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/@emulators/google/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ npm install @emulators/google
- `GET /gmail/v1/users/:userId/settings/sendAs` — list send-as aliases

### Calendar
- `GET /discovery/v1/apis/calendar/v3/rest` — Calendar API discovery document (no auth required)
- `GET /calendar/v3/users/:userId/calendarList` — list calendars
- `GET /calendar/v3/calendars/:calendarId/events` — list events
- `POST /calendar/v3/calendars/:calendarId/events` — create event
Expand Down
35 changes: 35 additions & 0 deletions packages/@emulators/google/src/__tests__/google.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1101,4 +1101,39 @@ describe("Google plugin integration", () => {
expect(uploadedMediaRes.status).toBe(200);
expect(Buffer.from(await uploadedMediaRes.arrayBuffer()).toString("utf8")).toBe(uploadedContent);
});

it("serves Calendar discovery document at the standard path", async () => {
// No auth required — matches real Google behavior
const res = await app.request(`${base}/discovery/v1/apis/calendar/v3/rest`);
expect(res.status).toBe(200);

const doc = (await res.json()) as any;
expect(doc.kind).toBe("discovery#restDescription");
expect(doc.name).toBe("calendar");
expect(doc.version).toBe("v3");
expect(doc.id).toBe("calendar:v3");

// Verify URLs reflect the server origin (baseUrl/basePath required by SDKs)
expect(doc.rootUrl).toMatch(/^https?:\/\//);
expect(doc.baseUrl).toMatch(/^https?:\/\/.*\/calendar\/v3\/$/);
expect(doc.basePath).toBe("/calendar/v3/");
expect(doc.servicePath).toBe("calendar/v3/");

// Verify all implemented resources are described
expect(doc.resources.calendarList.methods.list).toBeDefined();
expect(doc.resources.calendarList.methods.list.httpMethod).toBe("GET");

expect(doc.resources.events.methods.list).toBeDefined();
expect(doc.resources.events.methods.list.httpMethod).toBe("GET");
expect(doc.resources.events.methods.list.parameters.calendarId.required).toBe(true);

expect(doc.resources.events.methods.insert).toBeDefined();
expect(doc.resources.events.methods.insert.httpMethod).toBe("POST");

expect(doc.resources.events.methods.delete).toBeDefined();
expect(doc.resources.events.methods.delete.httpMethod).toBe("DELETE");

expect(doc.resources.freebusy.methods.query).toBeDefined();
expect(doc.resources.freebusy.methods.query.httpMethod).toBe("POST");
});
});
95 changes: 94 additions & 1 deletion packages/@emulators/google/src/routes/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,102 @@ import {
} from "../route-helpers.js";
import { getGoogleStore } from "../store.js";

export function calendarRoutes({ app, store }: RouteContext): void {
export function calendarRoutes({ app, store, baseUrl }: RouteContext): void {
const gs = getGoogleStore(store);

// Google API Discovery Document for Calendar v3.
// Enables google-api-python-client build("calendar", "v3") and similar SDK bootstrapping.
// Returns a minimal document describing the routes this emulator actually implements.
// No auth required — matches real Google behavior.
app.get("/discovery/v1/apis/calendar/v3/rest", (c) => {
Comment thread
yonatangross marked this conversation as resolved.
const origin = baseUrl || new URL(c.req.url).origin;
return c.json({
kind: "discovery#restDescription",
discoveryVersion: "v1",
id: "calendar:v3",
name: "calendar",
version: "v3",
title: "Calendar API",
description: "Manipulates events and other calendar data.",
protocol: "rest",
rootUrl: `${origin}/`,
basePath: "/calendar/v3/",
baseUrl: `${origin}/calendar/v3/`,
servicePath: "calendar/v3/",
batchPath: "batch/calendar/v3",
parameters: {},
schemas: {},
resources: {
calendarList: {
methods: {
list: {
id: "calendar.calendarList.list",
path: "users/me/calendarList",
httpMethod: "GET",
description: "Returns the calendars on the user's calendar list.",
response: { $ref: "CalendarList" },
},
},
},
events: {
methods: {
list: {
id: "calendar.events.list",
path: "calendars/{calendarId}/events",
httpMethod: "GET",
description: "Returns events on the specified calendar.",
parameters: {
calendarId: { type: "string", required: true, location: "path" },
timeMin: { type: "string", location: "query", description: "Lower bound (RFC3339) for event end time." },
timeMax: { type: "string", location: "query", description: "Upper bound (RFC3339) for event start time." },
maxResults: { type: "integer", location: "query", description: "Maximum number of events returned." },
pageToken: { type: "string", location: "query", description: "Token for pagination." },
q: { type: "string", location: "query", description: "Free text search terms." },
orderBy: { type: "string", location: "query", description: "Sort order (startTime or updated)." },
singleEvents: { type: "boolean", location: "query", description: "Whether to expand recurring events into instances." },
showDeleted: { type: "boolean", location: "query", description: "Whether to include deleted events." },
},
Comment thread
yonatangross marked this conversation as resolved.
response: { $ref: "Events" },
},
insert: {
id: "calendar.events.insert",
path: "calendars/{calendarId}/events",
httpMethod: "POST",
description: "Creates an event.",
parameters: {
calendarId: { type: "string", required: true, location: "path" },
},
request: { $ref: "Event" },
response: { $ref: "Event" },
},
delete: {
id: "calendar.events.delete",
path: "calendars/{calendarId}/events/{eventId}",
httpMethod: "DELETE",
description: "Deletes an event.",
parameters: {
calendarId: { type: "string", required: true, location: "path" },
eventId: { type: "string", required: true, location: "path" },
},
},
},
},
freebusy: {
methods: {
query: {
id: "calendar.freebusy.query",
path: "freeBusy",
httpMethod: "POST",
description: "Returns free/busy information for a set of calendars and groups.",
request: { $ref: "FreeBusyRequest" },
response: { $ref: "FreeBusyResponse" },
},
},
},
},
});
});

app.get("/calendar/v3/users/:userId/calendarList", (c) => {
const authEmail = requireGmailUser(c);
if (authEmail instanceof Response) return authEmail;
Expand Down
6 changes: 5 additions & 1 deletion packages/@emulators/google/src/routes/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { SignJWT } from "jose";
import type { RouteContext } from "@emulators/core";
import {
escapeHtml,
escapeAttr,
renderCardPage,
renderErrorPage,
renderUserButton,
Expand Down Expand Up @@ -313,6 +312,11 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo
return c.json({ error: "invalid_grant", error_description: "The code is incorrect or expired." }, 400);
}

// RFC 6749 §4.1.3: redirect_uri must match the one used in the authorization request.
if (redirect_uri && redirect_uri !== pending.redirectUri) {
return c.json({ error: "invalid_grant", error_description: "The redirect_uri does not match." }, 400);
}

if (pending.codeChallenge != null) {
if (code_verifier === undefined) {
return c.json({ error: "invalid_grant", error_description: "PKCE verification failed." }, 400);
Expand Down
10 changes: 10 additions & 0 deletions skills/google/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,16 @@ curl http://localhost:4002/gmail/v1/users/me/settings/sendAs \

## Google Calendar API

### Discovery Document

The emulator serves the Calendar v3 [Google API discovery document](https://developers.google.com/discovery/v1/reference/apis) so SDKs that bootstrap from discovery (`google-api-python-client`, `googleapis-discovery`, generated clients) work against the local URL with no code changes:

```bash
curl http://localhost:4002/discovery/v1/apis/calendar/v3/rest
```

`rootUrl` and `baseUrl` in the response point at the running emulator (resolved from the request origin, or from `--base-url` if set), so any SDK that follows discovery will route subsequent calls through the emulator.

### Calendar List

```bash
Expand Down