Redirects

Janus manages HTTP redirects as first-class, versioned data. Every change to a redirect is appended to a per-path history; the latest active entry is what gets served.

Data model

A redirect record is a plain Clojure map. The spec is ::janus.entities.specs.redirect/record.

Key Required Description

:origin

yes

Keyword identifying the publishing origin (e.g. :zugerzeitung.ch). Use :* for a global redirect that applies to all origins.

:path

yes

The path being redirected. Must start with / (e.g. /thema/film).

:target

yes

Absolute or relative URI to redirect to (e.g. https://www.tagblatt.ch/bergundbeiz or /film). Must not equal :path (self-redirects are ignored).

:type

yes

HTTP status code: 301 (permanent) or 302 (temporary).

:active

yes

true to serve the redirect; false to soft-delete it.

:user-id

no

ID of the user who made the change. nil when not supplied (e.g. from seeding).

:updated-at

no

java.time.Instant of the change. Set server-side by the facade on every HTTP write; nil for seed/legacy entries.

Storage

Redirects are stored in the $$redirects PState, partitioned by :path.

{String              ; path
 {Keyword            ; origin  (:* for global)
  [APersistentMap]}} ; chronological history of versions

Each entry in the history array conforms to ::redirect/record.

Versioned history and deduplication

Every write appends a new entry to the history array. If the incoming record is functionally identical to the latest entry (same :target, :type, :active) it is silently dropped — only :user-id and :updated-at are excluded from the comparison.

;; Submitting the same redirect twice produces one history entry
(foreign-append! depot {:path "/old" :origin :zuz :target (uri "/new")
                        :type 301 :active true})
(foreign-append! depot {:path "/old" :origin :zuz :target (uri "/new")
                        :type 301 :active true})
;; history count = 1

;; Changing :type produces a second entry
(foreign-append! depot {:path "/old" :origin :zuz :target (uri "/new")
                        :type 302 :active true})
;; history count = 2

Soft deletion

Setting :active false deactivates a redirect without removing it from history. The redirect is ignored during resolution but the full audit trail is preserved.

Global redirects

Use the reserved origin keyword :* to create a redirect that applies to every origin.

;; Applies to all origins
{:path "/press" :origin :* :target "https://press.example.com"
 :type 301 :active true}

Resolution order: an origin-specific redirect always wins over a global one. If both exist for the same path, the origin-specific entry is returned. If only the global one exists, it is returned. If neither is active, resolution falls through to the entity layer.

Resolution in path→entity

path→entity is the core routing query. It resolves a (origin, path) pair through several layers in order:

Step PState Outcome

1. Origin redirect

$$redirects

Latest active entry for [path origin]. Returned immediately if found.

2. Global redirect

$$redirects

Latest active entry for [path :*]. Returned if step 1 found nothing.

3. Path → entity

$$path→page

Resolves path to an entity ID. Falls through on miss.

4. Entity fetch

$$entities

Fetches the full denormalised entity. Adds :janus/sha ETag header.

5. Automatic topic page

avet` / `eavt

For /thema/* paths: queries imatrics concepts and builds a synthetic topic page.

6. Not found

Returns cognitect.anomalies/not-found.

HTTP API

The HTTP API exists primarily to back the redirects management application, which gives power users direct control over redirects within the system.

Endpoints are scoped under /redirect and /redirect-history.

List all redirects for an origin

GET /redirect/:origin

Returns the latest version of every redirect stored for the given origin. Uses the origin→redirects query topology (scans all paths).

List all redirects for an origin with full history

GET /redirect-history/:origin

Returns every redirect stored for the given origin, with the complete version history per path. Each entry in the response is a [path, [v1, v2, …​]] tuple where the versions are ordered chronologically. Uses the origin→redirects-with-history query topology (scans all paths).

Get a single redirect

GET /redirect/:origin/*path

Direct PState lookup for [path origin]. Returns the full history array or a 404 anomaly if the path is unknown.

Create or update a redirect

POST /redirect/:origin
Content-Type: application/json

{
  "path":    "/old-path",
  "target":  "https://www.example.com/new-path",
  "type":    301,
  "active":  true,
  "user-id": "[email protected]"
}

:origin is taken from the URL; :updated-at is injected server-side. The record is validated against ::redirect/record before storage.

Request body fields:

Field Required Description

path

yes

URI path starting with /.

target

yes

Absolute or relative URL.

type

yes

301 or 302.

active

yes

true to activate, false to deactivate.

user-id

no

Identifier of the acting user (for audit trail).

Global redirects via the API

Use * as the origin in the URL:

POST /redirect/*
GET  /redirect/*

Query topologies

Name Signature Description

paths→redirects

(origin, path-or-paths) → {path → entry}

Resolves one or multiple paths to their latest redirect for the given origin. Hashes on path; efficient for bulk lookups.

origin→redirects

(origin) → [entry]

Returns all latest redirects for an origin across all paths. Full scan — suitable for management UI, not for hot paths.

origin→redirects-with-history

(origin) → ]

Like origin→redirects but returns the full version history per path instead of only the latest entry. Full scan — suitable for management UI, not for hot paths.

Seeding

Redirects can be bulk-loaded from Excel/CSV sources via the seed tooling. Each row is validated against ::redirect/record before being appended to the depot. Invalid rows are logged and skipped; valid rows are appended with :append-ack.

See seed/src/io/forward_publishing/janus/seed/chm/redirects.clj for the CHM-specific seed implementation.