mirror of
https://github.com/get-drexa/drive.git
synced 2026-02-02 13:21:17 +00:00
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
This commit is contained in:
@@ -21,9 +21,13 @@ run-docs:
|
|||||||
|
|
||||||
# Generate API documentation
|
# Generate API documentation
|
||||||
docs:
|
docs:
|
||||||
@echo "Generating OpenAPI documentation..."
|
@echo "Generating Swagger 2.0 documentation..."
|
||||||
swag init -g cmd/drexa/main.go -o docs --parseDependency --parseInternal --outputTypes go,json,yaml
|
swag init -g cmd/drexa/main.go -o docs --parseDependency --parseInternal --outputTypes json
|
||||||
@echo "Documentation generated in docs/"
|
@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"
|
@echo "Run 'make run-docs' to start the documentation server"
|
||||||
|
|
||||||
# Install development tools
|
# Install development tools
|
||||||
@@ -38,4 +42,4 @@ fmt:
|
|||||||
# Clean build artifacts
|
# Clean build artifacts
|
||||||
clean:
|
clean:
|
||||||
rm -rf bin/
|
rm -rf bin/
|
||||||
rm -f docs/swagger.json docs/swagger.yaml
|
rm -f docs/swagger.json docs/swagger.yaml cmd/docs/openapi.json
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
_ "github.com/get-drexa/drexa/docs"
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||||
"github.com/swaggo/swag"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed openapi.json
|
||||||
|
var openapiSpec embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
port := flag.Int("port", 8081, "port to listen on")
|
port := flag.Int("port", 8081, "port to listen on")
|
||||||
apiURL := flag.String("api-url", "http://localhost:8080", "base URL of the API server")
|
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 {
|
app.Get("/openapi.json", func(c *fiber.Ctx) error {
|
||||||
c.Set("Content-Type", "application/json")
|
c.Set("Content-Type", "application/json")
|
||||||
c.Set("Access-Control-Allow-Origin", "*")
|
c.Set("Access-Control-Allow-Origin", "*")
|
||||||
doc, err := swag.ReadDoc()
|
doc, err := openapiSpec.ReadFile("openapi.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).SendString(err.Error())
|
return c.Status(fiber.StatusInternalServerError).SendString(err.Error())
|
||||||
}
|
}
|
||||||
return c.SendString(doc)
|
return c.Send(doc)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
@@ -81,4 +83,3 @@ func main() {
|
|||||||
|
|
||||||
log.Fatal(app.Listen(fmt.Sprintf(":%d", *port)))
|
log.Fatal(app.Listen(fmt.Sprintf(":%d", *port)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1890
apps/backend/cmd/docs/openapi.json
Normal file
1890
apps/backend/cmd/docs/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
105
apps/backend/docs/README.md
Normal file
105
apps/backend/docs/README.md
Normal file
@@ -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 {
|
||||||
|
```
|
||||||
|
|
||||||
@@ -441,10 +441,13 @@
|
|||||||
"BearerAuth": []
|
"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": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"directories"
|
"directories"
|
||||||
],
|
],
|
||||||
@@ -476,10 +479,10 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"204": {
|
"200": {
|
||||||
"description": "Items moved successfully",
|
"description": "Move operation results with moved, conflict, and error states",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"$ref": "#/definitions/internal_catalog.moveItemsToDirectoryResponse"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
@@ -505,15 +508,6 @@
|
|||||||
"type": "string"
|
"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": {
|
"internal_catalog.patchDirectoryRequest": {
|
||||||
"description": "Request to update directory properties",
|
"description": "Request to update directory properties",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -52,6 +52,32 @@ type postDirectoryContentRequest struct {
|
|||||||
Items []string `json:"items" example:"mElnUNCm8F22,kRp2XYTq9A55"`
|
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 {
|
func (h *HTTPHandler) currentDirectoryMiddleware(c *fiber.Ctx) error {
|
||||||
account := account.CurrentAccount(c)
|
account := account.CurrentAccount(c)
|
||||||
if account == nil {
|
if account == nil {
|
||||||
@@ -344,18 +370,18 @@ func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// moveItemsToDirectory moves files and directories into this directory
|
// moveItemsToDirectory moves files and directories into this directory
|
||||||
// @Summary Move items to 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
|
// @Tags directories
|
||||||
// @Accept json
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Param accountID path string true "Account ID" format(uuid)
|
// @Param accountID path string true "Account ID" format(uuid)
|
||||||
// @Param directoryID path string true "Target directory ID"
|
// @Param directoryID path string true "Target directory ID"
|
||||||
// @Param request body postDirectoryContentRequest true "Items to move"
|
// @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 400 {object} map[string]string "Invalid request or items not in same directory"
|
||||||
// @Failure 401 {string} string "Not authenticated"
|
// @Failure 401 {string} string "Not authenticated"
|
||||||
// @Failure 404 {object} map[string]string "One or more items not found"
|
// @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]
|
// @Router /accounts/{accountID}/directories/{directoryID}/content [post]
|
||||||
func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error {
|
func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error {
|
||||||
acc := account.CurrentAccount(c)
|
acc := account.CurrentAccount(c)
|
||||||
@@ -389,7 +415,7 @@ func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Move all nodes to the target directory
|
// 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 err != nil {
|
||||||
if errors.Is(err, virtualfs.ErrUnsupportedOperation) {
|
if errors.Is(err, virtualfs.ErrUnsupportedOperation) {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "All items must be in the same directory"})
|
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 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)
|
||||||
}
|
}
|
||||||
|
|||||||
49
apps/backend/scripts/patch-openapi.ts
Normal file
49
apps/backend/scripts/patch-openapi.ts
Normal file
@@ -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 <input.json> [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}`);
|
||||||
|
|
||||||
Reference in New Issue
Block a user