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
11 changes: 7 additions & 4 deletions packages/cli/script/lib/api-schema.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
/** Project SDK operation metadata into the CLI's bundled discovery index. */

import { API_BASE_PATH } from "@sentry/api/client";
import type { ApiOperation } from "@sentry/api/operations";
import type { ApiEndpoint } from "../../src/lib/api-schema.js";

/** Keep CLI grouping, output names and legacy deprecation detection local. */
/**
* Keep CLI grouping, output names and legacy deprecation detection local.
* Paths are full request paths, as users type and see them.
*/
export function projectApiOperations(
operations: readonly ApiOperation[],
): ApiEndpoint[] {
return operations
.map((operation) => ({
fn: operation.functionName,
method: operation.method,
path: operation.path,
path: `${API_BASE_PATH}${operation.path}`,
summary: operation.summary,
description: operation.description,
tags: operation.tags ? [...operation.tags] : undefined,
Expand Down Expand Up @@ -39,6 +43,5 @@ function deriveResource(url: string): string {
const segments = url
.split("/")
.filter((s) => s.length > 0 && !s.startsWith("{"));
const meaningful = segments.filter((s) => s !== "api" && s !== "0");
return meaningful.at(-1) ?? "unknown";
return segments.at(-1) ?? "unknown";
}
42 changes: 25 additions & 17 deletions packages/cli/src/commands/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { access, readFile } from "node:fs/promises";
// oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node";
import { parseApiPathScope } from "@sentry/api/client";
import { API_BASE_PATH, parseApiPathScope } from "@sentry/api/client";
import type { SentryContext } from "../context.js";
import {
appendSearchParams,
Expand Down Expand Up @@ -63,8 +63,9 @@ const log = logger.withTag("api");

/** Strips line breaks and surrounding indentation from copy-pasted endpoints. */
const LINE_BREAK_PATTERN = /[ \t]*[\r\n]+[ \t]*/g;
const API_PREFIX = "/api/0";
const API_PREFIX_WITH_SLASH = `${API_PREFIX}/`;
const API_PREFIX_WITH_SLASH = `${API_BASE_PATH}/`;
/** The base path as users type it, without the leading slash. */
const BARE_API_PREFIX = API_BASE_PATH.slice(1);
const PAGINATION_KEY = "api";

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
Expand Down Expand Up @@ -161,10 +162,10 @@ function splitAbsoluteApiUrl(url: URL): AbsoluteApiTarget {
strippedApiPrefix: true,
};
}
if (url.pathname.endsWith(API_PREFIX)) {
if (url.pathname.endsWith(API_BASE_PATH)) {
return {
endpoint: url.search,
baseUrl: `${url.origin}${url.pathname.slice(0, -API_PREFIX.length)}`,
baseUrl: `${url.origin}${url.pathname.slice(0, -API_BASE_PATH.length)}`,
strippedApiPrefix: true,
};
}
Expand Down Expand Up @@ -218,12 +219,11 @@ export function normalizeEndpoint(endpoint: string): string {
? relativeEndpoint.slice(1)
: relativeEndpoint;

// Strip api/0/ prefix if user accidentally included it — the base URL
// already includes /api/0/, so keeping it would produce a doubled path
// like /api/0/api/0/... (see CLI-K1).
// Also strip bare "api/0" to maintain idempotency.
if (trimmed.startsWith("api/0/") || trimmed === "api/0") {
trimmed = trimmed.slice(trimmed.startsWith("api/0/") ? 6 : 5);
// Strip the API base path if the user included it — the client adds it,
// so keeping it would produce a doubled path like /api/0/api/0/...
// (see CLI-K1). Also strip the bare base path to maintain idempotency.
if (hasBareApiPrefix(trimmed)) {
trimmed = trimmed.slice(BARE_API_PREFIX.length + 1);
}

// Split path and query string
Expand All @@ -240,6 +240,12 @@ export function normalizeEndpoint(endpoint: string): string {
return `${normalizedPath}${query}`;
}

function hasBareApiPrefix(endpoint: string): boolean {
return (
endpoint.startsWith(`${BARE_API_PREFIX}/`) || endpoint === BARE_API_PREFIX
);
}

function resolveApiTarget(endpoint: string): {
normalizedEndpoint: string;
requestBaseUrl?: string;
Expand All @@ -259,8 +265,7 @@ function resolveApiTarget(endpoint: string): {
requestBaseUrl: absoluteTarget?.baseUrl,
strippedApiPrefix:
absoluteTarget?.strippedApiPrefix ??
(withoutLeadingSlash.startsWith("api/0/") ||
withoutLeadingSlash === "api/0"),
hasBareApiPrefix(withoutLeadingSlash),
};
}

Expand Down Expand Up @@ -1105,7 +1110,10 @@ export function formatEmptyErrorBody(
endpoint: string | undefined,
): string {
const statusLabel = [status, statusText].filter(Boolean).join(" ");
const request = method && endpoint ? ` — ${method} /api/0/${endpoint}` : "";
const request =
method && endpoint
? ` — ${method} ${API_PREFIX_WITH_SLASH}${endpoint}`
: "";
return `HTTP ${statusLabel}${request}`;
}

Expand Down Expand Up @@ -1569,7 +1577,7 @@ export const apiCommand = buildCommand({
brief: topLevelBrief("api"),
fullDescription:
"Make a raw API request to the Sentry API. Similar to 'gh api' for GitHub. " +
"The endpoint is relative to /api/0/ (do not include the prefix). " +
`The endpoint is relative to ${API_PREFIX_WITH_SLASH} (do not include the prefix). ` +
"Absolute HTTP(S) Sentry URLs are also accepted; their origin is " +
"validated against your authenticated host. " +
"Authentication is handled automatically using your stored credentials.\n\n" +
Expand Down Expand Up @@ -1661,7 +1669,7 @@ export const apiCommand = buildCommand({
kind: "tuple",
parameters: [
{
brief: "API endpoint relative to /api/0/, or an absolute HTTP(S) URL",
brief: `API endpoint relative to ${API_PREFIX_WITH_SLASH}, or an absolute HTTP(S) URL`,
parse: parseApiEndpoint,
placeholder: "endpoint",
},
Expand Down Expand Up @@ -1756,7 +1764,7 @@ export const apiCommand = buildCommand({
// only surface the detail at debug level for troubleshooting
// (getsentry/cli#785 item #11).
log.debug(
"Stripped /api/0/ prefix from endpoint (auto-added by the API client)",
`Stripped ${API_PREFIX_WITH_SLASH} prefix from endpoint (auto-added by the API client)`,
);
}
const { body, params } = await resolveBody(flags, stdin);
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/src/lib/api-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* `script/generate-api-schema.ts` from Sentry's OpenAPI specification.
*/

import { API_BASE_PATH } from "@sentry/api/client";
import type { ApiOperation } from "@sentry/api/operations";
import endpoints from "../generated/api-schema.json" with { type: "json" };
import { fuzzyMatch } from "./fuzzy.js";
Expand Down Expand Up @@ -453,15 +454,15 @@ export function getAllEndpoints(): readonly ApiEndpoint[] {
}

/** The prefix `api` adds to an endpoint, left out of completed paths. */
const API_PATH_PREFIX = /^\/api\/0\//;
const API_PATH_PREFIX = `${API_BASE_PATH}/`;

/**
* Every schema path without its `/api/0/` prefix, each once: the endpoints
* Every schema path without the API base path, each once: the endpoints
* `sentry api <endpoint>` completes (see `CompletionValues.apiPaths`).
*/
export function getApiCompletionPaths(): string[] {
return [
...new Set(schema.map(({ path }) => path.replace(API_PATH_PREFIX, ""))),
...new Set(schema.map(({ path }) => path.slice(API_PATH_PREFIX.length))),
];
}

Expand Down
58 changes: 30 additions & 28 deletions packages/cli/src/lib/api/completion-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import type {
ListProjectTeamsData,
ResolveOrganizationShortIdData,
} from "@sentry/api";
import { API_BASE_PATH } from "@sentry/api/client";
import {
type CompletionEntity,
type CompletionEntityKind,
Expand Down Expand Up @@ -300,22 +301,22 @@ export const ATTRIBUTE_KINDS = {
const COMPLETION_SOURCES: readonly CompletionSource[] = [
{
kind: "issue",
url: "/api/0/organizations/{organization_id_or_slug}/issues/" satisfies ListOrganizationIssuesData["url"],
url: "/organizations/{organization_id_or_slug}/issues/" satisfies ListOrganizationIssuesData["url"],
extract: issueEntities,
},
{
kind: "issue",
url: "/api/0/organizations/{organization_id_or_slug}/issues/{issue_id}/" satisfies GetOrganizationIssueData["url"],
url: "/organizations/{organization_id_or_slug}/issues/{issue_id}/" satisfies GetOrganizationIssueData["url"],
extract: issueEntities,
},
{
kind: "tag-key",
url: "/api/0/organizations/{organization_id_or_slug}/issues/{issue_id}/" satisfies GetOrganizationIssueData["url"],
url: "/organizations/{organization_id_or_slug}/issues/{issue_id}/" satisfies GetOrganizationIssueData["url"],
extract: tagKeyEntities,
},
{
kind: "issue",
url: "/api/0/organizations/{organization_id_or_slug}/shortids/{issue_id}/" satisfies ResolveOrganizationShortIdData["url"],
url: "/organizations/{organization_id_or_slug}/shortids/{issue_id}/" satisfies ResolveOrganizationShortIdData["url"],
extract: (body) =>
issueEntities(
typeof body === "object" && body !== null
Expand All @@ -325,107 +326,107 @@ const COMPLETION_SOURCES: readonly CompletionSource[] = [
},
{
kind: "dashboard",
url: "/api/0/organizations/{organization_id_or_slug}/dashboards/" satisfies ListOrganizationDashboardsData["url"],
url: "/organizations/{organization_id_or_slug}/dashboards/" satisfies ListOrganizationDashboardsData["url"],
extract: dashboardEntities,
},
{
kind: "dashboard",
url: "/api/0/organizations/{organization_id_or_slug}/dashboards/{dashboard_id}/" satisfies GetOrganizationDashboardData["url"],
url: "/organizations/{organization_id_or_slug}/dashboards/{dashboard_id}/" satisfies GetOrganizationDashboardData["url"],
extract: dashboardEntities,
},
{
kind: "environment",
url: "/api/0/organizations/{organization_id_or_slug}/environments/" satisfies ListOrganizationEnvironmentsData["url"],
url: "/organizations/{organization_id_or_slug}/environments/" satisfies ListOrganizationEnvironmentsData["url"],
extract: fieldEntities("name"),
},
{
kind: "environment",
url: "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/environments/" satisfies ListProjectEnvironmentsData["url"],
url: "/projects/{organization_id_or_slug}/{project_id_or_slug}/environments/" satisfies ListProjectEnvironmentsData["url"],
extract: fieldEntities("name"),
},
{
kind: "release",
url: "/api/0/organizations/{organization_id_or_slug}/releases/" satisfies ListOrganizationReleasesData["url"],
url: "/organizations/{organization_id_or_slug}/releases/" satisfies ListOrganizationReleasesData["url"],
extract: fieldEntities("version", "shortVersion"),
},
{
kind: "release",
url: "/api/0/organizations/{organization_id_or_slug}/releases/{version}/" satisfies GetOrganizationReleaseData["url"],
url: "/organizations/{organization_id_or_slug}/releases/{version}/" satisfies GetOrganizationReleaseData["url"],
extract: fieldEntities("version", "shortVersion"),
},
{
kind: "release",
url: "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/releases/" satisfies ListProjectReleasesData["url"],
url: "/projects/{organization_id_or_slug}/{project_id_or_slug}/releases/" satisfies ListProjectReleasesData["url"],
extract: fieldEntities("version", "shortVersion"),
},
{
kind: "team",
url: "/api/0/organizations/{organization_id_or_slug}/teams/" satisfies ListOrganizationTeamsData["url"],
url: "/organizations/{organization_id_or_slug}/teams/" satisfies ListOrganizationTeamsData["url"],
extract: fieldEntities("slug", "name"),
},
{
kind: "team",
url: "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/teams/" satisfies ListProjectTeamsData["url"],
url: "/projects/{organization_id_or_slug}/{project_id_or_slug}/teams/" satisfies ListProjectTeamsData["url"],
extract: fieldEntities("slug", "name"),
},
{
kind: "owner",
url: "/api/0/organizations/{organization_id_or_slug}/teams/" satisfies ListOrganizationTeamsData["url"],
url: "/organizations/{organization_id_or_slug}/teams/" satisfies ListOrganizationTeamsData["url"],
extract: teamOwnerEntities,
},
{
kind: "owner",
url: "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/teams/" satisfies ListProjectTeamsData["url"],
url: "/projects/{organization_id_or_slug}/{project_id_or_slug}/teams/" satisfies ListProjectTeamsData["url"],
extract: teamOwnerEntities,
},
{
kind: "monitor",
url: "/api/0/organizations/{organization_id_or_slug}/monitors/" satisfies ListOrganizationMonitorsData["url"],
url: "/organizations/{organization_id_or_slug}/monitors/" satisfies ListOrganizationMonitorsData["url"],
extract: fieldEntities("slug", "name"),
},
{
kind: "monitor",
url: "/api/0/organizations/{organization_id_or_slug}/monitors/{monitor_id_or_slug}/" satisfies GetOrganizationMonitorData["url"],
url: "/organizations/{organization_id_or_slug}/monitors/{monitor_id_or_slug}/" satisfies GetOrganizationMonitorData["url"],
extract: fieldEntities("slug", "name"),
},
{
kind: "monitor",
url: "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/monitors/{monitor_id_or_slug}/" satisfies GetProjectMonitorData["url"],
url: "/projects/{organization_id_or_slug}/{project_id_or_slug}/monitors/{monitor_id_or_slug}/" satisfies GetProjectMonitorData["url"],
extract: fieldEntities("slug", "name"),
},
{
kind: "workflow",
url: "/api/0/organizations/{organization_id_or_slug}/workflows/" satisfies ListOrganizationWorkflowsData["url"],
url: "/organizations/{organization_id_or_slug}/workflows/" satisfies ListOrganizationWorkflowsData["url"],
extract: fieldEntities("id", "name"),
},
{
kind: "workflow",
url: "/api/0/organizations/{organization_id_or_slug}/workflows/{workflow_id}/" satisfies GetOrganizationWorkflowData["url"],
url: "/organizations/{organization_id_or_slug}/workflows/{workflow_id}/" satisfies GetOrganizationWorkflowData["url"],
extract: fieldEntities("id", "name"),
},
{
kind: "integration",
url: "/api/0/organizations/{organization_id_or_slug}/integrations/" satisfies ListOrganizationIntegrationsData["url"],
url: "/organizations/{organization_id_or_slug}/integrations/" satisfies ListOrganizationIntegrationsData["url"],
extract: integrationEntities,
},
{
kind: "sentry-app",
url: "/api/0/organizations/{organization_id_or_slug}/sentry-app-installations/" satisfies ListOrganizationSentryAppInstallationsData["url"],
url: "/organizations/{organization_id_or_slug}/sentry-app-installations/" satisfies ListOrganizationSentryAppInstallationsData["url"],
extract: sentryAppEntities,
},
{
kind: "app-link-field",
url: "/api/0/organizations/{organization_id_or_slug}/sentry-app-components/" satisfies ListOrganizationSentryAppComponentsData["url"],
url: "/organizations/{organization_id_or_slug}/sentry-app-components/" satisfies ListOrganizationSentryAppComponentsData["url"],
extract: appLinkFieldEntities,
},
{
kind: "dsn",
url: "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/keys/" satisfies ListProjectKeysData["url"],
url: "/projects/{organization_id_or_slug}/{project_id_or_slug}/keys/" satisfies ListProjectKeysData["url"],
extract: dsnEntities,
},
...Object.entries(ATTRIBUTE_KINDS).map(([dataset, kind]) => ({
kind,
url: "/api/0/organizations/{organization_id_or_slug}/trace-items/attributes/" satisfies ListOrganizationTraceItemAttributesData["url"],
url: "/organizations/{organization_id_or_slug}/trace-items/attributes/" satisfies ListOrganizationTraceItemAttributesData["url"],
query: { dataset },
extract: attributeEntities,
})),
Expand All @@ -435,13 +436,14 @@ const COMPLETION_SOURCES: readonly CompletionSource[] = [
const TEMPLATE_PARAM_RE = /\{(\w+)\}/g;

/**
* A regex matching paths that end in `template`. The organization
* A regex matching paths that end in the API base path and `template`. The organization
* parameter is the first capture group; other parameters match any
* segment.
*/
function templatePattern(template: string): RegExp {
const pattern = template.replace(TEMPLATE_PARAM_RE, (_, name) =>
name === "organization_id_or_slug" ? "([^/]+)" : "[^/]+",
const pattern = `${API_BASE_PATH}${template}`.replace(
TEMPLATE_PARAM_RE,
(_, name) => (name === "organization_id_or_slug" ? "([^/]+)" : "[^/]+"),
);
return new RegExp(`${pattern}$`);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/lib/api/infrastructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1268,7 +1268,8 @@ export async function prepareRawApiRequest(
const isStringBody = typeof body === "string";
const request = await createSentryClient(config).client.prepareRequest({
method,
url: buildSentryApiUrl("", appendSearchParams(endpoint, params)),
// Relative to the API base path, which the client adds.
url: `/${appendSearchParams(endpoint, params).replace(/^\/+/, "")}`,
query,
body,
headers: isStringBody ? { "Content-Type": null, ...headers } : headers,
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/lib/api/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
updateOrganizationIssue,
updateOrganizationIssues,
} from "@sentry/api";
import { API_BASE_PATH } from "@sentry/api/client";
import pLimit from "p-limit";

import type { SentryIssue } from "../../types/index.js";
Expand Down Expand Up @@ -736,7 +737,7 @@ export async function getSharedIssue(
): Promise<{ id: string }> {
const path = `organizations/${encodeURIComponent(orgSlug)}/shared/issues/${encodeURIComponent(shareId)}/`;
const normalizedBase = baseUrl.replace(TRAILING_SLASH_RE, "");
const url = `${normalizedBase}/api/0/${path}`;
const url = `${normalizedBase}${API_BASE_PATH}/${path}`;
const headers = new Headers({ "Content-Type": "application/json" });
// URL-scoped: headers only attach when `url`'s origin matches the trusted
// host, so IAP tokens etc. can't leak to an attacker-controlled share URL.
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/lib/api/preprod-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
type UploadProjectPreprodArtifactSnapshotData,
uploadProjectPreprodArtifactSnapshot,
} from "@sentry/api";
import { API_BASE_PATH } from "@sentry/api/client";
import {
array,
boolean,
Expand Down Expand Up @@ -614,7 +615,7 @@ export async function openSnapshotArchive(
snapshotId: string,
): Promise<Response> {
const regionUrl = stripTrailingSlashes(await resolveOrgRegion(org));
const url = `${regionUrl}/api/0/${snapshotArchiveEndpoint(
const url = `${regionUrl}${API_BASE_PATH}/${snapshotArchiveEndpoint(
org,
snapshotId,
)}?download`;
Expand Down
Loading
Loading