Manage technologies through the API, with SVG logos
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

/api/v1/technologies is a full CRUD with the panel's own rules: name and
slug are plain strings, the prose is translatable, `radar_ring: null`
takes one off the radar, and TechnologyPolicy still refuses to delete
one a project, member or call uses. The MCP server picks the tools up
from openapi.yaml on its own.

A logo is an SVG, so POST /v1/media now accepts one — sanitized with
enshrined/svg-sanitize (scripts, event handlers and remote references
removed), and served by Caddy under a Content-Security-Policy that
would stop a script regardless. `for` (pages or technologies) says what
an upload is for, which picks the directory the panel uses for it and
the permission it takes.

Creating a technology without a category answered 500: the column's
default never reached the model, so the response read a null enum. The
controller now states the default, as the panel does.

Beside a file, the MCP upload tool no longer demands the JSON body's
`url`: the body then carries the upload's other fields, such as `for`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 19:49:00 +02:00
parent a49816eb46
commit ac546eba2b
21 changed files with 789 additions and 38 deletions

View File

@@ -106,6 +106,8 @@ tags:
description: Translatable page content
- name: Categories
description: Translatable page categories
- name: Technologies
description: What the collective uses, and its place on the tech radar
- name: Users
description: Local user administration
- name: Media
@@ -1174,6 +1176,109 @@ paths:
"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/media:
post:
tags: [Media]
@@ -1188,12 +1293,19 @@ paths:
reserved or loopback address is refused — and redirects are not
followed: send the image's final address.
JPEG, PNG, WebP, GIF or AVIF, decided from the file's bytes rather than
its name or declared type; SVG is not accepted here. 20 MB at most.
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 pages.
either way. Requires permission to create or edit the records named in
`for`.
requestBody:
required: true
content:
@@ -1206,6 +1318,7 @@ paths:
type: string
format: binary
description: The image.
for: { $ref: "#/components/schemas/MediaFor" }
application/json:
schema:
type: object
@@ -1216,6 +1329,7 @@ paths:
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
@@ -2181,6 +2295,76 @@ components:
nullable: true
description: Must be after `published_at`. When it arrives the record leaves the site on its own.
MediaFor:
type: string
enum: [pages, technologies]
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: