Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c46d51e
feat: apiOptions
TkDodo Aug 1, 2025
7487078
fix: give url definition hints in getApiUrl
TkDodo Aug 1, 2025
c8f8a85
fix: manual type annoation with .as<>() should be able to override kn…
TkDodo Aug 1, 2025
1527094
Merge branch 'master' into tkdodo/feat/query-options
TkDodo Aug 1, 2025
02b8821
ref: we removed this "feature"
TkDodo Aug 1, 2025
f73b123
Merge branch 'master' into tkdodo/feat/query-options
TkDodo Aug 1, 2025
e55d95a
ref: remove unnecessary path check
TkDodo Aug 1, 2025
afe55de
Merge branch 'tkdodo/feat/query-options' into tkdodo/feat/api-url-gen…
TkDodo Aug 4, 2025
462c1ab
api url generation
TkDodo Aug 4, 2025
272e126
merge branch master
TkDodo Aug 4, 2025
38763e6
fix: merge conflicts
TkDodo Aug 4, 2025
8711a2b
ref: update known urls
TkDodo Aug 5, 2025
ce921c3
fix: allow other urls than KnownApiUrls
TkDodo Aug 5, 2025
f823816
Merge branch 'master' into tkdodo/feat/api-url-generation
TkDodo Aug 5, 2025
329f5b4
:hammer_and_wrench: apply pre-commit fixes
getsantry[bot] Aug 5, 2025
094a70f
fix: avoid Inefficient regular expression
TkDodo Aug 5, 2025
9ea54a2
fix: account for $param1:$param2 url patterns
TkDodo Aug 5, 2025
94856fc
fix: useRelease api
TkDodo Aug 5, 2025
78447d5
Merge branch 'master' into tkdodo/feat/api-url-generation
TkDodo Aug 7, 2025
8005be5
feat: type transformation for openAPI generated urls
TkDodo Aug 12, 2025
3eaf67b
Merge branch 'master' into tkdodo/feat/api-url-generation
TkDodo Aug 12, 2025
e5cb47c
chore: imports
TkDodo Aug 12, 2025
e5196d8
Merge branch 'master' into tkdodo/feat/api-url-generation
TkDodo Sep 1, 2025
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
171 changes: 171 additions & 0 deletions src/sentry/management/commands/generate_ts_api_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import itertools
import re
from importlib import import_module

from django.conf import settings
from django.core.management.base import BaseCommand
from django.urls import URLPattern, URLResolver

# --- UTILS ---


def snake_to_camel(s: str) -> str:
parts = s.split("_")
return parts[0] + "".join(p.capitalize() for p in parts[1:])


def final_cleanup(path: str) -> str:
path = path.replace(r"\/", "/")
path = re.sub(r"\([^)]*\)", "", path) # remove leftover groups
path = path.replace(".*", "")
path = re.sub(r"//+", "/", path)

# Ensure leading and trailing slash
path = "/" + path.strip("/")

if not path.endswith("/"):
path += "/"

return path


def replace_named_groups(regex: str) -> str:
result = []
last_end = 0

# Match all `(?P<paramName>...)` groups, even with nested `(...)` inside
pattern = re.compile(r"\(\?P<(\w+)>")

for match in pattern.finditer(regex):
start = match.start()
name = match.group(1)

# Find the matching closing parenthesis for this group
depth = 1
i = match.end()
while i < len(regex):
if regex[i] == "(":
depth += 1
elif regex[i] == ")":
depth -= 1
if depth == 0:
break
i += 1

# If we couldn't find the end, skip the group entirely (fail-safe)
if depth != 0:
continue

# Append text before the match and the replacement
result.append(regex[last_end:start])
result.append(f"${snake_to_camel(name)}")
last_end = i + 1 # skip the closing ')'

# Append the rest
result.append(regex[last_end:])
return "".join(result)


def convert_django_regex_to_ts_all(regex: str) -> list[str]:
regex = regex.strip("^$")

regex = replace_named_groups(regex)

# Handle non-capturing groups (e.g. (?:x|y) → x and y)
pattern = r"\(\?:([^)]+)\)"
matches = list(re.finditer(pattern, regex))

# Also detect optional static segments like 'plugins?'
optional_pattern = r"([a-zA-Z0-9_-]+)\?"
matches += list(re.finditer(optional_pattern, regex))

if not matches:
return [final_cleanup(regex)]

# Build route combinations
parts = []
last_end = 0

for match in matches:
start, end = match.span()
parts.append([regex[last_end:start]])

if match.re.pattern == pattern:
parts.append(match.group(1).split("|"))
else:
token = match.group(1)
parts.append([token, ""]) # with or without the optional char

last_end = end

parts.append([regex[last_end:]])

combos = itertools.product(*parts)
return [final_cleanup("".join(c)) for c in combos]


def normalize_regex_part(regex: str) -> str:
return regex.strip("^$")


def extract_ts_routes(urlpatterns, prefix="") -> list[str]:
routes = []

for pattern in urlpatterns:
if isinstance(pattern, URLPattern):
raw_regex = normalize_regex_part(pattern.pattern.regex.pattern)
full_path = prefix + raw_regex
ts_paths = convert_django_regex_to_ts_all(full_path)
for path in ts_paths:
routes.append(f"'{path}'")

elif isinstance(pattern, URLResolver):
new_prefix = prefix + normalize_regex_part(pattern.pattern.regex.pattern)
routes.extend(extract_ts_routes(pattern.url_patterns, new_prefix))

return routes


# --- COMMAND ---


class Command(BaseCommand):
help = "Generate TypeScript route types from Django urlpatterns"

def add_arguments(self, parser):
parser.add_argument(
"--output",
type=str,
default="routes.ts",
help="Output file for TypeScript route types",
)
parser.add_argument(
"--urls",
type=str,
default=settings.ROOT_URLCONF,
help="Python path to root URLconf (default: settings.ROOT_URLCONF)",
)

def handle(self, *args, **options):
urls_module_path = options["urls"]
output_file = options["output"]

self.stdout.write(f"🔍 Loading URLconf: {urls_module_path}")
urlconf = import_module(urls_module_path)
urlpatterns = getattr(urlconf, "urlpatterns", [])

ts_routes = extract_ts_routes(urlpatterns)
ts_routes = sorted(set(ts_routes))

with open(output_file, "w") as f:
f.write("/* prettier-ignore */\n")
f.write("// Auto-generated TypeScript route types\n")
f.write(
"// To update it run `sentry django generate_ts_api_routes --urls sentry.api.urls --output=path/to/thisfile.ts`\n"
)
f.write("export type KnownApiUrls =\n")
for route in ts_routes:
f.write(f" | {route}\n")
f.write(";\n")

self.stdout.write(self.style.SUCCESS(f"✅ Wrote {len(ts_routes)} routes to {output_file}"))
30 changes: 28 additions & 2 deletions static/app/api/apiDefinition.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
type KnownApiUrls = ['/projects/$orgSlug/$projectSlug/releases/$releaseVersion/'];
import type {KnownApiUrls} from './knownUrls';

Check failure on line 1 in static/app/api/apiDefinition.ts

View workflow job for this annotation

GitHub Actions / pre-commit lint

'KnownApiUrls' is defined but never used. Allowed unused vars must match /^_/u

Check failure on line 1 in static/app/api/apiDefinition.ts

View workflow job for this annotation

GitHub Actions / typescript

'KnownApiUrls' is declared but its value is never read.

Check failure on line 1 in static/app/api/apiDefinition.ts

View workflow job for this annotation

GitHub Actions / eslint

'KnownApiUrls' is defined but never used. Allowed unused vars must match /^_/u
import type {paths} from './openapi';

Check failure on line 2 in static/app/api/apiDefinition.ts

View workflow job for this annotation

GitHub Actions / typescript

Cannot find module './openapi' or its corresponding type declarations.

export type MaybeApiPath = KnownApiUrls[number] | (string & {});
type SnakeToCamel<S extends string> = S extends `${infer Head}_${infer Tail}`
? `${Head}${Capitalize<SnakeToCamel<Tail>>}`
: S;

// replace {param_name} → $paramName
type ParamToDollarCamel<S extends string> = S extends `{${infer Param}}`
? `$${SnakeToCamel<Param>}`
: S;

// Recursive: split by '/', transform params, join back
type TransformSegments<S extends string> = S extends `/${infer Rest}`
? `/${TransformSegments<Rest>}`
: S extends `${infer Segment}/${infer Tail}`
? `${ParamToDollarCamel<Segment>}/${TransformSegments<Tail>}`
: ParamToDollarCamel<S>;

// filter GET paths, strip `/api/0`, and transform params
type GetPaths = {
[P in keyof paths]: 'get' extends keyof paths[P]
? P extends `/api/0${infer Rest}`
? TransformSegments<Rest>
: never
: never;
}[keyof paths];

Check failure on line 27 in static/app/api/apiDefinition.ts

View workflow job for this annotation

GitHub Actions / typescript

Type 'symbol' cannot be used as an index type.

export type ApiPath = GetPaths | (string & {});
13 changes: 9 additions & 4 deletions static/app/api/apiOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
import type {ApiResult} from 'sentry/api';
import {fetchDataQuery, type QueryKeyEndpointOptions} from 'sentry/utils/queryClient';

import type {MaybeApiPath} from './apiDefinition';
import {getApiUrl, type ExtractPathParams, type OptionalPathParams} from './getApiUrl';
import type {ApiPath} from './apiDefinition';
import {
getApiUrl,
type ExtractPathParams,
type OptionalPathParams,
type PathParamOptions,

Check failure on line 11 in static/app/api/apiOptions.ts

View workflow job for this annotation

GitHub Actions / typescript

Module '"./getApiUrl"' declares 'PathParamOptions' locally, but it is not exported.
} from './getApiUrl';

type Options = QueryKeyEndpointOptions & {staleTime: number};

Expand All @@ -28,7 +33,7 @@

function _apiOptions<
TManualData = never,
TApiPath extends MaybeApiPath = MaybeApiPath,
TApiPath extends ApiPath = ApiPath,
// todo: infer the actual data type from the ApiMapping
TActualData = TManualData,
>(
Expand Down Expand Up @@ -67,7 +72,7 @@
export const apiOptions = {
as:
<TManualData>() =>
<TApiPath extends MaybeApiPath = MaybeApiPath>(
<TApiPath extends ApiPath = ApiPath>(
path: TApiPath,
options: Options & PathParamOptions<TApiPath>
) =>
Expand Down
15 changes: 15 additions & 0 deletions static/app/api/getApiUrl.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ describe('getApiUrl', () => {
expect(url).toBe('/projects/my-org/my-project/releases/v%201.0.0/');
});

test('advanced path params case', () => {
const url = getApiUrl(
'/organizations/$organizationIdOrSlug/events/$projectIdOrSlug:$eventId/',
{
path: {
organizationIdOrSlug: 'my-org',
projectIdOrSlug: 'my-project',
eventId: '12345',
},
}
);

expect(url).toBe('/organizations/my-org/events/my-project:12345/');
});

test('should stringify number path params', () => {
const url = getApiUrl('/items/$id/', {
path: {id: 123},
Expand Down
14 changes: 10 additions & 4 deletions static/app/api/getApiUrl.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import type {MaybeApiPath} from './apiDefinition';
import type {ApiPath} from './apiDefinition';

type StripDollar<T extends string> = T extends `$${infer Name}` ? Name : T;

type SplitColon<T extends string> = T extends `${infer A}:${infer B}`
? StripDollar<A> | SplitColon<B>
: StripDollar<T>;

export type ExtractPathParams<TApiPath extends string> =
TApiPath extends `${string}$${infer Param}/${infer Rest}`
? Param | ExtractPathParams<`/${Rest}`>
? SplitColon<Param> | ExtractPathParams<`/${Rest}`>
: TApiPath extends `${string}$${infer Param}`
? Param
? SplitColon<Param>
: never;

type PathParamOptions<TApiPath extends string> =
Expand All @@ -21,7 +27,7 @@ const paramRegex = /\$([a-zA-Z0-9_-]+)/g;

type ApiUrl = string & {__apiUrl: true};

export function getApiUrl<TApiPath extends MaybeApiPath>(
export function getApiUrl<TApiPath extends ApiPath>(
path: TApiPath,
...[options]: OptionalPathParams<TApiPath>
): ApiUrl {
Expand Down
Loading
Loading