Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/tangy-pants-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensadmin": patch
Comment thread
notrab marked this conversation as resolved.
Comment thread
vercel[bot] marked this conversation as resolved.
---

Introduced interactive REST API Reference playground (`/api/rest`) powered by Scalar, enabling discovery and testing of all REST APIs published by a connected ENSApi instance. Added `@ensnode/scalar-react` wrapper package.
1 change: 1 addition & 0 deletions apps/ensadmin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@ensnode/datasources": "workspace:*",
"@ensnode/ensnode-react": "workspace:*",
"@ensnode/ensnode-sdk": "workspace:*",
"@ensnode/scalar-react": "workspace:*",
"enssdk": "workspace:*",
"@formkit/auto-animate": "^0.9.0",
"@graphiql/plugin-explorer": "5.1.1",
Expand Down
25 changes: 25 additions & 0 deletions apps/ensadmin/src/app/@actions/api/rest/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"use client";

import { CopyButton } from "@namehash/namehash-ui";
import { CheckIcon, CopyIcon } from "lucide-react";

import { useOpenApiUrl } from "@/hooks/active/use-openapi-url";

export default function Actions() {
const url = useOpenApiUrl();

return (
<div className="flex w-full max-w-md min-w-0 items-center space-x-2">
<span className="font-mono text-xs select-none text-gray-500 truncate min-w-0" title={url}>
{url}
</span>
<CopyButton
value={url}
message="URL copied to clipboard!"
successIcon={<CheckIcon className="h-4 w-4" />}
icon={<CopyIcon className="h-4 w-4" />}
showToast={true}
/>
</div>
);
}
9 changes: 9 additions & 0 deletions apps/ensadmin/src/app/@breadcrumbs/(apis)/api/rest/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { BreadcrumbItem, BreadcrumbPage } from "@/components/ui/breadcrumb";

export default function Page() {
return (
<BreadcrumbItem>
<BreadcrumbPage>REST API Reference</BreadcrumbPage>
</BreadcrumbItem>
);
}
9 changes: 9 additions & 0 deletions apps/ensadmin/src/app/api/rest/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LoadingSpinner } from "@/components/loading-spinner";

export default function Loading() {
return (
<div className="flex flex-col items-center justify-center h-screen">
<LoadingSpinner className="h-16 w-16" />
</div>
);
}
22 changes: 22 additions & 0 deletions apps/ensadmin/src/app/api/rest/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"use client";

import { ScalarApiReference } from "@ensnode/scalar-react";

import { RequireENSAdminFeature } from "@/components/require-ensadmin-feature";
import { useOpenApiUrl } from "@/hooks/active/use-openapi-url";
import { useValidatedSelectedConnection } from "@/hooks/active/use-selected-connection";

function RestApiPage() {
const url = useOpenApiUrl();
const selectedConnection = useValidatedSelectedConnection();

return <ScalarApiReference url={url} serverUrl={selectedConnection.toString()} />;
Comment thread
notrab marked this conversation as resolved.
}

export default function Page() {
return (
<RequireENSAdminFeature title="REST API Reference" feature="restApi">
<RestApiPage />
</RequireENSAdminFeature>
);
}
6 changes: 6 additions & 0 deletions apps/ensadmin/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

@layer base {
:root {
--ensadmin-header-height: 4rem;

--background: 0 0% 100%;

--foreground: 222.2 84% 4.9%;
Expand Down Expand Up @@ -73,6 +75,10 @@
}

@layer base {
:root:has([data-collapsible="icon"]) {
--ensadmin-header-height: 3rem;
}

* {
@apply border-border outline-ring/50;
}
Expand Down
4 changes: 4 additions & 0 deletions apps/ensadmin/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ const navItems = [
title: "Omnigraph (ENS v1 + v2)",
url: "/api/omnigraph",
},
{
title: "REST API Reference",
url: "/api/rest",
},
],
},
];
Expand Down
2 changes: 1 addition & 1 deletion apps/ensadmin/src/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const Header = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement>>(
<header
ref={ref}
className={cn(
"flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12 border-b",
"sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 bg-background transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12 border-b",
className,
)}
{...props}
Expand Down
15 changes: 14 additions & 1 deletion apps/ensadmin/src/hooks/active/use-ensadmin-features.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export interface ENSAdminFeatures {
* Whether ENSAdmin's ENSNode Omnigraph API tooling is supported by the connected ENSNode.
*/
omnigraph: FeatureStatus;

/**
* Whether ENSAdmin's REST API Reference tooling is supported by the connected ENSNode.
* The REST API is available on any ENSApi instance that serves `/openapi.json`.
*/
restApi: FeatureStatus;
}

const prerequisiteResultToFeatureStatus = (result: PrerequisiteResult): FeatureStatus => {
Expand Down Expand Up @@ -104,5 +110,12 @@ export function useENSAdminFeatures(): ENSAdminFeatures {
return prerequisiteResultToFeatureStatus(hasOmnigraphApiConfigSupport(ensIndexerPublicConfig));
}, [configQuery]);

return { registrarActions, subgraph, omnigraph };
const restApi: FeatureStatus = useMemo(() => {
if (configQuery.status === "error") return CONFIG_ERROR_STATUS;
if (configQuery.status === "pending") return CONNECTING_STATUS;

return { type: "supported" };
}, [configQuery]);

return { registrarActions, subgraph, omnigraph, restApi };
}
13 changes: 13 additions & 0 deletions apps/ensadmin/src/hooks/active/use-openapi-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"use client";

import { useMemo } from "react";

import { useValidatedSelectedConnection } from "@/hooks/active/use-selected-connection";

export function useOpenApiUrl(): string {
const selectedConnection = useValidatedSelectedConnection();
return useMemo(
() => new URL("/openapi.json", selectedConnection).toString(),
[selectedConnection],
);
}
36 changes: 36 additions & 0 deletions packages/scalar-react/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "@ensnode/scalar-react",
"private": true,
"version": "0.0.0",
"type": "module",
"description": "Scalar API Reference React component for ENSNode",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/namehash/ensnode.git",
"directory": "packages/scalar-react"
},
"homepage": "https://github.com/namehash/ensnode/tree/main/packages/scalar-react",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"lint": "biome check --write .",
"lint:ci": "biome ci",
"typecheck": "tsgo --noEmit"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"dependencies": {
"@scalar/api-reference-react": "^0.5.2"
},
"devDependencies": {
"@ensnode/shared-configs": "workspace:*",
"@types/react": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:"
}
}
80 changes: 80 additions & 0 deletions packages/scalar-react/src/api-reference.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"use client";

import { ApiReferenceReact, type ReferenceProps } from "@scalar/api-reference-react";
import { useEffect, useMemo, useState } from "react";
import "@scalar/api-reference-react/style.css";

interface ScalarApiReferenceProps {
/** URL to the OpenAPI spec (e.g. `https://api.alpha.ensnode.io/openapi.json`) */
url: string;
/**
* Overrides the `servers` list in the OpenAPI spec so the playground targets
* this base URL instead (e.g. the currently active connection).
*/
serverUrl?: string;
}

const CUSTOM_CSS = `
.scalar-api-reference { --scalar-y-offset: 0; }
.references-layout {
height: calc(100svh - var(--ensadmin-header-height, 4rem)) !important;
min-height: 0 !important;
max-height: calc(100svh - var(--ensadmin-header-height, 4rem)) !important;
--full-height: calc(100svh - var(--ensadmin-header-height, 4rem)) !important;
grid-template-rows: var(--scalar-header-height, 0px) 1fr auto !important;
}
.references-rendered {
overflow-y: auto !important;
min-height: 0 !important;
}
.references-navigation-list {
height: 100% !important;
}
Comment thread
notrab marked this conversation as resolved.
.scalar-app, .scalar-api-reference {
--scalar-font: var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.light-mode {
--scalar-color-1: #121212;
--scalar-color-2: rgba(0, 0, 0, 0.6);
--scalar-color-3: rgba(0, 0, 0, 0.4);
--scalar-color-accent: hsl(222.2, 47.4%, 11.2%);
--scalar-background-1: #ffffff;
--scalar-background-2: #f6f5f4;
--scalar-background-3: #f1ede9;
--scalar-background-accent: color-mix(in srgb, hsl(222.2, 47.4%, 11.2%) 6%, transparent);
--scalar-border-color: hsl(214.3, 31.8%, 91.4%);
}
.scalar-mcp-layer { display: none !important; }
.section { padding-inline: 0 !important; }
.section-container:not(.section-container .section-container) {
padding-inline: clamp(16px, 4vw, 60px) !important;
}
`;

export function ScalarApiReference({ url, serverUrl }: ScalarApiReferenceProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);

Comment on lines +54 to +59
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ScalarApiReference returns null during the server render (typeof window === "undefined") but renders the full Scalar UI on the first client render. In Next.js client components this can cause a hydration mismatch (server markup is empty, client markup is not). Consider gating on a mounted state set in useEffect (or equivalent hydration guard) so the first client render matches the server output, then render Scalar after mount.

Copilot uses AI. Check for mistakes.
const configuration = useMemo<NonNullable<ReferenceProps["configuration"]>>(
() => ({
url,
servers: serverUrl ? [{ url: serverUrl }] : undefined,
theme: "none",
hideDownloadButton: true,
hiddenClients: true,
defaultOpenAllTags: true,
forceDarkModeState: "light",
hideDarkModeToggle: true,
withDefaultFonts: false,
hideClientButton: true,
customCss: CUSTOM_CSS,
}),
[url, serverUrl],
);

if (!mounted) return null;

return <ApiReferenceReact configuration={configuration} />;
}
1 change: 1 addition & 0 deletions packages/scalar-react/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ScalarApiReference } from "./api-reference";
10 changes: 10 additions & 0 deletions packages/scalar-react/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "@ensnode/shared-configs/tsconfig.lib.json",
"compilerOptions": {
"jsx": "react-jsx",
"rootDir": ".",
"types": ["react"]
},
"include": ["./**/*.ts", "./**/*.tsx"],
"exclude": ["dist", "**/__tests__/**"]
}
Loading
Loading