Manage technologies through the API, with SVG logos
/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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user