feat(backend): Taiga-Proxy — vier schmale Endpunkte, Ziel-URL aus der Server-Konfiguration (D91, #trk.create.proxy)

API First: /taiga/auth, /taiga/projects, /taiga/userstories, /taiga/tasks
in der OpenAPI-Spec; TaigaClient/TaigaProperties in
de.werkbaum.integration.taiga. Die API-URL kommt aus
WERKBAUM_TAIGA_API_URL (nie Request-Parameter — SSRF), das Token je
Aufruf im Header X-Taiga-Token (Authorization muessen OpenAPI-Werkzeuge
als Header-Parameter ignorieren) und geht als Bearer hinaus; der Server
speichert nichts und loggt keine Request-Bodies. Taiga-4xx werden samt
_error_message durchgereicht, 5xx/Netz sind 502, unkonfiguriert 503 —
und GET /info meldet das Feature (taiga). Tests gegen aufgezeichnete
Antwortformen auf einem JDK-HttpServer-Stub (statt WireMock: keine neue
Test-Abhaengigkeit, dieselbe Zusicherung); Gegenprobe: ohne den
type-Durchreich faellt genau der benannte Test. check gruen, 93 %
Coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-27 16:33:35 +02:00
co-authored by Claude Fable 5
parent 5a18505571
commit fd0e730656
15 changed files with 1015 additions and 5 deletions
@@ -33,8 +33,9 @@ class CorsConfiguration(private val properties: CorsProperties) {
val config = CorsConfiguration().apply {
allowedOriginPatterns = properties.allowedOrigins
allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
// Authorization fuer das Master-Passwort, Content-Type fuer JSON.
allowedHeaders = listOf("Authorization", "Content-Type")
// Authorization fuer das Master-Passwort, Content-Type fuer JSON,
// X-Taiga-Token fuer den Taiga-Proxy (D91).
allowedHeaders = listOf("Authorization", "Content-Type", "X-Taiga-Token")
// Nichts Vertrauliches im Spiel; Cookies werden nie mitgesendet.
allowCredentials = false
maxAge = 3600
@@ -18,6 +18,7 @@ import de.werkbaum.domain.ChangeFeed
import de.werkbaum.domain.ContentPatch
import de.werkbaum.domain.Document
import de.werkbaum.domain.DocumentHistoryEntry
import de.werkbaum.integration.taiga.TaigaProperties
import de.werkbaum.service.DocumentService
import de.werkbaum.service.LiveEditingService
import org.springframework.boot.info.BuildProperties
@@ -45,6 +46,7 @@ class DocumentsController(
* nicht — dann fehlt die Zusatzangabe, statt dass der Start scheitert.
*/
private val buildProperties: BuildProperties? = null,
private val taigaProperties: TaigaProperties,
) : DocumentsApi {
/**
@@ -60,6 +62,9 @@ class DocumentsController(
name = buildProperties?.name ?: "werkbaum-backend",
version = buildProperties?.version ?: "unbekannt",
builtAt = buildProperties?.time?.atOffset(java.time.ZoneOffset.UTC),
// Feature-Meldung des Taiga-Proxys (D91): Der Editor zeigt die
// Ticket-Aktionen nur, wo ein konfiguriertes Backend antwortet.
taiga = taigaProperties.configured,
)
)
@@ -8,6 +8,9 @@ import de.werkbaum.service.DocumentDeletedException
import de.werkbaum.service.DocumentNotFoundException
import de.werkbaum.service.InvalidPatchException
import de.werkbaum.service.StalePatchSequenceException
import de.werkbaum.integration.taiga.TaigaNotConfiguredException
import de.werkbaum.integration.taiga.TaigaUnavailableException
import de.werkbaum.integration.taiga.TaigaUpstreamException
import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.http.ResponseEntity
@@ -75,4 +78,33 @@ class GlobalExceptionHandler {
HttpStatus.BAD_REQUEST,
ex.message ?: "Ungültige Anfrage",
).apply { title = "Ungültige Anfrage" }
/* ---- Taiga-Proxy (D91) ---- */
/** Kein Ziel konfiguriert: 503 — der Editor fragt vorher `GET /info`. */
@ExceptionHandler(TaigaNotConfiguredException::class)
fun handleTaigaNotConfigured(ex: TaigaNotConfiguredException): ProblemDetail =
ProblemDetail.forStatusAndDetail(
HttpStatus.SERVICE_UNAVAILABLE,
ex.message ?: "Taiga nicht konfiguriert",
).apply { title = "Taiga nicht konfiguriert" }
@ExceptionHandler(TaigaUnavailableException::class)
fun handleTaigaUnavailable(ex: TaigaUnavailableException): ProblemDetail =
ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_GATEWAY,
ex.message ?: "Taiga nicht erreichbar",
).apply { title = "Taiga nicht erreichbar" }
/**
* Taiga hat mit einem Fehler geantwortet: 4xx wird durchgereicht — Taiga
* meldet z. B. falsche Zugangsdaten als 400, und der Text hilft dem
* Benutzer —, ein fremder 5xx wird zu 502.
*/
@ExceptionHandler(TaigaUpstreamException::class)
fun handleTaigaUpstream(ex: TaigaUpstreamException): ProblemDetail =
ProblemDetail.forStatusAndDetail(
if (ex.status in 400..499) HttpStatus.valueOf(ex.status) else HttpStatus.BAD_GATEWAY,
ex.message ?: "Taiga-Fehler",
).apply { title = "Taiga-Fehler" }
}
@@ -0,0 +1,79 @@
package de.werkbaum.api
import de.werkbaum.generated.api.TaigaApi
import de.werkbaum.generated.model.TaigaAuthRequest
import de.werkbaum.generated.model.TaigaProject
import de.werkbaum.generated.model.TaigaSession
import de.werkbaum.generated.model.TaigaStoryCreateRequest
import de.werkbaum.generated.model.TaigaTaskCreateRequest
import de.werkbaum.generated.model.TaigaTicket
import de.werkbaum.integration.taiga.TaigaClient
import de.werkbaum.integration.taiga.TaigaTicketData
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
/**
* Der Taiga-Proxy (D91) — implementiert das generierte Interface, wie der
* DocumentsController seines: Ändert sich die Spezifikation, schlägt hier
* der Compile fehl (API First).
*
* Hier gibt es nur die Abbildung API ↔ Client; alles Inhaltliche —
* Ziel-URL aus der Server-Konfiguration, Fehlerklassen, das schmale
* Antwortformat — liegt im [TaigaClient]. Kein Logging von Request-Bodies:
* Der Auth-Endpunkt sieht das Passwort nur im Durchflug.
*/
@RestController
@RequestMapping("/api/v1")
class TaigaController(private val client: TaigaClient) : TaigaApi {
override fun taigaLogin(taigaAuthRequest: TaigaAuthRequest): ResponseEntity<TaigaSession> {
val session = client.login(taigaAuthRequest.username, taigaAuthRequest.password)
return ResponseEntity.ok(
TaigaSession(
authToken = session.authToken,
userId = session.userId,
username = session.username,
fullName = session.fullName,
)
)
}
override fun taigaProjects(xTaigaToken: String, member: Long): ResponseEntity<List<TaigaProject>> =
ResponseEntity.ok(
client.projects(xTaigaToken, member).map {
TaigaProject(id = it.id, name = it.name, slug = it.slug)
}
)
override fun taigaCreateStory(
xTaigaToken: String,
taigaStoryCreateRequest: TaigaStoryCreateRequest,
): ResponseEntity<TaigaTicket> {
val ticket = client.createStory(
token = xTaigaToken,
project = taigaStoryCreateRequest.project,
subject = taigaStoryCreateRequest.subject,
)
return created(ticket)
}
override fun taigaCreateTask(
xTaigaToken: String,
taigaTaskCreateRequest: TaigaTaskCreateRequest,
): ResponseEntity<TaigaTicket> {
val ticket = client.createTask(
token = xTaigaToken,
project = taigaTaskCreateRequest.project,
subject = taigaTaskCreateRequest.subject,
userStory = taigaTaskCreateRequest.userStory,
)
return created(ticket)
}
private fun created(ticket: TaigaTicketData): ResponseEntity<TaigaTicket> =
ResponseEntity.status(HttpStatus.CREATED).body(
TaigaTicket(id = ticket.id, ref = ticket.ref, subject = ticket.subject)
)
}
@@ -0,0 +1,139 @@
package de.werkbaum.integration.taiga
import org.springframework.core.ParameterizedTypeReference
import org.springframework.http.MediaType
import org.springframework.http.client.JdkClientHttpRequestFactory
import org.springframework.stereotype.Service
import org.springframework.web.client.ResourceAccessException
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientResponseException
import java.net.http.HttpClient
import java.time.Duration
/** Keine Taiga-Instanz konfiguriert — der Proxy hat kein Ziel (503). */
class TaigaNotConfiguredException :
RuntimeException("Keine Taiga-Instanz konfiguriert (werkbaum.taiga.api-url)")
/** Taiga nicht erreichbar oder mit unbrauchbarer Antwort (502). */
class TaigaUnavailableException(message: String, cause: Throwable? = null) :
RuntimeException(message, cause)
/**
* Taiga hat mit einem Fehlerstatus geantwortet. 4xx wird durchgereicht
* (Taiga meldet z. B. falsche Zugangsdaten als 400), 5xx wird zu 502 —
* ein fremder Serverfehler ist aus Client-Sicht „Upstream kaputt“.
*/
class TaigaUpstreamException(val status: Int, message: String) : RuntimeException(message)
data class TaigaSessionData(
val authToken: String,
val userId: Long,
val username: String,
val fullName: String?,
)
data class TaigaProjectData(val id: Long, val name: String, val slug: String)
data class TaigaTicketData(val id: Long, val ref: Long, val subject: String)
/**
* Schmaler, benannter Client zur konfigurierten Taiga-Instanz (D91) — kein
* Durchreich-Proxy: genau die vier Aufrufe, die die Ticket-Anlage braucht.
*
* Das Token kommt je Aufruf vom Browser herein und geht als
* `Authorization: Bearer …` hinaus; der Server **speichert nichts** und
* **loggt keine Request-Bodies** (der Auth-Endpunkt sieht das Passwort nur
* im Durchflug). Die Antworten werden als Maps gelesen und auf die schmalen
* Datenklassen abgebildet — so hängt nichts an Taigas übrigen Feldern.
*/
@Service
class TaigaClient(private val properties: TaigaProperties) {
private val rest: RestClient = RestClient.builder()
.requestFactory(
JdkClientHttpRequestFactory(
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()
).apply { setReadTimeout(Duration.ofSeconds(20)) }
)
.build()
fun login(username: String, password: String): TaigaSessionData {
val map = exchange {
rest.post().uri(url("/auth"))
.contentType(MediaType.APPLICATION_JSON)
.body(mapOf("type" to properties.authType, "username" to username, "password" to password))
.retrieve().body(MAP)
} ?: throw TaigaUnavailableException("Leere Antwort von Taiga (/auth)")
return TaigaSessionData(
authToken = str(map, "auth_token"),
userId = num(map, "id"),
username = str(map, "username"),
fullName = map["full_name"] as? String,
)
}
fun projects(token: String, member: Long): List<TaigaProjectData> {
val list = exchange {
rest.get().uri(url("/projects?member=$member&order_by=user_order"))
.header("Authorization", "Bearer $token")
// Taiga paginiert sonst bei 30 — die Projektliste eines
// Nutzers soll vollständig sein.
.header("x-disable-pagination", "1")
.retrieve().body(LIST)
} ?: emptyList()
return list.map { TaigaProjectData(num(it, "id"), str(it, "name"), str(it, "slug")) }
}
fun createStory(token: String, project: Long, subject: String): TaigaTicketData =
create(token, "/userstories", mapOf("project" to project, "subject" to subject))
fun createTask(token: String, project: Long, subject: String, userStory: Long): TaigaTicketData =
// Taigas Feldname; unsere API sagt `userStory` (camelCase wie überall).
create(token, "/tasks", mapOf("project" to project, "subject" to subject, "user_story" to userStory))
private fun create(token: String, path: String, body: Map<String, Any>): TaigaTicketData {
val map = exchange {
rest.post().uri(url(path))
.header("Authorization", "Bearer $token")
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve().body(MAP)
} ?: throw TaigaUnavailableException("Leere Antwort von Taiga ($path)")
return TaigaTicketData(id = num(map, "id"), ref = num(map, "ref"), subject = str(map, "subject"))
}
private fun url(path: String): String {
if (!properties.configured) throw TaigaNotConfiguredException()
return properties.apiUrl.trimEnd('/') + path
}
private fun <T> exchange(call: () -> T): T =
try {
call()
} catch (e: RestClientResponseException) {
// Der Fehlertext kommt aus Taigas ANTWORT (`_error_message`) —
// nie aus der Anfrage; Zugangsdaten stehen darin nicht.
throw TaigaUpstreamException(e.statusCode.value(), errorMessage(e))
} catch (e: ResourceAccessException) {
throw TaigaUnavailableException("Taiga-Instanz nicht erreichbar: ${e.message}", e)
}
private fun errorMessage(e: RestClientResponseException): String {
val fromBody = Regex("\"_error_message\"\\s*:\\s*\"([^\"]*)\"")
.find(e.responseBodyAsString)?.groupValues?.get(1)
return fromBody ?: "Taiga antwortete mit ${e.statusCode.value()}"
}
private fun str(m: Map<String, Any?>, key: String): String =
m[key] as? String
?: throw TaigaUnavailableException("Unerwartete Taiga-Antwort: Feld '$key' fehlt")
private fun num(m: Map<String, Any?>, key: String): Long =
(m[key] as? Number)?.toLong()
?: throw TaigaUnavailableException("Unerwartete Taiga-Antwort: Feld '$key' fehlt")
companion object {
private val MAP = object : ParameterizedTypeReference<Map<String, Any?>>() {}
private val LIST = object : ParameterizedTypeReference<List<Map<String, Any?>>>() {}
}
}
@@ -0,0 +1,32 @@
package de.werkbaum.integration.taiga
import org.springframework.boot.context.properties.ConfigurationProperties
/**
* Der Taiga-Proxy (D91).
*
* Die Basis-URL der Taiga-API ist **Server-Konfiguration**, nie
* Request-Parameter — ein Proxy, der sein Ziel vom Aufrufer nimmt, ist ein
* offenes Relay (die SSRF-Falle naiver Proxies). Leer heißt: Feature aus;
* alle Taiga-Endpunkte antworten dann mit 503, und `GET /info` meldet
* `taiga: false`, sodass der Editor die Aktionen gar nicht erst zeigt.
*/
@ConfigurationProperties(prefix = "werkbaum.taiga")
data class TaigaProperties(
/**
* Basis-URL der Taiga-**API**, nicht des Frontends — bei der Zielinstanz
* liegt sie auf einem eigenen Host (`https://plan-api.hostsharing.net/api/v1`,
* aus deren `conf.json` gelesen; D91-Nachtrag 1).
*/
val apiUrl: String = "",
/**
* Login-Typ für `POST /auth`: `ldap` (LDAP-Plugin, so die Zielinstanz)
* oder `normal`. Nur der Auth-Endpunkt braucht ihn; bei der angekündigten
* OIDC-Umstellung wird er durch den Redirect-Flow ersetzt.
*/
val authType: String = "ldap",
) {
val configured: Boolean get() = apiUrl.isNotBlank()
}
@@ -50,6 +50,16 @@ werkbaum:
cors:
allowed-origins: "*"
# Taiga-Proxy (D91): Die Basis-URL der Taiga-API ist SERVER-Konfiguration,
# nie Request-Parameter (SSRF-Falle naiver Proxies). Leer = Feature aus;
# GET /info meldet es (taiga). Achtung: die API-URL, nicht das Frontend -
# bei der Zielinstanz z. B. https://plan-api.hostsharing.net/api/v1
taiga:
api-url: ${WERKBAUM_TAIGA_API_URL:}
# Login-Typ der Instanz fuer POST /taiga/auth: "ldap" (LDAP-Plugin,
# plan.hostsharing.net) oder "normal".
auth-type: ${WERKBAUM_TAIGA_AUTH_TYPE:ldap}
# Schutz der Dokumentenliste. BCrypt-Hash, NIE im Repository - er kommt aus
# der Umgebung. Ohne ihn bleibt GET /documents gesperrt.
master-password:
+291
View File
@@ -20,6 +20,14 @@ servers:
tags:
- name: Documents
description: Verwaltung von Dokumenten
- name: Taiga
description: >
Schmaler, benannter Proxy zur konfigurierten Taiga-Instanz (D91).
Kein Durchreich-Proxy: Die Taiga-Basis-URL ist Server-Konfiguration
(`werkbaum.taiga.api-url`), nie Request-Parameter - die SSRF-Falle
naiver Proxies. Das Token bleibt im Browser; der Server speichert
nichts. Ohne konfigurierte Instanz antworten alle Taiga-Endpunkte
mit 503; ob sie konfiguriert ist, meldet `GET /info` (`taiga`).
paths:
/documents:
@@ -363,6 +371,159 @@ paths:
"404":
$ref: "#/components/responses/NotFound"
/taiga/auth:
post:
tags: [Taiga]
operationId: taigaLogin
summary: Bei Taiga anmelden (Proxy)
description: >
Reicht Benutzername und Passwort einmalig an die konfigurierte
Taiga-Instanz durch (`POST <api-url>/auth`, mit dem serverseitig
konfigurierten Login-Typ, Voreinstellung `ldap` - D91-Nachtrag 1).
Der Endpunkt sieht das Passwort nur im Durchflug: Der Server
speichert nichts und loggt den Request-Body nie; das Token gehoert
dem Browser. Bei der angekuendigten OIDC-Umstellung der Instanz wird
dieser Endpunkt durch den Redirect-Flow ersetzt - die uebrigen
Taiga-Endpunkte bleiben unveraendert (sie nehmen nur das Token).
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaAuthRequest"
responses:
"200":
description: Anmeldung gelungen
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaSession"
"400":
description: >
Zugangsdaten abgelehnt - Taiga meldet falsche Anmeldedaten als
400, der Status wird durchgereicht.
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"502":
$ref: "#/components/responses/TaigaUnavailable"
"503":
$ref: "#/components/responses/TaigaNotConfigured"
/taiga/projects:
get:
tags: [Taiga]
operationId: taigaProjects
summary: Projekte des angemeldeten Nutzers auflisten (Proxy)
description: >
`GET <api-url>/projects?member=<userId>` - die Auswahlliste des
Projekt-Dialogs der Ticket-Anlage. Der `slug` ist zugleich der Wert
des Schlagworts `&taiga.<slug>` (SPEC par. 1).
parameters:
- $ref: "#/components/parameters/TaigaToken"
- name: member
in: query
required: true
description: Taiga-Benutzer-Id aus der Sitzung; filtert auf die eigenen Projekte.
schema:
type: integer
format: int64
responses:
"200":
description: Projekte des Nutzers
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/TaigaProject"
"401":
description: Token fehlt oder ist abgelaufen
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"502":
$ref: "#/components/responses/TaigaUnavailable"
"503":
$ref: "#/components/responses/TaigaNotConfigured"
/taiga/userstories:
post:
tags: [Taiga]
operationId: taigaCreateStory
summary: User Story anlegen (Proxy)
description: >
`POST <api-url>/userstories`. Die Antwort traegt die projektweite
`ref` - Werkbaum schreibt daraus `#US-<ref>` als Token an die
Knotenzeile (SPEC par. 11, D91-Nachtrag 2).
parameters:
- $ref: "#/components/parameters/TaigaToken"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaStoryCreateRequest"
responses:
"201":
description: Story wurde angelegt
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaTicket"
"400":
$ref: "#/components/responses/BadRequest"
"401":
description: Token fehlt oder ist abgelaufen
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"502":
$ref: "#/components/responses/TaigaUnavailable"
"503":
$ref: "#/components/responses/TaigaNotConfigured"
/taiga/tasks:
post:
tags: [Taiga]
operationId: taigaCreateTask
summary: Task unter einer User Story anlegen (Proxy)
description: >
`POST <api-url>/tasks`. Tasks haengen immer an einer Story
(`userStory` ist die Id, nicht die Ref) - storyless Tasks sind im
Kanban unsichtbar und werden bewusst nicht angeboten (D91). Die
Antwort traegt die `ref`; Werkbaum schreibt daraus `#T-<ref>`.
parameters:
- $ref: "#/components/parameters/TaigaToken"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaTaskCreateRequest"
responses:
"201":
description: Task wurde angelegt
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaTicket"
"400":
$ref: "#/components/responses/BadRequest"
"401":
description: Token fehlt oder ist abgelaufen
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"502":
$ref: "#/components/responses/TaigaUnavailable"
"503":
$ref: "#/components/responses/TaigaNotConfigured"
/info:
get:
tags: [Documents]
@@ -383,7 +544,37 @@ paths:
$ref: "#/components/schemas/ServiceInfo"
components:
parameters:
TaigaToken:
name: X-Taiga-Token
in: header
required: true
description: >
Das Taiga-Token aus `POST /taiga/auth`, nackt (ohne `Bearer `-Praefix).
Der Proxy setzt daraus den `Authorization: Bearer <token>`-Header der
Weiterleitung. Bewusst ein eigener Header-Name: Einen Header-Parameter
namens `Authorization` muessen OpenAPI-Werkzeuge laut Spezifikation
ignorieren, und der Name kollidierte mit dem Master-Passwort (Basic).
schema:
type: string
maxLength: 512
responses:
TaigaNotConfigured:
description: >
Keine Taiga-Instanz konfiguriert (`werkbaum.taiga.api-url`) - der
Editor fragt vorher `GET /info` (`taiga`) und zeigt die Aktionen
dann gar nicht erst.
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
TaigaUnavailable:
description: Taiga-Instanz nicht erreichbar oder antwortet fehlerhaft
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
NotFound:
description: Ressource nicht gefunden
content:
@@ -675,6 +866,106 @@ components:
type: string
format: date-time
description: Fehlt, wenn ohne Build-Informationen gestartet (z. B. aus der IDE).
taiga:
type: boolean
description: >
true, wenn eine Taiga-Instanz konfiguriert ist
(`werkbaum.taiga.api-url`) - der Editor zeigt die Ticket-Aktionen
im Knoten-Fenster nur dann (D91).
TaigaAuthRequest:
type: object
required: [username, password]
properties:
username:
type: string
minLength: 1
maxLength: 255
password:
type: string
minLength: 1
maxLength: 255
description: Wird nur durchgereicht - nie gespeichert, nie geloggt.
TaigaSession:
type: object
required: [authToken, userId, username]
properties:
authToken:
type: string
description: >
Bearer-Token der Taiga-Sitzung. Es gehoert dem Browser; der
Server merkt sich nichts davon.
userId:
type: integer
format: int64
description: Taiga-Benutzer-Id - der `member`-Filter der Projektliste.
username:
type: string
fullName:
type: string
TaigaProject:
type: object
required: [id, name, slug]
properties:
id:
type: integer
format: int64
name:
type: string
slug:
type: string
description: >
Zugleich der Wert des Schlagworts `&taiga.<slug>` im
Notationstext (SPEC par. 1, D91-Nachtrag 3).
TaigaStoryCreateRequest:
type: object
required: [project, subject]
properties:
project:
type: integer
format: int64
description: Taiga-Projekt-Id (aus der Projektliste).
subject:
type: string
minLength: 1
maxLength: 500
TaigaTaskCreateRequest:
type: object
required: [project, subject, userStory]
properties:
project:
type: integer
format: int64
subject:
type: string
minLength: 1
maxLength: 500
userStory:
type: integer
format: int64
description: Id (nicht Ref) der User Story, unter der die Task haengt.
TaigaTicket:
type: object
required: [id, ref, subject]
properties:
id:
type: integer
format: int64
ref:
type: integer
format: int64
description: >
Projektweite Nummer, fortlaufend ueber alle Typen. Werkbaum
schreibt daraus `#US-<ref>` bzw. `#T-<ref>` an die Knotenzeile -
die Praefixe traegt Werkbaum selbst, Taiga zeigt nur `#<ref>`
(D91-Nachtrag 2).
subject:
type: string
ProblemDetail:
type: object