This commit is contained in:
Jordi Enric
2026-02-08 13:02:02 +01:00
parent 478390ff0d
commit 948244ce2f
24 changed files with 3587 additions and 590 deletions
+40
View File
@@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+68
View File
@@ -0,0 +1,68 @@
# Zenblog API Demo
This is a demo Next.js application that showcases the Zenblog API endpoints using the `zenblog` npm package.
## Features
- **Posts List**: Browse all posts with pagination and filtering by category, tags, or author
- **Individual Post**: View full post content with HTML rendering
- **Categories**: Browse all available categories
- **Tags**: Browse all available tags
- **Authors**: View all authors and their posts
## Setup
1. Make sure you have the API server running on `localhost:8082`
2. Install dependencies from the root:
```bash
npm install
```
3. Build the zenblog package:
```bash
npm run build:zenblog
```
4. Update `.env.local` with your blog ID (already configured for testing)
5. Run the demo app:
```bash
npm run dev
```
The app will run on `http://localhost:3001`
## Configuration
The app is configured via environment variables in `.env.local`:
- `NEXT_PUBLIC_BLOG_ID`: Default Zenblog blog ID (optional, defaults to `53a970ef-cc74-40ac-ac53-c322cd4848cb`)
- `NEXT_PUBLIC_API_URL`: The API URL (defaults to `http://localhost:8082/api/public` for local testing)
### Changing Blog ID at Runtime
You can test different blog IDs without changing the code:
1. Click the "Change" button in the top right corner of the navigation
2. Enter a new blog ID
3. Click "Apply" to use the new blog ID
4. Click "Reset" to go back to the default blog ID
The blog ID is persisted in the URL as a query parameter (`?blogId=...`), so you can bookmark or share links with specific blog IDs.
## Pages
- `/` - Home page with links to all sections
- `/posts` - List of all posts with filtering options
- `/posts/[slug]` - Individual post page
- `/categories` - List of all categories
- `/tags` - List of all tags
- `/authors` - List of all authors
- `/authors/[slug]` - Individual author page with their posts
## API Endpoints Tested
This demo exercises all public API endpoints:
- `GET /blogs/:blogId/posts` - List posts (with filtering)
- `GET /blogs/:blogId/posts/:slug` - Get single post
- `GET /blogs/:blogId/categories` - List categories
- `GET /blogs/:blogId/tags` - List tags
- `GET /blogs/:blogId/authors` - List authors
- `GET /blogs/:blogId/authors/:slug` - Get single author
+111
View File
@@ -0,0 +1,111 @@
import Link from "next/link";
import { getZenblogClient } from "@/lib/zenblog";
import { notFound } from "next/navigation";
export default async function AuthorPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ blogId?: string }>;
}) {
const { slug } = await params;
const { blogId } = await searchParams;
const zenblog = getZenblogClient(blogId);
const withBlogId = (url: string) => {
if (!blogId) return url;
return `${url}?blogId=${blogId}`;
};
try {
const [authorResponse, postsResponse] = await Promise.all([
zenblog.authors.get({ slug }, { cache: "no-store" }),
zenblog.posts.list({ author: slug, cache: "no-store" }),
]);
const author = authorResponse.data;
const posts = postsResponse.data;
return (
<div className="max-w-4xl mx-auto">
<Link
href={withBlogId("/authors")}
className="text-blue-600 hover:text-blue-800 mb-4 inline-block"
>
Back to authors
</Link>
<div className="bg-white p-8 rounded-lg shadow mb-8">
<div className="flex flex-col md:flex-row gap-6 items-start">
{author.image_url && (
<img
src={author.image_url}
alt={author.name}
className="w-32 h-32 rounded-full object-cover"
/>
)}
<div className="flex-1">
<h1 className="text-3xl font-bold mb-2">{author.name}</h1>
{author.bio && (
<p className="text-gray-600 mb-4">{author.bio}</p>
)}
<div className="flex gap-4 text-sm">
{author.twitter_url && (
<a
href={author.twitter_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800"
>
Twitter
</a>
)}
{author.website_url && (
<a
href={author.website_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800"
>
Website
</a>
)}
</div>
</div>
</div>
</div>
<h2 className="text-2xl font-bold mb-4">Posts by {author.name}</h2>
<div className="grid gap-6">
{posts.map((post) => (
<div key={post.slug} className="bg-white p-6 rounded-lg shadow">
<h3 className="text-xl font-semibold mb-2">
<Link
href={withBlogId(`/posts/${post.slug}`)}
className="hover:text-blue-600"
>
{post.title}
</Link>
</h3>
{post.excerpt && (
<p className="text-gray-600 mb-3">{post.excerpt}</p>
)}
<div className="flex items-center gap-4 text-sm text-gray-500">
<time>{new Date(post.published_at).toLocaleDateString()}</time>
{post.category && (
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
{post.category.name}
</span>
)}
</div>
</div>
))}
</div>
</div>
);
} catch (error) {
console.error("Error fetching author:", error);
notFound();
}
}
+51
View File
@@ -0,0 +1,51 @@
import Link from "next/link";
import { getZenblogClient } from "@/lib/zenblog";
export default async function AuthorsPage({
searchParams,
}: {
searchParams: Promise<{ blogId?: string }>;
}) {
const { blogId } = await searchParams;
const zenblog = getZenblogClient(blogId);
const withBlogId = (url: string) => {
if (!blogId) return url;
return `${url}?blogId=${blogId}`;
};
const response = await zenblog.authors.list();
const authors = response.data;
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Authors</h1>
<p className="text-gray-600">Browse all blog authors</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{authors.map((author) => (
<Link
key={author.slug}
href={withBlogId(`/authors/${author.slug}`)}
className="bg-white p-6 rounded-lg shadow hover:shadow-lg transition-shadow"
>
{author.image_url && (
<img
src={author.image_url}
alt={author.name}
className="w-24 h-24 rounded-full mx-auto mb-4 object-cover"
/>
)}
<h2 className="text-xl font-semibold text-center mb-2">
{author.name}
</h2>
{author.bio && (
<p className="text-gray-600 text-sm text-center line-clamp-3">
{author.bio}
</p>
)}
</Link>
))}
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import Link from "next/link";
import { getZenblogClient } from "@/lib/zenblog";
export default async function CategoriesPage({
searchParams,
}: {
searchParams: Promise<{ blogId?: string }>;
}) {
const { blogId } = await searchParams;
const zenblog = getZenblogClient(blogId);
const withBlogId = (url: string) => {
if (!blogId) return url;
return `${url}${url.includes("?") ? "&" : "?"}blogId=${blogId}`;
};
const response = await zenblog.categories.list();
const categories = response.data;
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Categories</h1>
<p className="text-gray-600">Browse posts by category</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categories.map((category) => (
<Link
key={category.slug}
href={withBlogId(`/posts?category=${category.slug}`)}
className="bg-white p-6 rounded-lg shadow hover:shadow-lg transition-shadow"
>
<h2 className="text-xl font-semibold text-blue-600">
{category.name}
</h2>
</Link>
))}
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
const DEFAULT_BLOG_ID = "53a970ef-cc74-40ac-ac53-c322cd4848cb";
export function BlogIdSelector() {
const router = useRouter();
const searchParams = useSearchParams();
const [blogId, setBlogId] = useState(
searchParams.get("blogId") || DEFAULT_BLOG_ID
);
const [isEditing, setIsEditing] = useState(false);
const [tempBlogId, setTempBlogId] = useState(blogId);
useEffect(() => {
const urlBlogId = searchParams.get("blogId");
if (urlBlogId && urlBlogId !== blogId) {
setBlogId(urlBlogId);
setTempBlogId(urlBlogId);
}
}, [searchParams]);
const handleSave = () => {
if (tempBlogId.trim()) {
setBlogId(tempBlogId.trim());
// Update URL with new blogId
const newUrl = new URL(window.location.href);
newUrl.searchParams.set("blogId", tempBlogId.trim());
router.push(newUrl.pathname + newUrl.search);
setIsEditing(false);
}
};
const handleReset = () => {
setTempBlogId(DEFAULT_BLOG_ID);
setBlogId(DEFAULT_BLOG_ID);
// Remove blogId from URL
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete("blogId");
router.push(newUrl.pathname + newUrl.search);
setIsEditing(false);
};
return (
<div className="flex items-center gap-2">
{!isEditing ? (
<>
<span className="text-xs text-gray-500">
Blog: {blogId.slice(0, 8)}...
</span>
<button
onClick={() => setIsEditing(true)}
className="text-xs text-blue-600 hover:text-blue-800 font-medium"
>
Change
</button>
</>
) : (
<div className="flex items-center gap-2 p-2 bg-white border rounded-lg shadow-lg absolute top-12 right-4 z-50">
<div className="flex flex-col gap-2">
<label className="text-xs font-medium text-gray-700">
Blog ID:
</label>
<input
type="text"
value={tempBlogId}
onChange={(e) => setTempBlogId(e.target.value)}
className="px-2 py-1 text-xs border rounded w-64"
placeholder="Enter blog ID"
/>
<div className="flex gap-2">
<button
onClick={handleSave}
className="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
>
Apply
</button>
<button
onClick={handleReset}
className="px-3 py-1 text-xs bg-gray-200 text-gray-700 rounded hover:bg-gray-300"
>
Reset
</button>
<button
onClick={() => {
setIsEditing(false);
setTempBlogId(blogId);
}}
className="px-3 py-1 text-xs bg-gray-100 text-gray-600 rounded hover:bg-gray-200"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+67
View File
@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Suspense } from "react";
import { BlogIdSelector } from "./components/BlogIdSelector";
import "./globals.css";
export const metadata: Metadata = {
title: "Zenblog API Demo",
description: "Demo app showcasing the Zenblog API",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="antialiased bg-gray-50">
<nav className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16 items-center relative">
<div className="flex space-x-8">
<Link
href="/"
className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-900"
>
Home
</Link>
<Link
href="/posts"
className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-700 hover:text-gray-900"
>
Posts
</Link>
<Link
href="/categories"
className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-700 hover:text-gray-900"
>
Categories
</Link>
<Link
href="/tags"
className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-700 hover:text-gray-900"
>
Tags
</Link>
<Link
href="/authors"
className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-700 hover:text-gray-900"
>
Authors
</Link>
</div>
<Suspense fallback={null}>
<BlogIdSelector />
</Suspense>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>
</body>
</html>
);
}
+60
View File
@@ -0,0 +1,60 @@
export default function Home() {
return (
<div className="space-y-6">
<h1 className="text-4xl font-bold text-gray-900">Zenblog API Demo</h1>
<p className="text-lg text-gray-600">
This is a demo application showcasing the Zenblog API endpoints.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-2">Posts</h2>
<p className="text-gray-600 mb-4">
Browse all blog posts with filtering options
</p>
<a
href="/posts"
className="text-blue-600 hover:text-blue-800 font-medium"
>
View Posts
</a>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-2">Categories</h2>
<p className="text-gray-600 mb-4">
View all available categories
</p>
<a
href="/categories"
className="text-blue-600 hover:text-blue-800 font-medium"
>
View Categories
</a>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-2">Tags</h2>
<p className="text-gray-600 mb-4">
Browse all tags used in blog posts
</p>
<a
href="/tags"
className="text-blue-600 hover:text-blue-800 font-medium"
>
View Tags
</a>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-2">Authors</h2>
<p className="text-gray-600 mb-4">
View all blog authors and their posts
</p>
<a
href="/authors"
className="text-blue-600 hover:text-blue-800 font-medium"
>
View Authors
</a>
</div>
</div>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import Link from "next/link";
import { getZenblogClient } from "@/lib/zenblog";
import { notFound } from "next/navigation";
export default async function PostPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ blogId?: string }>;
}) {
const { slug } = await params;
const { blogId } = await searchParams;
const zenblog = getZenblogClient(blogId);
const withBlogId = (url: string) => {
if (!blogId) return url;
return `${url}?blogId=${blogId}`;
};
try {
const response = await zenblog.posts.get({ slug }, { cache: "no-store" });
const post = response.data;
return (
<div className="max-w-4xl mx-auto">
<Link
href={withBlogId("/posts")}
className="text-blue-600 hover:text-blue-800 mb-4 inline-block"
>
Back to posts
</Link>
<article className="bg-white p-8 rounded-lg shadow">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center gap-4 mb-6 text-gray-600">
{post.authors.map((author) => (
<Link
key={author.slug}
href={withBlogId(`/authors/${author.slug}`)}
className="hover:text-blue-600"
>
By {author.name}
</Link>
))}
<span></span>
<time>{new Date(post.published_at).toLocaleDateString()}</time>
</div>
{post.cover_image && (
<img
src={post.cover_image}
alt={post.title}
className="w-full h-auto rounded-lg mb-6"
/>
)}
{post.excerpt && (
<p className="text-xl text-gray-600 mb-6 italic">
{post.excerpt}
</p>
)}
<div className="flex flex-wrap gap-2 mb-6">
{post.category && (
<Link
href={withBlogId(`/posts?category=${post.category.slug}`)}
className="px-3 py-1 bg-blue-100 text-blue-700 rounded"
>
{post.category.name}
</Link>
)}
{post.tags.map((tag) => (
<Link
key={tag.slug}
href={withBlogId(`/posts?tags=${tag.slug}`)}
className="px-3 py-1 bg-gray-100 text-gray-700 rounded"
>
#{tag.name}
</Link>
))}
</div>
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: post.html_content }}
/>
</article>
</div>
);
} catch (error) {
console.error("Error fetching post:", error);
notFound();
}
}
+139
View File
@@ -0,0 +1,139 @@
import Link from "next/link";
import { getZenblogClient } from "@/lib/zenblog";
export default async function PostsPage({
searchParams,
}: {
searchParams: Promise<{
category?: string;
tags?: string;
author?: string;
limit?: string;
offset?: string;
blogId?: string;
}>;
}) {
const params = await searchParams;
const limit = params.limit ? parseInt(params.limit) : 10;
const offset = params.offset ? parseInt(params.offset) : 0;
const blogId = params.blogId;
const zenblog = getZenblogClient(blogId);
const response = await zenblog.posts.list({
limit,
offset,
category: params.category,
tags: params.tags?.split(","),
author: params.author,
cache: "no-store",
});
const posts = response.data;
const blogIdParam = blogId ? `blogId=${blogId}` : "";
const withBlogId = (url: string) => {
if (!blogId) return url;
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}${blogIdParam}`;
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Blog Posts</h1>
<div className="text-sm text-gray-500">
Showing {offset + 1} - {Math.min(offset + limit, response.total)} of{" "}
{response.total} posts
</div>
</div>
{params.category && (
<div className="text-sm text-gray-600">
Filtered by category: <strong>{params.category}</strong>
</div>
)}
{params.tags && (
<div className="text-sm text-gray-600">
Filtered by tags: <strong>{params.tags}</strong>
</div>
)}
{params.author && (
<div className="text-sm text-gray-600">
Filtered by author: <strong>{params.author}</strong>
</div>
)}
<div className="grid gap-6">
{posts.map((post) => (
<div key={post.slug} className="bg-white p-6 rounded-lg shadow">
<h2 className="text-2xl font-semibold mb-2">
<Link
href={withBlogId(`/posts/${post.slug}`)}
className="hover:text-blue-600"
>
{post.title}
</Link>
</h2>
{post.excerpt && (
<p className="text-gray-600 mb-4">{post.excerpt}</p>
)}
<div className="flex flex-wrap gap-2 mb-3">
{post.category && (
<Link
href={withBlogId(`/posts?category=${post.category.slug}`)}
className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-sm"
>
{post.category.name}
</Link>
)}
{post.tags.map((tag) => (
<Link
key={tag.slug}
href={withBlogId(`/posts?tags=${tag.slug}`)}
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm"
>
#{tag.name}
</Link>
))}
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
{post.authors.map((author) => (
<Link
key={author.slug}
href={withBlogId(`/authors/${author.slug}`)}
className="hover:text-blue-600"
>
By {author.name}
</Link>
))}
<span>{new Date(post.published_at).toLocaleDateString()}</span>
</div>
</div>
))}
</div>
<div className="flex justify-between items-center pt-6">
{offset > 0 && (
<Link
href={withBlogId(
`/posts?offset=${Math.max(0, offset - limit)}&limit=${limit}${params.category ? `&category=${params.category}` : ""}${params.tags ? `&tags=${params.tags}` : ""}${params.author ? `&author=${params.author}` : ""}`
)}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Previous
</Link>
)}
{offset + limit < response.total && (
<Link
href={withBlogId(
`/posts?offset=${offset + limit}&limit=${limit}${params.category ? `&category=${params.category}` : ""}${params.tags ? `&tags=${params.tags}` : ""}${params.author ? `&author=${params.author}` : ""}`
)}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 ml-auto"
>
Next
</Link>
)}
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import Link from "next/link";
import { getZenblogClient } from "@/lib/zenblog";
export default async function TagsPage({
searchParams,
}: {
searchParams: Promise<{ blogId?: string }>;
}) {
const { blogId } = await searchParams;
const zenblog = getZenblogClient(blogId);
const withBlogId = (url: string) => {
if (!blogId) return url;
return `${url}${url.includes("?") ? "&" : "?"}blogId=${blogId}`;
};
const response = await zenblog.tags.list();
const tags = response.data;
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Tags</h1>
<p className="text-gray-600">Browse posts by tag</p>
<div className="flex flex-wrap gap-3">
{tags.map((tag) => (
<Link
key={tag.slug}
href={withBlogId(`/posts?tags=${tag.slug}`)}
className="px-4 py-2 bg-white rounded-lg shadow hover:shadow-lg transition-shadow hover:bg-blue-50"
>
<span className="text-gray-700">#{tag.name}</span>
</Link>
))}
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { createZenblogClient } from "zenblog";
const DEFAULT_BLOG_ID = "53a970ef-cc74-40ac-ac53-c322cd4848cb";
if (!process.env.NEXT_PUBLIC_API_URL) {
throw new Error("NEXT_PUBLIC_API_URL is required");
}
export function getZenblogClient(blogId?: string) {
return createZenblogClient({
blogId: blogId || process.env.NEXT_PUBLIC_BLOG_ID || DEFAULT_BLOG_ID,
_url: process.env.NEXT_PUBLIC_API_URL,
_debug: true,
});
}
// Default client for convenience
export const zenblog = getZenblogClient();
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+28
View File
@@ -0,0 +1,28 @@
{
"name": "demo",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.1.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zenblog": "*"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.20",
"eslint": "^8",
"eslint-config-next": "^15.1.6",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
+13
View File
@@ -0,0 +1,13 @@
import type { Config } from "tailwindcss";
export default {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {},
},
plugins: [],
} satisfies Config;
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+3 -13
View File
@@ -2,7 +2,6 @@
import { ZendoLogo } from "@/components/ZendoLogo";
import Link from "next/link";
import { SidebarLink, SidebarTitle } from "../ui/sidebar";
import { endpoints } from "app/api/public/[...route]/public-api.constants";
import {
Drawer,
DrawerContent,
@@ -33,18 +32,9 @@ export default function DocsLayout({
{/* <SidebarLink href="/docs/typescript">TypeScript client</SidebarLink> */}
<SidebarLink href="/docs/nextjs">Next.js</SidebarLink>
<SidebarTitle>API Reference</SidebarTitle>
{endpoints.map((endpoint) => (
<SidebarLink
className="space-x-2"
key={endpoint.id}
href={`/docs/api/${endpoint.id}`}
>
<span className="font-mono text-xs font-medium text-slate-500">
{endpoint.method}
</span>
<span>{endpoint.title}</span>
</SidebarLink>
))}
<SidebarLink href="/api/public/docs">
API Documentation
</SidebarLink>
</div>
);
@@ -1,32 +1,62 @@
import { Context } from "hono";
import { Context, TypedResponse } from "hono";
import { StatusCode } from "hono/utils/http-status";
import { axiom, AXIOM_DATASETS } from "lib/axiom";
const ERROR_TABLE = "zenblog-errors";
type ErrorItem = {
message: string;
status: StatusCode;
};
const ERROR_MAP: Record<string, ErrorItem> = {
const ERROR_MAP = {
MISSING_BLOG_ID: { message: "No blogId provided", status: 400 },
MISSING_API_KEY: { message: "No API key provided", status: 400 },
MISSING_BLOG_ID_OR_SLUG: { message: "Missing blogId or slug", status: 400 },
NO_POSTS_FOUND: { message: "No posts found", status: 404 },
NO_AUTHORS_FOUND: { message: "No authors found", status: 404 },
INVALID_API_KEY: { message: "Invalid API key", status: 401 },
NO_CATEGORIES_FOUND: { message: "No categories found", status: 404 },
NO_TAGS_FOUND: { message: "No tags found", status: 404 },
AUTHOR_NOT_FOUND: { message: "Author not found", status: 404 },
};
} as const satisfies Record<string, ErrorItem>;
export const throwError = (ctx: Context, error: keyof typeof ERROR_MAP) => {
console.log(`🔴 ${ERROR_MAP[error]?.message}`);
type PublicApiErrorCode = keyof typeof ERROR_MAP;
type BadRequestErrorCode =
| "MISSING_BLOG_ID"
| "MISSING_API_KEY"
| "MISSING_BLOG_ID_OR_SLUG";
type NotFoundErrorCode =
| "NO_POSTS_FOUND"
| "NO_AUTHORS_FOUND"
| "NO_CATEGORIES_FOUND"
| "NO_TAGS_FOUND"
| "AUTHOR_NOT_FOUND";
type UnauthorizedErrorCode = "INVALID_API_KEY";
export function throwError(
ctx: Context,
error: BadRequestErrorCode
): TypedResponse<{ message: string }, 400>;
export function throwError(
ctx: Context,
error: NotFoundErrorCode
): TypedResponse<{ message: string }, 404>;
export function throwError(
ctx: Context,
error: UnauthorizedErrorCode
): TypedResponse<{ message: string }, 401>;
export function throwError(
ctx: Context,
error: PublicApiErrorCode
): TypedResponse<{ message: string }, 400 | 401 | 404> {
const errorItem = ERROR_MAP[error];
console.log(`🔴 ${errorItem.message}`);
axiom.ingest(AXIOM_DATASETS.api, {
error: ERROR_MAP[error]?.message,
error: errorItem.message,
request: ctx.req,
status: ERROR_MAP[error]?.status,
status: errorItem.status,
});
return ctx.json(
{ message: ERROR_MAP[error]?.message },
ERROR_MAP[error]?.status
{ message: errorItem.message },
errorItem.status
);
};
}
@@ -0,0 +1,150 @@
import { z } from "@hono/zod-openapi";
// Base entity schemas
export const TagSchema = z
.object({
name: z.string().openapi({ example: "Technology" }),
slug: z.string().openapi({ example: "technology" }),
})
.openapi("Tag");
export const CategorySchema = z
.object({
name: z.string().openapi({ example: "News" }),
slug: z.string().openapi({ example: "news" }),
})
.openapi("Category");
export const PostAuthorSchema = z
.object({
name: z.string().openapi({ example: "John Doe" }),
slug: z.string().openapi({ example: "john-doe" }),
image_url: z.string().openapi({ example: "https://example.com/avatar.jpg" }),
bio: z.string().optional().openapi({ example: "Software engineer and writer" }),
twitter_url: z.string().optional().openapi({ example: "https://twitter.com/johndoe" }),
website_url: z.string().optional().openapi({ example: "https://johndoe.com" }),
})
.openapi("PostAuthor");
export const AuthorSchema = z
.object({
name: z.string().openapi({ example: "John Doe" }),
slug: z.string().openapi({ example: "john-doe" }),
image_url: z.string().nullable().optional().openapi({ example: "https://example.com/avatar.jpg" }),
bio: z.string().nullable().optional().openapi({ example: "Software engineer and writer" }),
twitter: z.string().nullable().optional().openapi({
example: "https://twitter.com/johndoe",
deprecated: true,
description: "Deprecated. Use twitter_url.",
}),
website: z.string().nullable().optional().openapi({
example: "https://johndoe.com",
deprecated: true,
description: "Deprecated. Use website_url.",
}),
twitter_url: z.string().optional().openapi({ example: "https://twitter.com/johndoe" }),
website_url: z.string().optional().openapi({ example: "https://johndoe.com" }),
})
.openapi("Author");
export const PostSchema = z
.object({
title: z.string().openapi({ example: "My First Blog Post" }),
slug: z.string().openapi({ example: "my-first-blog-post" }),
published_at: z.string().openapi({ example: "2024-01-15T10:30:00Z" }),
excerpt: z.string().optional().openapi({ example: "A brief introduction to the post..." }),
cover_image: z.string().optional().openapi({ example: "https://example.com/cover.jpg" }),
tags: z.array(TagSchema),
category: CategorySchema.nullable(),
authors: z.array(PostAuthorSchema),
})
.openapi("Post");
export const PostWithContentSchema = PostSchema.extend({
html_content: z.string().openapi({ example: "<h1>Hello World</h1><p>This is my first post.</p>" }),
}).openapi("PostWithContent");
// Query parameter schemas
export const PaginationQuerySchema = z.object({
offset: z.string().optional().openapi({
param: { name: "offset", in: "query" },
example: "0",
description: "The offset for pagination"
}),
limit: z.string().optional().openapi({
param: { name: "limit", in: "query" },
example: "30",
description: "The limit for pagination"
}),
});
export const PostsQuerySchema = PaginationQuerySchema.extend({
category: z.string().optional().openapi({
param: { name: "category", in: "query" },
example: "news",
description: "Filter posts by category slug"
}),
tags: z.string().optional().openapi({
param: { name: "tags", in: "query" },
example: "tag1,tag2",
description: "Comma-separated list of tag slugs to filter by"
}),
author: z.string().optional().openapi({
param: { name: "author", in: "query" },
example: "john-doe",
description: "Filter posts by author slug"
}),
});
// Path parameter schemas
export const BlogIdParamSchema = z.object({
blogId: z.string().openapi({
param: { name: "blogId", in: "path" },
example: "53a970ef-cc74-40ac-ac53-c322cd4848cb",
description: "The unique identifier for the blog"
}),
});
export const SlugParamSchema = z.object({
slug: z.string().openapi({
param: { name: "slug", in: "path" },
example: "my-first-post",
description: "The slug identifier"
}),
});
// Response schemas
export const PaginatedResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
z.object({
data: z.array(dataSchema),
total: z.number().optional().openapi({ example: 100 }),
offset: z.number().optional().openapi({ example: 0 }),
limit: z.number().optional().openapi({ example: 30 }),
});
export const PostsResponseSchema = PaginatedResponseSchema(PostSchema).openapi("PostsResponse");
export const CategoriesResponseSchema = PaginatedResponseSchema(CategorySchema).openapi("CategoriesResponse");
export const TagsResponseSchema = PaginatedResponseSchema(TagSchema).openapi("TagsResponse");
export const AuthorsResponseSchema = PaginatedResponseSchema(AuthorSchema).openapi("AuthorsResponse");
export const PostBySlugResponseSchema = z
.object({
data: PostWithContentSchema,
})
.openapi("PostBySlugResponse");
export const AuthorBySlugResponseSchema = z
.object({
data: AuthorSchema,
})
.openapi("AuthorBySlugResponse");
// Error schema
export const ErrorResponseSchema = z
.object({
message: z.string().openapi({ example: "No posts found" }),
})
.openapi("ErrorResponse");
+333 -38
View File
@@ -2,7 +2,7 @@ import { handle } from "hono/vercel";
import { createClient } from "@/lib/server/supabase";
import { logger } from "hono/logger";
import { prettyJSON } from "hono/pretty-json";
import { Hono } from "hono";
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
categories,
postBySlug,
@@ -16,30 +16,82 @@ import { Post, PostWithContent } from "@zenblog/types";
import { throwError } from "./public-api.errors";
import { trackApiUsage } from "lib/axiom";
import { isValidBlogId } from "./public-api.validation";
import {
PostsQuerySchema,
PaginationQuerySchema,
BlogIdParamSchema,
SlugParamSchema,
PostsResponseSchema,
PostBySlugResponseSchema,
CategoriesResponseSchema,
TagsResponseSchema,
AuthorsResponseSchema,
AuthorBySlugResponseSchema,
ErrorResponseSchema,
} from "./public-api.schemas";
const app = new Hono()
.basePath("/api/public")
.use("*", logger())
.use("*", prettyJSON())
.use("*", async (ctx, next) => {
// middleware doesnt get the blogId param
// so we need to get it from the url
const rawBlogId = ctx.req.url.split("/")[6];
const app = new OpenAPIHono().basePath("/api/public");
if (isValidBlogId(rawBlogId)) {
const blogId: string = rawBlogId;
trackApiUsage({
blogId,
event: "api-usage",
timestamp: new Date().toISOString(),
path: ctx.req.url,
});
}
app.use("*", logger());
app.use("*", prettyJSON());
app.use("*", async (ctx, next) => {
// middleware doesnt get the blogId param
// so we need to get it from the url
const rawBlogId = ctx.req.url.split("/")[6];
await next();
});
if (isValidBlogId(rawBlogId)) {
const blogId: string = rawBlogId;
trackApiUsage({
blogId,
event: "api-usage",
timestamp: new Date().toISOString(),
path: ctx.req.url,
});
}
app.get(posts.path, async (c) => {
await next();
});
// Define route: Get posts
const getPostsRoute = createRoute({
method: "get",
path: "/blogs/{blogId}/posts",
request: {
params: BlogIdParamSchema,
query: PostsQuerySchema,
},
responses: {
200: {
content: {
"application/json": {
schema: PostsResponseSchema,
},
},
description: "List of posts for the blog",
},
400: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Invalid blogId provided",
},
404: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "No posts found",
},
},
tags: ["Posts"],
summary: "List posts",
description: "Get a paginated list of published posts for a blog. Supports filtering by category, tags, and author.",
});
app.openapi(getPostsRoute, async (c) => {
const rawBlogId = c.req.param("blogId");
const offset = parseInt(c.req.query("offset") || "0");
const limit = parseInt(c.req.query("limit") || "30");
@@ -141,7 +193,45 @@ app.get(posts.path, async (c) => {
return c.json(res, 200);
});
app.get(postBySlug.path, async (c) => {
// Define route: Get post by slug
const getPostBySlugRoute = createRoute({
method: "get",
path: "/blogs/{blogId}/posts/{slug}",
request: {
params: BlogIdParamSchema.merge(SlugParamSchema),
},
responses: {
200: {
content: {
"application/json": {
schema: PostBySlugResponseSchema,
},
},
description: "Post details with HTML content",
},
400: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Missing blogId or slug",
},
404: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Post not found",
},
},
tags: ["Posts"],
summary: "Get post by slug",
description: "Get a single post by its slug identifier, including full HTML content.",
});
app.openapi(getPostBySlugRoute, async (c) => {
const rawBlogId = c.req.param("blogId");
const slug = c.req.param("slug");
const supabase = createClient();
@@ -211,10 +301,49 @@ app.get(postBySlug.path, async (c) => {
}
}
return c.json({ data: formattedPost });
return c.json({ data: formattedPost }, 200);
});
app.get(categories.path, async (c) => {
// Define route: Get categories
const getCategoriesRoute = createRoute({
method: "get",
path: "/blogs/{blogId}/categories",
request: {
params: BlogIdParamSchema,
query: PaginationQuerySchema,
},
responses: {
200: {
content: {
"application/json": {
schema: CategoriesResponseSchema,
},
},
description: "List of categories for the blog",
},
400: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Invalid blogId provided",
},
404: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "No categories found",
},
},
tags: ["Categories"],
summary: "List categories",
description: "Get a paginated list of all categories for a blog.",
});
app.openapi(getCategoriesRoute, async (c) => {
const rawBlogId = c.req.param("blogId");
const offset = parseInt(c.req.query("offset") || "0");
const limit = parseInt(c.req.query("limit") || "30");
@@ -247,10 +376,49 @@ app.get(categories.path, async (c) => {
limit,
};
return c.json(res);
return c.json(res, 200);
});
app.get(tags.path, async (c) => {
// Define route: Get tags
const getTagsRoute = createRoute({
method: "get",
path: "/blogs/{blogId}/tags",
request: {
params: BlogIdParamSchema,
query: PaginationQuerySchema,
},
responses: {
200: {
content: {
"application/json": {
schema: TagsResponseSchema,
},
},
description: "List of tags for the blog",
},
400: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Invalid blogId provided",
},
404: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "No tags found",
},
},
tags: ["Tags"],
summary: "List tags",
description: "Get a paginated list of all tags for a blog.",
});
app.openapi(getTagsRoute, async (c) => {
const rawBlogId = c.req.param("blogId");
const offset = parseInt(c.req.query("offset") || "0");
const limit = parseInt(c.req.query("limit") || "30");
@@ -283,10 +451,49 @@ app.get(tags.path, async (c) => {
limit,
};
return c.json(res);
return c.json(res, 200);
});
app.get(authors.path, async (c) => {
// Define route: Get authors
const getAuthorsRoute = createRoute({
method: "get",
path: "/blogs/{blogId}/authors",
request: {
params: BlogIdParamSchema,
query: PaginationQuerySchema,
},
responses: {
200: {
content: {
"application/json": {
schema: AuthorsResponseSchema,
},
},
description: "List of authors for the blog",
},
400: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Invalid blogId provided",
},
404: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "No authors found",
},
},
tags: ["Authors"],
summary: "List authors",
description: "Get a paginated list of all authors for a blog.",
});
app.openapi(getAuthorsRoute, async (c) => {
const rawBlogId = c.req.param("blogId");
const offset = parseInt(c.req.query("offset") || "0");
const limit = parseInt(c.req.query("limit") || "30");
@@ -312,17 +519,62 @@ app.get(authors.path, async (c) => {
return throwError(c, "NO_AUTHORS_FOUND");
}
const res: PublicApiResponse<typeof authors> = {
data: authors,
const formattedAuthors =
authors?.map((author) => ({
...author,
twitter_url: author.twitter || undefined,
website_url: author.website || undefined,
})) || [];
const res: PublicApiResponse<typeof formattedAuthors> = {
data: formattedAuthors,
total: count || 0,
offset,
limit,
};
return c.json(res);
return c.json(res, 200);
});
app.get(authorBySlug.path, async (c) => {
// Define route: Get author by slug
const getAuthorBySlugRoute = createRoute({
method: "get",
path: "/blogs/{blogId}/authors/{slug}",
request: {
params: BlogIdParamSchema.merge(SlugParamSchema),
},
responses: {
200: {
content: {
"application/json": {
schema: AuthorBySlugResponseSchema,
},
},
description: "Author details",
},
400: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Missing blogId or slug",
},
404: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Author not found",
},
},
tags: ["Authors"],
summary: "Get author by slug",
description: "Get a single author by their slug identifier.",
});
app.openapi(getAuthorBySlugRoute, async (c) => {
const rawBlogId = c.req.param("blogId");
const slug = c.req.param("slug");
const supabase = createClient();
@@ -344,17 +596,60 @@ app.get(authorBySlug.path, async (c) => {
return throwError(c, "AUTHOR_NOT_FOUND");
}
const normalizedAuthor = {
...author,
image_url: author.image_url || "",
bio: author.bio || "",
website: author.website || "",
twitter: author.twitter || "",
};
return c.json({
data: {
...author,
image_url: author.image_url || "",
bio: author.bio || "",
website: author.website || "",
twitter: author.twitter || "",
...normalizedAuthor,
website_url: normalizedAuthor.website,
twitter_url: normalizedAuthor.twitter,
},
});
}, 200);
});
// OpenAPI documentation endpoint
app.doc("/openapi.json", {
openapi: "3.0.0",
info: {
version: "1.0.0",
title: "Zenblog Public API",
description: "Public API for accessing blog content from Zenblog. Use this API to fetch posts, categories, tags, and authors for your blog.",
},
servers: [
{
url: "https://zenblog.com",
description: "Production server",
},
...(process.env.NODE_ENV === "development"
? [
{
url: "http://localhost:8082",
description: "Development server",
},
]
: []),
],
});
// Scalar API Reference UI
import { apiReference } from "@scalar/hono-api-reference";
app.get(
"/docs",
apiReference({
theme: "purple",
spec: {
url: "/api/public/openapi.json",
},
})
);
export const GET = handle(app);
export const POST = handle(app);
export const PUT = handle(app);
+11 -10
View File
@@ -65,7 +65,7 @@ test("posts limit and offset", async () => {
});
test("posts endpoint filter by category", async () => {
const category = "news";
const category = "capybaras";
const response = await fetch(`${BASE_URL}/posts?category=${category}`);
@@ -79,7 +79,7 @@ test("posts endpoint filter by category", async () => {
});
test("posts endpoint filter by tag", async () => {
const tags = ["random", "test"];
const tags = ["marmots", "capybaras"];
const response = await fetch(`${BASE_URL}/posts?tags=${tags.join(",")}`);
@@ -98,20 +98,21 @@ test("posts endpoint filter by tag", async () => {
test("posts endpoint accepts multiple configuration of query params", async () => {
const queries = [
"category=hiking&tags=random,test",
"tags=test,random&category=hiking",
"tags=random,test&category=hiking&limit=4&offset=0",
"category=capybaras&tags=marmots,capybaras",
"tags=capybaras,marmots&category=capybaras",
"tags=marmots,capybaras&category=capybaras&limit=4&offset=0",
"limit=4&offset=2",
"tags=random,test",
"category=hiking",
"tags=marmots,capybaras",
"category=capybaras",
];
queries.forEach(async (query) => {
for (const query of queries) {
const response = await fetch(`${BASE_URL}/posts?${query}`);
expect(response.status).toBe(200);
const data = await response.json();
const parsedData = postsResponseSchema.parse(data);
expect(parsedData.data.length).toBeGreaterThan(0);
});
}
});
test("posts endpoint filters by author correctly", async () => {
@@ -136,7 +137,7 @@ test("authors endpoint returns correct data", async () => {
});
test("postBySlug endpoint returns correct data with tags", async () => {
const slug = "test";
const slug = "reginald-lord-of-the-highlands";
const response = await fetch(`${BASE_URL}/posts/${slug}`);
const data = await response.json();
+2134 -517
View File
File diff suppressed because it is too large Load Diff