Skip to content
Draft
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
60 changes: 48 additions & 12 deletions examples/cosmo-cargo/schema/shipments.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,33 @@
],
"servers": [
{
"url": "https://8f9618f3bc0f4eec989627ceaafd5e4d_oas.api.mockbin.io/",
"description": "Production environment"
},
{
"url": "https://api.sh.example.com/v1",
"description": "Production environment"
},
{
"url": "https://api.staging.sh.example.com/v1",
"description": "Staging environment"
"url": "https://{tenant}.{region}.{env}.sh.example.com/{version}",
"description": "Global default server with tenant, region, environment, and version variables",
"variables": {
"tenant": {
"default": "acme-logistics",
"description": "Tenant subdomain used to isolate organization data"
},
"region": {
"default": "us-east-1",
"enum": ["us-east-1", "us-west-2", "eu-central-1"],
"description": "Primary data residency region"
},
"env": {
"default": "prod",
"enum": ["prod", "staging", "dev"],
"description": "Deployment environment"
},
"version": {
"default": "v1",
"enum": ["v1", "v1-beta"],
"description": "API version path segment"
}
}
},
{
"url": "https://api.dev.sh.example.com/v1",
"description": "Development environment"
"url": "https://8f9618f3bc0f4eec989627ceaafd5e4d_oas.api.mockbin.io/",
"description": "Static mock server for quick local demos"
}
],
"security": [
Expand Down Expand Up @@ -1077,6 +1090,29 @@
}
},
"/shipments/{trackingNumber}": {
"servers": [
{
"url": "https://{region}.tracking.{env}.sh.example.com/{version}",
"description": "Path-level override for /shipment/{trackingNumber}",
"variables": {
"region": {
"default": "us-east-1",
"enum": ["us-east-1", "eu-central-1"],
"description": "Tracking service region"
},
"env": {
"default": "prod",
"enum": ["prod", "staging"],
"description": "Tracking environment"
},
"version": {
"default": "v1",
"enum": ["v1", "v1-edge"],
"description": "Tracking API version channel"
}
}
}
],
"get": {
"tags": ["Shipment Management"],
"summary": "Track a shipment",
Expand Down
10 changes: 9 additions & 1 deletion packages/zudoku/schema.graphql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 30 additions & 7 deletions packages/zudoku/src/lib/oas/graphql/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,10 +265,38 @@ SchemaTag.implement({
}),
});

type GraphQLServerVariable = {
name: string;
defaultValue: string;
description?: string;
enumValues?: string[];
};

const ServerVariableItem = builder
.objectRef<GraphQLServerVariable>("ServerVariable")
.implement({
fields: (t) => ({
name: t.exposeString("name"),
defaultValue: t.exposeString("defaultValue"),
description: t.exposeString("description", { nullable: true }),
enumValues: t.exposeStringList("enumValues", { nullable: true }),
}),
});

const ServerItem = builder.objectRef<ServerObject>("Server").implement({
fields: (t) => ({
url: t.exposeString("url"),
description: t.exposeString("description", { nullable: true }),
variables: t.field({
type: [ServerVariableItem],
resolve: (parent) =>
Object.entries(parent.variables ?? {}).map(([name, value]) => ({
name,
defaultValue: value.default,
description: value.description,
enumValues: value.enum,
})),
}),
}),
});

Expand Down Expand Up @@ -450,14 +478,9 @@ const OperationItem = builder
type: [ParameterItem],
nullable: true,
}),
servers: t.field({
servers: t.expose("servers", {
type: [ServerItem],
resolve: (parent, _, ctx) => {
// Return operation/path-level servers if defined, otherwise fall back to global servers
return parent.servers && parent.servers.length > 0
? parent.servers
: (ctx.schema.servers ?? []);
},
nullable: true,
}),
requestBody: t.field({
type: RequestBodyObject,
Expand Down
173 changes: 122 additions & 51 deletions packages/zudoku/src/lib/plugins/openapi/Endpoint.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,17 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { CheckIcon, CopyIcon } from "lucide-react";
import { useState, useTransition } from "react";
import { InlineCode } from "../../components/InlineCode.js";
import { CheckIcon, CopyIcon, EyeIcon } from "lucide-react";
import { useState } from "react";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "zudoku/ui/HoverCard.js";
import { Input } from "zudoku/ui/Input.js";
import { NativeSelect, NativeSelectOption } from "zudoku/ui/NativeSelect.js";
import { Button } from "../../ui/Button.js";
import { useCreateQuery } from "./client/useCreateQuery.js";
import { useOasConfig } from "./context.js";
import { graphql } from "./graphql/index.js";
import { SimpleSelect } from "./SimpleSelect.js";
import { cn } from "../../util/cn.js";
import type { Server } from "./graphql/graphql.js";
import { useSelectedServer } from "./state.js";

const ServersQuery = graphql(/* GraphQL */ `
query ServersQuery($input: JSON!, $type: SchemaType!) {
schema(input: $input, type: $type) {
url
servers {
url
}
}
}
`);

const CopyButton = ({ url }: { url: string }) => {
const [isCopied, setIsCopied] = useState(false);

Expand All @@ -43,43 +35,122 @@ const CopyButton = ({ url }: { url: string }) => {
);
};

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

const { servers } = result.data.schema;
const firstServer = servers.at(0);
export const Endpoint = ({ servers }: { servers: Server[] }) => {
const {
selectedServer,
selectedServerTemplate,
selectedServerVariables,
templateSegments,
setSelectedServer,
setSelectedServerVariable,
} = useSelectedServer(servers);

if (!firstServer) return null;
if (servers.length === 0) return null;

const selectedServerDefinition = servers.find(
(server) => server.url === selectedServerTemplate,
);
const templateVariables = selectedServerDefinition?.variables ?? [];

return (
<div className="flex items-center gap-1.5 flex-nowrap">
<span className="font-medium text-sm">Endpoint</span>
{servers.length > 1 ? (
<SimpleSelect
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"
onChange={(e) =>
startTransition(() => setSelectedServer(e.target.value))
}
value={selectedServer}
showChevrons={servers.length > 1}
options={servers.map((server) => ({
value: server.url,
label: server.url,
}))}
/>
) : (
<InlineCode className="text-xs px-2 py-1.5" selectOnClick>
{firstServer.url}
</InlineCode>
)}
<div className="border rounded-lg p-3">
<div className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-sm">Endpoint</span>
{servers.length > 1 && (
<NativeSelect
className="field-sizing-content truncate py-1 w-fit h-fit text-sm"
value={selectedServerTemplate}
onChange={(e) => setSelectedServer(e.target.value)}
>
{servers.map((server, index) => (
<NativeSelectOption key={server.url} value={server.url}>
{server.description ?? `Server ${index + 1}`}
</NativeSelectOption>
))}
</NativeSelect>
)}
</div>
{selectedServerDefinition?.description && (
<div className="text-xs text-muted-foreground">
{selectedServerDefinition.description}
</div>
)}

{templateVariables.length > 0 ? (
<div className="font-mono text-xs flex flex-wrap items-center gap-0.5">
{templateSegments.map((segment, index) => {
if (segment.type === "text") {
return (
// biome-ignore lint/suspicious/noArrayIndexKey: index should be stable
<span key={`text-${index}`} className="whitespace-pre-wrap">
{segment.value}
</span>
);
}

const variable = templateVariables.find(
(item) => item.name === segment.name,
);
const variableValue = selectedServerVariables[segment.name] ?? "";

if (variable?.enumValues && variable.enumValues.length > 0) {
return (
<select
key={`var-${segment.name}-${index}`}
className={cn(
"field-sizing-content max-w-42 border-x-0 border-t-0 border-b rounded-none font-mono text-xs!",
"dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
)}
value={variableValue}
onChange={(e) => {
setSelectedServerVariable(segment.name, e.target.value);
}}
>
{variable.enumValues.map((enumValue) => (
<option key={enumValue} value={enumValue}>
{enumValue}
</option>
))}
</select>
);
}

<CopyButton url={servers.length > 1 ? selectedServer : firstServer.url} />
return (
<Input
key={`var-${segment.name}-${index}`}
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!"
value={variableValue}
onChange={(e) => {
setSelectedServerVariable(segment.name, e.target.value);
}}
/>
);
})}
<ResolvedUrlPreview url={selectedServer} />
</div>
) : (
<div className="font-mono text-xs flex flex-wrap items-center py-1">
{selectedServer}
</div>
)}
</div>
</div>
);
};
Loading