Skip to content

Commit

Permalink
Feat/category enhancements (#443)
Browse files Browse the repository at this point in the history
* Catalog enhancements

* Add filter

* add generic

* pass generic

* add to sidebar

* allow to filter items

* better typing

* Fix plugin code generation

* Exclude from standalone build

---------

Co-authored-by: Daniel Lehr <[email protected]>
  • Loading branch information
mosch and dan-lee authored Jan 8, 2025
1 parent 5bc138e commit 9efd48a
Show file tree
Hide file tree
Showing 9 changed files with 208 additions and 97 deletions.
102 changes: 102 additions & 0 deletions docs/pages/configuration/api-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
---
title: API Catalog
sidebar_icon: book-open
---

If you're dealing with multiple APIs and multiple OpenAPI files the API Catalog comes in handy. It creates an overview over all your APIs and lets you organize them in Catagories and Tags.

## Enable API Catalog

The first step to enable the API Catalog, you need to add a `catalog` object to your Zudoku configuration file.

```js
const config = {
// ...
catalog: {
navigationId: "catalog",
label: "API Catalog",
},
// ...
};
```

You can then add your APIs to the catalog by adding the `categories` property to your API configuration.

```js
const config = {
// ...
apis: [
// ...
{
type: "file",
input: "./operational.json",
navigationId: "api-operational",
categories: [{ label: "General", tags: ["Operational"] }],
},
{
type: "file",
input: "./enduser.json",
navigationId: "api-enduser",
categories: [{ label: "General", tags: ["End-User"] }],
},
{
type: "file",
input: "./openapi.json",
navigationId: "api-auth",
categories: [{ label: "Other", tags: ["Authentication"] }],
},
// ...
],
// ...
};
```

## Advanced Configuration

### Select APIs to show in the catalog

You can select which APIs are shown in the catalog by using the `items` property. The `items` property is an array of navigation IDs of the APIs you want to show in the catalog.

```js
const config = {
// ...
catalog: {
navigationId: "catalog",
label: "API Catalog",
// Only show the operational API in the catalog
items: ["api-operational"],
},
apis: [
// ...
{
type: "file",
input: "./operational.json",
navigationId: "api-operational",
categories: [{ label: "General", tags: ["Operational"] }],
},
{
type: "file",
input: "./enduser.json",
navigationId: "api-enduser",
categories: [{ label: "General", tags: ["End-User"] }],
},
],
// ...
};
```

### Adding authentication & filter items for user

You can filter which APIs are shown in the catalog by using the `filter` property.

```js
const config = {
// ...
catalog: {
navigationId: "catalog",
label: "API Catalog",
filterItems: (item, { auth: AuthState }) => ,
},
// ...
};
```
18 changes: 2 additions & 16 deletions docs/pages/configuration/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,28 +33,14 @@ If you are using a URL to reference your OpenAPI document, you may need to ensur

If you have a local OpenAPI document that you want to use, you import it into your Zudoku configuration file using a standard `import` statement.

**For JSON files:**
**Configure like so:**

```ts
const config = {
// ...
apis: {
type: "file",
input: "./openapi.json",
navigationId: "api",
},
// ...
};
```

**For YAML files:**

```ts
const config = {
// ...
apis: {
type: "file",
input: "./openapi.yaml",
input: "./openapi.json", // Supports JSON and YAML files (ex. openapi.yaml)
navigationId: "api",
},
// ...
Expand Down
1 change: 1 addition & 0 deletions docs/zudoku.config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const config: ZudokuConfig = {
link: "configuration/overview",
items: [
"configuration/api-reference",
"configuration/api-catalog",
"configuration/navigation",
"configuration/search",
"configuration/authentication",
Expand Down
15 changes: 5 additions & 10 deletions examples/godzilla-inc/zudoku.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,11 @@ const config: ZudokuConfig = {
],
redirects: [{ from: "/", to: "/general" }],
sidebar: {
general: [
"general",
{ label: "Configuration", id: "general/configuration" },
{ label: "Authentication", id: "general/authentication" },
{ label: "Search", id: "general/search" },
],
general: ["general"],
},
catalog: {
catalogs: {
navigationId: "catalog",
label: "Foo",
label: "API Catalog",
},
apis: [
{
Expand All @@ -35,13 +30,13 @@ const config: ZudokuConfig = {
type: "file",
input: "./schema/logistics.json",
navigationId: "api-logistics",
categories: [{ label: "General", tags: ["Operational"] }],
categories: [{ label: "Non-General", tags: ["Operational"] }],
},
{
type: "file",
input: "./schema/destructive.json",
navigationId: "api-destructive",
categories: [{ label: "General", tags: ["End-User"] }],
categories: [{ label: "Non-General", tags: ["End-User"] }],
},
],
docs: {
Expand Down
3 changes: 2 additions & 1 deletion packages/zudoku/src/config/validators/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ const ApiCatalogSchema = z.object({
navigationId: z.string(),
label: z.string(),
items: z.array(z.string()).optional(),
filterItems: z.function().args(z.any()).returns(z.any()),
});

/**
Expand All @@ -307,7 +308,7 @@ export const CommonConfigSchema = z.object({
search: SearchSchema,
docs: z.union([DocsConfigSchema, z.array(DocsConfigSchema)]),
apis: z.union([ApiSchema, z.array(ApiSchema)]),
catalog: z.union([ApiCatalogSchema, z.array(ApiCatalogSchema)]),
catalogs: z.union([ApiCatalogSchema, z.array(ApiCatalogSchema)]),
apiKeys: ApiKeysSchema,
redirects: z.array(Redirect),
sitemap: SiteMapSchema,
Expand Down
8 changes: 5 additions & 3 deletions packages/zudoku/src/lib/authentication/providers/openid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import { useAuthState, UserProfile } from "../state.js";

const CODE_VERIFIER_KEY = "code-verifier";

interface TokenState {
export interface OpenIdProviderData {
accessToken: string;
idToken?: string;
refreshToken?: string;
expiresOn: Date;
tokenType: string;
Expand Down Expand Up @@ -101,9 +102,10 @@ export class OpenIDAuthenticationProvider implements AuthenticationProvider {
throw new AuthorizationError("No expires_in in response");
}

const tokens: TokenState = {
const tokens: OpenIdProviderData = {
accessToken: response.access_token,
refreshToken: response.refresh_token,
idToken: response.id_token,
expiresOn: new Date(Date.now() + response.expires_in * 1000),
tokenType: response.token_type,
};
Expand Down Expand Up @@ -201,7 +203,7 @@ export class OpenIDAuthenticationProvider implements AuthenticationProvider {
if (!providerData) {
throw new AuthorizationError("User is not authenticated");
}
const tokenState = providerData as TokenState;
const tokenState = providerData as OpenIdProviderData;

if (new Date(tokenState.expiresOn) < new Date()) {
if (!tokenState.refreshToken) {
Expand Down
119 changes: 59 additions & 60 deletions packages/zudoku/src/lib/plugins/api-catalog/Catalog.tsx
Original file line number Diff line number Diff line change
@@ -1,101 +1,103 @@
import slugify from "@sindresorhus/slugify";
import { Fragment } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { Head, Link } from "zudoku/components";
import { useAuthState } from "../../authentication/state.js";
import { Markdown } from "../../components/Markdown.js";
import { useExposedProps } from "../../util/useExposedProps.js";
import type { ApiCatalogItem, CatalogCategory } from "./index.js";
import { cn } from "../../util/cn.js";
import type { ApiCatalogPluginOptions } from "./index.js";

const getKey = (category: string, tag: string) => slugify(`${category}-${tag}`);

export const Catalog = ({
items,
filterCatalogItems = (items) => items,
categories,
label = "API Library",
}: {
label: string;
items: ApiCatalogItem[];
categories: CatalogCategory[];
}) => {
const { searchParams, setSearchParams } = useExposedProps();
}: Omit<ApiCatalogPluginOptions, "navigationId">) => {
const [searchParams, setSearchParams] = useSearchParams();
const activeCategory = searchParams.get("category");
const auth = useAuthState();

const catalogItems = useSuspenseQuery({
queryFn: () => filterCatalogItems(items, { auth }),
queryKey: ["catalogItems", auth],
});

return (
<section className="pt-[--padding-content-top] pb-[--padding-content-bottom]">
<Head>
<title>{label}</title>
</Head>
<div className="grid grid-cols-12 gap-12">
<div className="flex flex-col gap-4 col-span-3 px-4 not-prose sticky top-48">
<div className="justify-between">
{categories.map((category, idx) => (
<Fragment key={category.label}>
<div className="flex flex-col gap-4 col-span-3 not-prose sticky top-48">
<div className="max-w-[--side-nav-width] flex flex-col gap-4 justify-between">
{categories?.map((category, idx) => (
<div key={category.label}>
<div className="flex justify-between mb-2.5">
<span className="font-medium text-sm">{category.label}</span>
{idx === 0 && activeCategory && (
<button
type="button"
className="text-end text-sm mr-8 text-foreground/60 hover:text-foreground"
className="text-end text-sm text-foreground/60 hover:text-foreground"
onClick={() => setSearchParams({})}
>
Clear
</button>
)}
</div>
<ul className="space-y-1 [&>li]:py-2">
{Array.from(
new Set(
category.tags
.map((tag) => ({
tag,
count: items.filter((api) =>
api.categories.find((c) => c.tags.includes(tag)),
).length,
}))
.map(({ tag, count }) => (
<li
key={slugify(category.label + " " + tag)}
className={`flex px-4 rounded-lg -translate-x-4 justify-between text-sm cursor-pointer hover:text-primary transition ${
slugify(tag) === activeCategory
? "font-medium bg-border/30 rounded"
: ""
}`}
onClick={() =>
setSearchParams({
category: slugify(category.label + " " + tag),
})
}
{category.tags
.map((tag) => ({
tag,
count: items.filter((api) =>
api.categories.find((c) => c.tags.includes(tag)),
).length,
}))
.map(({ tag, count }) => {
const slug = getKey(category.label, tag);
const isActive = slug === activeCategory;

return (
<li
key={slug}
className={cn(
"flex rounded-lg justify-between text-sm cursor-pointer hover:text-primary transition px-[--padding-nav-item] -mx-[--padding-nav-item]",
isActive && "bg-border/30 rounded",
)}
onClick={() => setSearchParams({ category: slug })}
>
<span>{tag}</span>
<span
className={cn(
"flex items-center justify-center border rounded-md w-8 text-xs font-semibold",
isActive &&
"bg-primary border-primary text-primary-foreground",
)}
>
<span>{tag}</span>
<span
className={`flex items-center justify-center border rounded-md w-8 text-xs font-semibold ${
slugify(tag) === activeCategory
? "bg-primary border-primary text-primary-foreground"
: ""
}`}
>
{count}
</span>
</li>
)),
),
)}
{count}
</span>
</li>
);
})}
</ul>
</Fragment>
</div>
))}
</div>
</div>
<div className="col-span-9">
<h3 className="mt-0 text-2xl font-bold mb-4">{label}</h3>

<div className="grid grid-cols-2 gap-4">
{items
{catalogItems.data
.filter(
(api) =>
!activeCategory ||
api.categories.find((c) =>
c.tags.find(
(t) => slugify(c.label + " " + t) === activeCategory,
),
c.tags.find((t) => getKey(c.label, t) === activeCategory),
),
)
.map((api, i) => (
.map((api) => (
<Link
to={{
pathname: `/${api.path}`,
Expand All @@ -104,10 +106,7 @@ export const Catalog = ({
className="no-underline hover:!text-foreground"
key={api.path}
>
<div
className="border h-full rounded p-4 flex flex-col gap-2 cursor-pointer hover:bg-border/20 font-normal"
key={i}
>
<div className="border h-full rounded p-4 flex flex-col gap-2 cursor-pointer hover:bg-border/20 font-normal">
<span className="font-semibold">{api.label}</span>
<Markdown
className="text-sm whitespace-pre-wrap mb-6 line-clamp-2"
Expand Down
Loading

0 comments on commit 9efd48a

Please sign in to comment.