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:
co-authored by
Claude Fable 5
parent
5a18505571
commit
fd0e730656
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user