Skip to content

Commit eddeeb9

Browse files
committed
Feat: Improve OpenAPI servers
1 parent 20f0743 commit eddeeb9

File tree

15 files changed

+474
-195
lines changed

15 files changed

+474
-195
lines changed

examples/cosmo-cargo/schema/shipments.json

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,20 +54,33 @@
5454
],
5555
"servers": [
5656
{
57-
"url": "https://8f9618f3bc0f4eec989627ceaafd5e4d_oas.api.mockbin.io/",
58-
"description": "Production environment"
59-
},
60-
{
61-
"url": "https://api.sh.example.com/v1",
62-
"description": "Production environment"
63-
},
64-
{
65-
"url": "https://api.staging.sh.example.com/v1",
66-
"description": "Staging environment"
57+
"url": "https://{tenant}.{region}.{env}.sh.example.com/{version}",
58+
"description": "Global default server with tenant, region, environment, and version variables",
59+
"variables": {
60+
"tenant": {
61+
"default": "acme-logistics",
62+
"description": "Tenant subdomain used to isolate organization data"
63+
},
64+
"region": {
65+
"default": "us-east-1",
66+
"enum": ["us-east-1", "us-west-2", "eu-central-1"],
67+
"description": "Primary data residency region"
68+
},
69+
"env": {
70+
"default": "prod",
71+
"enum": ["prod", "staging", "dev"],
72+
"description": "Deployment environment"
73+
},
74+
"version": {
75+
"default": "v1",
76+
"enum": ["v1", "v1-beta"],
77+
"description": "API version path segment"
78+
}
79+
}
6780
},
6881
{
69-
"url": "https://api.dev.sh.example.com/v1",
70-
"description": "Development environment"
82+
"url": "https://8f9618f3bc0f4eec989627ceaafd5e4d_oas.api.mockbin.io/",
83+
"description": "Static mock server for quick local demos"
7184
}
7285
],
7386
"security": [
@@ -1077,6 +1090,29 @@
10771090
}
10781091
},
10791092
"/shipments/{trackingNumber}": {
1093+
"servers": [
1094+
{
1095+
"url": "https://{region}.tracking.{env}.sh.example.com/{version}",
1096+
"description": "Path-level override for /shipment/{trackingNumber}",
1097+
"variables": {
1098+
"region": {
1099+
"default": "us-east-1",
1100+
"enum": ["us-east-1", "eu-central-1"],
1101+
"description": "Tracking service region"
1102+
},
1103+
"env": {
1104+
"default": "prod",
1105+
"enum": ["prod", "staging"],
1106+
"description": "Tracking environment"
1107+
},
1108+
"version": {
1109+
"default": "v1",
1110+
"enum": ["v1", "v1-edge"],
1111+
"description": "Tracking API version channel"
1112+
}
1113+
}
1114+
}
1115+
],
10801116
"get": {
10811117
"tags": ["Shipment Management"],
10821118
"summary": "Track a shipment",

packages/zudoku/schema.graphql

Lines changed: 9 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/zudoku/src/lib/oas/graphql/index.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -265,10 +265,38 @@ SchemaTag.implement({
265265
}),
266266
});
267267

268+
type GraphQLServerVariable = {
269+
name: string;
270+
defaultValue: string;
271+
description?: string;
272+
enumValues?: string[];
273+
};
274+
275+
const ServerVariableItem = builder
276+
.objectRef<GraphQLServerVariable>("ServerVariable")
277+
.implement({
278+
fields: (t) => ({
279+
name: t.exposeString("name"),
280+
defaultValue: t.exposeString("defaultValue"),
281+
description: t.exposeString("description", { nullable: true }),
282+
enumValues: t.exposeStringList("enumValues", { nullable: true }),
283+
}),
284+
});
285+
268286
const ServerItem = builder.objectRef<ServerObject>("Server").implement({
269287
fields: (t) => ({
270288
url: t.exposeString("url"),
271289
description: t.exposeString("description", { nullable: true }),
290+
variables: t.field({
291+
type: [ServerVariableItem],
292+
resolve: (parent) =>
293+
Object.entries(parent.variables ?? {}).map(([name, value]) => ({
294+
name,
295+
defaultValue: value.default,
296+
description: value.description,
297+
enumValues: value.enum,
298+
})),
299+
}),
272300
}),
273301
});
274302

@@ -450,14 +478,9 @@ const OperationItem = builder
450478
type: [ParameterItem],
451479
nullable: true,
452480
}),
453-
servers: t.field({
481+
servers: t.expose("servers", {
454482
type: [ServerItem],
455-
resolve: (parent, _, ctx) => {
456-
// Return operation/path-level servers if defined, otherwise fall back to global servers
457-
return parent.servers && parent.servers.length > 0
458-
? parent.servers
459-
: (ctx.schema.servers ?? []);
460-
},
483+
nullable: true,
461484
}),
462485
requestBody: t.field({
463486
type: RequestBodyObject,
Lines changed: 122 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,17 @@
1-
import { useSuspenseQuery } from "@tanstack/react-query";
2-
import { CheckIcon, CopyIcon } from "lucide-react";
3-
import { useState, useTransition } from "react";
4-
import { InlineCode } from "../../components/InlineCode.js";
1+
import { CheckIcon, CopyIcon, EyeIcon } from "lucide-react";
2+
import { useState } from "react";
3+
import {
4+
HoverCard,
5+
HoverCardContent,
6+
HoverCardTrigger,
7+
} from "zudoku/ui/HoverCard.js";
8+
import { Input } from "zudoku/ui/Input.js";
9+
import { NativeSelect, NativeSelectOption } from "zudoku/ui/NativeSelect.js";
510
import { Button } from "../../ui/Button.js";
6-
import { useCreateQuery } from "./client/useCreateQuery.js";
7-
import { useOasConfig } from "./context.js";
8-
import { graphql } from "./graphql/index.js";
9-
import { SimpleSelect } from "./SimpleSelect.js";
11+
import { cn } from "../../util/cn.js";
12+
import type { Server } from "./graphql/graphql.js";
1013
import { useSelectedServer } from "./state.js";
1114

12-
const ServersQuery = graphql(/* GraphQL */ `
13-
query ServersQuery($input: JSON!, $type: SchemaType!) {
14-
schema(input: $input, type: $type) {
15-
url
16-
servers {
17-
url
18-
}
19-
}
20-
}
21-
`);
22-
2315
const CopyButton = ({ url }: { url: string }) => {
2416
const [isCopied, setIsCopied] = useState(false);
2517

@@ -43,43 +35,122 @@ const CopyButton = ({ url }: { url: string }) => {
4335
);
4436
};
4537

46-
export const Endpoint = () => {
47-
const { input, type } = useOasConfig();
48-
const query = useCreateQuery(ServersQuery, { input, type });
49-
const result = useSuspenseQuery(query);
50-
const [, startTransition] = useTransition();
51-
const { selectedServer, setSelectedServer } = useSelectedServer(
52-
result.data.schema.servers,
53-
);
38+
const ResolvedUrlPreview = ({ url }: { url: string }) => (
39+
<HoverCard openDelay={0}>
40+
<HoverCardTrigger asChild>
41+
<Button variant="ghost" size="icon-xs">
42+
<EyeIcon size={14} />
43+
</Button>
44+
</HoverCardTrigger>
45+
<HoverCardContent className="w-fit max-w-xl flex items-center justify-between gap-2">
46+
<pre className="text-xs font-mono whitespace-pre-wrap break-all">
47+
{url}
48+
</pre>
49+
<CopyButton url={url} />
50+
</HoverCardContent>
51+
</HoverCard>
52+
);
5453

55-
const { servers } = result.data.schema;
56-
const firstServer = servers.at(0);
54+
export const Endpoint = ({ servers }: { servers: Server[] }) => {
55+
const {
56+
selectedServer,
57+
selectedServerTemplate,
58+
selectedServerVariables,
59+
templateSegments,
60+
setSelectedServer,
61+
setSelectedServerVariable,
62+
} = useSelectedServer(servers);
5763

58-
if (!firstServer) return null;
64+
if (servers.length === 0) return null;
65+
66+
const selectedServerDefinition = servers.find(
67+
(server) => server.url === selectedServerTemplate,
68+
);
69+
const templateVariables = selectedServerDefinition?.variables ?? [];
5970

6071
return (
61-
<div className="flex items-center gap-1.5 flex-nowrap">
62-
<span className="font-medium text-sm">Endpoint</span>
63-
{servers.length > 1 ? (
64-
<SimpleSelect
65-
className="font-mono text-xs border-input bg-transparent dark:bg-input/30 dark:hover:bg-input/50 py-1.5 max-w-[450px] truncate"
66-
onChange={(e) =>
67-
startTransition(() => setSelectedServer(e.target.value))
68-
}
69-
value={selectedServer}
70-
showChevrons={servers.length > 1}
71-
options={servers.map((server) => ({
72-
value: server.url,
73-
label: server.url,
74-
}))}
75-
/>
76-
) : (
77-
<InlineCode className="text-xs px-2 py-1.5" selectOnClick>
78-
{firstServer.url}
79-
</InlineCode>
80-
)}
72+
<div className="border rounded-lg p-3">
73+
<div className="flex flex-col gap-1.5">
74+
<div className="flex flex-wrap items-center gap-2">
75+
<span className="font-medium text-sm">Endpoint</span>
76+
{servers.length > 1 && (
77+
<NativeSelect
78+
className="field-sizing-content truncate py-1 w-fit h-fit text-sm"
79+
value={selectedServerTemplate}
80+
onChange={(e) => setSelectedServer(e.target.value)}
81+
>
82+
{servers.map((server, index) => (
83+
<NativeSelectOption key={server.url} value={server.url}>
84+
{server.description ?? `Server ${index + 1}`}
85+
</NativeSelectOption>
86+
))}
87+
</NativeSelect>
88+
)}
89+
</div>
90+
{selectedServerDefinition?.description && (
91+
<div className="text-xs text-muted-foreground">
92+
{selectedServerDefinition.description}
93+
</div>
94+
)}
95+
96+
{templateVariables.length > 0 ? (
97+
<div className="font-mono text-xs flex flex-wrap items-center gap-0.5">
98+
{templateSegments.map((segment, index) => {
99+
if (segment.type === "text") {
100+
return (
101+
// biome-ignore lint/suspicious/noArrayIndexKey: index should be stable
102+
<span key={`text-${index}`} className="whitespace-pre-wrap">
103+
{segment.value}
104+
</span>
105+
);
106+
}
107+
108+
const variable = templateVariables.find(
109+
(item) => item.name === segment.name,
110+
);
111+
const variableValue = selectedServerVariables[segment.name] ?? "";
112+
113+
if (variable?.enumValues && variable.enumValues.length > 0) {
114+
return (
115+
<select
116+
key={`var-${segment.name}-${index}`}
117+
className={cn(
118+
"field-sizing-content max-w-42 border-x-0 border-t-0 border-b rounded-none font-mono text-xs!",
119+
"dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
120+
)}
121+
value={variableValue}
122+
onChange={(e) => {
123+
setSelectedServerVariable(segment.name, e.target.value);
124+
}}
125+
>
126+
{variable.enumValues.map((enumValue) => (
127+
<option key={enumValue} value={enumValue}>
128+
{enumValue}
129+
</option>
130+
))}
131+
</select>
132+
);
133+
}
81134

82-
<CopyButton url={servers.length > 1 ? selectedServer : firstServer.url} />
135+
return (
136+
<Input
137+
key={`var-${segment.name}-${index}`}
138+
className="field-sizing-content w-fit max-w-36 h-fit px-1 py-0.5 border-x-0 border-t-0 border-b rounded-none font-mono text-xs!"
139+
value={variableValue}
140+
onChange={(e) => {
141+
setSelectedServerVariable(segment.name, e.target.value);
142+
}}
143+
/>
144+
);
145+
})}
146+
<ResolvedUrlPreview url={selectedServer} />
147+
</div>
148+
) : (
149+
<div className="font-mono text-xs flex flex-wrap items-center py-1">
150+
{selectedServer}
151+
</div>
152+
)}
153+
</div>
83154
</div>
84155
);
85156
};

0 commit comments

Comments
 (0)