Skip to content

Commit

Permalink
feat: Add OAS server selector in overview list and playground (#247)
Browse files Browse the repository at this point in the history
  • Loading branch information
dan-lee authored Oct 4, 2024
1 parent e3f7e48 commit 746df24
Show file tree
Hide file tree
Showing 14 changed files with 332 additions and 44 deletions.
18 changes: 10 additions & 8 deletions packages/zudoku/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type MediaTypeObject {
encoding: [EncodingItem!]
examples: [ExampleItem!]
mediaType: String!
schema: JSON!
schema: JSON
}

type OperationItem {
Expand Down Expand Up @@ -77,7 +77,7 @@ type RequestBodyObject {

type ResponseItem {
content: [MediaTypeObject!]
description: String!
description: String
headers: JSON
links: JSON
statusCode: String!
Expand All @@ -86,13 +86,9 @@ type ResponseItem {
type Schema {
description: String
openapi: String!
operations(
method: String
operationId: String
path: String
tag: String
): [OperationItem!]!
operations(method: String, operationId: String, path: String, tag: String): [OperationItem!]!
paths: [PathItem!]!
servers: [Server!]!
tags(name: String): [SchemaTag!]!
title: String!
url: String!
Expand All @@ -105,6 +101,11 @@ type SchemaTag {
operations: [OperationItem!]!
}

type Server {
description: String
url: String!
}

type TagItem {
description: String
name: String!
Expand All @@ -119,6 +120,7 @@ enum ParameterIn {

enum SchemaType {
file
raw
url
}

Expand Down
17 changes: 17 additions & 0 deletions packages/zudoku/src/lib/authentication/state.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";

export const useAuthState = create<AuthState>(() => ({
isPending: false,
Expand All @@ -19,3 +20,19 @@ export interface UserProfile {
pictureUrl: string | undefined;
[key: string]: string | boolean | undefined;
}

interface SelectedServerState {
selectedServer?: string;
setSelectedServer: (newServer: string) => void;
}

export const useSelectedServerStore = create<SelectedServerState>()(
persist(
(set) => ({
selectedServer: undefined,
setSelectedServer: (newServer: string) =>
set({ selectedServer: newServer }),
}),
{ name: "zudoku-selected-server" },
),
);
10 changes: 10 additions & 0 deletions packages/zudoku/src/lib/components/InlineCode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,21 @@ import { cn } from "../util/cn.js";
export const InlineCode = ({
className,
children,
selectOnClick,
}: {
className?: string;
children: ReactNode;
selectOnClick?: boolean;
}) => (
<code
onClick={(e) => {
if (!selectOnClick) return;
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(e.currentTarget);
selection?.removeAllRanges();
selection?.addRange(range);
}}
className={cn(
"font-mono border p-1 py-0.5 rounded bg-border/50 dark:bg-border/70 whitespace-nowrap",
className,
Expand Down
2 changes: 1 addition & 1 deletion packages/zudoku/src/lib/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const Layout = ({ children }: { children?: ReactNode }) => {
<div className="w-full max-w-screen-2xl mx-auto px-10 lg:px-12">
<Suspense
fallback={
<main className="grid h-full place-items-center">
<main className="grid h-[calc(100vh-var(--header-height))] place-items-center">
<Spinner />
</main>
}
Expand Down
12 changes: 12 additions & 0 deletions packages/zudoku/src/lib/oas/graphql/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type ParameterObject,
type PathsObject,
type SchemaObject,
type ServerObject,
type TagObject,
} from "../parser/index.js";

Expand Down Expand Up @@ -158,6 +159,13 @@ const SchemaTag = builder.objectRef<TagObject>("SchemaTag").implement({
}),
});

const ServerItem = builder.objectRef<ServerObject>("Server").implement({
fields: (t) => ({
url: t.exposeString("url"),
description: t.exposeString("description", { nullable: true }),
}),
});

const PathItem = builder
.objectRef<{
path: string;
Expand Down Expand Up @@ -371,6 +379,10 @@ const Schema = builder.objectRef<OpenAPIDocument>("Schema").implement({
fields: (t) => ({
openapi: t.string({ resolve: (root) => root.openapi }),
url: t.string({ resolve: (root) => root.servers?.at(0)?.url ?? "/" }),
servers: t.field({
type: [ServerItem],
resolve: (root) => root.servers ?? [],
}),
title: t.string({ resolve: (root) => root.info.title }),
version: t.string({ resolve: (root) => root.info.version }),
description: t.string({
Expand Down
1 change: 1 addition & 0 deletions packages/zudoku/src/lib/oas/parser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type TagObject = DeepOmitReference<OpenAPIV3_1.TagObject>;
export type ExampleObject = DeepOmitReference<OpenAPIV3_1.ExampleObject>;
export type EncodingObject = DeepOmitReference<OpenAPIV3_1.EncodingObject>;
export type SchemaObject = DeepOmitReference<OpenAPIV3_1.SchemaObject>;
export type ServerObject = DeepOmitReference<OpenAPIV3_1.ServerObject>;

export const HttpMethods = Object.values(OpenAPIV3.HttpMethods);

Expand Down
108 changes: 86 additions & 22 deletions packages/zudoku/src/lib/plugins/openapi/Endpoint.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,95 @@
import { CheckIcon, CopyIcon } from "lucide-react";
import { useState } from "react";
import { useState, useTransition } from "react";
import { useSelectedServerStore } from "../../authentication/state.js";
import { InlineCode } from "../../components/InlineCode.js";
import { Button } from "../../ui/Button.js";
import { useOasConfig } from "./context.js";
import { graphql } from "./graphql/index.js";
import { SimpleSelect } from "./SimpleSelect.js";
import { useQuery } from "./util/urql.js";

export const Endpoint = ({ url }: { url: string }) => {
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);

return (
<div className="my-4 flex items-center justify-end gap-2 text-sm">
<span className="font-medium">Endpoint:</span>
<InlineCode className="p-1.5 flex gap-2.5 items-center text-xs">
{url}
<button
onClick={() => {
void navigator.clipboard.writeText(url).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
});
}}
type="button"
>
{isCopied ? (
<CheckIcon className="text-green-600" size={14} />
) : (
<CopyIcon size={14} strokeWidth={1.3} />
)}
</button>
</InlineCode>
<Button
onClick={() => {
void navigator.clipboard.writeText(url).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
});
}}
variant="ghost"
size="icon"
>
{isCopied ? (
<CheckIcon className="text-green-600" size={14} />
) : (
<CopyIcon size={14} strokeWidth={1.3} />
)}
</Button>
);
};

const context = { suspense: true } as const;

export const Endpoint = () => {
const [result] = useQuery({
query: ServersQuery,
variables: useOasConfig(),
context,
});
const [, startTransition] = useTransition();
const { selectedServer, setSelectedServer } = useSelectedServerStore();

if (!result.data) return null;

const { servers } = result.data.schema;

if (servers.length === 1) {
return (
<div className="flex items-center gap-2">
<span className="font-medium text-sm">Endpoint:</span>
<InlineCode className="text-xs px-2 py-1.5" selectOnClick>
{servers[0].url}
</InlineCode>
<CopyButton url={servers[0].url} />
</div>
);
}

return (
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-sm">
{servers.length > 1 ? "Endpoints" : "Endpoint"}:
</span>

<SimpleSelect
className="font-mono text-xs bg-border/50 dark:bg-border/70 py-1.5 max-w-[450px] truncate"
onChange={(e) =>
startTransition(() => {
setSelectedServer(e.target.value);
})
}
value={selectedServer ?? result.data.schema.url}
showChevrons={servers.length > 1}
options={servers.map((server) => ({
value: server.url,
label: server.url,
}))}
/>
<CopyButton url={selectedServer ?? result.data.schema.url} />
</div>
);
};
4 changes: 3 additions & 1 deletion packages/zudoku/src/lib/plugins/openapi/OperationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ export const OperationList = () => {
<Markdown content={result.data.schema.description ?? ""} />
</div>
<hr />
<Endpoint url={result.data.schema.url} />
<div className="my-4 flex justify-end">
<Endpoint />
</div>

{result.data.schema.tags
.filter((tag) => tag.operations.length > 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { PlaygroundDialog } from "./playground/PlaygroundDialog.js";

export const PlaygroundDialogWrapper = ({
server,
servers,
operation,
}: {
server: string;
servers?: string[];
operation: OperationListItemResult;
}) => {
const headers = operation.parameters
Expand All @@ -29,6 +31,7 @@ export const PlaygroundDialogWrapper = ({
return (
<PlaygroundDialog
server={server}
servers={servers}
method={operation.method}
url={operation.path}
headers={headers}
Expand Down
19 changes: 17 additions & 2 deletions packages/zudoku/src/lib/plugins/openapi/Sidecar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { HTTPSnippet } from "@zudoku/httpsnippet";
import { Fragment, useMemo, useTransition } from "react";
import { useSearchParams } from "react-router-dom";
import { useSelectedServerStore } from "../../authentication/state.js";
import { TextColorMap } from "../../components/navigation/SidebarBadge.js";
import { SyntaxHighlight } from "../../components/SyntaxHighlight.js";
import type { SchemaObject } from "../../oas/parser/index.js";
Expand Down Expand Up @@ -65,6 +66,9 @@ export const GetServerQuery = graphql(/* GraphQL */ `
query getServerQuery($input: JSON!, $type: SchemaType!) {
schema(input: $input, type: $type) {
url
servers {
url
}
}
}
`);
Expand Down Expand Up @@ -135,6 +139,8 @@ export const Sidecar = ({
);
});

const { selectedServer } = useSelectedServerStore();

const code = useMemo(() => {
const example = requestBodyContent?.[0]?.schema
? generateSchemaExample(requestBodyContent[0].schema as SchemaObject)
Expand All @@ -143,7 +149,7 @@ export const Sidecar = ({
const snippet = new HTTPSnippet({
method: operation.method.toLocaleUpperCase(),
url:
(result.data?.schema.url ?? "") +
(selectedServer ?? result.data?.schema.url ?? "") +
operation.path.replaceAll("{", ":").replaceAll("}", ""),
postData: example
? {
Expand All @@ -160,7 +166,13 @@ export const Sidecar = ({
});

return getConverted(snippet, selectedLang);
}, [selectedLang, operation.method, operation.path, requestBodyContent]);
}, [
selectedServer,
selectedLang,
operation.method,
operation.path,
requestBodyContent,
]);

return (
<aside className="flex flex-col overflow-hidden sticky top-[--scroll-padding] gap-4">
Expand All @@ -175,6 +187,9 @@ export const Sidecar = ({
</span>
<PlaygroundDialogWrapper
server={result.data?.schema.url ?? ""}
servers={
result.data?.schema.servers.map((server) => server.url) ?? []
}
operation={operation}
/>
</SidecarBox.Head>
Expand Down
12 changes: 10 additions & 2 deletions packages/zudoku/src/lib/plugins/openapi/SimpleSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const SimpleSelect = ({
onChange,
className,
options,
showChevrons = true,
}: {
value: string;
onChange: ChangeEventHandler<HTMLSelectElement>;
Expand All @@ -15,12 +16,14 @@ export const SimpleSelect = ({
value: string;
label: string;
}[];
showChevrons?: boolean;
}) => (
<div className={cn("grid", className)}>
<div className="grid">
<select
className={cn(
"row-start-1 col-start-1 border border-input text-foreground px-2 py-1 pe-6",
"rounded-md appearance-none bg-zinc-50 hover:bg-white dark:bg-zinc-800 hover:dark:bg-zinc-800/75",
className,
)}
value={value}
onChange={onChange}
Expand All @@ -31,7 +34,12 @@ export const SimpleSelect = ({
</option>
))}
</select>
<div className="row-start-1 col-start-1 self-center justify-self-end relative end-2 pointer-events-none">
<div
className={cn(
!showChevrons && "hidden",
"row-start-1 col-start-1 self-center justify-self-end relative end-2 pointer-events-none",
)}
>
<ChevronsUpDownIcon size={14} />
</div>
</div>
Expand Down
Loading

0 comments on commit 746df24

Please sign in to comment.