@molecule/api-resource-article
API resource · articles · API (Node) · v1.1.2 · Apache-2.0
Articles and blog posts: unique slugs, tags, favorites, a followed-authors feed, and filtered, paginated lists
npm install @molecule/api-resource-articleHow it works
@molecule/api-resource-article is an API resource: the routes, validation and storage for articles, built on the database and auth cores so it runs on whichever providers your app has bonded.
import { routes, requestHandlerMap } from '@molecule/api-resource-article'
// Wire routes into your Express app via mlcl inject:
// POST /articles create (signed in)
// GET /articles ?tag= &author= &favoritedBy= &limit= &offset=
// GET /articles/feed authors the signed-in user follows
// GET /articles/tags every tag in use, most-used first (?limit= caps it)
// GET /articles/:slug
// PUT /articles/:slug author only; a new title gives a new slug
// DELETE /articles/:slug author only
// POST /articles/:slug/favorite idempotent
// DELETE /articles/:slug/favorite idempotentWorks with: @molecule/api-resource-follow, @molecule/api-resource-reaction, @molecule/api-slug
Reference
Articles and blog posts for molecule.dev.
Articles with unique slugs, tags, favorites, a feed of the authors a user
follows, and lists filtered by tag, author and who favorited them — the
domain of a blog, a Medium-style platform (RealWorld "Conduit"), a news or
docs site. Favorites are @molecule/api-resource-reaction reactions and the
feed reads @molecule/api-resource-follow's follows, so they are the same
favorites and follows the rest of the app sees.
Tables this package owns: articles and article_tags. The join between them is
article_tags.articleId = articles.id (a declared FK, ON DELETE CASCADE; ("articleId", "tag") is
unique, and article_tags.position is the order the tags were given in). It also ships
src/__setup__/reactions.sql and src/__setup__/follows.sql, which create the reactions and follows tables it reads by name.
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
import { routes, requestHandlerMap } from '@molecule/api-resource-article'
// Wire routes into your Express app via mlcl inject:
// POST /articles create (signed in)
// GET /articles ?tag= &author= &favoritedBy= &limit= &offset=
// GET /articles/feed authors the signed-in user follows
// GET /articles/tags every tag in use, most-used first (?limit= caps it)
// GET /articles/:slug
// PUT /articles/:slug author only; a new title gives a new slug
// DELETE /articles/:slug author only
// POST /articles/:slug/favorite idempotent
// DELETE /articles/:slug/favorite idempotent
// An API with its own response shape (a spec you must match) uses the
// service and writes its own envelope:
import { createArticle, listArticles } from '@molecule/api-resource-article'
router.post('/api/articles', authenticate, async (req, res) => {
const article = await createArticle(userId, req.body.article)
res.status(201).json({ article: { ...article, author: await profileOf(article.authorId) } })
})
const page = await listArticles({ tag: 'dragons', limit: 20, offset: 0, viewerId })
// page.data[0].favorited, page.data[0].favoritesCount, page.total
// RealWorld's `GET /api/tags` returns EVERY tag in use — that is `allTags()`,
// not `popularTags()`, whose default is the top 20:
import { allTags } from '@molecule/api-resource-article'
router.get('/api/tags', async (_req, res) => {
res.json({ tags: (await allTags()).map((tag) => tag.tag) })
})
Type
resource
Installation
npm install @molecule/api-resource-article @molecule/api-database @molecule/api-i18n @molecule/api-logger @molecule/api-resource @molecule/api-resource-follow @molecule/api-resource-reaction @molecule/api-slug zod
API
Interfaces
ArticleRow
An article row as stored. tagList is a JSON array of tag names.
interface ArticleRow {
/** Unique article identifier. */
id: string
/** URL slug, unique across articles. */
slug: string
/** Title. */
title: string
/** Short summary shown in lists. */
description: string
/** Full body (markdown or plain text — the app decides how to render it). */
body: string
/** Tag names, JSON-encoded. */
tagList: string
/** The user who wrote it. */
authorId: string
/** When it was created (ISO 8601, millisecond precision). */
createdAt: string
/** When it was last updated (ISO 8601). */
updatedAt: string
}
Article
An article as the service returns it: tags decoded, favorites counted, and whether the viewing user favorited it.
interface Article {
/** Unique article identifier. */
id: string
/** URL slug, unique across articles. */
slug: string
/** Title. */
title: string
/** Short summary shown in lists. */
description: string
/** Full body. */
body: string
/** Tag names, in the order they were given. */
tagList: string[]
/** The user who wrote it. */
authorId: string
/** When it was created (ISO 8601). */
createdAt: string
/** When it was last updated (ISO 8601). */
updatedAt: string
/** How many users favorited it. */
favoritesCount: number
/** Whether the viewing user favorited it (`false` with no viewer). */
favorited: boolean
}
CreateArticleInput
Input for creating an article.
interface CreateArticleInput {
/** Title (the slug is derived from it). */
title: string
/** Short summary. */
description?: string
/** Full body. */
body: string
/** Tag names. */
tagList?: string[]
}
UpdateArticleInput
Input for updating an article. A changed title gives the article a new slug.
interface UpdateArticleInput {
/** New title. */
title?: string
/** New summary. */
description?: string
/** New body. */
body?: string
/** New tag names (replaces the old ones). */
tagList?: string[]
}
ListArticlesOptions
Filters and paging for listArticles.
interface ListArticlesOptions {
/** Only articles with this tag. */
tag?: string
/** Only articles by this author id. */
authorId?: string
/** Only articles this user id favorited. */
favoritedBy?: string
/** Page size (default 20, at most 100). */
limit?: number
/** Rows to skip. */
offset?: number
/** The viewing user, for `favorited`. */
viewerId?: string
}
PaginatedArticles
A page of articles.
interface PaginatedArticles {
/** The articles on this page, newest first. */
data: Article[]
/** Articles matching the filters across all pages. */
total: number
/** Page size used. */
limit: number
/** Rows skipped. */
offset: number
}
TagCount
A tag and how many articles carry it.
interface TagCount {
/** Tag name. */
tag: string
/** Articles with this tag. */
count: number
}
Types
AuthorChange
The outcome of a change that only the author may make.
type AuthorChange<T> =
{ status: 'ok'; article: T } | { status: 'not_found' } | { status: 'forbidden' }
Functions
create(req, res)
POST /articles — create an article as the signed-in user.
function create(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req— Body:{ title, body, description?, tagList? }.res— 201 with the article.
list(req, res)
GET /articles — newest first; ?tag=, ?author= (user id),
?favoritedBy= (user id), ?limit=, ?offset=.
function list(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req— The request.res—{ data, total, limit, offset }.
feed(req, res)
GET /articles/feed — articles by the authors the signed-in user follows.
function feed(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req—?limit=,?offset=.res—{ data, total, limit, offset }.
tags(req, res)
GET /articles/tags — every tag in use, most-used first.
With no ?limit= this is the whole list, the shape RealWorld's GET /tags
asks for; ?limit= narrows it to that many most-used tags.
function tags(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req— The request;?limit=caps the list.res—[{ tag, count }].
read(req, res)
GET /articles/:slug.
function read(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req— The request.res— The article, or 404.
update(req, res)
PUT /articles/:slug — author only; a new title gives a new slug.
function update(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req— Body: any of{ title, description, body, tagList }.res— The updated article, 403 or 404.
del(req, res)
DELETE /articles/:slug — author only.
function del(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req— The request.res— 204, 403 or 404.
hydrateArticles(rows, viewerId)
Turn stored rows into Articles: decoded tags, favorite counts and the
viewer's own favorites, in two queries for the whole page.
function hydrateArticles(rows: ArticleRow[], viewerId?: string): Promise<Article[]>
rows— Article rows.viewerId— The viewing user, if any.
Returns: The articles, in the order given.
createArticle(authorId, input)
Create an article with a unique slug derived from its title.
function createArticle(authorId: string, input: CreateArticleInput): Promise<Article>
authorId— The writing user.input— Title, body, optional description and tags.
Returns: The created article.
findArticleRow(slug)
The stored row for a slug.
function findArticleRow(slug: string): Promise<ArticleRow | null>
slug— The article slug.
Returns: The row, or null.
getArticleBySlug(slug, viewerId)
An article by slug.
function getArticleBySlug(slug: string, viewerId?: string): Promise<Article | null>
slug— The article slug.viewerId— The viewing user, forfavorited.
Returns: The article, or null.
listArticles(options)
Articles newest first, filtered by tag, author and who favorited them.
function listArticles(options?: ListArticlesOptions): Promise<PaginatedArticles>
options— Filters, paging and the viewer.
Returns: A page of articles and the total matching.
getFeed(userId, options)
Articles by the users userId follows, newest first.
function getFeed(
userId: string,
options?: { limit?: number; offset?: number },
): Promise<PaginatedArticles>
userId— The reader.options— Paging.
Returns: A page of articles and the total.
updateArticle(slug, userId, input)
Update an article. Only its author may; a changed title gives it a new slug.
function updateArticle(
slug: string,
userId: string,
input: UpdateArticleInput,
): Promise<AuthorChange<Article>>
slug— The article slug.userId— The requesting user.input— The fields to change.
Returns: The updated article, or why not.
deleteArticle(slug, userId)
Delete an article and its tags and favorites. Only its author may.
function deleteArticle(slug: string, userId: string): Promise<AuthorChange<true>>
slug— The article slug.userId— The requesting user.
Returns: Whether it was deleted, or why not.
setFavorite(slug, userId, favorite)
Favorite or unfavorite an article for a user (idempotent both ways).
function setFavorite(slug: string, userId: string, favorite: boolean): Promise<Article | null>
slug— The article slug.userId— The user.favorite— True to favorite, false to remove the favorite.
Returns: The article after the change, or null when there is no such article.
allTags()
Every tag in use, most-used first (ties alphabetical).
This is the list a GET /tags endpoint returns, in the shape RealWorld's
GET /api/tags asks for: all of the tags in use, however many the
database holds. Use it for that endpoint — popularTags is the top-N
leaderboard and its default of 20 silently drops every tag past the
twentieth.
function allTags(): Promise<TagCount[]>
Returns: Every tag in use, with its article count.
popularTags(limit)
The limit most-used tags, most-used first (ties alphabetical).
This is a leaderboard, not "every tag". The default is the top 20, so a
GET /tags-shaped endpoint that returns all tags in use (RealWorld's
GET /api/tags) must call allTags — or pass
Number.POSITIVE_INFINITY — or it silently drops every tag past the
twentieth once the database holds more than 20. Pass an explicit limit
when the caller really did ask for the N most-used, e.g. a sidebar of ten.
function popularTags(limit?: number): Promise<TagCount[]>
limit— How many tags to return, most-used first (default 20;Number.POSITIVE_INFINITYfor every tag).
Returns: The tags with their article counts.
Constants
favorite
POST /articles/:slug/favorite — idempotent; returns the article.
const favorite: (req: MoleculeRequest, res: MoleculeResponse) => Promise<void>
unfavorite
DELETE /articles/:slug/favorite — idempotent; returns the article.
const unfavorite: (req: MoleculeRequest, res: MoleculeResponse) => Promise<void>
requestHandlerMap
Handler map for article routes.
const requestHandlerMap: {
readonly create: typeof create
readonly list: typeof list
readonly feed: typeof feed
readonly tags: typeof tags
readonly read: typeof read
readonly update: typeof update
readonly del: typeof del
readonly favorite: (req: MoleculeRequest, res: MoleculeResponse) => Promise<void>
readonly unfavorite: (req: MoleculeRequest, res: MoleculeResponse) => Promise<void>
}
routes
Routes for articles, the feed, tags and favorites.
const routes: readonly [
{
readonly method: 'post'
readonly path: '/articles'
readonly handler: 'create'
readonly middlewares: readonly ['authenticate']
},
{ readonly method: 'get'; readonly path: '/articles'; readonly handler: 'list' },
{
readonly method: 'get'
readonly path: '/articles/feed'
readonly handler: 'feed'
readonly middlewares: readonly ['authenticate']
},
{ readonly method: 'get'; readonly path: '/articles/tags'; readonly handler: 'tags' },
{ readonly method: 'get'; readonly path: '/articles/:slug'; readonly handler: 'read' },
{
readonly method: 'put'
readonly path: '/articles/:slug'
readonly handler: 'update'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'delete'
readonly path: '/articles/:slug'
readonly handler: 'del'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'post'
readonly path: '/articles/:slug/favorite'
readonly handler: 'favorite'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'delete'
readonly path: '/articles/:slug/favorite'
readonly handler: 'unfavorite'
readonly middlewares: readonly ['authenticate']
},
]
ARTICLE_RESOURCE_TYPE
The resource type favorites and comments on articles are stored under.
const ARTICLE_RESOURCE_TYPE: 'article'
FAVORITE_REACTION
The reaction type a favorite is stored as.
const FAVORITE_REACTION: 'favorite'
RESERVED_ARTICLE_SLUGS
Slugs the routes use for themselves (/articles/feed, /articles/tags): an
article titled "Feed" gets feed-2, or its page would be unreachable.
const RESERVED_ARTICLE_SLUGS: ReadonlySet<string>
MAX_ARTICLES_PAGE
Largest page a list returns.
const MAX_ARTICLES_PAGE: 100
createArticleSchema
Schema for creating an article.
const createArticleSchema: z.ZodObject<
{
title: z.ZodString
description: z.ZodOptional<z.ZodString>
body: z.ZodString
tagList: z.ZodOptional<z.ZodArray<z.ZodString>>
},
z.core.$strip
>
updateArticleSchema
Schema for updating an article (every field optional).
const updateArticleSchema: z.ZodObject<
{
title: z.ZodOptional<z.ZodString>
description: z.ZodOptional<z.ZodString>
body: z.ZodOptional<z.ZodString>
tagList: z.ZodOptional<z.ZodArray<z.ZodString>>
},
z.core.$strip
>
Injection Notes
Requirements
Peer dependencies:
@molecule/api-database^1.0.1@molecule/api-i18n^1.0.1@molecule/api-logger^1.0.1@molecule/api-resource^1.0.1@molecule/api-resource-follow^1.0.5@molecule/api-resource-reaction^1.0.3@molecule/api-slug^1.0.0zod^4.0.0
Runtime Dependencies
-
@molecule/api-database -
@molecule/api-i18n -
@molecule/api-logger -
@molecule/api-resource -
@molecule/api-resource-follow -
@molecule/api-resource-reaction -
@molecule/api-slug -
zod -
A scaffold that lists this package already has these tables — its
__setup__SQL is copied into the app at create time and replayed on every boot, so do not copy it intoapi/migrations/yourself. This package ships every table it reads, four in all.src/__setup__/articles.sqlcreates thearticlesandarticle_tagstables;src/__setup__/articles_position.sqladds thepositioncolumn to a database whosearticle_tagspredates it;src/__setup__/reactions.sqlandsrc/__setup__/follows.sqlcreate thereactionsandfollowstables, which this package reads directly by name on every response (favorites) and on its feed (who a reader follows). Those last two are copies of@molecule/api-resource-reaction's and@molecule/api-resource-follow's own setup files, statement for statement, and each creates its table only if it is not there yet, so an app that also installs those packages runs both copies harmlessly. A scaffold copies the tables a package's registry entry declares, and this one declares all four, so a project that installs only this package gets every table its endpoints need. Applyingsrc/__setup__/articles.sqlalone leaves every article response failing withno such table: reactions. -
article_tagskeeps the order the tags were given in, in itspositioncolumn (0-based). An app that reads the join table itself must order byposition— a submission-order assertion (tagList[0],tagList[1]) reads the wrong tag otherwise.Article.tagListis already in that order. -
Tags:
allTags()is every tag in use;popularTags(limit)is the top 20 by default. AGET /tagsendpoint (RealWorld'sGET /api/tags, which returns the whole list) built onpopularTags()with no argument drops every tag past the twentieth as soon as the database holds more than 20 — callallTags()for that endpoint, and pass alimitonly where the caller asked for the N most-used. The package's ownGET /articles/tagsroute does exactly this: no?limit=is the whole list. -
Lists are newest first and paginated —
{ data, total, limit, offset }, at most 100 per page.createdAtis written with milliseconds, so two articles from the same second still come back in order on SQLite. -
Slugs are unique and stable. The first "Hello World" is
hello-world, the nexthello-world-2;feedandtagsare never used (they are routes). Changing the title changes the slug — link with the slug the response returns. -
The author is the session user, always: create ignores any client author, and update/delete answer 403 to anyone else.
-
Reads are public;
favoritedisfalsewithout a signed-in viewer. -
Article bodies are stored as given. Render markdown with
@molecule/app-markdown+ a provider (sanitized HTML) on the client. -
Comments on articles:
@molecule/api-resource-commentwith resource type'article'and the article id. An API whose comment ids are integers (RealWorld's) creates them with itscreateNumberedCommentand finds them withgetCommentByNumber.
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
- Publishing an article opens its page at the slug the API returned, and it shows first in the global list.
- Two articles with the same title get different slugs and both open.
- Filtering by a tag shows only articles with that tag.
- Favoriting increments the count, survives a reload, and unfavoriting brings it back; a signed-out visitor sees the count but no "favorited".
- The feed shows only articles by followed authors, and an empty feed shows a readable empty state.
- Only the author sees edit/delete; another user's direct attempt is refused.
- Deleting removes the article from every list after reload.