From 528aa943faba497385dfaef0465be77aa77d90eb Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sun, 14 Dec 2025 16:43:05 +0000 Subject: [PATCH] feat: migrate to OpenAPI 3.0 with oneOf unions - Add swagger2openapi conversion step to generate OpenAPI 3.0 - Add patch-openapi.ts script to inject oneOf discriminated unions - Update docs server to embed static openapi.json - Update moveItemsToDirectory response to use oneOf for items - Add docs/README.md documenting the pipeline - Use bun instead of node for scripts --- apps/backend/Makefile | 12 +- apps/backend/cmd/docs/main.go | 11 +- apps/backend/cmd/docs/openapi.json | 1890 ++++++++++++++++++++ apps/backend/docs/README.md | 105 ++ apps/backend/docs/swagger.json | 75 +- apps/backend/internal/catalog/directory.go | 53 +- apps/backend/scripts/patch-openapi.ts | 49 + 7 files changed, 2168 insertions(+), 27 deletions(-) create mode 100644 apps/backend/cmd/docs/openapi.json create mode 100644 apps/backend/docs/README.md create mode 100644 apps/backend/scripts/patch-openapi.ts diff --git a/apps/backend/Makefile b/apps/backend/Makefile index b6b4139..754f9dd 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -21,9 +21,13 @@ run-docs: # Generate API documentation docs: - @echo "Generating OpenAPI documentation..." - swag init -g cmd/drexa/main.go -o docs --parseDependency --parseInternal --outputTypes go,json,yaml - @echo "Documentation generated in docs/" + @echo "Generating Swagger 2.0 documentation..." + swag init -g cmd/drexa/main.go -o docs --parseDependency --parseInternal --outputTypes json + @echo "Converting to OpenAPI 3.0..." + bunx --bun swagger2openapi docs/swagger.json -o cmd/docs/openapi.json --patch + @echo "Patching OpenAPI spec with oneOf types..." + bun scripts/patch-openapi.ts cmd/docs/openapi.json + @echo "Documentation generated in docs/ and cmd/docs/" @echo "Run 'make run-docs' to start the documentation server" # Install development tools @@ -38,4 +42,4 @@ fmt: # Clean build artifacts clean: rm -rf bin/ - rm -f docs/swagger.json docs/swagger.yaml + rm -f docs/swagger.json docs/swagger.yaml cmd/docs/openapi.json diff --git a/apps/backend/cmd/docs/main.go b/apps/backend/cmd/docs/main.go index 781baed..afbf0c1 100644 --- a/apps/backend/cmd/docs/main.go +++ b/apps/backend/cmd/docs/main.go @@ -1,18 +1,20 @@ package main import ( + "embed" "flag" "fmt" "log" "os" - _ "github.com/get-drexa/drexa/docs" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/logger" - "github.com/swaggo/swag" ) +//go:embed openapi.json +var openapiSpec embed.FS + func main() { port := flag.Int("port", 8081, "port to listen on") apiURL := flag.String("api-url", "http://localhost:8080", "base URL of the API server") @@ -64,11 +66,11 @@ func main() { app.Get("/openapi.json", func(c *fiber.Ctx) error { c.Set("Content-Type", "application/json") c.Set("Access-Control-Allow-Origin", "*") - doc, err := swag.ReadDoc() + doc, err := openapiSpec.ReadFile("openapi.json") if err != nil { return c.Status(fiber.StatusInternalServerError).SendString(err.Error()) } - return c.SendString(doc) + return c.Send(doc) }) // Health check @@ -81,4 +83,3 @@ func main() { log.Fatal(app.Listen(fmt.Sprintf(":%d", *port))) } - diff --git a/apps/backend/cmd/docs/openapi.json b/apps/backend/cmd/docs/openapi.json new file mode 100644 index 0000000..c2cd1af --- /dev/null +++ b/apps/backend/cmd/docs/openapi.json @@ -0,0 +1,1890 @@ +{ + "openapi": "3.0.0", + "info": { + "description": "Drexa is a file storage and management API. It provides endpoints for authentication, user management, file uploads, and virtual filesystem operations.", + "title": "Drexa API", + "contact": { + "name": "Drexa Support", + "url": "https://github.com/get-drexa/drexa" + }, + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + }, + "version": "1.0" + }, + "paths": { + "/accounts": { + "post": { + "description": "Create a new user account with email and password. Returns the account, user, and authentication tokens.", + "tags": [ + "accounts" + ], + "summary": "Register new account", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_account.registerAccountRequest" + } + } + }, + "description": "Registration details", + "required": true + }, + "responses": { + "200": { + "description": "Account created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_account.registerAccountResponse" + } + } + } + }, + "400": { + "description": "Invalid request body", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "409": { + "description": "Email already registered", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/accounts/{accountID}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Retrieve account details including storage usage and quota", + "tags": [ + "accounts" + ], + "summary": "Get account", + "parameters": [ + { + "description": "Account ID", + "name": "accountID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Account details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_account.Account" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Account not found", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/accounts/{accountID}/directories": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new directory within a parent directory", + "tags": [ + "directories" + ], + "summary": "Create directory", + "parameters": [ + { + "description": "Account ID", + "name": "accountID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "description": "Include additional fields", + "name": "include", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "path" + ] + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_catalog.createDirectoryRequest" + } + } + }, + "description": "Directory details", + "required": true + }, + "responses": { + "200": { + "description": "Created directory", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_catalog.DirectoryInfo" + } + } + } + }, + "400": { + "description": "Parent not found or not a directory", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "409": { + "description": "Directory already exists", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } + }, + "/accounts/{accountID}/directories/{directoryID}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Retrieve metadata for a specific directory", + "tags": [ + "directories" + ], + "summary": "Get directory info", + "parameters": [ + { + "description": "Account ID", + "name": "accountID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "description": "Directory ID", + "name": "directoryID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Include additional fields", + "name": "include", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "path" + ] + } + } + ], + "responses": { + "200": { + "description": "Directory metadata", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_catalog.DirectoryInfo" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Directory not found", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a directory permanently or move it to trash. Deleting a directory also affects all its contents.", + "tags": [ + "directories" + ], + "summary": "Delete directory", + "parameters": [ + { + "description": "Account ID", + "name": "accountID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "description": "Directory ID", + "name": "directoryID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Move to trash instead of permanent delete", + "name": "trash", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "204": { + "description": "Directory deleted", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Directory not found", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update directory properties such as name (rename)", + "tags": [ + "directories" + ], + "summary": "Update directory", + "parameters": [ + { + "description": "Account ID", + "name": "accountID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "description": "Directory ID", + "name": "directoryID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_catalog.patchDirectoryRequest" + } + } + }, + "description": "Directory update", + "required": true + }, + "responses": { + "200": { + "description": "Updated directory metadata", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_catalog.DirectoryInfo" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Directory not found", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/accounts/{accountID}/directories/{directoryID}/content": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get all files and subdirectories within a directory", + "tags": [ + "directories" + ], + "summary": "List directory contents", + "parameters": [ + { + "description": "Account ID", + "name": "accountID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "description": "Directory ID", + "name": "directoryID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Array of FileInfo and DirectoryInfo objects", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {} + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Directory not found", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Move one or more files or directories into this directory. Returns detailed status for each item including which were successfully moved, which had conflicts, and which encountered errors.", + "tags": [ + "directories" + ], + "summary": "Move items to directory", + "parameters": [ + { + "description": "Account ID", + "name": "accountID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "description": "Target directory ID", + "name": "directoryID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_catalog.postDirectoryContentRequest" + } + } + }, + "description": "Items to move", + "required": true + }, + "responses": { + "200": { + "description": "Move operation results with moved, conflict, and error states", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_catalog.moveItemsToDirectoryResponse" + } + } + } + }, + "400": { + "description": "Invalid request or items not in same directory", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "One or more items not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } + }, + "/accounts/{accountID}/files/{fileID}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Retrieve metadata for a specific file", + "tags": [ + "files" + ], + "summary": "Get file info", + "parameters": [ + { + "description": "Account ID", + "name": "accountID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "description": "File ID", + "name": "fileID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "File metadata", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_catalog.FileInfo" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "File not found", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a file permanently or move it to trash", + "tags": [ + "files" + ], + "summary": "Delete file", + "parameters": [ + { + "description": "Account ID", + "name": "accountID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "description": "File ID", + "name": "fileID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Move to trash instead of permanent delete", + "name": "trash", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Trashed file info (when trash=true)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_catalog.FileInfo" + } + } + } + }, + "204": { + "description": "Permanently deleted (when trash=false)", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "File not found", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update file properties such as name (rename)", + "tags": [ + "files" + ], + "summary": "Update file", + "parameters": [ + { + "description": "Account ID", + "name": "accountID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "description": "File ID", + "name": "fileID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_catalog.patchFileRequest" + } + } + }, + "description": "File update", + "required": true + }, + "responses": { + "200": { + "description": "Updated file metadata", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_catalog.FileInfo" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "File not found", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/accounts/{accountID}/files/{fileID}/content": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Download the file content. May redirect to a signed URL for external storage.", + "tags": [ + "files" + ], + "summary": "Download file", + "parameters": [ + { + "description": "Account ID", + "name": "accountID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "description": "File ID", + "name": "fileID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "File content stream", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "307": { + "description": "Redirect to download URL", + "content": { + "application/octet-stream": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/octet-stream": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "File not found", + "content": { + "application/octet-stream": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/accounts/{accountID}/uploads": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Start a new file upload session. Returns an upload URL to PUT file content to.", + "tags": [ + "uploads" + ], + "summary": "Create upload session", + "parameters": [ + { + "description": "Account ID", + "name": "accountID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_upload.createUploadRequest" + } + } + }, + "description": "Upload details", + "required": true + }, + "responses": { + "200": { + "description": "Upload session created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_upload.Upload" + } + } + } + }, + "400": { + "description": "Parent is not a directory", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Parent directory not found", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "409": { + "description": "File with this name already exists", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } + }, + "/accounts/{accountID}/uploads/{uploadID}": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Mark an upload as completed after content has been uploaded. This finalizes the file in the filesystem.", + "tags": [ + "uploads" + ], + "summary": "Complete upload", + "parameters": [ + { + "description": "Account ID", + "name": "accountID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "description": "Upload session ID", + "name": "uploadID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_upload.updateUploadRequest" + } + } + }, + "description": "Status update", + "required": true + }, + "responses": { + "200": { + "description": "Upload completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_upload.Upload" + } + } + } + }, + "400": { + "description": "Content not uploaded yet or invalid status", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Upload session not found", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/accounts/{accountID}/uploads/{uploadID}/content": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Stream file content to complete an upload. Send raw binary data in the request body.", + "tags": [ + "uploads" + ], + "summary": "Upload file content", + "parameters": [ + { + "description": "Account ID", + "name": "accountID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "description": "Upload session ID", + "name": "uploadID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "description": "File content (binary)", + "required": true + }, + "responses": { + "204": { + "description": "Content received successfully", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Upload session not found", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/auth/login": { + "post": { + "description": "Authenticate with email and password to receive JWT tokens. Tokens can be delivered via HTTP-only cookies or in the response body based on the tokenDelivery field.", + "tags": [ + "auth" + ], + "summary": "User login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_auth.loginRequest" + } + } + }, + "description": "Login credentials", + "required": true + }, + "responses": { + "200": { + "description": "Successful authentication", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_auth.loginResponse" + } + } + } + }, + "400": { + "description": "Invalid request body or token delivery method", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "401": { + "description": "Invalid email or password", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } + }, + "/auth/tokens": { + "post": { + "description": "Exchange a valid refresh token for a new pair of access and refresh tokens. The old refresh token is invalidated (rotation).", + "tags": [ + "auth" + ], + "summary": "Refresh access token", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_auth.refreshAccessTokenRequest" + } + } + }, + "description": "Refresh token", + "required": true + }, + "responses": { + "200": { + "description": "New tokens", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_auth.tokenResponse" + } + } + } + }, + "400": { + "description": "Invalid request body", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "401": { + "description": "Invalid, expired, or reused refresh token", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } + }, + "/users/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Retrieve the authenticated user's profile information", + "tags": [ + "users" + ], + "summary": "Get current user", + "responses": { + "200": { + "description": "User profile", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_user.User" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "servers": [ + { + "url": "//localhost:8080/api" + } + ], + "components": { + "securitySchemes": { + "BearerAuth": { + "description": "JWT access token. Format: \"Bearer {token}\"", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + }, + "schemas": { + "github_com_get-drexa_drexa_internal_user.User": { + "description": "User account information", + "type": "object", + "properties": { + "displayName": { + "description": "User's display name", + "type": "string", + "example": "John Doe" + }, + "email": { + "description": "User's email address", + "type": "string", + "example": "john@example.com" + }, + "id": { + "description": "Unique user identifier", + "type": "string", + "example": "550e8400-e29b-41d4-a716-446655440000" + } + } + }, + "github_com_get-drexa_drexa_internal_virtualfs.PathSegment": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "internal_account.Account": { + "description": "Storage account with usage and quota details", + "type": "object", + "properties": { + "createdAt": { + "description": "When the account was created (ISO 8601)", + "type": "string", + "example": "2024-12-13T15:04:05Z" + }, + "id": { + "description": "Unique account identifier", + "type": "string", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "storageQuotaBytes": { + "description": "Maximum storage quota in bytes", + "type": "integer", + "example": 10737418240 + }, + "storageUsageBytes": { + "description": "Current storage usage in bytes", + "type": "integer", + "example": 1073741824 + }, + "updatedAt": { + "description": "When the account was last updated (ISO 8601)", + "type": "string", + "example": "2024-12-13T16:30:00Z" + }, + "userId": { + "description": "ID of the user who owns this account", + "type": "string", + "example": "550e8400-e29b-41d4-a716-446655440001" + } + } + }, + "internal_account.registerAccountRequest": { + "description": "Request to create a new account and user", + "type": "object", + "properties": { + "displayName": { + "description": "Display name for the user", + "type": "string", + "example": "Jane Doe" + }, + "email": { + "description": "Email address for the new account", + "type": "string", + "example": "newuser@example.com" + }, + "password": { + "description": "Password for the new account (min 8 characters)", + "type": "string", + "example": "securepassword123" + } + } + }, + "internal_account.registerAccountResponse": { + "description": "Response after successful account registration", + "type": "object", + "properties": { + "accessToken": { + "description": "JWT access token for immediate authentication", + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAifQ.signature" + }, + "account": { + "description": "The created account", + "allOf": [ + { + "$ref": "#/components/schemas/internal_account.Account" + } + ] + }, + "refreshToken": { + "description": "Base64 URL encoded refresh token", + "type": "string", + "example": "dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi" + }, + "user": { + "description": "The created user", + "allOf": [ + { + "$ref": "#/components/schemas/github_com_get-drexa_drexa_internal_user.User" + } + ] + } + } + }, + "internal_auth.loginRequest": { + "description": "Login request with email, password, and token delivery preference", + "type": "object", + "properties": { + "email": { + "description": "User's email address", + "type": "string", + "example": "user@example.com" + }, + "password": { + "description": "User's password", + "type": "string", + "example": "secretpassword123" + }, + "tokenDelivery": { + "description": "How to deliver tokens: \"cookie\" (set HTTP-only cookies) or \"body\" (include in response)", + "type": "string", + "enum": [ + "cookie", + "body" + ], + "example": "body" + } + } + }, + "internal_auth.loginResponse": { + "description": "Login response containing user info and optionally tokens", + "type": "object", + "properties": { + "accessToken": { + "description": "JWT access token (only included when tokenDelivery is \"body\")", + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAifQ.signature" + }, + "refreshToken": { + "description": "Base64 URL encoded refresh token (only included when tokenDelivery is \"body\")", + "type": "string", + "example": "dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi" + }, + "user": { + "description": "Authenticated user information", + "allOf": [ + { + "$ref": "#/components/schemas/github_com_get-drexa_drexa_internal_user.User" + } + ] + } + } + }, + "internal_auth.refreshAccessTokenRequest": { + "description": "Request to exchange a refresh token for new tokens", + "type": "object", + "properties": { + "refreshToken": { + "description": "Base64 URL encoded refresh token", + "type": "string", + "example": "dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi" + } + } + }, + "internal_auth.tokenResponse": { + "description": "Response containing new access token and refresh token", + "type": "object", + "properties": { + "accessToken": { + "description": "New JWT access token", + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAifQ.signature" + }, + "refreshToken": { + "description": "New base64 URL encoded refresh token", + "type": "string", + "example": "xK9mPqRsTuVwXyZ0AbCdEfGhIjKlMnOpQrStUvWxYz1234567890abcdefgh" + } + } + }, + "internal_catalog.DirectoryInfo": { + "description": "Directory information including path and timestamps", + "type": "object", + "properties": { + "createdAt": { + "description": "When the directory was created (ISO 8601)", + "type": "string", + "example": "2024-12-13T15:04:05Z" + }, + "deletedAt": { + "description": "When the directory was trashed, null if not trashed (ISO 8601)", + "type": "string", + "example": "2024-12-14T10:00:00Z" + }, + "id": { + "description": "Unique directory identifier", + "type": "string", + "example": "kRp2XYTq9A55" + }, + "kind": { + "description": "Item type, always \"directory\"", + "type": "string", + "example": "directory" + }, + "name": { + "description": "Directory name", + "type": "string", + "example": "My Documents" + }, + "path": { + "description": "Full path from root (included when ?include=path)", + "type": "array", + "items": { + "$ref": "#/components/schemas/github_com_get-drexa_drexa_internal_virtualfs.PathSegment" + } + }, + "updatedAt": { + "description": "When the directory was last updated (ISO 8601)", + "type": "string", + "example": "2024-12-13T16:30:00Z" + } + } + }, + "internal_catalog.FileInfo": { + "description": "File information including name, size, and timestamps", + "type": "object", + "properties": { + "createdAt": { + "description": "When the file was created (ISO 8601)", + "type": "string", + "example": "2024-12-13T15:04:05Z" + }, + "deletedAt": { + "description": "When the file was trashed, null if not trashed (ISO 8601)", + "type": "string", + "example": "2024-12-14T10:00:00Z" + }, + "id": { + "description": "Unique file identifier", + "type": "string", + "example": "mElnUNCm8F22" + }, + "kind": { + "description": "Item type, always \"file\"", + "type": "string", + "example": "file" + }, + "mimeType": { + "description": "MIME type of the file", + "type": "string", + "example": "application/pdf" + }, + "name": { + "description": "File name", + "type": "string", + "example": "document.pdf" + }, + "size": { + "description": "File size in bytes", + "type": "integer", + "example": 1048576 + }, + "updatedAt": { + "description": "When the file was last updated (ISO 8601)", + "type": "string", + "example": "2024-12-13T16:30:00Z" + } + } + }, + "internal_catalog.createDirectoryRequest": { + "description": "Request to create a new directory", + "type": "object", + "properties": { + "name": { + "description": "Name for the new directory", + "type": "string", + "example": "New Folder" + }, + "parentID": { + "description": "ID of the parent directory", + "type": "string", + "example": "kRp2XYTq9A55" + } + } + }, + "internal_catalog.moveItemError": { + "description": "Error details for a failed item move", + "type": "object", + "properties": { + "error": { + "description": "Error message describing what went wrong", + "type": "string", + "example": "permission denied" + }, + "id": { + "description": "ID of the item that failed to move", + "type": "string", + "example": "mElnUNCm8F22" + } + } + }, + "internal_catalog.moveItemsToDirectoryResponse": { + "description": "Response from moving items to a directory with status for each item", + "type": "object", + "properties": { + "conflicts": { + "description": "Array of IDs of items that conflicted with existing items in the target directory", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "xYz123AbC456" + ] + }, + "errors": { + "description": "Array of errors that occurred during the move operation", + "type": "array", + "items": { + "$ref": "#/components/schemas/internal_catalog.moveItemError" + } + }, + "items": { + "type": "array", + "description": "Array of items included in the request (FileInfo or DirectoryInfo objects)", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/internal_catalog.FileInfo" + }, + { + "$ref": "#/components/schemas/internal_catalog.DirectoryInfo" + } + ], + "discriminator": { + "propertyName": "kind", + "mapping": { + "file": "#/components/schemas/internal_catalog.FileInfo", + "directory": "#/components/schemas/internal_catalog.DirectoryInfo" + } + } + } + }, + "moved": { + "description": "Array of IDs of successfully moved items", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "mElnUNCm8F22", + "kRp2XYTq9A55" + ] + } + } + }, + "internal_catalog.patchDirectoryRequest": { + "description": "Request to update directory properties", + "type": "object", + "properties": { + "name": { + "description": "New name for the directory", + "type": "string", + "example": "My Documents" + } + } + }, + "internal_catalog.patchFileRequest": { + "description": "Request to update file properties", + "type": "object", + "properties": { + "name": { + "description": "New name for the file", + "type": "string", + "example": "renamed-document.pdf" + } + } + }, + "internal_catalog.postDirectoryContentRequest": { + "description": "Request to move items into this directory", + "type": "object", + "properties": { + "items": { + "description": "Array of file/directory IDs to move", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "mElnUNCm8F22", + "kRp2XYTq9A55" + ] + } + } + }, + "internal_upload.Status": { + "description": "Upload status enumeration", + "type": "string", + "enum": [ + "pending", + "completed", + "failed" + ], + "x-enum-varnames": [ + "StatusPending", + "StatusCompleted", + "StatusFailed" + ] + }, + "internal_upload.Upload": { + "description": "File upload session with status and upload URL", + "type": "object", + "properties": { + "id": { + "description": "Unique upload session identifier", + "type": "string", + "example": "xNq5RVBt3K88" + }, + "status": { + "description": "Current upload status", + "enum": [ + "pending", + "completed", + "failed" + ], + "allOf": [ + { + "$ref": "#/components/schemas/internal_upload.Status" + } + ], + "example": "pending" + }, + "uploadUrl": { + "description": "URL to upload file content to", + "type": "string", + "example": "https://api.example.com/api/accounts/550e8400-e29b-41d4-a716-446655440000/uploads/xNq5RVBt3K88/content" + } + } + }, + "internal_upload.createUploadRequest": { + "description": "Request to initiate a file upload", + "type": "object", + "properties": { + "name": { + "description": "Name of the file being uploaded", + "type": "string", + "example": "document.pdf" + }, + "parentId": { + "description": "ID of the parent directory to upload into", + "type": "string", + "example": "kRp2XYTq9A55" + } + } + }, + "internal_upload.updateUploadRequest": { + "description": "Request to update upload status (e.g., mark as completed)", + "type": "object", + "properties": { + "status": { + "description": "New status for the upload", + "enum": [ + "completed" + ], + "allOf": [ + { + "$ref": "#/components/schemas/internal_upload.Status" + } + ], + "example": "completed" + } + } + }, + "internal_user.User": { + "description": "User account information", + "type": "object", + "properties": { + "displayName": { + "description": "User's display name", + "type": "string", + "example": "John Doe" + }, + "email": { + "description": "User's email address", + "type": "string", + "example": "john@example.com" + }, + "id": { + "description": "Unique user identifier", + "type": "string", + "example": "550e8400-e29b-41d4-a716-446655440000" + } + } + } + } + } +} \ No newline at end of file diff --git a/apps/backend/docs/README.md b/apps/backend/docs/README.md new file mode 100644 index 0000000..8d40e5a --- /dev/null +++ b/apps/backend/docs/README.md @@ -0,0 +1,105 @@ +# API Documentation Pipeline + +This document describes how API documentation is generated for the Drexa backend. + +## Overview + +The documentation pipeline converts Go code annotations into OpenAPI 3.0 specification that powers the Scalar API documentation UI. + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Go Code + │ │ Swagger 2.0 │ │ OpenAPI 3.0 │ │ OpenAPI 3.0 │ +│ swag annotations│ ──▶ │ (swagger.json) │ ──▶ │ (converted) │ ──▶ │ (patched) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ │ + swag swagger2openapi patch-openapi.ts cmd/docs/ + openapi.json +``` + +## Why This Pipeline? + +**The problem:** We want OpenAPI 3.0 features (like `oneOf` for union types) but the best annotation-based doc generator for Go (swag) only outputs Swagger 2.0. + +**The solution:** A three-step pipeline: +1. Generate Swagger 2.0 with swag (keeps our existing handlers and annotations) +2. Convert to OpenAPI 3.0 with swagger2openapi +3. Patch in `oneOf` discriminated unions where needed + +## Commands + +### Generate documentation +```bash +make docs +``` + +This runs: +1. `swag init` → generates `docs/swagger.json` +2. `bunx swagger2openapi` → converts to `cmd/docs/openapi.json` +3. `bun scripts/patch-openapi.ts` → patches oneOf types + +### Start documentation server +```bash +make run-docs +``` + +Opens Scalar UI at http://localhost:8081 + +## Adding New Union Types + +If you add a new API that returns a union type (like `FileInfo | DirectoryInfo`), you need to update `scripts/patch-openapi.ts` to patch the schema. + +Example: +```typescript +if (schemas['your_package.YourResponseType']) { + const response = schemas['your_package.YourResponseType']; + if (response.properties?.items) { + response.properties.items = { + type: 'array', + items: { + oneOf: [ + { $ref: '#/components/schemas/TypeA' }, + { $ref: '#/components/schemas/TypeB' } + ], + discriminator: { + propertyName: 'kind', + mapping: { + a: '#/components/schemas/TypeA', + b: '#/components/schemas/TypeB' + } + } + } + }; + } +} +``` + +## Files + +| File | Purpose | +|------|---------| +| `docs/swagger.json` | Generated Swagger 2.0 spec (intermediate) | +| `cmd/docs/openapi.json` | Final OpenAPI 3.0 spec (embedded in docs server) | +| `scripts/patch-openapi.ts` | Patches oneOf types into the spec | +| `cmd/docs/main.go` | Scalar documentation server | + +## Swag Annotations + +Documentation is written as Go comments. See [swag documentation](https://github.com/swaggo/swag) for syntax. + +Example: +```go +// createDirectory creates a new directory +// @Summary Create directory +// @Description Create a new directory within a parent directory +// @Tags directories +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param accountID path string true "Account ID" +// @Param request body createDirectoryRequest true "Directory details" +// @Success 200 {object} DirectoryInfo "Created directory" +// @Failure 400 {object} map[string]string "Bad request" +// @Router /accounts/{accountID}/directories [post] +func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error { +``` + diff --git a/apps/backend/docs/swagger.json b/apps/backend/docs/swagger.json index a136b3c..93296fa 100644 --- a/apps/backend/docs/swagger.json +++ b/apps/backend/docs/swagger.json @@ -441,10 +441,13 @@ "BearerAuth": [] } ], - "description": "Move one or more files or directories into this directory. All items must currently be in the same source directory.", + "description": "Move one or more files or directories into this directory. Returns detailed status for each item including which were successfully moved, which had conflicts, and which encountered errors.", "consumes": [ "application/json" ], + "produces": [ + "application/json" + ], "tags": [ "directories" ], @@ -476,10 +479,10 @@ } ], "responses": { - "204": { - "description": "Items moved successfully", + "200": { + "description": "Move operation results with moved, conflict, and error states", "schema": { - "type": "string" + "$ref": "#/definitions/internal_catalog.moveItemsToDirectoryResponse" } }, "400": { @@ -505,15 +508,6 @@ "type": "string" } } - }, - "409": { - "description": "Name conflict in target directory", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } } } } @@ -1427,6 +1421,61 @@ } } }, + "internal_catalog.moveItemError": { + "description": "Error details for a failed item move", + "type": "object", + "properties": { + "error": { + "description": "Error message describing what went wrong", + "type": "string", + "example": "permission denied" + }, + "id": { + "description": "ID of the item that failed to move", + "type": "string", + "example": "mElnUNCm8F22" + } + } + }, + "internal_catalog.moveItemsToDirectoryResponse": { + "description": "Response from moving items to a directory with status for each item", + "type": "object", + "properties": { + "conflicts": { + "description": "Array of IDs of items that conflicted with existing items in the target directory", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "xYz123AbC456" + ] + }, + "errors": { + "description": "Array of errors that occurred during the move operation", + "type": "array", + "items": { + "$ref": "#/definitions/internal_catalog.moveItemError" + } + }, + "items": { + "description": "Array of items included in the request (FileInfo or DirectoryInfo objects)", + "type": "array", + "items": {} + }, + "moved": { + "description": "Array of IDs of successfully moved items", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "mElnUNCm8F22", + "kRp2XYTq9A55" + ] + } + } + }, "internal_catalog.patchDirectoryRequest": { "description": "Request to update directory properties", "type": "object", diff --git a/apps/backend/internal/catalog/directory.go b/apps/backend/internal/catalog/directory.go index 463ca3d..c5fa988 100644 --- a/apps/backend/internal/catalog/directory.go +++ b/apps/backend/internal/catalog/directory.go @@ -52,6 +52,32 @@ type postDirectoryContentRequest struct { Items []string `json:"items" example:"mElnUNCm8F22,kRp2XYTq9A55"` } +// moveItemsToDirectoryResponse represents the response to a request +// to move items into a directory. +// @Description Response from moving items to a directory with status for each item +type moveItemsToDirectoryResponse struct { + // Array of items included in the request (FileInfo or DirectoryInfo objects) + Items []any `json:"items"` + + // Array of IDs of successfully moved items + Moved []string `json:"moved" example:"mElnUNCm8F22,kRp2XYTq9A55"` + + // Array of IDs of items that conflicted with existing items in the target directory + Conflicts []string `json:"conflicts" example:"xYz123AbC456"` + + // Array of errors that occurred during the move operation + Errors []moveItemError `json:"errors"` +} + +// moveItemError represents an error that occurred while moving a specific item +// @Description Error details for a failed item move +type moveItemError struct { + // ID of the item that failed to move + ID string `json:"id" example:"mElnUNCm8F22"` + // Error message describing what went wrong + Error string `json:"error" example:"permission denied"` +} + func (h *HTTPHandler) currentDirectoryMiddleware(c *fiber.Ctx) error { account := account.CurrentAccount(c) if account == nil { @@ -344,18 +370,18 @@ func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error { // moveItemsToDirectory moves files and directories into this directory // @Summary Move items to directory -// @Description Move one or more files or directories into this directory. All items must currently be in the same source directory. +// @Description Move one or more files or directories into this directory. Returns detailed status for each item including which were successfully moved, which had conflicts, and which encountered errors. // @Tags directories // @Accept json +// @Produce json // @Security BearerAuth // @Param accountID path string true "Account ID" format(uuid) // @Param directoryID path string true "Target directory ID" // @Param request body postDirectoryContentRequest true "Items to move" -// @Success 204 {string} string "Items moved successfully" +// @Success 200 {object} moveItemsToDirectoryResponse "Move operation results with moved, conflict, and error states" // @Failure 400 {object} map[string]string "Invalid request or items not in same directory" // @Failure 401 {string} string "Not authenticated" // @Failure 404 {object} map[string]string "One or more items not found" -// @Failure 409 {object} map[string]string "Name conflict in target directory" // @Router /accounts/{accountID}/directories/{directoryID}/content [post] func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error { acc := account.CurrentAccount(c) @@ -389,7 +415,7 @@ func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error { } // Move all nodes to the target directory - err = h.vfs.MoveNodesInSameDirectory(c.Context(), tx, nodes, targetDir.ID) + result, err := h.vfs.MoveNodesInSameDirectory(c.Context(), tx, nodes, targetDir.ID) if err != nil { if errors.Is(err, virtualfs.ErrUnsupportedOperation) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "All items must be in the same directory"}) @@ -405,5 +431,22 @@ func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error { return httperr.Internal(err) } - return c.SendStatus(fiber.StatusNoContent) + res := moveItemsToDirectoryResponse{} + + for _, node := range result.Moved { + res.Items = append(res.Items, toDirectoryItem(node)) + res.Moved = append(res.Moved, node.PublicID) + } + for _, node := range result.Conflicts { + res.Items = append(res.Items, toDirectoryItem(node)) + res.Conflicts = append(res.Conflicts, node.PublicID) + } + for _, err := range result.Errors { + res.Errors = append(res.Errors, moveItemError{ + ID: err.Node.PublicID, + Error: err.Error.Error(), + }) + } + + return c.JSON(res) } diff --git a/apps/backend/scripts/patch-openapi.ts b/apps/backend/scripts/patch-openapi.ts new file mode 100644 index 0000000..ce520bd --- /dev/null +++ b/apps/backend/scripts/patch-openapi.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env bun +/** + * Post-process OpenAPI spec to add oneOf for union types. + * + * This script patches the converted OpenAPI 3.0 spec to add proper oneOf + * discriminated unions where swag's Swagger 2.0 output couldn't express them. + */ + +const inputFile = Bun.argv[2]; +const outputFile = Bun.argv[3] || inputFile; + +if (!inputFile) { + console.error('Usage: bun patch-openapi.ts [output.json]'); + process.exit(1); +} + +const file = Bun.file(inputFile); +const spec = await file.json(); + +// Find the moveItemsToDirectoryResponse schema and update items to use oneOf +const schemas = spec.components?.schemas || {}; + +if (schemas['internal_catalog.moveItemsToDirectoryResponse']) { + const response = schemas['internal_catalog.moveItemsToDirectoryResponse']; + if (response.properties?.items) { + response.properties.items = { + type: 'array', + description: 'Array of items included in the request (FileInfo or DirectoryInfo objects)', + items: { + oneOf: [ + { $ref: '#/components/schemas/internal_catalog.FileInfo' }, + { $ref: '#/components/schemas/internal_catalog.DirectoryInfo' } + ], + discriminator: { + propertyName: 'kind', + mapping: { + file: '#/components/schemas/internal_catalog.FileInfo', + directory: '#/components/schemas/internal_catalog.DirectoryInfo' + } + } + } + }; + console.log('✓ Patched moveItemsToDirectoryResponse.items with oneOf'); + } +} + +await Bun.write(outputFile, JSON.stringify(spec, null, 2)); +console.log(`✓ Written to ${outputFile}`); +