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
134 changes: 41 additions & 93 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ import {
type UpdateClientKeyParams,
updateClientKey,
} from "@sentry/toolkit-core/api-client/endpoints/client-keys";
import {
getDashboard,
listDashboards,
} from "@sentry/toolkit-core/api-client/endpoints/dashboards";
import {
getEventCommitters,
getRelease,
listReleaseCommits,
listReleaseDeploys,
listReleases,
} from "@sentry/toolkit-core/api-client/endpoints/releases";
import {
linkProjectRepository,
listRepositories,
Expand Down Expand Up @@ -1629,30 +1640,12 @@ export class SentryApiService {
},
opts?: RequestOptions,
): Promise<{ dashboards: DashboardListItem[]; nextCursor: string | null }> {
const queryParams = new URLSearchParams();
queryParams.set("per_page", String(limit));
if (query) {
queryParams.set("query", query);
}
if (sortBy) {
queryParams.set("sort", sortBy);
}
if (cursor) {
queryParams.set("cursor", cursor);
}

const response = await this.request(
apiPath`/organizations/${organizationSlug}/dashboards/` +
`?${queryParams.toString()}`,
undefined,
opts,
const { items, nextCursor } = await listDashboards(
this.target(opts?.host),
{ organizationSlug, query, sortBy, limit, cursor },
DashboardListSchema,
);
const body = await readJsonResponse(response);

return {
dashboards: DashboardListSchema.parse(body),
nextCursor: getNextCursor(response.headers.get("link")),
};
return { dashboards: items, nextCursor };
}

async getDashboard(
Expand All @@ -1665,12 +1658,11 @@ export class SentryApiService {
},
opts?: RequestOptions,
): Promise<Dashboard> {
const body = await this.requestJSON(
apiPath`/organizations/${organizationSlug}/dashboards/${dashboardId}/`,
undefined,
opts,
return await getDashboard(
this.target(opts?.host),
{ organizationSlug, dashboardId },
DashboardSchema,
);
return DashboardSchema.parse(body);
}

/**
Expand Down Expand Up @@ -2565,24 +2557,11 @@ export class SentryApiService {
},
opts?: RequestOptions,
): Promise<ReleaseList> {
const searchQuery = new URLSearchParams();
if (query) {
searchQuery.set("query", query);
}
if (limit !== undefined) {
searchQuery.set("per_page", String(limit));
}

const path = projectSlug
? apiPath`/projects/${organizationSlug}/${projectSlug}/releases/`
: apiPath`/organizations/${organizationSlug}/releases/`;

const body = await this.requestJSON(
searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path,
undefined,
opts,
return await listReleases(
this.target(opts?.host),
{ organizationSlug, projectSlug, query, limit },
ReleaseListSchema,
);
return ReleaseListSchema.parse(body);
}

async getReleaseDetails(
Expand All @@ -2599,20 +2578,11 @@ export class SentryApiService {
},
opts?: RequestOptions,
): Promise<ReleaseDetails> {
const searchQuery = new URLSearchParams();
if (includeHealth) {
searchQuery.set("health", "1");
}

const path = projectSlugOrId
? apiPath`/projects/${organizationSlug}/${projectSlugOrId}/releases/${releaseVersion}/`
: apiPath`/organizations/${organizationSlug}/releases/${releaseVersion}/`;
const body = await this.requestJSON(
searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path,
undefined,
opts,
return await getRelease(
this.target(opts?.host),
{ organizationSlug, releaseVersion, projectSlugOrId, includeHealth },
ReleaseDetailsSchema,
);
return ReleaseDetailsSchema.parse(body);
}

async listReleaseDeploys(
Expand All @@ -2629,24 +2599,11 @@ export class SentryApiService {
},
opts?: RequestOptions,
): Promise<DeployList> {
const searchQuery = new URLSearchParams();
if (projectSlugOrId) {
searchQuery.set(
isNumericId(projectSlugOrId) ? "project" : "projectSlug",
projectSlugOrId,
);
}
if (limit !== undefined) {
searchQuery.set("per_page", String(limit));
}

const path = apiPath`/organizations/${organizationSlug}/releases/${releaseVersion}/deploys/`;
const body = await this.requestJSON(
searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path,
undefined,
opts,
return await listReleaseDeploys(
this.target(opts?.host),
{ organizationSlug, releaseVersion, projectSlugOrId, limit },
DeployListSchema,
);
return DeployListSchema.parse(body);
}

async listReleaseCommits(
Expand All @@ -2663,20 +2620,11 @@ export class SentryApiService {
},
opts?: RequestOptions,
): Promise<CommitList> {
const searchQuery = new URLSearchParams();
if (limit !== undefined) {
searchQuery.set("per_page", String(limit));
}

const path = projectSlugOrId
? apiPath`/projects/${organizationSlug}/${projectSlugOrId}/releases/${releaseVersion}/commits/`
: apiPath`/organizations/${organizationSlug}/releases/${releaseVersion}/commits/`;
const body = await this.requestJSON(
searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path,
undefined,
opts,
return await listReleaseCommits(
this.target(opts?.host),
{ organizationSlug, releaseVersion, projectSlugOrId, limit },
CommitListSchema,
);
return CommitListSchema.parse(body);
}

/**
Expand All @@ -2696,12 +2644,12 @@ export class SentryApiService {
},
opts?: RequestOptions,
): Promise<CommitterList> {
const body = await this.requestJSON(
apiPath`/projects/${organizationSlug}/${projectSlug}/events/${eventId}/committers/`,
undefined,
opts,
const { committers } = await getEventCommitters(
this.target(opts?.host),
{ organizationSlug, projectSlug, eventId },
CommittersResponseSchema,
);
return CommittersResponseSchema.parse(body).committers;
return committers;
}

async listMonitors(
Expand Down
6 changes: 5 additions & 1 deletion packages/toolkit-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ the one exception.
- `errors` holds `ApiError` and its status subclasses, `createApiError`, and
the `UserInputError` and `ConfigurationError` the client throws. The MCP
re-exports them from `packages/mcp-core/src/errors.ts`.
- `api-path` builds API paths with every identifier segment encoded.
- `api-path` builds API paths with every identifier segment encoded, and
`withQuery` appends a query string when it is not empty.
- `validate` checks a response with any
[Standard Schema](https://standardschema.dev) validator the host passes in:
Zod in the MCP, valibot in the CLI. Shared code depends only on the
Expand Down Expand Up @@ -63,6 +64,9 @@ the one exception.
`updateClientKey`.
- `endpoints/repositories`: `listRepositories` and
`linkProjectRepository`.
- `endpoints/dashboards`: `listDashboards` and `getDashboard`.
- `endpoints/releases`: `listReleases`, `getRelease`,
`listReleaseDeploys`, `listReleaseCommits`, and `getEventCommitters`.
- `pagination`, `retry`, `transport`, and `response-cache` are described in
the sections below. The `node:zlib` body codec stays outside, at
`response-cache/codec`, since browsers lack it.
Expand Down
11 changes: 10 additions & 1 deletion packages/toolkit-core/src/api-client/api-path.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { UserInputError } from "./errors.js";
import { apiPath } from "./api-path.js";
import { apiPath, withQuery } from "./api-path.js";

/**
* Resolves a path the way `fetch` does. Dot-segment normalization happens during URL
Expand Down Expand Up @@ -89,3 +89,12 @@ describe("apiPath", () => {
});
});
});

describe("withQuery", () => {
it("appends a non-empty query and leaves the path alone otherwise", () => {
expect(withQuery("/a/", new URLSearchParams({ q: "x y" }))).toBe(
"/a/?q=x+y",
);
expect(withQuery("/a/", new URLSearchParams())).toBe("/a/");
});
});
6 changes: 6 additions & 0 deletions packages/toolkit-core/src/api-client/api-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,9 @@ export function apiPath(
return acc + literal + encoded;
}, "");
}

/** `path` with `query` appended, or `path` alone when `query` is empty. */
export function withQuery(path: string, query: URLSearchParams): string {
const queryString = query.toString();
return queryString ? `${path}?${queryString}` : path;
}
52 changes: 52 additions & 0 deletions packages/toolkit-core/src/api-client/endpoints/dashboards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
jsonResponse,
nextLink,
ORIGIN,
scriptedTarget,
} from "../../test-utils/api-target.js";
import { getDashboard, listDashboards } from "./dashboards.js";

const DashboardZod = z.object({ id: z.string() });

describe("listDashboards", () => {
it("reads one page with the filters and returns the next cursor", async () => {
const path = "/organizations/acme/dashboards/";
const { apiTarget, request } = scriptedTarget(
jsonResponse([{ id: "1" }], nextLink(path, "0:10:0")),
);

const page = await listDashboards(
apiTarget,
{
organizationSlug: "acme",
query: "web",
sortBy: "-dateCreated",
cursor: "0:0:0",
},
z.array(DashboardZod),
);

expect(page).toEqual({ items: [{ id: "1" }], nextCursor: "0:10:0" });
expect(request.mock.calls[0]?.[0]).toBe(
`${ORIGIN}/api/0${path}?per_page=10&query=web&sort=-dateCreated&cursor=0%3A0%3A0`,
);
});
});

describe("getDashboard", () => {
it("reads one dashboard", async () => {
const { apiTarget, request } = scriptedTarget(jsonResponse({ id: "7" }));

await getDashboard(
apiTarget,
{ organizationSlug: "acme", dashboardId: "7" },
DashboardZod,
);

expect(request.mock.calls[0]?.[0]).toBe(
`${ORIGIN}/api/0/organizations/acme/dashboards/7/`,
);
});
});
65 changes: 65 additions & 0 deletions packages/toolkit-core/src/api-client/endpoints/dashboards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Dashboard endpoints. Each wrapper validates the response with the schema
* the host passes, so the host's schema decides the result type.
*/

import { apiPath } from "../api-path.js";
import { type ApiTarget, type ListPage, nextPageCursor } from "../core.js";
import { readJsonResponse } from "../response.js";
import { type ResponseSchema, validateResponse } from "../validate.js";

export type ListDashboardsParams = {
organizationSlug: string;
/** Filters by title. */
query?: string | null;
sortBy?: "title" | "-title" | "dateCreated" | "-dateCreated";
/** Page size. Default: 10. */
limit?: number;
/** Resumes from a previous call's `nextCursor`. */
cursor?: string | null;
};

/** One page of the organization's dashboards. */
export async function listDashboards<T>(
{ core, origin }: ApiTarget,
{ organizationSlug, query, sortBy, limit = 10, cursor }: ListDashboardsParams,
schema: ResponseSchema<T[]>,
): Promise<ListPage<T>> {
const params = new URLSearchParams();
params.set("per_page", String(limit));
if (query) {
params.set("query", query);
}
if (sortBy) {
params.set("sort", sortBy);
}
if (cursor) {
params.set("cursor", cursor);
}
const response = await core.request(
origin,
`${apiPath`/organizations/${organizationSlug}/dashboards/`}?${params.toString()}`,
);
return {
items: validateResponse(schema, await readJsonResponse(response)),
nextCursor: nextPageCursor(response),
};
}

export type GetDashboardParams = {
organizationSlug: string;
dashboardId: string;
};

/** One dashboard with its widgets. */
export async function getDashboard<T>(
{ core, origin }: ApiTarget,
{ organizationSlug, dashboardId }: GetDashboardParams,
schema: ResponseSchema<T>,
): Promise<T> {
const body = await core.requestJson(
origin,
apiPath`/organizations/${organizationSlug}/dashboards/${dashboardId}/`,
);
return validateResponse(schema, body);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { listOrganizations as listOrganizationsOperation } from "@sentry/api";
import { apiPath } from "../api-path.js";
import { apiPath, withQuery } from "../api-path.js";
import { type ApiTarget, type ListPage, listPage } from "../core.js";
import { type ResponseSchema, validateResponse } from "../validate.js";

Expand Down Expand Up @@ -68,11 +68,9 @@ export async function getOrganization<T>(
if (params.detailed === false) {
query.set("detailed", "0");
}
const queryString = query.toString();
const path = apiPath`/organizations/${params.organizationSlug}/`;
const body = await core.requestJson(
origin,
`${path}${queryString ? `?${queryString}` : ""}`,
withQuery(apiPath`/organizations/${params.organizationSlug}/`, query),
);
return validateResponse(schema, body);
}
Expand Down
Loading
Loading