Files
landing-api/public/openapi.yaml
Josep Piguillem 82ef2b38dd
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Manage projects, members and services through the API
/api/v1/projects, /members and /services: create, list, show, update,
delete and restore, with the panel's own rules — so the MCP server can
load the collective's work, people and offer without anyone retyping
them into forms. The MCP server picks the tools up from openapi.yaml.

- Relations travel as ids and are replaced whole when sent, left alone
  when not: a project's technology_ids, service_ids and members with
  each one's role; a member's technology_ids; a service's
  technology_ids and project_ids. Record and relations are saved in
  one transaction.
- Publishing stays a permission of its own. Without projects.publish
  or services.publish, published_at, unpublish_at and is_published are
  refused — the fields the panel disables for the same people.
- MemberPolicy still lets someone edit their own profile.
- Uploads accept `for: projects` and `for: members`, landing where the
  panel's own upload fields put them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:07:20 +02:00

3127 lines
112 KiB
YAML

openapi: 3.0.3
info:
title: Pireon Admin API
version: "1.0.0"
description: |
REST API of the admin application.
## Two APIs in one document
- **`/public/v1/*`** — unauthenticated, read-only. Consumed by the public
frontend, which renders server-side. Everything it returns is already
published on the public website.
- **`/v1/*`** — authenticated administration, described further down.
## Authentication
Every endpoint under `/v1` except `POST /v1/auth/token` requires a bearer token issued
by the **Pireon identity server** (`https://oauth.pireon.dev`). This
application does not issue tokens of its own; it validates the ones the
identity server issued.
Obtain a token through `POST /v1/auth/token`, which proxies the identity
server's password grant. The panel's own sign-in form uses the same grant,
so `PIREON_PASSWORD_GRANT=false` closes both. Authorization Code + PKCE
against `https://oauth.pireon.dev/oauth/authorize` remains available for
clients that prefer never to handle a password.
## Authorisation
The identity server establishes *who* the caller is. What they may *do* is
decided here, by the roles and permissions administered in this app's
panel. `GET /v1/me` returns the caller's effective permissions; drive your
UI from that array rather than from role names.
## Languages
Content fields (`title`, `slug`, `excerpt`, `body`, …) are translatable.
By default they are returned resolved to a single language, chosen as:
1. `?lang=xx`
2. the user's stored preference
3. the `Accept-Language` header
4. the application default (`es`)
Pass `?with=translations` to receive every language alongside, and send a
`{"es": "...", "ca": "..."}` object to write specific languages. A write
that names only some languages leaves the others untouched.
servers:
- url: /api
description: API base URL
tags:
- name: Public
description: |
Unauthenticated, read-only endpoints for the public frontend.
Records are addressed **by slug within the active locale**, because that
is the access pattern a server-rendered frontend has: it receives a URL,
not an id. A slug only resolves in the language it belongs to, so one
record never answers on two addresses.
Every single-record response carries `alternates` — the same record's
path in each language that actually has a translation. Only this API
knows that `/proyectos/mi-web` and `/ca/projectes/el-meu-web` are one
thing, so without it the frontend cannot emit correct `hreflang` tags.
Locales with no translation are omitted rather than pointing at a URL
that would 404.
Responses carry `ETag`, `Cache-Control: s-maxage`,
`stale-while-revalidate` and `Vary: Accept-Language`. Send
`If-None-Match` to revalidate cheaply, and watch `content_version` from
`/site` to know when a cached copy is genuinely out of date.
Unpublished records can be reached two ways: the shared preview secret
(`X-Preview-Token`), which unlocks everything, or a signed link generated
in the panel for one record (`?preview=…`), which expires and unlocks
only that one.
- name: Submissions
description: |
The public API's only write endpoints: the two forms the site offers.
They carry their own throttle, a honeypot and an off switch, because
they are the one place a stranger can put a row in the database. What
arrives lands in the panel's inbox, and the people who can read it are
notified.
- name: Telemetry
description: |
The frontend reporting back, rather than asking for something.
Also a write endpoint, but the author is the frontend itself and not a
visitor, so unlike the submissions it holds a shared secret
(`X-Ingest-Token`) instead of a honeypot. An unset secret disables the
endpoint; it never opens it.
Two things arrive here: addresses that failed, which are the raw material
of a redirect, and records that were read, which is the only way this
side can know whether anything it publishes is opened.
Neither carries anything about a person — no address, no session, no user
agent, no time of day.
- name: Auth
description: Token exchange and caller identity
- name: Pages
description: Translatable page content
- name: Categories
description: Translatable page categories
- name: Projects
description: The collective's work — translatable, with its team and stack
- name: Members
description: The people in the collective
- name: Services
description: What the collective offers
- name: Technologies
description: What the collective uses, and its place on the tech radar
- name: Users
description: Local user administration
- name: Media
description: Images uploaded for a record to point at
- name: Roles
description: Local authorisation model (read-only)
security:
- bearerAuth: []
paths:
/public/v1/site:
get:
tags: [Public]
summary: Locales and headline numbers
description: What the frontend needs once on any page.
security: []
responses:
"200":
description: Site metadata
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
locales:
type: array
items:
type: object
properties:
code: { type: string, example: ca }
name: { type: string, example: Català }
flag: { type: string }
is_default: { type: boolean }
default_locale: { type: string, example: es }
content_version:
type: integer
description: |
Moves whenever published content does. Keep it as part
of your cache key and everything downstream goes stale
the moment the site really changed, rather than when a
timer says so.
submissions_enabled:
type: boolean
description: |
Whether the forms are accepting messages. Hide them
when false rather than rendering one that would be
refused on submit.
stats:
type: object
properties:
projects: { type: integer }
active_projects: { type: integer }
members: { type: integer }
open_calls: { type: integer }
good_first_issues: { type: integer }
stars: { type: integer }
"503": { $ref: "#/components/responses/Maintenance" }
/public/v1/slugs:
get:
tags: [Public]
summary: Every public URL, grouped by type and locale
description: |
One request instead of paging through every collection — this is what
the frontend builds its sitemap and route table from.
security: []
responses:
"200":
description: All published URLs
content:
application/json:
schema:
type: object
properties:
data:
type: object
additionalProperties:
type: array
items:
type: object
properties:
id: { type: integer }
updated_at: { type: string, format: date-time }
alternates:
type: object
additionalProperties: { type: string }
example:
es: /proyectos/mi-web
ca: /ca/projectes/el-meu-web
"503": { $ref: "#/components/responses/Maintenance" }
/public/v1/redirects:
get:
tags: [Public]
summary: URLs that used to work
description: |
Every enabled redirect, so the frontend can answer a 301 instead of a
404. Most were written by the panel itself the moment somebody renamed
a slug — the only moment the old address was still known.
Returned whole rather than looked up one at a time: there are never
many, and an SSR frontend would otherwise call this API on every 404 it
serves.
security: []
responses:
"200":
description: Enabled redirects
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
from: { type: string, example: /proyectos/nombre-viejo }
to: { type: string, example: /proyectos/nombre-nuevo }
status: { type: integer, enum: [301, 302] }
locale:
type: string
nullable: true
description: Null applies in every language.
"503": { $ref: "#/components/responses/Maintenance" }
/public/v1/projects:
get:
tags: [Public]
summary: List published projects
security: []
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/PerPage"
- name: status
in: query
schema: { type: string, enum: [idea, active, paused, completed, archived] }
- name: featured
in: query
schema: { type: boolean }
- name: needs_help
in: query
description: Only projects looking for contributors.
schema: { type: boolean }
- name: technology
in: query
description: Technology slug; repeat for several.
schema: { type: string }
- name: member
in: query
schema: { type: string }
- name: translated
in: query
description: Exclude projects with no translation in the active locale.
schema: { type: boolean }
responses:
"200":
description: Paginated projects
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/PublicProject" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
"503": { $ref: "#/components/responses/Maintenance" }
/public/v1/projects/{slug}:
get:
tags: [Public]
summary: A single project, with its write-up
description: |
Adds the long-form fields the listing omits: `description`, `decisions`
and `postmortem`, plus members, open calls and live repository stats.
security: []
parameters:
- name: slug
in: path
required: true
description: The slug in the active locale.
schema: { type: string }
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/PreviewToken"
responses:
"200":
description: The project
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/PublicProject" }
"404": { description: No project with that slug in that language }
"503": { $ref: "#/components/responses/Maintenance" }
/public/v1/members:
get:
tags: [Public]
summary: List public members
security: []
parameters:
- $ref: "#/components/parameters/Lang"
- name: include_past
in: query
description: Include people who have left, for an alumni section.
schema: { type: boolean }
- name: technology
in: query
schema: { type: string }
responses:
"200":
description: Paginated members
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/PublicMember" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
"503": { $ref: "#/components/responses/Maintenance" }
/public/v1/members/{slug}:
get:
tags: [Public]
summary: A single member, with their projects
security: []
parameters:
- name: slug
in: path
required: true
schema: { type: string }
- $ref: "#/components/parameters/Lang"
responses:
"200":
description: The member
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/PublicMember" }
"404": { description: Not found }
"503": { $ref: "#/components/responses/Maintenance" }
/public/v1/calls:
get:
tags: [Public]
summary: Open calls for contributors
description: |
Excludes closed calls, calls outside their window, and calls attached
to an unpublished project.
security: []
parameters:
- $ref: "#/components/parameters/Lang"
- name: technology
in: query
schema: { type: string }
- name: max_hours_per_week
in: query
description: '"I have four hours a week" — the filter people actually apply.'
schema: { type: integer }
responses:
"200":
description: Paginated calls
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/PublicCall" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
"503": { $ref: "#/components/responses/Maintenance" }
/public/v1/calls/{slug}:
get:
tags: [Public]
summary: A single open call
security: []
parameters:
- name: slug
in: path
required: true
schema: { type: string }
- $ref: "#/components/parameters/Lang"
responses:
"200":
description: The call
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/PublicCall" }
"404": { description: Not found or no longer open }
"503": { $ref: "#/components/responses/Maintenance" }
/public/v1/calls/{slug}/apply:
post:
tags: [Submissions]
summary: Answer a call
description: |
The public API's only write endpoints are this one and
`/public/v1/contact`.
Only an **open** call accepts applications; anything else answers 404,
because letting someone write a message for a position that is already
filled wastes their time.
Submitting the same address to the same call twice inside the
deduplication window (24 h by default) answers **200** with
`duplicate: true` and stores nothing further: a double-clicked submit
button is not a second person.
Throttling is per e-mail address plus a site-wide hourly ceiling —
deliberately not per IP, since with an SSR frontend every submission
arrives from the same address.
security: []
parameters:
- name: slug
in: path
required: true
schema: { type: string }
- $ref: "#/components/parameters/Lang"
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/Submission" }
responses:
"201": { $ref: "#/components/responses/SubmissionAccepted" }
"200":
description: Recognised as a repeat of a submission already stored
content:
application/json:
schema: { $ref: "#/components/schemas/SubmissionReceipt" }
"404": { description: No open call with that slug in this language }
"422": { $ref: "#/components/responses/ValidationError" }
"429": { description: Too many submissions from this address }
"503":
description: |
The public site is in maintenance, or submissions are switched off
(`SITE_ACCEPT_SUBMISSIONS=false`). The frontend can tell the two
apart by the `maintenance` and `submissions_enabled` keys.
/public/v1/contact:
post:
tags: [Submissions]
summary: Write in without a specific call
description: |
The general form: somebody offering time with no call in mind, or an
association asking for help. `kind` chooses between the two, and
`organisation` becomes required when it is `request`.
security: []
parameters:
- $ref: "#/components/parameters/Lang"
requestBody:
required: true
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/Submission"
- type: object
properties:
kind:
type: string
enum: [contribute, request]
default: contribute
responses:
"201": { $ref: "#/components/responses/SubmissionAccepted" }
"200":
description: Recognised as a repeat of a submission already stored
content:
application/json:
schema: { $ref: "#/components/schemas/SubmissionReceipt" }
"422": { $ref: "#/components/responses/ValidationError" }
"429": { description: Too many submissions from this address }
"503": { description: "Maintenance, or submissions switched off" }
/public/v1/telemetry/not-found:
post:
tags: [Telemetry]
summary: Report addresses the frontend could not resolve
description: |
Redirects were only ever written from this side: renaming a slug leaves
a rule behind, because that is the one moment the old address is still
known. Every other broken link — the previous website's, one printed on
paper, another site linking a page that moved years ago — fails on the
frontend, in front of a visitor, and never reaches the panel.
This is where the frontend says so. Reports land in
**Administration → Missing URLs**, where one becomes a redirect with a
single action.
Accepts either a single report or a batch of up to
`SITE_TELEMETRY_MAX_BATCH` (50 by default). Paths are normalised
exactly as redirects are — trailing slashes and query strings are
stripped — and stored as one row per `(path, locale)` with a hit count,
so a crawler costs one row rather than one per request.
Obvious scans (`/wp-admin`, `/.env`, anything ending in `.php`) are
dropped rather than stored. The response says how many of the reports
were kept, so the frontend can tell its filter from this one.
Not affected by maintenance mode: a 404 during a deploy is still a 404,
and those are the most interesting ones.
security:
- ingestToken: []
parameters:
- $ref: "#/components/parameters/IngestToken"
requestBody:
required: true
content:
application/json:
schema:
oneOf:
- $ref: "#/components/schemas/MissingUrlReport"
- type: object
required: [hits]
properties:
hits:
type: array
minItems: 1
maxItems: 50
items: { $ref: "#/components/schemas/MissingUrlReport" }
responses:
"202":
description: |
Reports accepted. Nothing here is a resource the caller owns, and a
report merged into an existing count or dropped as noise is still
successful — hence 202 rather than 201.
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
received: { type: integer, example: 3 }
recorded: { type: integer, example: 1 }
"403":
description: |
Missing or wrong `X-Ingest-Token`, or reporting is disabled because
`SITE_INGEST_TOKEN` is unset.
"422": { $ref: "#/components/responses/ValidationError" }
"429": { description: Too many reports from this address }
/public/v1/telemetry/views:
post:
tags: [Telemetry]
summary: Report what was read
description: |
The panel can say what exists and what is published. It has never been
able to say whether any of it gets opened — which is the fact behind
most editorial decisions: what to translate next, whether the featured
project is one anybody looks at, whether a page earns the work of
keeping it accurate.
Reports are addressed the way the frontend already thinks: it fetched
`/public/v1/projects/mi-web` to draw the page, so it reports a **type**
and a **slug** rather than parsing its own URL back apart. A slug that
no longer resolves is not counted — that is a 404, and
`/telemetry/not-found` is where those belong.
**There is no visitor in this.** No address, no session, no user agent,
no referrer, no time of day. What is stored is one row per record, per
language, per day, holding a number — enough for the questions above
and not enough to reconstruct anybody. Reading figures are kept for
400 days and then pruned.
Accepts a single report or a batch of up to
`SITE_TELEMETRY_MAX_BATCH`.
security:
- ingestToken: []
parameters:
- $ref: "#/components/parameters/IngestToken"
requestBody:
required: true
content:
application/json:
schema:
oneOf:
- $ref: "#/components/schemas/ViewReport"
- type: object
required: [views]
properties:
views:
type: array
minItems: 1
maxItems: 50
items: { $ref: "#/components/schemas/ViewReport" }
responses:
"202":
description: |
Reports accepted. `recorded` counts the ones that named something
this API still serves.
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
received: { type: integer, example: 2 }
recorded: { type: integer, example: 2 }
"403":
description: |
Missing or wrong `X-Ingest-Token`, or reporting is disabled because
`SITE_INGEST_TOKEN` is unset.
"422": { $ref: "#/components/responses/ValidationError" }
"429": { description: Too many reports from this address }
/public/v1/services:
get:
tags: [Public]
summary: What the collective offers
description: |
The counterpart to a project: one says what has been built, this says
what somebody would be asking for. Ordered as arranged in the panel.
security: []
parameters:
- $ref: "#/components/parameters/Lang"
- name: featured
in: query
schema: { type: boolean }
responses:
"200":
description: Published services
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/PublicService" }
"503": { $ref: "#/components/responses/Maintenance" }
/public/v1/services/{slug}:
get:
tags: [Public]
summary: A single service
description: |
Carries the work that backs the offer and what people said about it —
a service page with neither is a promise.
security: []
parameters:
- name: slug
in: path
required: true
schema: { type: string }
- $ref: "#/components/parameters/Lang"
responses:
"200":
description: The service
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/PublicService" }
"404": { description: "Not found in this language, or not published" }
"503": { $ref: "#/components/responses/Maintenance" }
/public/v1/clients:
get:
tags: [Public]
summary: The wall of logos
description: |
Organisations the work was for. Anyone who asked not to appear is
absent whatever else is true of them, and by default so is anyone with
no published project behind them — a logo the site could not justify
showing.
security: []
parameters:
- $ref: "#/components/parameters/Lang"
- name: with_projects
in: query
description: Set to 0 to include clients with nothing published yet.
schema: { type: boolean, default: true }
responses:
"200":
description: Public clients
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/PublicClient" }
"503": { $ref: "#/components/responses/Maintenance" }
/public/v1/testimonials:
get:
tags: [Public]
summary: What other people said
description: |
Attached to a client, a project or a service rather than to a page, so
ask for the ones belonging to whatever you are rendering.
security: []
parameters:
- $ref: "#/components/parameters/Lang"
- name: client
in: query
description: Client slug.
schema: { type: string }
- name: service
in: query
description: Service slug, in the active language.
schema: { type: string }
responses:
"200":
description: Public testimonials
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/PublicTestimonial" }
"503": { $ref: "#/components/responses/Maintenance" }
/public/v1/talks:
get:
tags: [Public]
summary: Talks given elsewhere
security: []
parameters:
- $ref: "#/components/parameters/Lang"
- name: with_video
in: query
schema: { type: boolean }
- name: limit
in: query
schema: { type: integer, default: 50, maximum: 100 }
responses:
"200":
description: Public talks, newest first
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/PublicTalk" }
"503": { $ref: "#/components/responses/Maintenance" }
/public/v1/pages:
get:
tags: [Public]
summary: List published pages
security: []
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/PerPage"
- name: category
in: query
schema: { type: string }
- name: featured
in: query
schema: { type: boolean }
responses:
"200":
description: Paginated pages
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/PublicPage" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
"503": { $ref: "#/components/responses/Maintenance" }
/public/v1/pages/{slug}:
get:
tags: [Public]
summary: A single published page
security: []
parameters:
- name: slug
in: path
required: true
schema: { type: string }
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/PreviewToken"
responses:
"200":
description: The page
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/PublicPage" }
"404": { description: Not found }
"503": { $ref: "#/components/responses/Maintenance" }
/public/v1/technologies:
get:
tags: [Public]
summary: Technologies in use
security: []
parameters:
- $ref: "#/components/parameters/Lang"
- name: category
in: query
schema: { type: string, enum: [language, framework, datastore, infrastructure, tool, practice] }
- name: in_use
in: query
description: Defaults to true — only technologies attached to a project.
schema: { type: boolean }
responses:
"200":
description: Technologies
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/PublicTechnology" }
"503": { $ref: "#/components/responses/Maintenance" }
/public/v1/radar:
get:
tags: [Public]
summary: The tech radar
description: |
Technologies placed on a ring — adopt, trial, assess or hold — with the
note explaining why. Technologies with no ring are omitted.
security: []
parameters:
- $ref: "#/components/parameters/Lang"
responses:
"200":
description: Radar entries
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/PublicTechnology" }
"503": { $ref: "#/components/responses/Maintenance" }
/public/v1/good-first-issues:
get:
tags: [Public]
summary: Newcomer-friendly open issues across all repositories
description: |
Mirrored from the git provider and refreshed hourly. Claimed issues,
archived repositories and repositories hidden from the site are
excluded — an issue somebody already took is not an invitation.
security: []
parameters:
- name: limit
in: query
schema: { type: integer, default: 20, maximum: 100 }
- name: language
in: query
description: Filter by the repository's primary language.
schema: { type: string, example: PHP }
- name: include_help_wanted
in: query
schema: { type: boolean, default: true }
responses:
"200":
description: Open issues
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/PublicIssue" }
"503": { $ref: "#/components/responses/Maintenance" }
/v1/auth/token:
post:
tags: [Auth]
summary: Exchange credentials or a refresh token for an access token
description: |
Proxies the identity server's `password` and `refresh_token` grants so
first-party clients need not embed the OAuth server's URL or secret.
Rate limited to 6 requests per minute per account and address, and 30
per address overall. Browser clients should use Authorization Code +
PKCE instead.
security: []
requestBody:
required: true
content:
application/json:
schema:
oneOf:
- $ref: "#/components/schemas/PasswordGrantRequest"
- $ref: "#/components/schemas/RefreshGrantRequest"
responses:
"200":
description: Token issued
content:
application/json:
schema: { $ref: "#/components/schemas/TokenResponse" }
"401": { description: Credentials rejected by the identity server }
"403": { description: Account inactive or awaiting approval }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/me:
get:
tags: [Auth]
summary: The current caller, with their effective local permissions
responses:
"200":
description: The authenticated user
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/User" }
"401": { $ref: "#/components/responses/Unauthorized" }
/v1/pages:
get:
tags: [Pages]
summary: List pages
description: |
Callers without the `pages.publish` permission only ever see published
pages.
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
- $ref: "#/components/parameters/PerPage"
- name: status
in: query
schema: { type: string, enum: [draft, review, published, archived] }
- name: category_id
in: query
schema: { type: integer }
- name: is_featured
in: query
schema: { type: boolean }
- name: search
in: query
description: Matches title and excerpt within the active language only.
schema: { type: string }
- name: translated_in
in: query
description: Only pages that actually carry a translation for this locale.
schema: { type: string, enum: [es, en, ca] }
- name: trashed
in: query
schema: { type: boolean }
- name: sort
in: query
description: 'Prefix with "-" for descending, e.g. `-published_at`.'
schema:
type: string
enum: [title, created_at, updated_at, published_at, status,
-title, -created_at, -updated_at, -published_at, -status]
responses:
"200":
description: Paginated pages
content:
application/json:
schema: { $ref: "#/components/schemas/PageCollection" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [Pages]
summary: Create a page
description: >
Requires `pages.create`. Setting `status` to `published` additionally
requires `pages.publish`.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/PageWrite" }
responses:
"201":
description: Page created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Page" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/pages/{page}:
parameters:
- name: page
in: path
required: true
schema: { type: integer }
get:
tags: [Pages]
summary: Show a page
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
responses:
"200":
description: The page
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Page" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Pages]
summary: Update a page
description: |
Partial update. Translatable fields merge into the existing map, so
sending only `{"title": {"ca": "..."}}` leaves Spanish and English
intact. Authors may always edit their own unpublished pages.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/PageWrite" }
responses:
"200":
description: Page updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Page" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
tags: [Pages]
summary: Soft-delete a page
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/pages/{page}/restore:
post:
tags: [Pages]
summary: Restore a soft-deleted page
parameters:
- name: page
in: path
required: true
schema: { type: integer }
responses:
"200":
description: Page restored
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Page" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/categories:
get:
tags: [Categories]
summary: List categories
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
- $ref: "#/components/parameters/PerPage"
- name: is_visible
in: query
schema: { type: boolean }
- name: search
in: query
schema: { type: string }
responses:
"200":
description: Paginated categories
content:
application/json:
schema: { $ref: "#/components/schemas/CategoryCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [Categories]
summary: Create a category
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CategoryWrite" }
responses:
"201":
description: Category created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Category" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/categories/{category}:
parameters:
- name: category
in: path
required: true
schema: { type: integer }
get:
tags: [Categories]
summary: Show a category
responses:
"200":
description: The category
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Category" }
patch:
tags: [Categories]
summary: Update a category
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CategoryWrite" }
responses:
"200":
description: Category updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Category" }
delete:
tags: [Categories]
summary: Soft-delete a category
description: Refused with 403 while the category still holds pages.
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/technologies:
get:
tags: [Technologies]
summary: List technologies
description: Ordered by `position`, then name.
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
- $ref: "#/components/parameters/PerPage"
- name: category
in: query
schema: { type: string, enum: [language, framework, datastore, infrastructure, tool, practice] }
- name: radar_ring
in: query
description: '`none` for the technologies kept off the radar.'
schema: { type: string, enum: [adopt, trial, assess, hold, none] }
- name: search
in: query
description: Matches the name.
schema: { type: string }
responses:
"200":
description: Paginated technologies
content:
application/json:
schema: { $ref: "#/components/schemas/TechnologyCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [Technologies]
summary: Create a technology
description: |
For a logo, upload it first with `POST /v1/media` and `for:
technologies`, and send the `path` it answers with as `logo`.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/TechnologyWrite" }
responses:
"201":
description: Technology created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Technology" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/technologies/{technology}:
parameters:
- name: technology
in: path
required: true
schema: { type: integer }
get:
tags: [Technologies]
summary: Show a technology
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
responses:
"200":
description: The technology
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Technology" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Technologies]
summary: Update a technology
description: |
Partial update. `description` and `radar_note` merge into the existing
translations; send `radar_ring: null` to take it off the radar.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/TechnologyWrite" }
responses:
"200":
description: Technology updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Technology" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
tags: [Technologies]
summary: Delete a technology
description: Refused with 403 while a project, member or call still uses it.
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/projects:
get:
tags: [Projects]
summary: List projects
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
- $ref: "#/components/parameters/PerPage"
- name: status
in: query
schema: { type: string, enum: [idea, active, paused, completed, archived] }
- name: search
in: query
description: Matches the title within the active language only.
schema: { type: string }
- name: trashed
in: query
schema: { type: boolean }
responses:
"200":
description: Paginated projects
content:
application/json:
schema: { $ref: "#/components/schemas/ProjectCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [Projects]
summary: Create a project
description: |
Relations are sent as ids and replaced whole when present:
`technology_ids`, `service_ids`, and `members` with each one's role.
`published_at` and `unpublish_at` require `projects.publish`. For a
cover, upload it with `POST /v1/media` and `for: projects`.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ProjectWrite" }
responses:
"201":
description: Project created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Project" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/projects/{project}:
parameters:
- name: project
in: path
required: true
schema: { type: integer }
get:
tags: [Projects]
summary: Show a project
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
responses:
"200":
description: The project
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Project" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Projects]
summary: Update a project
description: Partial update. Translatable fields merge into the existing map.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ProjectWrite" }
responses:
"200":
description: Project updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Project" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
tags: [Projects]
summary: Soft-delete a project
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/projects/{project}/restore:
post:
tags: [Projects]
summary: Restore a soft-deleted project
parameters:
- name: project
in: path
required: true
schema: { type: integer }
responses:
"200":
description: Project restored
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Project" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/members:
get:
tags: [Members]
summary: List members
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
- $ref: "#/components/parameters/PerPage"
- name: search
in: query
description: Matches the name.
schema: { type: string }
- name: is_public
in: query
schema: { type: boolean }
- name: trashed
in: query
schema: { type: boolean }
responses:
"200":
description: Paginated members
content:
application/json:
schema: { $ref: "#/components/schemas/MemberCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [Members]
summary: Create a member
description: |
`links` is a label → URL map. `technology_ids` is replaced whole when
present; which projects someone is on is set from the project, where
the role belongs. For a photo, upload it with `POST /v1/media` and
`for: members`. Someone may edit their own profile without
`members.update`.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/MemberWrite" }
responses:
"201":
description: Member created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Member" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/members/{member}:
parameters:
- name: member
in: path
required: true
schema: { type: integer }
get:
tags: [Members]
summary: Show a member
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
responses:
"200":
description: The member
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Member" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Members]
summary: Update a member
description: Partial update. Translatable fields merge into the existing map.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/MemberWrite" }
responses:
"200":
description: Member updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Member" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
tags: [Members]
summary: Soft-delete a member
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/members/{member}/restore:
post:
tags: [Members]
summary: Restore a soft-deleted member
parameters:
- name: member
in: path
required: true
schema: { type: integer }
responses:
"200":
description: Member restored
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Member" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/services:
get:
tags: [Services]
summary: List services
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
- $ref: "#/components/parameters/PerPage"
- name: is_published
in: query
schema: { type: boolean }
- name: search
in: query
description: Matches the title within the active language only.
schema: { type: string }
- name: trashed
in: query
schema: { type: boolean }
responses:
"200":
description: Paginated services
content:
application/json:
schema: { $ref: "#/components/schemas/ServiceCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [Services]
summary: Create a service
description: |
`technology_ids` and `project_ids` are replaced whole when present.
`is_published`, `published_at` and `unpublish_at` require
`services.publish`.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ServiceWrite" }
responses:
"201":
description: Service created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Service" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/services/{service}:
parameters:
- name: service
in: path
required: true
schema: { type: integer }
get:
tags: [Services]
summary: Show a service
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
responses:
"200":
description: The service
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Service" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Services]
summary: Update a service
description: Partial update. Translatable fields merge into the existing map.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ServiceWrite" }
responses:
"200":
description: Service updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Service" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
tags: [Services]
summary: Soft-delete a service
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/services/{service}/restore:
post:
tags: [Services]
summary: Restore a soft-deleted service
parameters:
- name: service
in: path
required: true
schema: { type: integer }
responses:
"200":
description: Service restored
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Service" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/media:
post:
tags: [Media]
summary: Upload an image
description: |
Stores an image and answers with the `path` a record points at — send
it as a page's `featured_image`. Two ways in, exactly one per request:
- **`multipart/form-data`** with the image as `file`.
- **`application/json`** with a `url`, which the API downloads itself.
HTTPS only, from a public address — anything resolving to a private,
reserved or loopback address is refused — and redirects are not
followed: send the image's final address.
JPEG, PNG, WebP, GIF, AVIF or SVG, decided from the file's bytes rather
than its name or declared type. An SVG is stored sanitized: scripts,
event handlers and references to anything outside the file are
removed. 20 MB at most.
`for` says which kind of record the image is for — `pages` (the
default) or `technologies` — which decides where it is stored and whose
permission it takes.
The smaller versions and the placeholder are derived afterwards, on the
queue: `processed` says whether that has happened, and the image serves
either way. Requires permission to create or edit the records named in
`for`.
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
description: The image.
for: { $ref: "#/components/schemas/MediaFor" }
application/json:
schema:
type: object
required: [url]
properties:
url:
type: string
format: uri
description: An `https://` address the API downloads the image from.
example: https://images.example.org/hero.jpg
for: { $ref: "#/components/schemas/MediaFor" }
responses:
"201":
description: Image stored
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Media" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/users:
get:
tags: [Users]
summary: List local users
description: Requires `users.view_any`.
parameters:
- $ref: "#/components/parameters/PerPage"
- name: search
in: query
schema: { type: string }
- name: is_active
in: query
schema: { type: boolean }
- name: role
in: query
schema: { type: string }
responses:
"200":
description: Paginated users
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/User" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/users/{user}:
parameters:
- name: user
in: path
required: true
schema: { type: integer }
get:
tags: [Users]
summary: Show a user
description: Reading your own record is always permitted.
responses:
"200":
description: The user
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/User" }
patch:
tags: [Users]
summary: Update a user's local access
description: |
Only fields this application owns are writable. Name and email belong
to the identity server and are refreshed on every sign-in, so change
those at `oauth.pireon.dev` instead.
Assigning roles additionally requires `roles.update`, and only a super
admin may grant `super_admin`.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
is_active: { type: boolean }
locale: { type: string, enum: [es, en, ca], nullable: true }
roles:
type: array
items: { type: string }
example: [editor]
responses:
"200":
description: User updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/User" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/roles:
get:
tags: [Roles]
summary: List roles and their permissions
responses:
"200":
description: All roles
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Role" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/roles/catalogue:
get:
tags: [Roles]
summary: The full permission catalogue, grouped by resource
description: Useful for building a role editor outside the panel.
responses:
"200":
description: Permission catalogue
content:
application/json:
schema:
type: object
properties:
data:
type: object
additionalProperties:
type: array
items: { type: string }
example:
general: [panel.access]
pages: [pages.view_any, pages.create, pages.publish]
/v1/roles/{role}:
get:
tags: [Roles]
summary: Show a role
parameters:
- name: role
in: path
required: true
schema: { type: integer }
responses:
"200":
description: The role
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Role" }
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: |
An access token issued by https://oauth.pireon.dev. Validated on each
request against the identity server's `GET /api/user`, with the result
cached briefly.
ingestToken:
type: apiKey
in: header
name: X-Ingest-Token
description: |
Shared secret held by the frontend, for the endpoints it calls on its
own behalf rather than on a visitor's. In a header rather than the
query string so it stays out of access logs. An unset secret disables
those endpoints instead of opening them.
parameters:
Lang:
name: lang
in: query
description: Language to resolve translatable fields into, overriding Accept-Language.
schema: { type: string, enum: [es, en, ca] }
With:
name: with
in: query
description: Comma-separated extras. `translations` adds every language.
schema: { type: string, example: translations }
PreviewToken:
name: X-Preview-Token
in: header
description: |
Shared secret that lets the frontend render unpublished content. When
valid, the `published` filter is skipped and the response is marked
`no-store`. An unset secret never grants access.
schema: { type: string }
IngestToken:
name: X-Ingest-Token
in: header
required: true
description: Shared secret for the endpoints the frontend calls on its own behalf.
schema: { type: string }
PerPage:
name: per_page
in: query
schema: { type: integer, default: 15, maximum: 100 }
responses:
Maintenance:
description: |
The public site is in maintenance. Returned by every `/public/v1`
endpoint while the flag is on, with the notice already resolved to the
request's language so the frontend can render a maintenance page from
it directly. Never cached, and accompanied by `Retry-After`.
The panel and the admin API are unaffected — this is deliberately not
Laravel's own maintenance mode.
headers:
Retry-After:
schema: { type: integer, example: 3600 }
content:
application/json:
schema:
type: object
properties:
message: { type: string, example: Estamos migrando el Gitea. Volvemos en un rato. }
maintenance: { type: boolean, example: true }
locale: { type: string, example: es }
since: { type: string, format: date-time, nullable: true }
until: { type: string, format: date-time, nullable: true }
Unauthorized:
description: Missing, expired, or revoked bearer token
content:
application/json:
schema:
type: object
properties:
message: { type: string, example: Unauthenticated. }
Forbidden:
description: The caller lacks the required local permission
content:
application/json:
schema:
type: object
properties:
message: { type: string, example: This action is unauthorized. }
ValidationError:
description: Validation failed
content:
application/json:
schema:
type: object
properties:
message: { type: string }
errors:
type: object
additionalProperties:
type: array
items: { type: string }
example:
slug: ['El slug «inicio» ya está en uso en el idioma «es».']
SubmissionAccepted:
description: |
Stored, and the people who can read the inbox have been told.
The body of the submission is never echoed back, so a guessed id
cannot be used to read somebody else's message. Always `no-store`.
headers:
Cache-Control:
schema: { type: string, example: "no-store, private" }
content:
application/json:
schema: { $ref: "#/components/schemas/SubmissionReceipt" }
schemas:
Image:
type: object
description: |
Everything needed to render an image well server-side. A path alone
was never enough: without `width`/`height` the page reflows as images
arrive, without `srcset` every visitor downloads the original, and
without `alt` the page is not accessible.
Degrades on purpose — an upload whose derived versions are not ready
yet still carries a `url`, with the rest null, so the frontend renders
a plain `<img>` rather than a gap.
properties:
url:
type: string
format: uri
description: Absolute, because the frontend is on another host.
alt:
type: string
description: |
Empty string when the image is decorative — that is what `alt=""`
means — never absent. Follows the requested language.
width: { type: integer, nullable: true }
height: { type: integer, nullable: true }
aspect_ratio: { type: number, nullable: true }
mime: { type: string, nullable: true }
placeholder:
type: string
nullable: true
description: |
Tiny blurred data URI to paint while the real image loads.
srcset:
type: object
nullable: true
additionalProperties: { type: string }
description: |
Keyed by format, ready to use verbatim — build a `<picture>` with
AVIF first and WebP behind it. Only widths that actually exist are
listed, and an image is never scaled up.
example:
webp: "https://admin.example/storage/projects/cover-400.webp 400w, https://admin.example/storage/projects/cover-800.webp 800w"
ViewReport:
type: object
required: [type, slug]
properties:
type:
type: string
enum: [page, project, member, call, service]
description: |
The same vocabulary `/slugs` and the path configuration use, so a
report can only ever name something this API serves.
slug:
type: string
maxLength: 200
example: mi-web
description: The slug in the language given below, not a full path.
locale:
type: string
enum: [es, en, ca]
description: |
Which language was rendered. Falls back to the negotiated locale.
Counted separately per language, because which language people
read something in is most of what these figures are for.
count:
type: integer
minimum: 1
maximum: 1000
default: 1
description: |
For a frontend that batches rather than reporting each render.
Capped: the frontend is trusted to count its own renders, not to
invent an audience.
MissingUrlReport:
type: object
required: [path]
properties:
path:
type: string
maxLength: 500
pattern: "^/"
example: /proyectos/nombre-viejo
description: |
A path, not a URL. The frontend knows its own origin, and accepting
a full address would let a caller file a 404 against somebody
else's domain. Normalised on arrival: query strings and trailing
slashes are stripped, so `/algo/` and `/algo?utm_source=x` are one
address rather than three.
locale:
type: string
enum: [es, en, ca]
description: |
Which language was being rendered. Falls back to the negotiated
locale when absent, because it is half of the key that keeps one
address in two languages as two separate problems — they need two
different redirects.
referrer:
type: string
format: uri
maxLength: 500
nullable: true
description: |
Straight from the `Referer` header. The most useful field here: one
on our own domain means something we publish links to a dead
address, which the panel flags as an error rather than a warning.
Submission:
type: object
required: [name, email, message, consent]
properties:
name: { type: string, minLength: 2, maxLength: 120 }
email: { type: string, format: email, maxLength: 255 }
message:
type: string
minLength: 20
maxLength: 5000
description: |
The minimum length costs a real person nothing and rules out the
one-word submissions that are never anything but noise.
organisation:
type: string
maxLength: 160
nullable: true
description: Required when `kind` is `request`.
hours_per_week:
type: integer
minimum: 1
maximum: 40
nullable: true
description: |
Mirrors the call's own ask, so the two numbers can be compared
without reading the message.
links:
type: object
maxProperties: 5
additionalProperties: { type: string, format: uri }
example: { github: "https://github.com/ada", web: "https://ada.example" }
consent:
type: boolean
description: |
Must be `true`. The sender is not a user of this system, so their
agreement to have their details stored travels with the payload
rather than being assumed of the frontend.
website:
type: string
nullable: true
description: |
Honeypot — the field name comes from `SITE_HONEYPOT_FIELD`. Never
render it visibly. A submission carrying it is filed as spam
rather than dropped, and still answers 201, so a bot learns
nothing and a mistakenly-tripped human is recoverable.
SubmissionReceipt:
type: object
properties:
data:
type: object
properties:
id: { type: integer }
kind: { type: string, enum: [contribute, request] }
status: { type: string, example: new }
duplicate:
type: boolean
description: True when this repeated an earlier submission.
message:
type: string
description: Confirmation text, already in the visitor's language.
example: Hemos recibido tu mensaje. Te contestamos en cuanto podamos.
PublicProject:
type: object
properties:
id: { type: integer }
locale: { type: string, example: es }
title: { type: string }
slug: { type: string }
tagline: { type: string, nullable: true }
description:
type: string
nullable: true
description: Detail endpoint only.
decisions:
type: string
nullable: true
description: |
Why the technical choices were made. Detail endpoint only.
postmortem:
type: string
nullable: true
description: |
What went wrong and what was learned. Detail endpoint only.
meta_title:
type: string
nullable: true
description: |
What a search engine or a chat preview should show instead of the
title. Detail endpoint only.
meta_description:
type: string
nullable: true
description: Detail endpoint only.
status: { type: string, enum: [idea, active, paused, completed, archived] }
is_featured: { type: boolean }
needs_help:
type: boolean
description: Flagged directly, or carrying at least one open call.
cover_image:
allOf: [{ $ref: "#/components/schemas/Image" }]
nullable: true
gallery:
type: array
description: |
Screenshots and before-and-after, in the order they were arranged
in the panel. **Detail endpoint only** — a listing of twenty
projects has no use for every screenshot of each one.
items:
allOf:
- $ref: "#/components/schemas/Image"
- type: object
properties:
caption:
type: string
nullable: true
description: |
Shown to everyone, unlike `alt`, which is read by
whoever cannot see the image. "Before" and "after"
are captions.
demo_url: { type: string, nullable: true }
client:
allOf: [{ $ref: "#/components/schemas/PublicClient" }]
nullable: true
description: |
The NGO or association the work serves. Used to be two free-text
fields, which made the same organisation two different things.
services:
type: array
items: { $ref: "#/components/schemas/PublicService" }
testimonials:
type: array
items: { $ref: "#/components/schemas/PublicTestimonial" }
talks:
type: array
items: { $ref: "#/components/schemas/PublicTalk" }
started_at: { type: string, format: date, nullable: true }
ended_at: { type: string, format: date, nullable: true }
published_at: { type: string, format: date-time, nullable: true }
unpublish_at:
type: string
format: date-time
nullable: true
description: |
When the record leaves the site. Enforced by the query itself, so
it holds whether or not anything is running. Use it to expire a
cache downstream at the same moment.
updated_at: { type: string, format: date-time }
technologies:
type: array
items: { $ref: "#/components/schemas/PublicTechnology" }
members:
type: array
items: { $ref: "#/components/schemas/PublicMember" }
lead: { $ref: "#/components/schemas/PublicMember" }
calls:
type: array
items: { $ref: "#/components/schemas/PublicCall" }
repositories:
type: array
items: { $ref: "#/components/schemas/PublicRepository" }
alternates: { $ref: "#/components/schemas/Alternates" }
path: { type: string, nullable: true, example: /proyectos/mi-web }
PublicMember:
type: object
properties:
id: { type: integer }
name: { type: string }
slug: { type: string }
headline: { type: string, nullable: true }
bio: { type: string, nullable: true }
avatar:
allOf: [{ $ref: "#/components/schemas/Image" }]
nullable: true
github_username: { type: string, nullable: true }
links:
type: object
additionalProperties: { type: string }
example: { github: "https://github.com/ada", website: "https://ada.dev" }
is_founder: { type: boolean }
is_current:
type: boolean
description: False for people who have left; their work is kept.
joined_at: { type: string, format: date, nullable: true }
left_at: { type: string, format: date, nullable: true }
role:
type: string
nullable: true
description: Their role on a project, when nested under one.
technologies:
type: array
items: { $ref: "#/components/schemas/PublicTechnology" }
projects:
type: array
items: { $ref: "#/components/schemas/PublicProject" }
alternates: { $ref: "#/components/schemas/Alternates" }
path: { type: string, example: /equipo/ada }
PublicCall:
type: object
description: |
A call for contributors. The commitment fields are the point: time is
the scarce resource in a volunteer collective, and stating it plainly
is what lets someone decide.
properties:
id: { type: integer }
locale: { type: string }
title: { type: string }
slug: { type: string }
description: { type: string, nullable: true }
what_you_get:
type: string
nullable: true
description: The explicit exchange offered for unpaid work.
status: { type: string, enum: [open, filled, closed] }
hours_per_week: { type: integer, nullable: true, example: 4 }
duration_weeks: { type: integer, nullable: true, example: 8 }
total_hours: { type: integer, nullable: true, example: 32 }
seniority: { type: string, nullable: true, enum: [any, junior, mid, senior] }
closes_at: { type: string, format: date, nullable: true }
project: { $ref: "#/components/schemas/PublicProject" }
technologies:
type: array
items: { $ref: "#/components/schemas/PublicTechnology" }
alternates: { $ref: "#/components/schemas/Alternates" }
path: { type: string, nullable: true }
PublicTechnology:
type: object
properties:
id: { type: integer }
name:
type: string
description: Not translated — technology names are proper nouns.
example: Laravel
slug: { type: string }
category: { type: string, enum: [language, framework, datastore, infrastructure, tool, practice] }
color: { type: string, nullable: true }
icon: { type: string, nullable: true }
logo:
allOf: [{ $ref: "#/components/schemas/Image" }]
nullable: true
description: |
The technology's own logo. Its `alt` is the technology's name —
there is nothing to write and nothing to get wrong.
website: { type: string, nullable: true }
ring:
type: string
nullable: true
enum: [adopt, trial, assess, hold]
description: Radar placement. Null for technologies used but not placed.
description: { type: string, nullable: true }
radar_note:
type: string
nullable: true
description: Why it sits in that ring.
projects_count: { type: integer }
members_count: { type: integer }
PublicRepository:
type: object
description: Mirrored from the git provider and refreshed hourly.
properties:
id: { type: integer }
provider: { type: string, enum: [github, gitlab, gitea] }
full_name: { type: string, example: colectivo/web }
url: { type: string, nullable: true }
description: { type: string, nullable: true }
primary_language: { type: string, nullable: true }
languages:
type: object
additionalProperties: { type: integer }
description: Bytes of code per language.
topics:
type: array
items: { type: string }
license: { type: string, nullable: true }
stars: { type: integer }
forks: { type: integer }
contributors_count: { type: integer }
open_issues_count: { type: integer }
is_archived: { type: boolean }
is_dormant:
type: boolean
description: |
Nothing pushed for six months. Lets the frontend avoid presenting
dead work as live.
pushed_at: { type: string, format: date-time, nullable: true }
synced_at: { type: string, format: date-time, nullable: true }
good_first_issues:
type: array
items: { $ref: "#/components/schemas/PublicIssue" }
PublicIssue:
type: object
properties:
number: { type: integer }
title: { type: string }
url: { type: string }
labels:
type: array
items: { type: string }
is_good_first_issue: { type: boolean }
is_help_wanted: { type: boolean }
is_available:
type: boolean
description: Nobody has claimed it. A claimed issue is not an invitation.
comments: { type: integer }
created_at: { type: string, format: date-time, nullable: true }
repository:
type: object
properties:
full_name: { type: string }
url: { type: string, nullable: true }
primary_language: { type: string, nullable: true }
project:
type: object
nullable: true
properties:
title: { type: string }
slug: { type: string }
PublicService:
type: object
properties:
id: { type: integer }
locale: { type: string }
title: { type: string }
slug: { type: string }
tagline: { type: string, nullable: true }
description:
type: string
nullable: true
description: Detail endpoint only.
meta_title:
type: string
nullable: true
description: |
What a search engine or a chat preview should show instead of the
title. Detail endpoint only.
meta_description:
type: string
nullable: true
description: Detail endpoint only.
icon:
type: string
nullable: true
description: A Heroicons name, e.g. `heroicon-o-code-bracket`.
is_featured: { type: boolean }
position: { type: integer }
updated_at:
type: string
format: date-time
nullable: true
description: |
So a cache downstream can tell whether its copy is still the
current one.
projects:
type: array
description: The published work that backs the offer. Detail endpoint only.
items: { $ref: "#/components/schemas/PublicProject" }
technologies:
type: array
items: { $ref: "#/components/schemas/PublicTechnology" }
testimonials:
type: array
items: { $ref: "#/components/schemas/PublicTestimonial" }
alternates: { $ref: "#/components/schemas/Alternates" }
path: { type: string, nullable: true }
PublicClient:
type: object
properties:
id: { type: integer }
name:
type: string
description: A proper noun, so it is the same in every language.
slug: { type: string }
sector: { type: string, nullable: true }
url: { type: string, nullable: true }
logo:
allOf: [{ $ref: "#/components/schemas/Image" }]
nullable: true
description: Its alt text is the organisation's name.
projects_count: { type: integer }
PublicTestimonial:
type: object
properties:
id: { type: integer }
quote: { type: string }
original_locale:
type: string
description: The language it was actually said in.
is_original:
type: boolean
description: |
False when the quote you are reading was translated by us. Say so —
"translated from Spanish" — rather than implying they phrased it
that way.
author:
type: object
properties:
name: { type: string }
role: { type: string, nullable: true }
avatar:
allOf: [{ $ref: "#/components/schemas/Image" }]
nullable: true
given_at: { type: string, format: date, nullable: true }
client:
allOf: [{ $ref: "#/components/schemas/PublicClient" }]
nullable: true
PublicTalk:
type: object
properties:
id: { type: integer }
title:
type: string
description: |
Not translated: the name of a talk given once, in one language.
language: { type: string }
summary: { type: string, nullable: true }
event: { type: string }
city: { type: string, nullable: true }
held_at: { type: string, format: date, nullable: true }
video_url: { type: string, nullable: true }
slides_url: { type: string, nullable: true }
speaker:
allOf: [{ $ref: "#/components/schemas/PublicMember" }]
nullable: true
PublicPage:
type: object
properties:
id: { type: integer }
locale: { type: string }
title: { type: string, nullable: true }
slug: { type: string, nullable: true }
excerpt: { type: string, nullable: true }
body: { type: string, nullable: true }
meta_title: { type: string, nullable: true }
meta_description: { type: string, nullable: true }
status: { type: string }
is_featured: { type: boolean }
featured_image:
allOf: [{ $ref: "#/components/schemas/Image" }]
nullable: true
published_at: { type: string, format: date-time, nullable: true }
unpublish_at:
type: string
format: date-time
nullable: true
description: |
When the record leaves the site. Enforced by the query itself, so
it holds whether or not anything is running. Use it to expire a
cache downstream at the same moment.
updated_at: { type: string, format: date-time }
category: { $ref: "#/components/schemas/PublicCategory" }
alternates: { $ref: "#/components/schemas/Alternates" }
path: { type: string, nullable: true }
PublicCategory:
type: object
properties:
id: { type: integer }
name: { type: string, nullable: true }
slug: { type: string, nullable: true }
description: { type: string, nullable: true }
color: { type: string, nullable: true }
position: { type: integer }
pages_count: { type: integer }
Alternates:
type: object
description: |
The same record's path in each language that has a translation. Feed
these straight into `<link rel="alternate" hreflang>`. Languages with
no translation are absent, so no advertised URL ever 404s.
additionalProperties: { type: string }
example:
es: /proyectos/mi-web
en: /en/projects/my-web
ca: /ca/projectes/el-meu-web
TranslatedField:
description: |
Either a plain string (applies to the active language) or an object
keyed by locale.
oneOf:
- type: string
- type: object
additionalProperties: { type: string, nullable: true }
example: { es: "Inicio", en: "Home", ca: "Inici" }
Page:
type: object
properties:
id: { type: integer }
locale:
type: string
description: The language the fields below were resolved into.
example: es
title: { type: string, nullable: true }
slug: { type: string, nullable: true }
excerpt: { type: string, nullable: true }
body: { type: string, nullable: true }
meta_title: { type: string, nullable: true }
meta_description: { type: string, nullable: true }
status: { type: string, enum: [draft, review, published, archived] }
status_label: { type: string, description: Translated status name. }
is_featured: { type: boolean }
featured_image:
type: string
nullable: true
description: |
The stored path, because this is the editing API and it is what a
client PATCHes back. What was derived from it travels alongside.
featured_image_media:
allOf: [{ $ref: "#/components/schemas/Image" }]
nullable: true
readOnly: true
published_at: { type: string, format: date-time, nullable: true }
unpublish_at:
type: string
format: date-time
nullable: true
description: |
When the record leaves the site. Enforced by the query itself, so
it holds whether or not anything is running. Use it to expire a
cache downstream at the same moment.
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
deleted_at: { type: string, format: date-time, nullable: true }
category: { $ref: "#/components/schemas/Category" }
author: { $ref: "#/components/schemas/User" }
featured_image_alt:
type: string
nullable: true
description: |
What the image shows, resolved into the language above — unlike
`PageWrite`'s field of the same name, which carries every
language at once.
translations:
type: object
nullable: true
description: Present only when `?with=translations` was passed.
additionalProperties:
type: object
additionalProperties: { type: string }
PageWrite:
type: object
required: [title, slug]
properties:
title: { $ref: "#/components/schemas/TranslatedField" }
slug:
allOf: [{ $ref: "#/components/schemas/TranslatedField" }]
description: Unique per language. The same slug may be reused across languages.
excerpt: { $ref: "#/components/schemas/TranslatedField" }
body: { $ref: "#/components/schemas/TranslatedField" }
meta_title: { $ref: "#/components/schemas/TranslatedField" }
meta_description: { $ref: "#/components/schemas/TranslatedField" }
category_id: { type: integer, nullable: true }
status: { type: string, enum: [draft, review, published, archived] }
featured_image:
type: string
nullable: true
description: Path returned by the upload, e.g. "pages/hero.jpg".
featured_image_alt:
allOf: [{ $ref: "#/components/schemas/TranslatedField" }]
description: |
What the image shows, per language. Belongs to the record rather
than to the file: the same image can need a different description
depending on where it is used.
is_featured: { type: boolean }
published_at: { type: string, format: date-time, nullable: true }
unpublish_at:
type: string
format: date-time
nullable: true
description: Must be after `published_at`. When it arrives the record leaves the site on its own.
Project:
type: object
properties:
id: { type: integer }
locale: { type: string, description: The language the fields below were resolved into. }
title: { type: string, nullable: true }
slug: { type: string, nullable: true }
tagline: { type: string, nullable: true }
description: { type: string, nullable: true, description: HTML from the panel's rich editor. }
decisions: { type: string, nullable: true, description: HTML. Why the technical choices were made. }
postmortem: { type: string, nullable: true, description: HTML. What went wrong and what was learned. }
meta_title: { type: string, nullable: true }
meta_description: { type: string, nullable: true }
cover_image_alt: { type: string, nullable: true }
status: { type: string, enum: [idea, active, paused, completed, archived] }
is_featured: { type: boolean }
needs_help: { type: boolean }
demo_url: { type: string, nullable: true, format: uri }
cover_image: { type: string, nullable: true, description: The stored path — what a client PATCHes back. }
cover_image_media:
allOf: [{ $ref: "#/components/schemas/Image" }]
nullable: true
readOnly: true
started_at: { type: string, format: date, nullable: true }
ended_at: { type: string, format: date, nullable: true }
published_at: { type: string, format: date-time, nullable: true, description: On the site from then on; null keeps it off. }
unpublish_at: { type: string, format: date-time, nullable: true }
position: { type: integer }
client_id: { type: integer, nullable: true }
lead_member_id: { type: integer, nullable: true }
technology_ids: { type: array, items: { type: integer } }
service_ids: { type: array, items: { type: integer } }
members:
type: array
items:
type: object
properties:
id: { type: integer }
name: { type: string }
role: { type: string, nullable: true }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
deleted_at: { type: string, format: date-time, nullable: true }
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
ProjectWrite:
type: object
required: [title, slug]
properties:
title: { $ref: "#/components/schemas/TranslatedField" }
slug:
allOf: [{ $ref: "#/components/schemas/TranslatedField" }]
description: Unique per language.
tagline: { $ref: "#/components/schemas/TranslatedField" }
description: { $ref: "#/components/schemas/TranslatedField" }
decisions: { $ref: "#/components/schemas/TranslatedField" }
postmortem: { $ref: "#/components/schemas/TranslatedField" }
meta_title: { $ref: "#/components/schemas/TranslatedField" }
meta_description: { $ref: "#/components/schemas/TranslatedField" }
cover_image_alt: { $ref: "#/components/schemas/TranslatedField" }
status: { type: string, enum: [idea, active, paused, completed, archived] }
is_featured: { type: boolean }
needs_help: { type: boolean }
demo_url: { type: string, format: uri, nullable: true }
cover_image: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=projects`. }
started_at: { type: string, format: date, nullable: true }
ended_at: { type: string, format: date, nullable: true }
published_at: { type: string, format: date-time, nullable: true, description: Requires `projects.publish`. }
unpublish_at: { type: string, format: date-time, nullable: true, description: Requires `projects.publish`. }
position: { type: integer, minimum: 0 }
client_id: { type: integer, nullable: true }
lead_member_id: { type: integer, nullable: true }
technology_ids: { type: array, items: { type: integer } }
service_ids: { type: array, items: { type: integer } }
members:
type: array
description: The whole team, replacing the previous one.
items:
type: object
required: [id]
properties:
id: { type: integer }
role: { type: string, nullable: true, maxLength: 120 }
ProjectCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Project" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
Member:
type: object
properties:
id: { type: integer }
locale: { type: string }
name: { type: string }
slug: { type: string }
headline: { type: string, nullable: true }
bio: { type: string, nullable: true, description: Plain text. }
avatar: { type: string, nullable: true, description: The stored path. }
avatar_media:
allOf: [{ $ref: "#/components/schemas/Image" }]
nullable: true
readOnly: true
email: { type: string, nullable: true, description: Never shown on the site. }
github_username: { type: string, nullable: true }
links: { type: object, additionalProperties: { type: string }, description: Label → URL. }
is_public: { type: boolean }
is_founder: { type: boolean }
joined_at: { type: string, format: date, nullable: true }
left_at: { type: string, format: date, nullable: true }
position: { type: integer }
technology_ids: { type: array, items: { type: integer } }
projects:
type: array
items:
type: object
properties:
id: { type: integer }
title: { type: string, nullable: true }
role: { type: string, nullable: true }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
deleted_at: { type: string, format: date-time, nullable: true }
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
MemberWrite:
type: object
required: [name, slug]
properties:
name: { type: string, maxLength: 255 }
slug: { type: string, maxLength: 255, pattern: "^[A-Za-z0-9_-]+$" }
headline: { $ref: "#/components/schemas/TranslatedField" }
bio: { $ref: "#/components/schemas/TranslatedField" }
avatar: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=members`. }
email: { type: string, format: email, nullable: true }
github_username: { type: string, nullable: true, maxLength: 120 }
links: { type: object, nullable: true, additionalProperties: { type: string, format: uri } }
is_public: { type: boolean }
is_founder: { type: boolean }
joined_at: { type: string, format: date, nullable: true }
left_at: { type: string, format: date, nullable: true }
position: { type: integer, minimum: 0 }
technology_ids: { type: array, items: { type: integer } }
MemberCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Member" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
Service:
type: object
properties:
id: { type: integer }
locale: { type: string }
title: { type: string, nullable: true }
slug: { type: string, nullable: true }
tagline: { type: string, nullable: true }
description: { type: string, nullable: true, description: Plain text. }
meta_title: { type: string, nullable: true }
meta_description: { type: string, nullable: true }
icon: { type: string, nullable: true }
is_featured: { type: boolean }
is_published: { type: boolean }
published_at: { type: string, format: date-time, nullable: true }
unpublish_at: { type: string, format: date-time, nullable: true }
position: { type: integer }
technology_ids: { type: array, items: { type: integer } }
project_ids: { type: array, items: { type: integer } }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
deleted_at: { type: string, format: date-time, nullable: true }
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
ServiceWrite:
type: object
required: [title, slug]
properties:
title: { $ref: "#/components/schemas/TranslatedField" }
slug:
allOf: [{ $ref: "#/components/schemas/TranslatedField" }]
description: Unique per language.
tagline: { $ref: "#/components/schemas/TranslatedField" }
description: { $ref: "#/components/schemas/TranslatedField" }
meta_title: { $ref: "#/components/schemas/TranslatedField" }
meta_description: { $ref: "#/components/schemas/TranslatedField" }
icon: { type: string, nullable: true, maxLength: 120, description: "An icon name, e.g. `support`." }
is_featured: { type: boolean }
position: { type: integer, minimum: 0 }
is_published: { type: boolean, description: Requires `services.publish`. }
published_at: { type: string, format: date-time, nullable: true, description: Requires `services.publish`. }
unpublish_at: { type: string, format: date-time, nullable: true, description: Requires `services.publish`. }
technology_ids: { type: array, items: { type: integer } }
project_ids: { type: array, items: { type: integer } }
ServiceCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Service" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
MediaFor:
type: string
enum: [pages, technologies, projects, members]
default: pages
description: The kind of record the image is for.
Technology:
type: object
properties:
id: { type: integer }
locale: { type: string, description: The language the prose below was resolved into. }
name: { type: string, description: A proper noun — the same in every language. }
slug: { type: string }
description: { type: string, nullable: true }
radar_note: { type: string, nullable: true, description: Why it sits in its ring. }
category: { type: string, enum: [language, framework, datastore, infrastructure, tool, practice] }
radar_ring:
type: string
enum: [adopt, trial, assess, hold]
nullable: true
description: Null keeps it off the radar; projects can still use it.
color: { type: string, nullable: true, example: "#DD0031" }
logo:
type: string
nullable: true
description: The stored path — what `POST /v1/media` answered — and what a client PATCHes back.
logo_media:
allOf: [{ $ref: "#/components/schemas/Image" }]
nullable: true
readOnly: true
website: { type: string, nullable: true, format: uri }
position: { type: integer }
projects_count: { type: integer }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
translations:
type: object
nullable: true
description: Every language of `description` and `radar_note`, with `?with=translations`.
TechnologyWrite:
type: object
required: [name, slug]
properties:
name: { type: string, maxLength: 120 }
slug: { type: string, maxLength: 120, pattern: "^[A-Za-z0-9_-]+$" }
description: { $ref: "#/components/schemas/TranslatedField" }
radar_note: { $ref: "#/components/schemas/TranslatedField" }
category:
type: string
enum: [language, framework, datastore, infrastructure, tool, practice]
default: tool
radar_ring: { type: string, enum: [adopt, trial, assess, hold], nullable: true }
color: { type: string, nullable: true, pattern: "^#[0-9a-fA-F]{6}$" }
logo:
type: string
nullable: true
description: A path from `POST /v1/media`, uploaded with `for=technologies`.
website: { type: string, format: uri, nullable: true, maxLength: 255 }
position: { type: integer, minimum: 0 }
TechnologyCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Technology" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
Media:
type: object
properties:
path:
type: string
description: What a record stores, e.g. as a page's `featured_image`.
example: pages/Xk3fQ9sLw2PmN8vR1tY6uZ0aB4cD7eF5gH2jK9mN.jpg
url: { type: string, format: uri, description: Absolute. }
mime: { type: string, nullable: true, example: image/jpeg }
size: { type: integer, nullable: true, description: Bytes. }
width: { type: integer, nullable: true }
height: { type: integer, nullable: true }
processed:
type: boolean
description: |
Whether the queue has derived the smaller versions and the
placeholder yet. The image serves either way.
Category:
type: object
properties:
id: { type: integer }
locale: { type: string, example: es }
name: { type: string, nullable: true }
slug: { type: string, nullable: true }
description: { type: string, nullable: true }
color: { type: string, nullable: true, example: "#4f46e5" }
position: { type: integer }
is_visible: { type: boolean }
pages_count: { type: integer }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
translations:
type: object
nullable: true
description: Present only when `?with=translations` was passed.
additionalProperties:
type: object
additionalProperties: { type: string }
CategoryWrite:
type: object
required: [name, slug]
properties:
name: { $ref: "#/components/schemas/TranslatedField" }
slug: { $ref: "#/components/schemas/TranslatedField" }
description: { $ref: "#/components/schemas/TranslatedField" }
color: { type: string, pattern: '^#[0-9a-fA-F]{6}$' }
position: { type: integer, minimum: 0 }
is_visible: { type: boolean }
User:
type: object
properties:
id: { type: integer, description: Local id. }
oauth_id: { type: integer, nullable: true, description: This person's id on the identity server. }
name: { type: string }
email: { type: string, format: email }
avatar_url: { type: string, nullable: true }
locale: { type: string, nullable: true, enum: [es, en, ca] }
is_active: { type: boolean }
last_login_at: { type: string, format: date-time, nullable: true }
created_at: { type: string, format: date-time }
roles:
type: array
items: { type: string }
description: Local roles — what this person may do in this app.
example: [editor]
permissions:
type: array
items: { type: string }
nullable: true
description: Effective local permissions. Returned by `/v1/me` only.
remote_roles:
type: array
items: { type: string }
description: |
Roles reported by the identity server. Informational only; they
grant nothing here.
Role:
type: object
properties:
id: { type: integer }
name: { type: string, example: editor }
guard_name: { type: string, example: web }
users_count: { type: integer }
permissions:
type: array
items: { type: string }
created_at: { type: string, format: date-time }
PasswordGrantRequest:
type: object
required: [grant_type, username, password]
properties:
grant_type: { type: string, enum: [password] }
username: { type: string, format: email }
password: { type: string, format: password }
scope: { type: string, example: "account:read" }
RefreshGrantRequest:
type: object
required: [grant_type, refresh_token]
properties:
grant_type: { type: string, enum: [refresh_token] }
refresh_token: { type: string }
TokenResponse:
type: object
properties:
access_token: { type: string }
refresh_token: { type: string, nullable: true }
token_type: { type: string, example: Bearer }
expires_at: { type: string, format: date-time, nullable: true }
scopes:
type: array
items: { type: string }
user:
type: object
properties:
id: { type: integer }
name: { type: string }
email: { type: string }
roles:
type: array
items: { type: string }
permissions:
type: array
items: { type: string }
PaginationMeta:
type: object
description: |
Laravel's own paginator shape, sent as-is — nothing on this side
trims it down, so this describes exactly what `->paginate()` sends
rather than a curated subset of it.
properties:
current_page: { type: integer }
from: { type: integer, nullable: true, description: Null on an empty page. }
last_page: { type: integer }
links:
type: array
description: One entry per page number shown by a "1 2 3 … 9" control, plus prev/next.
items:
type: object
properties:
url: { type: string, nullable: true }
label: { type: string, description: "Already translated — e.g. «Anterior», not «Previous»." }
page: { type: integer, nullable: true }
active: { type: boolean }
path: { type: string }
per_page: { type: integer }
to: { type: integer, nullable: true, description: Null on an empty page. }
total: { type: integer }
PaginationLinks:
type: object
description: The four addresses a client actually navigates with, ready to use verbatim.
properties:
first: { type: string }
last: { type: string }
prev: { type: string, nullable: true }
next: { type: string, nullable: true }
PageCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Page" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
CategoryCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Category" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }