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
39 changes: 39 additions & 0 deletions .changeset/openapi-documentation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
"nextly": patch
"create-nextly-app": patch
"@nextlyhq/admin": patch
"@nextlyhq/admin-css": patch
"@nextlyhq/blocks-engine": patch
"@nextlyhq/blocks-react": patch
"@nextlyhq/ui": patch
"@nextlyhq/adapter-drizzle": patch
"@nextlyhq/adapter-postgres": patch
"@nextlyhq/adapter-mysql": patch
"@nextlyhq/adapter-sqlite": patch
"@nextlyhq/storage-s3": patch
"@nextlyhq/storage-uploadthing": patch
"@nextlyhq/storage-vercel-blob": patch
"@nextlyhq/plugin-form-builder": patch
"@nextlyhq/plugin-page-builder": patch
"@nextlyhq/plugin-seo": patch
"@nextlyhq/plugin-api-docs": patch
"@nextlyhq/plugin-sdk": patch
"@nextlyhq/eslint-config": patch
"@nextlyhq/prettier-config": patch
"@nextlyhq/telemetry": patch
"@nextlyhq/tsconfig": patch
"@nextlyhq/builder": patch
"@nextlyhq/module-specifiers": patch
---

Add OpenAPI documentation, delivered as a plugin. The new
`@nextlyhq/plugin-api-docs` generates a complete OpenAPI 3.1 spec at request time
from three derived sources — a filesystem scan of the app's route files (which
discovers mounts and their verbs, including the media double-mount), the admin
REST operations exposed by a new read-only `listAdminRestOperations()` seam, and
every registered plugin's routes via `listPluginRoutes()` — and serves it plus an
interactive Scalar reference, admin-gated by default. The error component is
generated from the live error-code enum, plugin routes can carry an optional
`openapi?` annotation, and the plugin exposes typed excludes (paths, services,
error codes) and explicit mount overrides. Core nextly gains only the two small
introspection seams on the plugin-sdk surface.
10 changes: 7 additions & 3 deletions apps/playground/nextly.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
* runtime ignores this field. See packages/nextly/src/auth/handlers/session.ts.
*/

import { apiDocsPlugin } from "@nextlyhq/plugin-api-docs";
import { formBuilderPlugin } from "@nextlyhq/plugin-form-builder";
import { pageBuilder } from "@nextlyhq/plugin-page-builder";
import { defineConfig } from "nextly/config";
Expand Down Expand Up @@ -82,17 +83,20 @@ export default defineConfig({
singles: [Homepage, LandingPage, SiteSettings],
fieldGroups: [Seo],
// Dev-harness plugins: page builder and form builder are what a contributor
// works against.
// works against. The api-docs plugin is the local test surface for OpenAPI
// documentation — it serves the Scalar reference at /admin/api/docs and the
// spec at /admin/api/docs/spec.json, and adds an "API Docs" sidebar entry.
//
// The styling fixture is registered only for the e2e run. It exists so
// plugin-admin-styling.spec.ts can prove a plugin's admin UI is styled in the
// real admin, and plugin-page-routing.spec.ts can resolve a deep link to a
// plugin-admin-styling.spec.ts can prove a plugin's admin UI is styled in
// the real admin, and plugin-page-routing.spec.ts can resolve a deep link to a
// plugin page. In a normal `pnpm dev:app` it is neither of those things: it
// is a test double listed among real plugins, and it injects a showcase
// section into the Posts collection list, both of which read as product.
plugins: [
pageBuilder(),
formBuilderPlugin,
apiDocsPlugin({ visibility: "public" }),
...(process.env.NEXTLY_E2E_STYLE_FIXTURE === "1"
? [styleFixturePlugin]
: []),
Expand Down
1 change: 1 addition & 0 deletions apps/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@nextlyhq/admin": "workspace:^",
"@nextlyhq/blocks-react": "workspace:^",
"@nextlyhq/builder": "workspace:*",
"@nextlyhq/plugin-api-docs": "workspace:^",
"@nextlyhq/plugin-form-builder": "workspace:^",
"@nextlyhq/plugin-page-builder": "workspace:^",
"@nextlyhq/plugin-sdk": "workspace:^",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@admin/components/layout/sidebar";
import { Link } from "@admin/components/ui/link";
import { useBranding } from "@admin/context/providers/BrandingProvider";
import { useCurrentUserPermissions } from "@admin/hooks/useCurrentUserPermissions";
import { resolveVisibleMenuItems } from "@admin/lib/plugins/menu";
Expand Down Expand Up @@ -74,12 +73,17 @@ function PluginMenuLeaf({
return (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={active} tooltip={item.label}>
<Link href={item.to}>
{/* A plugin menu item is an arbitrary URL by contract — it may point at
a plugin HTTP route (e.g. the api-docs reference) rather than an
admin page. Client-side routing such a URL resolves against the page
tree and lands on the admin 404, so plugin items navigate with a
full page load. */}
<a href={item.to}>
<Icon
className={cn("shrink-0", !active && "text-muted-foreground")}
/>
<span>{item.label}</span>
</Link>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
);
Expand Down Expand Up @@ -121,10 +125,12 @@ function PluginMenuBranch({
return (
<SidebarMenuSubItem key={child.to}>
<SidebarMenuSubButton asChild isActive={childActive}>
<Link href={child.to}>
{/* Same full-load contract as the leaf: a child may target a
plugin HTTP route, which is not an admin page. */}
<a href={child.to}>
<ChildIcon className="h-3.5 w-3.5 shrink-0" />
<span>{child.label}</span>
</Link>
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { DynamicCollectionNav } from "@admin/components/features/dashboard/Dynam
import { DynamicPluginNav } from "@admin/components/features/dashboard/DynamicPluginNav";
import { DynamicPluginSectionItems } from "@admin/components/features/dashboard/DynamicPluginSectionItems";
import { DynamicSingleNav } from "@admin/components/features/dashboard/DynamicSingleNav";
import { PluginMenuItems } from "@admin/components/features/dashboard/PluginMenuItems";
import * as Icons from "@admin/components/icons";
import { Layers, Puzzle, FileText, Database } from "@admin/components/icons";
import { Link } from "@admin/components/ui/link";
Expand Down Expand Up @@ -107,8 +108,10 @@ export function SubSidebarContent({
onChange={onPluginSearchChange}
/>
<div className="space-y-1">
{/* Names what the panel contains: the installed-plugins overview and
navigation into each plugin's collections. Nothing here installs
{/* Names what the panel contains: the installed-plugins overview,
navigation into each plugin's collections, and any declarative
menu items plugins contribute via `contributes.admin.menu` —
e.g. the api-docs plugin's reference link. Nothing here installs
a plugin, which happens through the Nextly config. */}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<p className="text-xs font-bold uppercase tracking-wider text-sidebar-foreground px-3 mb-2">
Plugins
Expand Down Expand Up @@ -138,6 +141,7 @@ export function SubSidebarContent({
</SidebarMenuButton>
</SidebarMenuItem>
)}
<PluginMenuItems isActive={isActive} />
</SidebarMenu>
</div>
</div>
Expand Down
5 changes: 4 additions & 1 deletion packages/nextly/src/dispatcher/handlers/user-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ import type { MethodHandler, Params } from "../types";

type UsersService = ServiceContainer["users"];

const USER_METHODS: Record<string, MethodHandler<UsersService>> = {
// Exported (additive; dispatch behaviour unchanged) so the OpenAPI route-
// descriptor registry's agreement test can assert its `users` operations are a
// subset of this live map — the single source of truth for which methods exist.
export const USER_METHODS: Record<string, MethodHandler<UsersService>> = {
listUsers: {
execute: async (svc, p) => {
const result = await svc.listUsers({
Expand Down
45 changes: 45 additions & 0 deletions packages/nextly/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ export {
type PluginFilterRegistry,
type PluginActionRegistry,
type PluginRoute,
type PluginRouteOpenApi,
type PluginRouteContext,
type PluginRouteHandler,
type Middleware,
Expand Down Expand Up @@ -819,3 +820,47 @@ export {
type MediaLike,
type GetMediaVariantOptions,
} from "./lib/media-variant";

// ============================================================
// ADMIN REST INTROSPECTION
// ============================================================

// General "what REST operations does the admin catch-all expose" seam. Not
// OpenAPI-specific — the api-docs plugin consumes it, and any introspection
// tooling (client generators, permission auditors) can too.
export {
listAdminRestOperations,
restOperationsForService,
dedupeRestOperations,
type AdminRestOperation,
type RestHttpMethod,
type RestAuthMode,
} from "./route-handler/admin-rest-descriptors";

// Content-surface introspection: every registered collection/single with its
// fields, across code-first, plugin-contributed, and admin-Builder origins.
export {
listContentSurfaces,
type ContentSurfaceInfo,
type ContentSurfaces,
} from "./route-handler/content-surfaces";

// Mounted-surface introspection: operation templates for the standalone
// first-party handlers (media factory, health check) a host app mounts. Lives
// beside the catch-all tables in admin-rest-descriptors — one routes file.
export {
listMediaSurfaceOperations,
listHealthSurfaceOperations,
type MountedSurfaceOperation,
} from "./route-handler/admin-rest-descriptors";

// Request-auth introspection: is this caller carrying a valid session/API key?
// Lets a plugin serving a public route distinguish anonymous from logged-in.
export { isAuthenticatedApiRequest } from "./route-handler/request-auth";

// Read-only view of plugin-contributed routes (the plugin-route mirror of the
// admin REST introspection above).
export {
listPluginRoutes,
type PluginRouteInfo,
} from "./plugins/routes/route-registry";
7 changes: 7 additions & 0 deletions packages/nextly/src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,15 @@ export type {
// Plugin HTTP routes — `contributes.routes` surface.
export type {
PluginRoute,
PluginRouteOpenApi,
PluginRouteContext,
PluginRouteHandler,
Middleware,
RouteMethod,
} from "./routes/route-types";

// Read-only plugin-route introspection (safe view, no contexts/handlers).
export {
listPluginRoutes,
type PluginRouteInfo,
} from "./routes/route-registry";
48 changes: 47 additions & 1 deletion packages/nextly/src/plugins/routes/collect-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ function thrownCode(fn: () => unknown): string {

function plugin(
name: string,
routes: Array<{ method: "GET" | "POST"; path: string }>,
routes: Array<{
method: "GET" | "POST";
path: string;
mount?: "admin-api";
}>,
enabled?: boolean
): PluginDefinition {
return {
Expand Down Expand Up @@ -62,6 +66,25 @@ describe("collectPluginRoutes", () => {
expect(collected).toHaveLength(2);
});

it("keeps an admin-api route directly under the admin API root", () => {
const [route] = collectPluginRoutes([
plugin("@a/docs", [{ method: "GET", path: "/docs", mount: "admin-api" }]),
]);
expect(route?.fullPath).toBe("/docs");
});

it("rejects an admin-api route that would shadow the REST surface", () => {
expect(
thrownCode(() =>
collectPluginRoutes([
plugin("@a/docs", [
{ method: "GET", path: "/users", mount: "admin-api" },
]),
])
)
).toBe("NEXTLY_ROUTE_COLLISION");
});

it("rejects a path without a leading slash", () => {
expect(
thrownCode(() =>
Expand All @@ -78,3 +101,26 @@ describe("collectPluginRoutes", () => {
).toEqual([]);
});
});

describe("admin-api dynamic-segment guard", () => {
it("rejects an admin-api route whose first segment is dynamic (:resource wildcards every REST path)", () => {
expect(
thrownCode(() =>
collectPluginRoutes([
plugin("@a/grab", [
{ method: "GET", path: "/:resource", mount: "admin-api" },
]),
])
)
).toBe("NEXTLY_ROUTE_COLLISION");
});

it("still allows a dynamic segment AFTER a static first segment", () => {
const routes = collectPluginRoutes([
plugin("@a/docs", [
{ method: "GET", path: "/docs/:pageId", mount: "admin-api" },
]),
]);
expect(routes[0]?.fullPath).toBe("/docs/:pageId");
});
});
42 changes: 38 additions & 4 deletions packages/nextly/src/plugins/routes/collect-routes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { listAdminRestOperations } from "../../route-handler/admin-rest-descriptors";
import type { PluginDefinition } from "../plugin-context";

import { routeCollisionError, routeInvalidPathError } from "./route-error";
Expand All @@ -10,25 +11,44 @@ export interface CollectedRoute {
method: PluginRoute["method"];
/** The plugin-declared path (within its namespace). */
path: string;
/** Namespaced path: `/plugins/<pluginName><path>`. */
/** Match path under the admin API root (namespaced, or the path for admin-api mounts). */
fullPath: string;
route: PluginRoute;
}

/**
* First path segments an `admin-api` mounted route may not use: every system
* REST resource (derived live from the admin REST seam, so it stays current as
* the surface grows) plus the plugin namespace itself, the media sub-mount, and
* the dev-only branches. A plugin route matching BEFORE the REST router would
* shadow the built-in handler, so this is refused at boot.
*/
function reservedAdminApiSegments(): Set<string> {
const segments = new Set<string>(["plugins", "media", "_dev", "dev-reload"]);
for (const op of listAdminRestOperations()) {
const first = op.path.split("/").filter(Boolean)[0];
if (first) segments.add(first);
}
return segments;
}

/**
* Pure fold of every ENABLED plugin's `contributes.routes` into namespaced,
* collision-checked routes. Disabled plugins (`enabled: false`) skip
* behavior — including routes — while their schema is still applied.
*
* Throws {@link routeInvalidPathError} for a path without a leading slash and
* {@link routeCollisionError} when two routes share a `(method, full path)`.
* Throws {@link routeInvalidPathError} for a path without a leading slash,
* {@link routeCollisionError} when two routes share a `(method, full path)`, and
* a collision error when an `admin-api` mounted route's first segment names a
* system REST resource.
*/
export function collectPluginRoutes(
plugins: PluginDefinition[]
): CollectedRoute[] {
const collected: CollectedRoute[] = [];
// Tracks the first owner of each (method, fullPath) for collision reporting.
const seen = new Map<string, string>();
const reserved = reservedAdminApiSegments();

for (const plugin of plugins) {
if (plugin.enabled === false) continue;
Expand All @@ -39,7 +59,21 @@ export function collectPluginRoutes(
if (!route.path.startsWith("/")) {
throw routeInvalidPathError(plugin.name, route.path);
}
const fullPath = pluginRouteFullPath(plugin.name, route.path);
const fullPath = pluginRouteFullPath(plugin.name, route);
// An admin-api route sits ahead of the REST router in dispatch order, so
// neither a system-resource first segment NOR a dynamic one may lead its
// path: `:resource` matches any segment in the registry, which would
// wildcard-shadow the built-in surface the same as a literal name.
const firstSegment = route.path.split("/").filter(Boolean)[0] ?? "";
if (
route.mount === "admin-api" &&
(firstSegment.startsWith(":") || reserved.has(firstSegment))
) {
throw routeCollisionError(route.method, fullPath, [
plugin.name,
"admin REST surface",
]);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const key = `${route.method} ${fullPath}`;
const existingOwner = seen.get(key);
if (existingOwner !== undefined) {
Expand Down
Loading
Loading