initial backend: Dokumente, Historie, API und Persistenz (D76)
- Liquibase-Schema (`document`, `document_history`) + Rollback-scripts
- Spring Boot with JPA-repositories, entities and services
- REST-API (`/documents`, `/documents/{id}`, `/documents/{id}/history`)
- OpenAPI-specifikation for CRUD-Operationen and history
- config files (`application.yaml`, `Liquibase`, H2 im PostgreSQL-Modus)
- preps for future live-editing/delta-updates
- Exceptions for conflikt- and not-found cases (409/404)
- keeping document hostory even after `delete` for RESTORE functionality
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
package com.example.editor
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
|
||||
@SpringBootApplication
|
||||
class EditorBackendApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<EditorBackendApplication>(*args)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.example.editor.api
|
||||
|
||||
import com.example.editor.generated.api.DocumentsApi
|
||||
import com.example.editor.generated.model.Document as ApiDocument
|
||||
import com.example.editor.generated.model.DocumentCreateRequest
|
||||
import com.example.editor.generated.model.DocumentHistoryEntry as ApiHistoryEntry
|
||||
import com.example.editor.generated.model.DocumentUpdateRequest
|
||||
import com.example.editor.generated.model.RestoreRequest
|
||||
import com.example.editor.domain.Document
|
||||
import com.example.editor.domain.DocumentHistoryEntry
|
||||
import com.example.editor.service.DocumentService
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Implementiert das aus der OpenAPI-Spezifikation generierte Interface.
|
||||
* Aendert sich die Spezifikation, schlaegt hier der Compile fehl – so bleibt
|
||||
* die Implementierung immer synchron zum Vertrag (API First).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
class DocumentsController(
|
||||
private val service: DocumentService,
|
||||
) : DocumentsApi {
|
||||
|
||||
override fun listDocuments(): ResponseEntity<List<ApiDocument>> =
|
||||
ResponseEntity.ok(service.findAll().map { it.toApi() })
|
||||
|
||||
override fun createDocument(documentCreateRequest: DocumentCreateRequest): ResponseEntity<ApiDocument> {
|
||||
val created = service.create(
|
||||
title = documentCreateRequest.title,
|
||||
content = documentCreateRequest.content,
|
||||
)
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(created.toApi())
|
||||
}
|
||||
|
||||
override fun getDocument(documentId: UUID): ResponseEntity<ApiDocument> =
|
||||
ResponseEntity.ok(service.findById(documentId).toApi())
|
||||
|
||||
override fun updateDocument(
|
||||
documentId: UUID,
|
||||
documentUpdateRequest: DocumentUpdateRequest,
|
||||
): ResponseEntity<ApiDocument> {
|
||||
val updated = service.update(
|
||||
id = documentId,
|
||||
title = documentUpdateRequest.title,
|
||||
content = documentUpdateRequest.content,
|
||||
)
|
||||
return ResponseEntity.ok(updated.toApi())
|
||||
}
|
||||
|
||||
override fun deleteDocument(documentId: UUID): ResponseEntity<Unit> {
|
||||
service.delete(documentId)
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
|
||||
override fun getDocumentHistory(documentId: UUID): ResponseEntity<List<ApiHistoryEntry>> =
|
||||
ResponseEntity.ok(service.history(documentId).map { it.toApi() })
|
||||
|
||||
override fun restoreDocument(
|
||||
documentId: UUID,
|
||||
restoreRequest: RestoreRequest?,
|
||||
): ResponseEntity<ApiDocument> {
|
||||
val restored = service.restore(documentId, restoreRequest?.version)
|
||||
return ResponseEntity.ok(restored.toApi())
|
||||
}
|
||||
|
||||
private fun Document.toApi(): ApiDocument = ApiDocument(
|
||||
id = id,
|
||||
title = title,
|
||||
content = content,
|
||||
version = version,
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt,
|
||||
)
|
||||
|
||||
private fun DocumentHistoryEntry.toApi(): ApiHistoryEntry = ApiHistoryEntry(
|
||||
documentId = documentId,
|
||||
version = version,
|
||||
title = title,
|
||||
content = content,
|
||||
changeType = ApiHistoryEntry.ChangeType.valueOf(changeType.name),
|
||||
timestamp = timestamp,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.example.editor.api
|
||||
|
||||
import com.example.editor.service.DocumentConflictException
|
||||
import com.example.editor.service.DocumentNotFoundException
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ProblemDetail
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
|
||||
/**
|
||||
* Zentrale Fehlerbehandlung im Problem-Details-Format (RFC 9457),
|
||||
* passend zum ProblemDetail-Schema der OpenAPI-Spezifikation.
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(DocumentNotFoundException::class)
|
||||
fun handleNotFound(ex: DocumentNotFoundException): ProblemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.message ?: "Nicht gefunden").apply {
|
||||
title = "Dokument nicht gefunden"
|
||||
}
|
||||
|
||||
@ExceptionHandler(DocumentConflictException::class)
|
||||
fun handleConflict(ex: DocumentConflictException): ProblemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.message ?: "Konflikt").apply {
|
||||
title = "Konflikt"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.editor.domain
|
||||
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Internes Domänenmodell (bewusst getrennt vom generierten API-Modell).
|
||||
*
|
||||
* [version] wird bei jeder Änderung inkrementiert und dient später als Basis
|
||||
* für Optimistic Locking und Live-Editing-Konflikterkennung.
|
||||
* [content] ist ein opaker String – bei clientseitiger Verschlüsselung wird
|
||||
* hier später Ciphertext gespeichert, ohne dass sich das Modell ändert.
|
||||
*/
|
||||
data class Document(
|
||||
val id: UUID,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val version: Long,
|
||||
val createdAt: OffsetDateTime,
|
||||
val updatedAt: OffsetDateTime,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.example.editor.domain
|
||||
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
enum class ChangeType { CREATED, UPDATED, DELETED, RESTORED }
|
||||
|
||||
/**
|
||||
* Ein Eintrag der Dokumenthistorie. Die Historie wird getrennt vom Dokument
|
||||
* gespeichert und überlebt daher ein DELETE – Grundlage für die
|
||||
* Wiederherstellung und später auch für Audit/Live-Editing-Replays.
|
||||
*/
|
||||
data class DocumentHistoryEntry(
|
||||
val documentId: UUID,
|
||||
val version: Long,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val changeType: ChangeType,
|
||||
val timestamp: OffsetDateTime,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.example.editor.persistence
|
||||
|
||||
import com.example.editor.domain.Document
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(name = "document")
|
||||
class DocumentEntity(
|
||||
@Id
|
||||
val id: UUID,
|
||||
|
||||
@Column(nullable = false)
|
||||
var title: String,
|
||||
|
||||
@Column(nullable = false, columnDefinition = "text")
|
||||
var content: String,
|
||||
|
||||
@Column(nullable = false)
|
||||
var version: Long,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: OffsetDateTime,
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: OffsetDateTime,
|
||||
) {
|
||||
fun toDomain() = Document(
|
||||
id = id,
|
||||
title = title,
|
||||
content = content,
|
||||
version = version,
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt,
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun fromDomain(document: Document) = DocumentEntity(
|
||||
id = document.id,
|
||||
title = document.title,
|
||||
content = document.content,
|
||||
version = document.version,
|
||||
createdAt = document.createdAt,
|
||||
updatedAt = document.updatedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.example.editor.persistence
|
||||
|
||||
import com.example.editor.domain.ChangeType
|
||||
import com.example.editor.domain.DocumentHistoryEntry
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.EnumType
|
||||
import jakarta.persistence.Enumerated
|
||||
import jakarta.persistence.GeneratedValue
|
||||
import jakarta.persistence.GenerationType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(name = "document_history")
|
||||
class DocumentHistoryEntity(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "document_id", nullable = false)
|
||||
val documentId: UUID,
|
||||
|
||||
@Column(nullable = false)
|
||||
val version: Long,
|
||||
|
||||
@Column(nullable = false)
|
||||
val title: String,
|
||||
|
||||
@Column(nullable = false, columnDefinition = "text")
|
||||
val content: String,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "change_type", nullable = false, length = 16)
|
||||
val changeType: ChangeType,
|
||||
|
||||
@Column(name = "change_time", nullable = false)
|
||||
val changeTime: OffsetDateTime,
|
||||
) {
|
||||
fun toDomain() = DocumentHistoryEntry(
|
||||
documentId = documentId,
|
||||
version = version,
|
||||
title = title,
|
||||
content = content,
|
||||
changeType = changeType,
|
||||
timestamp = changeTime,
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun fromDomain(entry: DocumentHistoryEntry) = DocumentHistoryEntity(
|
||||
documentId = entry.documentId,
|
||||
version = entry.version,
|
||||
title = entry.title,
|
||||
content = entry.content,
|
||||
changeType = entry.changeType,
|
||||
changeTime = entry.timestamp,
|
||||
)
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.example.editor.persistence
|
||||
|
||||
import com.example.editor.domain.DocumentHistoryEntry
|
||||
import com.example.editor.repository.DocumentHistoryRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.util.UUID
|
||||
|
||||
@Repository
|
||||
class JpaDocumentHistoryRepository(
|
||||
private val jpa: DocumentHistoryJpaRepository,
|
||||
) : DocumentHistoryRepository {
|
||||
|
||||
override fun append(entry: DocumentHistoryEntry) {
|
||||
jpa.save(DocumentHistoryEntity.fromDomain(entry))
|
||||
}
|
||||
|
||||
override fun findByDocumentId(documentId: UUID): List<DocumentHistoryEntry> =
|
||||
jpa.findByDocumentIdOrderByIdAsc(documentId).map { it.toDomain() }
|
||||
|
||||
override fun clear() = jpa.deleteAll()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.example.editor.persistence
|
||||
|
||||
import com.example.editor.domain.Document
|
||||
import com.example.editor.repository.DocumentRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* JPA-Adapter fuer das fachliche Repository-Interface.
|
||||
* Ersetzt die fruehere In-Memory-Implementierung.
|
||||
*/
|
||||
@Repository
|
||||
class JpaDocumentRepository(
|
||||
private val jpa: DocumentJpaRepository,
|
||||
) : DocumentRepository {
|
||||
|
||||
override fun findAll(): List<Document> =
|
||||
jpa.findAll().map { it.toDomain() }.sortedBy { it.createdAt }
|
||||
|
||||
override fun findById(id: UUID): Document? =
|
||||
jpa.findById(id).map { it.toDomain() }.orElse(null)
|
||||
|
||||
override fun save(document: Document): Document {
|
||||
jpa.save(DocumentEntity.fromDomain(document))
|
||||
return document
|
||||
}
|
||||
|
||||
override fun deleteById(id: UUID): Boolean {
|
||||
if (!jpa.existsById(id)) return false
|
||||
jpa.deleteById(id)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun clear() = jpa.deleteAll()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.example.editor.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import java.util.UUID
|
||||
|
||||
interface DocumentJpaRepository : JpaRepository<DocumentEntity, UUID>
|
||||
|
||||
interface DocumentHistoryJpaRepository : JpaRepository<DocumentHistoryEntity, Long> {
|
||||
fun findByDocumentIdOrderByIdAsc(documentId: UUID): List<DocumentHistoryEntity>
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.example.editor.repository
|
||||
|
||||
import com.example.editor.domain.DocumentHistoryEntry
|
||||
import java.util.UUID
|
||||
|
||||
interface DocumentHistoryRepository {
|
||||
fun append(entry: DocumentHistoryEntry)
|
||||
|
||||
/** Alle Einträge zu einem Dokument, älteste zuerst. */
|
||||
fun findByDocumentId(documentId: UUID): List<DocumentHistoryEntry>
|
||||
|
||||
fun clear()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.editor.repository
|
||||
|
||||
import com.example.editor.domain.Document
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Abstraktion über die Persistenz. Die In-Memory-Implementierung ist ein
|
||||
* Platzhalter und kann später durch JPA/R2DBC ersetzt werden, ohne dass
|
||||
* Service oder Controller angepasst werden müssen.
|
||||
*/
|
||||
interface DocumentRepository {
|
||||
fun findAll(): List<Document>
|
||||
fun findById(id: UUID): Document?
|
||||
fun save(document: Document): Document
|
||||
fun deleteById(id: UUID): Boolean
|
||||
fun clear()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.example.editor.service
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import java.time.Clock
|
||||
|
||||
@Configuration
|
||||
class ClockConfiguration {
|
||||
@Bean
|
||||
fun clock(): Clock = Clock.systemUTC()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.example.editor.service
|
||||
|
||||
class DocumentConflictException(message: String) : RuntimeException(message)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.example.editor.service
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
class DocumentNotFoundException(id: UUID) :
|
||||
RuntimeException("Dokument mit ID $id wurde nicht gefunden")
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.example.editor.service
|
||||
|
||||
import com.example.editor.domain.ChangeType
|
||||
import com.example.editor.domain.Document
|
||||
import com.example.editor.domain.DocumentHistoryEntry
|
||||
import com.example.editor.repository.DocumentHistoryRepository
|
||||
import com.example.editor.repository.DocumentRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.Clock
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
class DocumentService(
|
||||
private val repository: DocumentRepository,
|
||||
private val historyRepository: DocumentHistoryRepository,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
|
||||
fun findAll(): List<Document> = repository.findAll()
|
||||
|
||||
fun findById(id: UUID): Document =
|
||||
repository.findById(id) ?: throw DocumentNotFoundException(id)
|
||||
|
||||
fun create(title: String, content: String): Document {
|
||||
val now = OffsetDateTime.now(clock)
|
||||
val document = Document(
|
||||
id = UUID.randomUUID(),
|
||||
title = title,
|
||||
content = content,
|
||||
version = 1,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
)
|
||||
repository.save(document)
|
||||
recordHistory(document, ChangeType.CREATED)
|
||||
return document
|
||||
}
|
||||
|
||||
fun update(id: UUID, title: String, content: String): Document {
|
||||
val existing = findById(id)
|
||||
val updated = existing.copy(
|
||||
title = title,
|
||||
content = content,
|
||||
version = existing.version + 1,
|
||||
updatedAt = OffsetDateTime.now(clock),
|
||||
)
|
||||
repository.save(updated)
|
||||
recordHistory(updated, ChangeType.UPDATED)
|
||||
return updated
|
||||
}
|
||||
|
||||
fun delete(id: UUID) {
|
||||
val existing = findById(id)
|
||||
repository.deleteById(id)
|
||||
// Tombstone-Eintrag: konserviert den letzten Stand und überlebt das DELETE.
|
||||
recordHistory(
|
||||
existing.copy(
|
||||
version = existing.version + 1,
|
||||
updatedAt = OffsetDateTime.now(clock),
|
||||
),
|
||||
ChangeType.DELETED,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Historie eines Dokuments – funktioniert auch für bereits gelöschte
|
||||
* Dokumente. 404 nur, wenn die UUID gänzlich unbekannt ist.
|
||||
*/
|
||||
fun history(id: UUID): List<DocumentHistoryEntry> {
|
||||
val entries = historyRepository.findByDocumentId(id)
|
||||
if (entries.isEmpty()) throw DocumentNotFoundException(id)
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Stellt ein Dokument unter derselben UUID wieder her.
|
||||
*
|
||||
* - Ohne [targetVersion]: letzter inhaltlicher Stand vor dem Löschen.
|
||||
* Existiert das Dokument noch, gibt es einen Konflikt (409).
|
||||
* - Mit [targetVersion]: Inhalt dieser Version wird als neue Version
|
||||
* übernommen – funktioniert auch als Rollback für existierende Dokumente.
|
||||
*/
|
||||
fun restore(id: UUID, targetVersion: Long? = null): Document {
|
||||
val entries = historyRepository.findByDocumentId(id)
|
||||
if (entries.isEmpty()) throw DocumentNotFoundException(id)
|
||||
|
||||
val existing = repository.findById(id)
|
||||
if (existing != null && targetVersion == null) {
|
||||
throw DocumentConflictException(
|
||||
"Dokument $id existiert noch; zum Rollback bitte eine Zielversion angeben"
|
||||
)
|
||||
}
|
||||
|
||||
val snapshot = if (targetVersion != null) {
|
||||
entries.lastOrNull { it.version == targetVersion && it.changeType != ChangeType.DELETED }
|
||||
?: throw DocumentNotFoundException(id)
|
||||
} else {
|
||||
entries.last { it.changeType != ChangeType.DELETED }
|
||||
}
|
||||
|
||||
val now = OffsetDateTime.now(clock)
|
||||
val lastVersion = maxOf(entries.maxOf { it.version }, existing?.version ?: 0)
|
||||
val restored = Document(
|
||||
id = id,
|
||||
title = snapshot.title,
|
||||
content = snapshot.content,
|
||||
version = lastVersion + 1,
|
||||
createdAt = existing?.createdAt ?: entries.first().timestamp,
|
||||
updatedAt = now,
|
||||
)
|
||||
repository.save(restored)
|
||||
recordHistory(restored, ChangeType.RESTORED)
|
||||
return restored
|
||||
}
|
||||
|
||||
private fun recordHistory(document: Document, changeType: ChangeType) {
|
||||
historyRepository.append(
|
||||
DocumentHistoryEntry(
|
||||
documentId = document.id,
|
||||
version = document.version,
|
||||
title = document.title,
|
||||
content = document.content,
|
||||
changeType = changeType,
|
||||
timestamp = document.updatedAt,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
spring:
|
||||
application:
|
||||
name: editor-backend
|
||||
|
||||
datasource:
|
||||
# H2 im File-Modus mit PostgreSQL-Kompatibilitaet.
|
||||
# Spaeterer Umstieg auf echtes PostgreSQL = im Wesentlichen nur diese URL aendern.
|
||||
url: jdbc:h2:file:./data/editor;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
|
||||
username: sa
|
||||
password: ""
|
||||
driver-class-name: org.h2.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate # Schema kommt ausschliesslich von Liquibase
|
||||
open-in-view: false
|
||||
|
||||
liquibase:
|
||||
change-log: classpath:db/changelog/db.changelog-master.sql
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
@@ -0,0 +1,28 @@
|
||||
--liquibase formatted sql
|
||||
|
||||
--changeset editor:001-create-document
|
||||
CREATE TABLE document (
|
||||
id UUID PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
version BIGINT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
--rollback DROP TABLE document;
|
||||
|
||||
--changeset editor:002-create-document-history
|
||||
CREATE TABLE document_history (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
document_id UUID NOT NULL,
|
||||
version BIGINT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
change_type VARCHAR(16) NOT NULL,
|
||||
change_time TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
--rollback DROP TABLE document_history;
|
||||
|
||||
--changeset editor:003-index-document-history
|
||||
CREATE INDEX idx_document_history_document_id ON document_history (document_id);
|
||||
--rollback DROP INDEX idx_document_history_document_id;
|
||||
@@ -0,0 +1,308 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Editor Backend API
|
||||
description: |
|
||||
CRUD-Grundgeruest fuer Dokumente.
|
||||
|
||||
Vorbereitete Erweiterungen (noch nicht aktiv):
|
||||
- Autorisierung: securitySchemes.bearerAuth ist definiert, wird aber noch
|
||||
auf keine Operation angewendet.
|
||||
- Live-Editing: Das Feld `version` dient spaeter der Konflikterkennung
|
||||
(Optimistic Locking) und als Basis fuer Delta-Updates via WebSocket.
|
||||
- Clientseitige Verschluesselung: `content` ist ein opaker String. Der
|
||||
Server interpretiert den Inhalt nicht, sodass spaeter Ciphertext
|
||||
transportiert werden kann, ohne die API zu aendern.
|
||||
version: 0.1.0
|
||||
|
||||
servers:
|
||||
- url: /api/v1
|
||||
|
||||
tags:
|
||||
- name: Documents
|
||||
description: Verwaltung von Dokumenten
|
||||
|
||||
paths:
|
||||
/documents:
|
||||
get:
|
||||
tags: [Documents]
|
||||
operationId: listDocuments
|
||||
summary: Alle Dokumente auflisten
|
||||
responses:
|
||||
"200":
|
||||
description: Liste aller Dokumente
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Document"
|
||||
post:
|
||||
tags: [Documents]
|
||||
operationId: createDocument
|
||||
summary: Neues Dokument anlegen
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DocumentCreateRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Dokument wurde angelegt
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Document"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
|
||||
/documents/{documentId}:
|
||||
parameters:
|
||||
- name: documentId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
get:
|
||||
tags: [Documents]
|
||||
operationId: getDocument
|
||||
summary: Einzelnes Dokument abrufen
|
||||
responses:
|
||||
"200":
|
||||
description: Das angeforderte Dokument
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Document"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
put:
|
||||
tags: [Documents]
|
||||
operationId: updateDocument
|
||||
summary: Dokument vollstaendig aktualisieren
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DocumentUpdateRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Aktualisiertes Dokument
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Document"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
description: Versionskonflikt (fuer spaeteres Optimistic Locking reserviert)
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
delete:
|
||||
tags: [Documents]
|
||||
operationId: deleteDocument
|
||||
summary: Dokument loeschen
|
||||
responses:
|
||||
"204":
|
||||
description: Dokument wurde geloescht
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/documents/{documentId}/history:
|
||||
parameters:
|
||||
- name: documentId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
get:
|
||||
tags: [Documents]
|
||||
operationId: getDocumentHistory
|
||||
summary: Historie eines Dokuments abrufen
|
||||
description: >
|
||||
Liefert alle Versionen eines Dokuments in chronologischer Reihenfolge.
|
||||
Die Historie bleibt auch nach dem Loeschen des Dokuments erhalten.
|
||||
responses:
|
||||
"200":
|
||||
description: Historie des Dokuments (aelteste zuerst)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DocumentHistoryEntry"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/documents/{documentId}/restore:
|
||||
parameters:
|
||||
- name: documentId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
post:
|
||||
tags: [Documents]
|
||||
operationId: restoreDocument
|
||||
summary: Geloeschtes Dokument aus der Historie wiederherstellen
|
||||
description: >
|
||||
Stellt ein geloeschtes Dokument unter derselben UUID wieder her.
|
||||
Ohne Request-Body wird der letzte Stand vor dem Loeschen
|
||||
wiederhergestellt; optional kann eine bestimmte Version angegeben
|
||||
werden (auch als Rollback fuer ein noch existierendes Dokument).
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RestoreRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Wiederhergestelltes Dokument
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Document"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
description: >
|
||||
Dokument existiert noch und es wurde keine Zielversion angegeben.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
|
||||
components:
|
||||
responses:
|
||||
NotFound:
|
||||
description: Ressource nicht gefunden
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
BadRequest:
|
||||
description: Ungueltige Anfrage
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
|
||||
schemas:
|
||||
Document:
|
||||
type: object
|
||||
required: [id, title, content, version, createdAt, updatedAt]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
readOnly: true
|
||||
title:
|
||||
type: string
|
||||
maxLength: 255
|
||||
content:
|
||||
type: string
|
||||
description: >
|
||||
Opaker Inhalt. Bei clientseitiger Verschluesselung enthaelt dieses
|
||||
Feld spaeter den Ciphertext.
|
||||
version:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Wird bei jeder Aenderung inkrementiert (Basis fuer Live-Editing/Konflikterkennung).
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
|
||||
DocumentCreateRequest:
|
||||
type: object
|
||||
required: [title, content]
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 255
|
||||
content:
|
||||
type: string
|
||||
|
||||
DocumentUpdateRequest:
|
||||
type: object
|
||||
required: [title, content]
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 255
|
||||
content:
|
||||
type: string
|
||||
expectedVersion:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Optional; wird spaeter fuer Optimistic Locking ausgewertet.
|
||||
|
||||
DocumentHistoryEntry:
|
||||
type: object
|
||||
required: [documentId, version, title, content, changeType, timestamp]
|
||||
properties:
|
||||
documentId:
|
||||
type: string
|
||||
format: uuid
|
||||
version:
|
||||
type: integer
|
||||
format: int64
|
||||
title:
|
||||
type: string
|
||||
content:
|
||||
type: string
|
||||
changeType:
|
||||
type: string
|
||||
enum: [CREATED, UPDATED, DELETED, RESTORED]
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
RestoreRequest:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: integer
|
||||
format: int64
|
||||
description: >
|
||||
Optionale Zielversion. Ohne Angabe wird der letzte inhaltliche
|
||||
Stand vor dem Loeschen wiederhergestellt.
|
||||
|
||||
ProblemDetail:
|
||||
type: object
|
||||
description: Fehlerformat nach RFC 9457 (Problem Details)
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
status:
|
||||
type: integer
|
||||
detail:
|
||||
type: string
|
||||
instance:
|
||||
type: string
|
||||
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: >
|
||||
Noch nicht aktiv. Wird bei Einfuehrung der Autorisierung auf die
|
||||
Operationen angewendet (security: - bearerAuth: []).
|
||||
Reference in New Issue
Block a user