atproto pds in zig pds.zat.dev
pds atproto
24

Configure Feed

Select the types of activity you want to include in your feed.

Add generated API reference

zzstoatzz (Jun 1, 2026, 4:27 PM -0500) 527063c3 8d1a8182

+468 -71
+2 -2
build.zig.zon
··· 5 5 .minimum_zig_version = "0.16.0", 6 6 .dependencies = .{ 7 7 .zat = .{ 8 - .url = "git+https://tangled.org/zat.dev/zat#485f1d485a9b8e7b703e8627a6b6a8c3e3c36a0e", 9 - .hash = "zat-0.3.4-5PuC7ltTCQBUlFGCyduMbwKM929pVX-w7qxlwWtLZ9sV", 8 + .url = "git+https://tangled.org/zat.dev/zat#v0.3.5", 9 + .hash = "zat-0.3.5-5PuC7opUCQDOLCSXwKHv2Cp8YcxTqycUlaRajKs-IHwg", 10 10 }, 11 11 .zqlite = .{ 12 12 .url = "git+https://github.com/karlseguin/zqlite.zig?ref=master#05a88d6758753e1c63fdd45b211dde2057094b0c",
+333
src/http/docs.zig
··· 1 + const std = @import("std"); 2 + const config = @import("../core/config.zig"); 3 + const http_api = @import("api.zig"); 4 + const router = @import("router.zig"); 5 + 6 + const http = std.http; 7 + 8 + const Endpoint = router.Endpoint; 9 + const endpoints = router.endpoints; 10 + 11 + const group_order = [_][]const u8{ 12 + "zds", 13 + "status", 14 + "discovery", 15 + "oauth", 16 + "server", 17 + "account", 18 + "preferences", 19 + "repo", 20 + "sync", 21 + "identity", 22 + }; 23 + 24 + pub fn serve(request: *http_api.Request) !void { 25 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 26 + defer arena.deinit(); 27 + const body = try renderHtml(arena.allocator()); 28 + try http_api.respond(request, .ok, body, &html_headers); 29 + } 30 + 31 + pub fn serveOpenApi(request: *http_api.Request) !void { 32 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 33 + defer arena.deinit(); 34 + const body = try renderOpenApi(arena.allocator()); 35 + try http_api.respond(request, .ok, body, &json_headers); 36 + } 37 + 38 + fn renderHtml(allocator: std.mem.Allocator) ![]const u8 { 39 + var group_buttons: std.Io.Writer.Allocating = .init(allocator); 40 + defer group_buttons.deinit(); 41 + try group_buttons.writer.print("<button class=\"chip active\" type=\"button\" data-group=\"all\">All <span>{d}</span></button>", .{endpoints.len}); 42 + for (group_order) |group| { 43 + const total = groupCount(group); 44 + if (total == 0) continue; 45 + try group_buttons.writer.print( 46 + "<button class=\"chip\" type=\"button\" data-group={f}>{s} <span>{d}</span></button>", 47 + .{ std.json.fmt(group, .{}), try escapeHtml(allocator, group), total }, 48 + ); 49 + } 50 + 51 + var cards: std.Io.Writer.Allocating = .init(allocator); 52 + defer cards.deinit(); 53 + for (endpoints, 0..) |endpoint, i| { 54 + try renderEndpointCard(allocator, &cards.writer, endpoint, i == 0); 55 + } 56 + 57 + const public_url = config.publicUrl(); 58 + return std.fmt.allocPrint(allocator, 59 + \\<!doctype html> 60 + \\<html lang="en"> 61 + \\<head> 62 + \\ <meta charset="utf-8"> 63 + \\ <meta name="viewport" content="width=device-width, initial-scale=1"> 64 + \\ <meta name="color-scheme" content="dark light"> 65 + \\ <title>zds api</title> 66 + \\ <link rel="canonical" href="{s}/api"> 67 + \\ <link rel="icon" href="/favicon.svg" type="image/svg+xml"> 68 + \\ <style>{s}</style> 69 + \\</head> 70 + \\<body> 71 + \\ <main class="shell"> 72 + \\ <header> 73 + \\ <a class="brand" href="/">zds</a> 74 + \\ <nav aria-label="api links"> 75 + \\ <a href="/api/openapi.json">OpenAPI JSON</a> 76 + \\ <a href="/xrpc/com.atproto.server.describeServer">describeServer</a> 77 + \\ </nav> 78 + \\ </header> 79 + \\ <section class="intro"> 80 + \\ <p class="eyebrow">endpoint reference</p> 81 + \\ <h1>{s}</h1> 82 + \\ <p class="lede">This PDS exposes {d} documented HTTP operations, including XRPC methods at <code>/xrpc/{{NSID}}</code>.</p> 83 + \\ <div class="stats" aria-label="endpoint summary"> 84 + \\ <span><strong>{d}</strong> operations</span> 85 + \\ <span><strong>{d}</strong> groups</span> 86 + \\ <span><strong>{d}</strong> XRPC methods</span> 87 + \\ </div> 88 + \\ </section> 89 + \\ <section class="tools" aria-label="filters"> 90 + \\ <label class="search"> 91 + \\ <span>Search</span> 92 + \\ <input id="search" type="search" placeholder="com.atproto.repo.applyWrites, sync, bearer"> 93 + \\ </label> 94 + \\ <div class="chips" id="groups" aria-label="groups">{s}</div> 95 + \\ </section> 96 + \\ <section class="endpoints" id="endpoints" aria-label="endpoints">{s}</section> 97 + \\ </main> 98 + \\ <script>{s}</script> 99 + \\</body> 100 + \\</html> 101 + , .{ 102 + try escapeHtml(allocator, public_url), 103 + css, 104 + try escapeHtml(allocator, public_url), 105 + endpoints.len, 106 + endpoints.len, 107 + groupCountTotal(), 108 + xrpcCount(), 109 + group_buttons.written(), 110 + cards.written(), 111 + script, 112 + }); 113 + } 114 + 115 + fn groupCount(group: []const u8) usize { 116 + var count: usize = 0; 117 + for (endpoints) |endpoint| { 118 + if (std.mem.eql(u8, endpoint.group, group)) count += 1; 119 + } 120 + return count; 121 + } 122 + 123 + fn groupCountTotal() usize { 124 + var count: usize = 0; 125 + for (group_order) |group| { 126 + if (groupCount(group) > 0) count += 1; 127 + } 128 + return count; 129 + } 130 + 131 + fn renderEndpointCard(allocator: std.mem.Allocator, writer: *std.Io.Writer, endpoint: Endpoint, open: bool) !void { 132 + const method_class = try lower(allocator, endpoint.method); 133 + const details_open = if (open) " open" else ""; 134 + const nsid = endpoint.nsid(); 135 + const search_text = try std.fmt.allocPrint(allocator, "{s} {s} {s} {s} {s}", .{ endpoint.method, endpoint.path, endpoint.group, endpoint.auth, endpoint.summary }); 136 + try writer.print( 137 + \\<details class="endpoint" data-group={f} data-method={f} data-search={f}{s}> 138 + \\ <summary><span class="method {s}">{s}</span><code>{s}</code><span class="auth">{s}</span></summary> 139 + \\ <div class="body"> 140 + \\ <p>{s}</p> 141 + , .{ 142 + std.json.fmt(endpoint.group, .{}), 143 + std.json.fmt(endpoint.method, .{}), 144 + std.json.fmt(search_text, .{}), 145 + details_open, 146 + method_class, 147 + try escapeHtml(allocator, endpoint.method), 148 + try escapeHtml(allocator, endpoint.path), 149 + try escapeHtml(allocator, endpoint.auth), 150 + try escapeHtml(allocator, endpoint.summary), 151 + }); 152 + 153 + if (nsid) |id| { 154 + try writer.print("<div class=\"nsid\"><span>NSID</span><code>{s}</code></div>", .{try escapeHtml(allocator, id)}); 155 + } 156 + try writer.writeAll("<div class=\"meta-grid\">"); 157 + try renderList(allocator, writer, "Query", endpoint.params); 158 + try renderList(allocator, writer, "Body", endpoint.body); 159 + try renderList(allocator, writer, "Response", endpoint.response); 160 + try writer.writeAll("</div>"); 161 + if (endpoint.notes.len > 0) { 162 + try writer.print("<p class=\"note\">{s}</p>", .{try escapeHtml(allocator, endpoint.notes)}); 163 + } 164 + try renderCurl(allocator, writer, endpoint); 165 + try writer.writeAll("</div></details>"); 166 + } 167 + 168 + fn renderList(allocator: std.mem.Allocator, writer: *std.Io.Writer, label: []const u8, items: []const []const u8) !void { 169 + try writer.print("<div><h2>{s}</h2>", .{label}); 170 + if (items.len == 0) { 171 + try writer.writeAll("<p class=\"muted\">none documented yet</p></div>"); 172 + return; 173 + } 174 + try writer.writeAll("<ul>"); 175 + for (items) |item| try writer.print("<li><code>{s}</code></li>", .{try escapeHtml(allocator, item)}); 176 + try writer.writeAll("</ul></div>"); 177 + } 178 + 179 + fn renderCurl(allocator: std.mem.Allocator, writer: *std.Io.Writer, endpoint: Endpoint) !void { 180 + const token = if (std.mem.indexOf(u8, endpoint.auth, "bearer") != null or std.mem.indexOf(u8, endpoint.auth, "service") != null) " \\\n -H 'authorization: Bearer $TOKEN'" else ""; 181 + const body = if (std.mem.eql(u8, endpoint.method, "POST")) " \\\n -H 'content-type: application/json' \\\n --data '{}'" else ""; 182 + try writer.print( 183 + "<pre><code>curl -sS -X {s}{s} \"$PDS{s}\"{s}</code></pre>", 184 + .{ try escapeHtml(allocator, endpoint.method), token, try escapeHtml(allocator, endpoint.path), body }, 185 + ); 186 + } 187 + 188 + fn renderOpenApi(allocator: std.mem.Allocator) ![]const u8 { 189 + var out: std.Io.Writer.Allocating = .init(allocator); 190 + defer out.deinit(); 191 + try out.writer.print( 192 + \\{{"openapi":"3.1.0","info":{{"title":"ZDS endpoint reference","version":"0.1.0","description":"Endpoint inventory generated by ZDS."}},"servers":[{{"url":{f}}}],"paths":{{ 193 + , .{std.json.fmt(config.publicUrl(), .{})}); 194 + 195 + var first_path = true; 196 + for (endpoints, 0..) |endpoint, i| { 197 + if (pathSeenBefore(i)) continue; 198 + if (!first_path) try out.writer.writeAll(","); 199 + first_path = false; 200 + try out.writer.print("{f}:{{", .{std.json.fmt(endpoint.path, .{})}); 201 + var first_method = true; 202 + for (endpoints) |method_endpoint| { 203 + if (!std.mem.eql(u8, endpoint.path, method_endpoint.path)) continue; 204 + if (!first_method) try out.writer.writeAll(","); 205 + first_method = false; 206 + try renderOperation(&out.writer, method_endpoint); 207 + } 208 + try out.writer.writeAll("}"); 209 + } 210 + 211 + try out.writer.writeAll("}}\n"); 212 + return out.toOwnedSlice(); 213 + } 214 + 215 + fn renderOperation(writer: *std.Io.Writer, endpoint: Endpoint) !void { 216 + try writer.print( 217 + \\{f}:{{"summary":{f},"tags":[{f}],"description":{f},"responses":{{"200":{{"description":"OK"}}}}}} 218 + , .{ 219 + std.json.fmt(tryLowerStatic(endpoint.method), .{}), 220 + std.json.fmt(endpoint.summary, .{}), 221 + std.json.fmt(endpoint.group, .{}), 222 + std.json.fmt(endpointDescription(endpoint), .{}), 223 + }); 224 + } 225 + 226 + fn endpointDescription(endpoint: Endpoint) []const u8 { 227 + _ = endpoint; 228 + return "Generated from the ZDS endpoint inventory. Request and response schemas are intentionally sparse in this MVP."; 229 + } 230 + 231 + fn pathSeenBefore(index: usize) bool { 232 + for (endpoints[0..index]) |endpoint| { 233 + if (std.mem.eql(u8, endpoint.path, endpoints[index].path)) return true; 234 + } 235 + return false; 236 + } 237 + 238 + fn xrpcCount() usize { 239 + var count: usize = 0; 240 + for (endpoints) |endpoint| { 241 + if (endpoint.nsid() != null) count += 1; 242 + } 243 + return count; 244 + } 245 + 246 + fn lower(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { 247 + const out = try allocator.alloc(u8, input.len); 248 + for (input, 0..) |c, i| out[i] = std.ascii.toLower(c); 249 + return out; 250 + } 251 + 252 + fn tryLowerStatic(input: []const u8) []const u8 { 253 + if (std.mem.eql(u8, input, "GET")) return "get"; 254 + if (std.mem.eql(u8, input, "HEAD")) return "head"; 255 + if (std.mem.eql(u8, input, "POST")) return "post"; 256 + return input; 257 + } 258 + 259 + fn escapeHtml(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { 260 + var out: std.Io.Writer.Allocating = .init(allocator); 261 + defer out.deinit(); 262 + for (input) |c| { 263 + switch (c) { 264 + '&' => try out.writer.writeAll("&amp;"), 265 + '<' => try out.writer.writeAll("&lt;"), 266 + '>' => try out.writer.writeAll("&gt;"), 267 + '"' => try out.writer.writeAll("&quot;"), 268 + '\'' => try out.writer.writeAll("&#39;"), 269 + else => try out.writer.writeByte(c), 270 + } 271 + } 272 + return out.toOwnedSlice(); 273 + } 274 + 275 + const html_headers = [_]http.Header{ 276 + .{ .name = "content-type", .value = "text/html; charset=utf-8" }, 277 + .{ .name = "cache-control", .value = "no-store" }, 278 + .{ .name = "access-control-allow-origin", .value = "*" }, 279 + .{ .name = "access-control-allow-private-network", .value = "true" }, 280 + .{ .name = "connection", .value = "close" }, 281 + }; 282 + 283 + const json_headers = [_]http.Header{ 284 + .{ .name = "content-type", .value = "application/openapi+json; charset=utf-8" }, 285 + .{ .name = "cache-control", .value = "no-store" }, 286 + .{ .name = "access-control-allow-origin", .value = "*" }, 287 + .{ .name = "access-control-allow-private-network", .value = "true" }, 288 + .{ .name = "connection", .value = "close" }, 289 + }; 290 + 291 + const css = 292 + \\:root{color-scheme:dark light;--bg:#111411;--panel:#171b18;--text:#eef4ed;--muted:#98a69b;--line:#2d352f;--accent:#7ee787;--cyan:#7dd3fc;--rose:#f0a6ca;--gold:#e6c36a;--shadow:0 14px 38px rgba(0,0,0,.24);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} 293 + \\*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text)}a{color:inherit}.shell{width:min(1180px,calc(100% - 32px));margin:0 auto;padding:24px 0 56px}header{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:54px}.brand{font-weight:800;text-decoration:none;font-size:1.25rem}nav{display:flex;gap:10px;flex-wrap:wrap}nav a{border:1px solid var(--line);border-radius:8px;padding:9px 12px;text-decoration:none;color:var(--muted);background:rgba(255,255,255,.02)} 294 + \\.intro{display:grid;gap:14px;margin-bottom:28px}.eyebrow{text-transform:uppercase;letter-spacing:.08em;color:var(--accent);font-size:.78rem;font-weight:800;margin:0}h1{font-size:clamp(2.4rem,8vw,5.8rem);line-height:.9;margin:0;letter-spacing:0}.lede{max-width:760px;color:var(--muted);font-size:1.12rem;line-height:1.6;margin:0}.lede code{color:var(--cyan)}.stats{display:flex;gap:10px;flex-wrap:wrap;margin-top:6px}.stats span{border:1px solid var(--line);border-radius:8px;padding:10px 12px;color:var(--muted);background:rgba(255,255,255,.025)}.stats strong{color:var(--text)} 295 + \\.tools{position:sticky;top:0;z-index:5;background:color-mix(in srgb,var(--bg) 90%,transparent);backdrop-filter:blur(16px);padding:12px 0 16px;margin-bottom:12px;border-bottom:1px solid var(--line)}.search{display:grid;gap:7px;margin-bottom:12px}.search span{font-size:.78rem;text-transform:uppercase;color:var(--muted);font-weight:800}.search input{width:100%;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);padding:13px 14px;font:inherit;outline:none}.search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(126,231,135,.15)}.chips{display:flex;flex-wrap:wrap;gap:8px}.chip{border:1px solid var(--line);border-radius:999px;background:transparent;color:var(--muted);padding:8px 11px;font:inherit;cursor:pointer}.chip.active{background:var(--text);border-color:var(--text);color:var(--bg)}.chip span{opacity:.72} 296 + \\.endpoints{display:grid;gap:10px}.endpoint{border:1px solid var(--line);border-radius:8px;background:var(--panel);box-shadow:var(--shadow);overflow:hidden}.endpoint[hidden]{display:none}.endpoint summary{list-style:none;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:12px;align-items:center;padding:15px;cursor:pointer}.endpoint summary::-webkit-details-marker{display:none}.endpoint code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow-wrap:anywhere}.method{font-size:.76rem;font-weight:900;border-radius:6px;padding:5px 7px;color:#071008}.method.get{background:var(--cyan)}.method.post{background:var(--accent)}.method.head{background:var(--gold)}.auth{font-size:.78rem;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:5px 8px;white-space:nowrap}.body{border-top:1px solid var(--line);padding:16px;display:grid;gap:15px}.body p{margin:0;color:var(--muted);line-height:1.55}.nsid{display:flex;gap:10px;align-items:center;color:var(--muted)}.nsid span{font-size:.75rem;text-transform:uppercase;font-weight:900;color:var(--rose)}.meta-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.meta-grid>div{border:1px solid var(--line);border-radius:8px;padding:12px;background:rgba(255,255,255,.018)}h2{font-size:.78rem;text-transform:uppercase;color:var(--muted);margin:0 0 8px}ul{margin:0;padding-left:18px}.muted{color:var(--muted)}.note{border-left:3px solid var(--rose);padding-left:12px}pre{margin:0;white-space:pre-wrap;background:#090b09;color:#d6ead8;border-radius:8px;border:1px solid var(--line);padding:12px;overflow:auto} 297 + \\@media (max-width:720px){.shell{width:min(100% - 20px,1180px);padding-top:16px}header{align-items:flex-start;margin-bottom:32px}.endpoint summary{grid-template-columns:auto minmax(0,1fr);}.endpoint summary .auth{grid-column:2}.meta-grid{grid-template-columns:1fr}nav{justify-content:flex-end}} 298 + \\@media (prefers-color-scheme:light){:root{--bg:#f6f7f3;--panel:#ffffff;--text:#111411;--muted:#5b665f;--line:#d9dfd8;--shadow:0 12px 34px rgba(33,45,36,.09)}pre{background:#101510;color:#ecf7ec}} 299 + ; 300 + 301 + const script = 302 + \\const search=document.querySelector("#search"); 303 + \\const chips=[...document.querySelectorAll(".chip")]; 304 + \\const cards=[...document.querySelectorAll(".endpoint")]; 305 + \\let group="all"; 306 + \\function apply(){ 307 + \\ const q=search.value.trim().toLowerCase(); 308 + \\ for(const card of cards){ 309 + \\ const groupMatch=group==="all"||card.dataset.group===group; 310 + \\ const text=(card.dataset.search||"").toLowerCase(); 311 + \\ card.hidden=!(groupMatch&&text.includes(q)); 312 + \\ } 313 + \\} 314 + \\search.addEventListener("input",apply); 315 + \\for(const chip of chips){ 316 + \\ chip.addEventListener("click",()=>{ 317 + \\ group=chip.dataset.group; 318 + \\ for(const other of chips) other.classList.toggle("active",other===chip); 319 + \\ apply(); 320 + \\ }); 321 + \\} 322 + ; 323 + 324 + test "endpoint inventory covers XRPC paths" { 325 + try std.testing.expect(endpoints.len > 50); 326 + try std.testing.expect(xrpcCount() > 30); 327 + for (endpoints) |endpoint| { 328 + if (endpoint.nsid()) |id| { 329 + try std.testing.expect(id.len > 0); 330 + try std.testing.expect(std.mem.indexOfScalar(u8, id, '/') == null); 331 + } 332 + } 333 + }
+129 -69
src/http/router.zig
··· 5 5 pub const Route = enum { 6 6 cors_preflight, 7 7 root, 8 + api_docs, 9 + api_openapi, 8 10 favicon, 9 11 og_image, 10 12 health, ··· 76 78 not_found, 77 79 }; 78 80 81 + pub const Endpoint = struct { 82 + route: Route, 83 + method: []const u8, 84 + path: []const u8, 85 + group: []const u8, 86 + auth: []const u8, 87 + summary: []const u8, 88 + params: []const []const u8 = &.{}, 89 + body: []const []const u8 = &.{}, 90 + response: []const []const u8 = &.{}, 91 + notes: []const u8 = "", 92 + 93 + pub fn nsid(endpoint: Endpoint) ?[]const u8 { 94 + const prefix = "/xrpc/"; 95 + if (!std.mem.startsWith(u8, endpoint.path, prefix)) return null; 96 + return endpoint.path[prefix.len..]; 97 + } 98 + }; 99 + 100 + pub const endpoints = [_]Endpoint{ 101 + .{ .route = .api_docs, .method = "GET", .path = "/api", .group = "zds", .auth = "public", .summary = "Interactive endpoint inventory for this ZDS instance." }, 102 + .{ .route = .api_docs, .method = "HEAD", .path = "/api", .group = "zds", .auth = "public", .summary = "Endpoint inventory probe without a response body." }, 103 + .{ .route = .api_openapi, .method = "GET", .path = "/api/openapi.json", .group = "zds", .auth = "public", .summary = "OpenAPI 3.1 export generated from the same endpoint inventory." }, 104 + .{ .route = .api_openapi, .method = "HEAD", .path = "/api/openapi.json", .group = "zds", .auth = "public", .summary = "OpenAPI export probe without a response body." }, 105 + .{ .route = .root, .method = "GET", .path = "/", .group = "zds", .auth = "public", .summary = "Public landing page for the PDS." }, 106 + .{ .route = .root, .method = "HEAD", .path = "/", .group = "zds", .auth = "public", .summary = "Landing page probe." }, 107 + .{ .route = .favicon, .method = "GET", .path = "/favicon.svg", .group = "zds", .auth = "public", .summary = "SVG favicon." }, 108 + .{ .route = .favicon, .method = "HEAD", .path = "/favicon.svg", .group = "zds", .auth = "public", .summary = "SVG favicon probe." }, 109 + .{ .route = .og_image, .method = "GET", .path = "/og-image", .group = "zds", .auth = "public", .summary = "Social preview image." }, 110 + .{ .route = .og_image, .method = "HEAD", .path = "/og-image", .group = "zds", .auth = "public", .summary = "Social preview image probe." }, 111 + .{ .route = .health, .method = "GET", .path = "/xrpc/_health", .group = "status", .auth = "public", .summary = "PDS health probe.", .response = &.{ "version", "status" } }, 112 + .{ .route = .health, .method = "HEAD", .path = "/xrpc/_health", .group = "status", .auth = "public", .summary = "PDS health probe without a response body." }, 113 + .{ .route = .did_json, .method = "GET", .path = "/.well-known/did.json", .group = "discovery", .auth = "public", .summary = "DID document for the server DID." }, 114 + .{ .route = .atproto_did, .method = "GET", .path = "/.well-known/atproto-did", .group = "discovery", .auth = "public", .summary = "atproto DID discovery document." }, 115 + .{ .route = .oauth_protected_resource, .method = "GET", .path = "/.well-known/oauth-protected-resource", .group = "oauth", .auth = "public", .summary = "OAuth protected resource metadata." }, 116 + .{ .route = .oauth_authorization_server, .method = "GET", .path = "/.well-known/oauth-authorization-server", .group = "oauth", .auth = "public", .summary = "OAuth authorization server metadata." }, 117 + .{ .route = .oauth_jwks, .method = "GET", .path = "/oauth/jwks", .group = "oauth", .auth = "public", .summary = "OAuth signing keys." }, 118 + .{ .route = .oauth_par, .method = "POST", .path = "/oauth/par", .group = "oauth", .auth = "client", .summary = "Pushed authorization request endpoint." }, 119 + .{ .route = .oauth_authorize, .method = "GET", .path = "/oauth/authorize", .group = "oauth", .auth = "browser", .summary = "Authorization consent page.", .params = &.{"request_uri"} }, 120 + .{ .route = .oauth_authorize, .method = "POST", .path = "/oauth/authorize", .group = "oauth", .auth = "browser", .summary = "Authorization consent submission." }, 121 + .{ .route = .oauth_token, .method = "POST", .path = "/oauth/token", .group = "oauth", .auth = "client", .summary = "OAuth token endpoint." }, 122 + .{ .route = .oauth_introspect, .method = "POST", .path = "/oauth/introspect", .group = "oauth", .auth = "client", .summary = "OAuth token introspection endpoint." }, 123 + .{ .route = .oauth_revoke, .method = "POST", .path = "/oauth/revoke", .group = "oauth", .auth = "client", .summary = "OAuth token revocation endpoint." }, 124 + .{ .route = .oauth_passkey_options, .method = "POST", .path = "/oauth/passkey/options", .group = "oauth", .auth = "browser", .summary = "Passkey login challenge for OAuth flows." }, 125 + .{ .route = .oauth_passkey_finish, .method = "POST", .path = "/oauth/passkey/finish", .group = "oauth", .auth = "browser", .summary = "Passkey login completion for OAuth flows." }, 126 + .{ .route = .security_page, .method = "GET", .path = "/security", .group = "account", .auth = "session", .summary = "Security settings page." }, 127 + .{ .route = .passkeys_page, .method = "GET", .path = "/passkeys", .group = "account", .auth = "session", .summary = "Compatibility redirect to security settings." }, 128 + 129 + .{ .route = .describe_server, .method = "GET", .path = "/xrpc/com.atproto.server.describeServer", .group = "server", .auth = "public", .summary = "Describe account creation capabilities for this PDS." }, 130 + .{ .route = .reserve_signing_key, .method = "POST", .path = "/xrpc/com.atproto.server.reserveSigningKey", .group = "server", .auth = "public", .summary = "Reserve a signing key for account creation." }, 131 + .{ .route = .create_account, .method = "POST", .path = "/xrpc/com.atproto.server.createAccount", .group = "server", .auth = "invite", .summary = "Create a new account on this PDS.", .body = &.{ "handle", "email", "password", "inviteCode", "signingKey" } }, 132 + .{ .route = .create_invite_code, .method = "POST", .path = "/xrpc/com.atproto.server.createInviteCode", .group = "server", .auth = "admin", .summary = "Create one invite code.", .body = &.{ "useCount", "forAccount" } }, 133 + .{ .route = .create_invite_codes, .method = "POST", .path = "/xrpc/com.atproto.server.createInviteCodes", .group = "server", .auth = "admin", .summary = "Create invite codes in bulk.", .body = &.{ "codeCount", "useCount", "forAccounts" } }, 134 + .{ .route = .get_account_invite_codes, .method = "GET", .path = "/xrpc/com.atproto.server.getAccountInviteCodes", .group = "server", .auth = "bearer", .summary = "List invite codes associated with the signed-in account." }, 135 + .{ .route = .list_app_passwords, .method = "GET", .path = "/xrpc/com.atproto.server.listAppPasswords", .group = "server", .auth = "bearer", .summary = "List app passwords for the signed-in account." }, 136 + .{ .route = .create_app_password, .method = "POST", .path = "/xrpc/com.atproto.server.createAppPassword", .group = "server", .auth = "bearer", .summary = "Create an app password.", .body = &.{ "name", "privileged" } }, 137 + .{ .route = .revoke_app_password, .method = "POST", .path = "/xrpc/com.atproto.server.revokeAppPassword", .group = "server", .auth = "bearer", .summary = "Revoke an app password.", .body = &.{"name"} }, 138 + .{ .route = .start_passkey_registration, .method = "POST", .path = "/xrpc/com.atproto.server.startPasskeyRegistration", .group = "server", .auth = "bearer", .summary = "Begin passkey registration." }, 139 + .{ .route = .finish_passkey_registration, .method = "POST", .path = "/xrpc/com.atproto.server.finishPasskeyRegistration", .group = "server", .auth = "bearer", .summary = "Finish passkey registration." }, 140 + .{ .route = .list_passkeys, .method = "GET", .path = "/xrpc/com.atproto.server.listPasskeys", .group = "server", .auth = "bearer", .summary = "List passkeys for the signed-in account." }, 141 + .{ .route = .delete_passkey, .method = "POST", .path = "/xrpc/com.atproto.server.deletePasskey", .group = "server", .auth = "bearer", .summary = "Delete a passkey.", .body = &.{"id"} }, 142 + .{ .route = .update_passkey, .method = "POST", .path = "/xrpc/com.atproto.server.updatePasskey", .group = "server", .auth = "bearer", .summary = "Rename or update a passkey.", .body = &.{ "id", "name" } }, 143 + .{ .route = .create_session, .method = "POST", .path = "/xrpc/com.atproto.server.createSession", .group = "server", .auth = "password", .summary = "Create an app password session.", .body = &.{ "identifier", "password" } }, 144 + .{ .route = .refresh_session, .method = "POST", .path = "/xrpc/com.atproto.server.refreshSession", .group = "server", .auth = "refresh bearer", .summary = "Refresh an app password session." }, 145 + .{ .route = .get_session, .method = "GET", .path = "/xrpc/com.atproto.server.getSession", .group = "server", .auth = "bearer", .summary = "Describe the current app password session." }, 146 + .{ .route = .get_service_auth, .method = "GET", .path = "/xrpc/com.atproto.server.getServiceAuth", .group = "server", .auth = "bearer", .summary = "Mint a service auth token.", .params = &.{ "aud", "exp", "lxm" } }, 147 + .{ .route = .activate_account, .method = "POST", .path = "/xrpc/com.atproto.server.activateAccount", .group = "server", .auth = "bearer", .summary = "Reactivate the signed-in account." }, 148 + .{ .route = .deactivate_account, .method = "POST", .path = "/xrpc/com.atproto.server.deactivateAccount", .group = "server", .auth = "bearer", .summary = "Deactivate the signed-in account." }, 149 + .{ .route = .request_email_confirmation, .method = "POST", .path = "/xrpc/com.atproto.server.requestEmailConfirmation", .group = "server", .auth = "bearer", .summary = "Request an email confirmation token." }, 150 + .{ .route = .confirm_email, .method = "POST", .path = "/xrpc/com.atproto.server.confirmEmail", .group = "server", .auth = "bearer", .summary = "Confirm an account email address.", .body = &.{ "email", "token" } }, 151 + .{ .route = .request_email_update, .method = "POST", .path = "/xrpc/com.atproto.server.requestEmailUpdate", .group = "server", .auth = "bearer", .summary = "Request an email update token." }, 152 + .{ .route = .update_email, .method = "POST", .path = "/xrpc/com.atproto.server.updateEmail", .group = "server", .auth = "bearer", .summary = "Update an account email address.", .body = &.{ "email", "token" } }, 153 + .{ .route = .check_account_status, .method = "GET", .path = "/xrpc/com.atproto.server.checkAccountStatus", .group = "server", .auth = "bearer", .summary = "Check account status for the signed-in account." }, 154 + 155 + .{ .route = .app_preferences_get, .method = "GET", .path = "/xrpc/app.bsky.actor.getPreferences", .group = "preferences", .auth = "bearer", .summary = "Read Bluesky actor preferences." }, 156 + .{ .route = .app_preferences_put, .method = "POST", .path = "/xrpc/app.bsky.actor.putPreferences", .group = "preferences", .auth = "bearer", .summary = "Replace Bluesky actor preferences.", .body = &.{"preferences"} }, 157 + 158 + .{ .route = .repo_create_record, .method = "POST", .path = "/xrpc/com.atproto.repo.createRecord", .group = "repo", .auth = "bearer", .summary = "Create a record in a repository collection.", .body = &.{ "repo", "collection", "rkey", "validate", "record", "swapCommit" } }, 159 + .{ .route = .repo_put_record, .method = "POST", .path = "/xrpc/com.atproto.repo.putRecord", .group = "repo", .auth = "bearer", .summary = "Create or replace a record by rkey.", .body = &.{ "repo", "collection", "rkey", "validate", "record", "swapRecord", "swapCommit" } }, 160 + .{ .route = .repo_describe_repo, .method = "GET", .path = "/xrpc/com.atproto.repo.describeRepo", .group = "repo", .auth = "public", .summary = "Describe a repository.", .params = &.{"repo"} }, 161 + .{ .route = .repo_get_record, .method = "GET", .path = "/xrpc/com.atproto.repo.getRecord", .group = "repo", .auth = "public", .summary = "Read a record.", .params = &.{ "repo", "collection", "rkey", "cid" } }, 162 + .{ .route = .repo_list_records, .method = "GET", .path = "/xrpc/com.atproto.repo.listRecords", .group = "repo", .auth = "public", .summary = "List records in a collection.", .params = &.{ "repo", "collection", "limit", "cursor", "reverse" } }, 163 + .{ .route = .repo_delete_record, .method = "POST", .path = "/xrpc/com.atproto.repo.deleteRecord", .group = "repo", .auth = "bearer", .summary = "Delete a record.", .body = &.{ "repo", "collection", "rkey", "swapRecord", "swapCommit" } }, 164 + .{ .route = .repo_apply_writes, .method = "POST", .path = "/xrpc/com.atproto.repo.applyWrites", .group = "repo", .auth = "bearer", .summary = "Apply multiple repo writes in one commit.", .body = &.{ "repo", "validate", "writes", "swapCommit" } }, 165 + .{ .route = .repo_import_repo, .method = "POST", .path = "/xrpc/com.atproto.repo.importRepo", .group = "repo", .auth = "bearer", .summary = "Import a repository CAR." }, 166 + .{ .route = .repo_upload_blob, .method = "POST", .path = "/xrpc/com.atproto.repo.uploadBlob", .group = "repo", .auth = "bearer or service", .summary = "Upload a blob for the signed-in repository." }, 167 + .{ .route = .repo_list_missing_blobs, .method = "GET", .path = "/xrpc/com.atproto.repo.listMissingBlobs", .group = "repo", .auth = "bearer", .summary = "List blob CIDs referenced by records but missing from storage.", .params = &.{ "cursor", "limit" } }, 168 + 169 + .{ .route = .sync_get_blob, .method = "GET", .path = "/xrpc/com.atproto.sync.getBlob", .group = "sync", .auth = "public", .summary = "Fetch a repository blob.", .params = &.{ "did", "cid" } }, 170 + .{ .route = .sync_get_blob, .method = "HEAD", .path = "/xrpc/com.atproto.sync.getBlob", .group = "sync", .auth = "public", .summary = "Probe a repository blob without a response body.", .params = &.{ "did", "cid" } }, 171 + .{ .route = .sync_get_repo, .method = "GET", .path = "/xrpc/com.atproto.sync.getRepo", .group = "sync", .auth = "public", .summary = "Fetch a repository CAR.", .params = &.{ "did", "since" } }, 172 + .{ .route = .sync_get_repo, .method = "HEAD", .path = "/xrpc/com.atproto.sync.getRepo", .group = "sync", .auth = "public", .summary = "Probe a repository CAR without a response body.", .params = &.{ "did", "since" } }, 173 + .{ .route = .sync_get_latest_commit, .method = "GET", .path = "/xrpc/com.atproto.sync.getLatestCommit", .group = "sync", .auth = "public", .summary = "Fetch the latest commit CID and revision.", .params = &.{"did"} }, 174 + .{ .route = .sync_get_latest_commit, .method = "HEAD", .path = "/xrpc/com.atproto.sync.getLatestCommit", .group = "sync", .auth = "public", .summary = "Probe latest commit metadata without a response body.", .params = &.{"did"} }, 175 + .{ .route = .sync_list_repos, .method = "GET", .path = "/xrpc/com.atproto.sync.listRepos", .group = "sync", .auth = "public", .summary = "List hosted repositories.", .params = &.{ "limit", "cursor" } }, 176 + .{ .route = .sync_list_repos, .method = "HEAD", .path = "/xrpc/com.atproto.sync.listRepos", .group = "sync", .auth = "public", .summary = "Probe hosted repositories without a response body.", .params = &.{ "limit", "cursor" } }, 177 + .{ .route = .sync_list_blobs, .method = "GET", .path = "/xrpc/com.atproto.sync.listBlobs", .group = "sync", .auth = "public", .summary = "List repository blob CIDs.", .params = &.{ "did", "since", "limit", "cursor" } }, 178 + .{ .route = .sync_list_blobs, .method = "HEAD", .path = "/xrpc/com.atproto.sync.listBlobs", .group = "sync", .auth = "public", .summary = "Probe repository blob CIDs without a response body.", .params = &.{ "did", "since", "limit", "cursor" } }, 179 + .{ .route = .sync_subscribe_repos, .method = "GET", .path = "/xrpc/com.atproto.sync.subscribeRepos", .group = "sync", .auth = "websocket", .summary = "Subscribe to repository commit events.", .params = &.{"cursor"}, .notes = "Upgrades to a websocket connection." }, 180 + .{ .route = .sync_get_repo_status, .method = "GET", .path = "/xrpc/com.atproto.sync.getRepoStatus", .group = "sync", .auth = "public", .summary = "Read repository availability status.", .params = &.{"did"} }, 181 + .{ .route = .sync_notify_of_update, .method = "POST", .path = "/xrpc/com.atproto.sync.notifyOfUpdate", .group = "sync", .auth = "public", .summary = "Notify this PDS that another repo has updated.", .body = &.{"hostname"} }, 182 + .{ .route = .sync_request_crawl, .method = "POST", .path = "/xrpc/com.atproto.sync.requestCrawl", .group = "sync", .auth = "public", .summary = "Ask a relay to crawl this PDS.", .body = &.{"hostname"} }, 183 + 184 + .{ .route = .identity_get_recommended_did_credentials, .method = "GET", .path = "/xrpc/com.atproto.identity.getRecommendedDidCredentials", .group = "identity", .auth = "bearer", .summary = "Get recommended DID credentials for the signed-in account." }, 185 + .{ .route = .identity_request_plc_operation_signature, .method = "POST", .path = "/xrpc/com.atproto.identity.requestPlcOperationSignature", .group = "identity", .auth = "bearer", .summary = "Request a PLC operation signature." }, 186 + .{ .route = .identity_sign_plc_operation, .method = "POST", .path = "/xrpc/com.atproto.identity.signPlcOperation", .group = "identity", .auth = "bearer", .summary = "Sign a PLC operation." }, 187 + .{ .route = .identity_submit_plc_operation, .method = "POST", .path = "/xrpc/com.atproto.identity.submitPlcOperation", .group = "identity", .auth = "bearer", .summary = "Submit a PLC operation." }, 188 + .{ .route = .identity_resolve_handle, .method = "GET", .path = "/xrpc/com.atproto.identity.resolveHandle", .group = "identity", .auth = "public", .summary = "Resolve a handle to a DID.", .params = &.{"handle"} }, 189 + }; 190 + 79 191 pub fn route(method: httpz.Method, target: []const u8) Route { 80 192 if (method == .OPTIONS) return .cors_preflight; 81 193 82 194 const path = stripQuery(target); 83 - if ((method == .GET or method == .HEAD) and std.mem.eql(u8, path, "/")) return .root; 84 - if ((method == .GET or method == .HEAD) and std.mem.eql(u8, path, "/favicon.svg")) return .favicon; 85 - if ((method == .GET or method == .HEAD) and std.mem.eql(u8, path, "/og-image")) return .og_image; 86 - if ((method == .GET or method == .HEAD) and std.mem.eql(u8, path, xrpc.healthPath)) return .health; 87 - if (method == .GET and std.mem.eql(u8, path, "/.well-known/did.json")) return .did_json; 88 - if (method == .GET and std.mem.eql(u8, path, "/.well-known/oauth-protected-resource")) return .oauth_protected_resource; 89 - if (method == .GET and std.mem.eql(u8, path, "/.well-known/oauth-authorization-server")) return .oauth_authorization_server; 90 - if (method == .GET and std.mem.eql(u8, path, "/oauth/jwks")) return .oauth_jwks; 91 - if (method == .POST and std.mem.eql(u8, path, "/oauth/par")) return .oauth_par; 92 - if ((method == .GET or method == .POST) and std.mem.eql(u8, path, "/oauth/authorize")) return .oauth_authorize; 93 - if (method == .POST and std.mem.eql(u8, path, "/oauth/token")) return .oauth_token; 94 - if (method == .POST and std.mem.eql(u8, path, "/oauth/introspect")) return .oauth_introspect; 95 - if (method == .POST and std.mem.eql(u8, path, "/oauth/revoke")) return .oauth_revoke; 96 - if (method == .POST and std.mem.eql(u8, path, "/oauth/passkey/options")) return .oauth_passkey_options; 97 - if (method == .POST and std.mem.eql(u8, path, "/oauth/passkey/finish")) return .oauth_passkey_finish; 98 - if (method == .GET and std.mem.eql(u8, path, "/passkeys")) return .passkeys_page; 99 - if (method == .GET and std.mem.eql(u8, path, "/security")) return .security_page; 100 - if (method == .GET and std.mem.eql(u8, path, "/.well-known/atproto-did")) return .atproto_did; 101 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.describeServer")) return .describe_server; 102 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.reserveSigningKey")) return .reserve_signing_key; 103 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createAccount")) return .create_account; 104 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createInviteCode")) return .create_invite_code; 105 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createInviteCodes")) return .create_invite_codes; 106 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.getAccountInviteCodes")) return .get_account_invite_codes; 107 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.listAppPasswords")) return .list_app_passwords; 108 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createAppPassword")) return .create_app_password; 109 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.revokeAppPassword")) return .revoke_app_password; 110 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.startPasskeyRegistration")) return .start_passkey_registration; 111 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.finishPasskeyRegistration")) return .finish_passkey_registration; 112 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.listPasskeys")) return .list_passkeys; 113 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.deletePasskey")) return .delete_passkey; 114 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.updatePasskey")) return .update_passkey; 115 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createSession")) return .create_session; 116 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.refreshSession")) return .refresh_session; 117 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.getSession")) return .get_session; 118 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.getServiceAuth")) return .get_service_auth; 119 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.activateAccount")) return .activate_account; 120 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.deactivateAccount")) return .deactivate_account; 121 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.requestEmailConfirmation")) return .request_email_confirmation; 122 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.confirmEmail")) return .confirm_email; 123 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.requestEmailUpdate")) return .request_email_update; 124 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.updateEmail")) return .update_email; 125 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.checkAccountStatus")) return .check_account_status; 126 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/app.bsky.actor.getPreferences")) return .app_preferences_get; 127 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/app.bsky.actor.putPreferences")) return .app_preferences_put; 128 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.createRecord")) return .repo_create_record; 129 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.putRecord")) return .repo_put_record; 130 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.describeRepo")) return .repo_describe_repo; 131 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.getRecord")) return .repo_get_record; 132 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.listRecords")) return .repo_list_records; 133 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.deleteRecord")) return .repo_delete_record; 134 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.applyWrites")) return .repo_apply_writes; 135 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.importRepo")) return .repo_import_repo; 136 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.uploadBlob")) return .repo_upload_blob; 137 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.listMissingBlobs")) return .repo_list_missing_blobs; 138 - if ((method == .GET or method == .HEAD) and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.getBlob")) return .sync_get_blob; 139 - if ((method == .GET or method == .HEAD) and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.getRepo")) return .sync_get_repo; 140 - if ((method == .GET or method == .HEAD) and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.getLatestCommit")) return .sync_get_latest_commit; 141 - if ((method == .GET or method == .HEAD) and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.listRepos")) return .sync_list_repos; 142 - if ((method == .GET or method == .HEAD) and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.listBlobs")) return .sync_list_blobs; 143 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.subscribeRepos")) return .sync_subscribe_repos; 144 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.getRepoStatus")) return .sync_get_repo_status; 145 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.notifyOfUpdate")) return .sync_notify_of_update; 146 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.sync.requestCrawl")) return .sync_request_crawl; 147 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.identity.getRecommendedDidCredentials")) return .identity_get_recommended_did_credentials; 148 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.identity.requestPlcOperationSignature")) return .identity_request_plc_operation_signature; 149 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.identity.signPlcOperation")) return .identity_sign_plc_operation; 150 - if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.identity.submitPlcOperation")) return .identity_submit_plc_operation; 151 - if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.identity.resolveHandle")) return .identity_resolve_handle; 195 + for (endpoints) |endpoint| { 196 + if (methodMatches(endpoint.method, method) and std.mem.eql(u8, path, endpoint.path)) return endpoint.route; 197 + } 152 198 153 199 return .not_found; 154 200 } 155 201 202 + fn methodMatches(expected: []const u8, actual: httpz.Method) bool { 203 + return switch (actual) { 204 + .GET => std.mem.eql(u8, expected, "GET"), 205 + .HEAD => std.mem.eql(u8, expected, "HEAD"), 206 + .POST => std.mem.eql(u8, expected, "POST"), 207 + else => false, 208 + }; 209 + } 210 + 156 211 fn stripQuery(target: []const u8) []const u8 { 157 212 const end = std.mem.indexOfScalar(u8, target, '?') orelse target.len; 158 213 return target[0..end]; ··· 162 217 try std.testing.expectEqual(Route.cors_preflight, route(.OPTIONS, "/anything")); 163 218 try std.testing.expectEqual(Route.root, route(.GET, "/")); 164 219 try std.testing.expectEqual(Route.root, route(.HEAD, "/")); 220 + try std.testing.expectEqual(Route.api_docs, route(.GET, "/api")); 221 + try std.testing.expectEqual(Route.api_docs, route(.HEAD, "/api")); 222 + try std.testing.expectEqual(Route.api_docs, route(.GET, "/api?group=repo")); 223 + try std.testing.expectEqual(Route.api_openapi, route(.GET, "/api/openapi.json")); 224 + try std.testing.expectEqual(Route.api_openapi, route(.HEAD, "/api/openapi.json")); 165 225 try std.testing.expectEqual(Route.favicon, route(.GET, "/favicon.svg")); 166 226 try std.testing.expectEqual(Route.favicon, route(.HEAD, "/favicon.svg")); 167 227 try std.testing.expectEqual(Route.og_image, route(.GET, "/og-image"));
+3
src/http/server.zig
··· 10 10 const httpz = @import("httpz"); 11 11 const log = @import("../core/log.zig"); 12 12 const http_api = @import("api.zig"); 13 + const docs = @import("docs.zig"); 13 14 const passkeys = @import("../internal/passkeys.zig"); 14 15 const landing = @import("landing/mod.zig"); 15 16 const router = @import("router.zig"); ··· 49 50 switch (route) { 50 51 .cors_preflight => try corsPreflight(request), 51 52 .root => try landing.serve(request), 53 + .api_docs => try docs.serve(request), 54 + .api_openapi => try docs.serveOpenApi(request), 52 55 .favicon => try landing.serveFavicon(request), 53 56 .og_image => try landing.serveOgImage(request), 54 57 .health => try health(request),
+1
src/root.zig
··· 24 24 25 25 pub const http = struct { 26 26 pub const api = @import("http/api.zig"); 27 + pub const docs = @import("http/docs.zig"); 27 28 pub const landing = @import("http/landing/mod.zig"); 28 29 pub const router = @import("http/router.zig"); 29 30 pub const server = @import("http/server.zig");