@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-article

npm · Source on GitHub

How 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    idempotent

Works 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.ts JSDoc, 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, for favorited.

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_INFINITY for 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.0
  • zod ^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 into api/migrations/ yourself. This package ships every table it reads, four in all. src/__setup__/articles.sql creates the articles and article_tags tables; src/__setup__/articles_position.sql adds the position column to a database whose article_tags predates it; src/__setup__/reactions.sql and src/__setup__/follows.sql create the reactions and follows tables, 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. Applying src/__setup__/articles.sql alone leaves every article response failing with no such table: reactions.

  • article_tags keeps the order the tags were given in, in its position column (0-based). An app that reads the join table itself must order by position — a submission-order assertion (tagList[0], tagList[1]) reads the wrong tag otherwise. Article.tagList is already in that order.

  • Tags: allTags() is every tag in use; popularTags(limit) is the top 20 by default. A GET /tags endpoint (RealWorld's GET /api/tags, which returns the whole list) built on popularTags() with no argument drops every tag past the twentieth as soon as the database holds more than 20 — call allTags() for that endpoint, and pass a limit only where the caller asked for the N most-used. The package's own GET /articles/tags route does exactly this: no ?limit= is the whole list.

  • Lists are newest first and paginated — { data, total, limit, offset }, at most 100 per page. createdAt is 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 next hello-world-2; feed and tags are 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; favorited is false without 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-comment with resource type 'article' and the article id. An API whose comment ids are integers (RealWorld's) creates them with its createNumberedComment and finds them with getCommentByNumber.

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.