mirror of
https://github.com/jordienr/zenblog.git
synced 2026-08-24 10:14:46 -05:00
openapi
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Generated
+2134
-517
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user