feat: initial commit
This commit is contained in:
commit
1b81917307
17 changed files with 1565 additions and 0 deletions
138
internal/api/handlers.go
Normal file
138
internal/api/handlers.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"blockyserver/internal/render"
|
||||
"blockyserver/internal/service"
|
||||
)
|
||||
|
||||
// Handlers contains HTTP handlers for the API
|
||||
type Handlers struct {
|
||||
svc *service.MergeService
|
||||
}
|
||||
|
||||
// NewHandlers creates a new Handlers instance
|
||||
func NewHandlers(svc *service.MergeService) *Handlers {
|
||||
return &Handlers{svc: svc}
|
||||
}
|
||||
|
||||
// HandleGLB handles POST /render/glb
|
||||
func (h *Handlers) HandleGLB(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "failed to read request body")
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
result, err := h.svc.MergeFromJSON(body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "model/gltf-binary")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=character.glb")
|
||||
w.Write(result.GLBBytes)
|
||||
}
|
||||
|
||||
// HandlePNG handles POST /render/png
|
||||
func (h *Handlers) HandlePNG(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "failed to read request body")
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var req PNGRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ApplyDefaults()
|
||||
|
||||
if req.Character == nil {
|
||||
writeError(w, http.StatusBadRequest, "character field is required")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.MergeFromJSON(req.Character)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "merge failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
pngBytes, err := render.RenderPNG(result.GLBBytes, result.Atlas, req.Rotation, req.Background, req.Width, req.Height)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "render failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Write(pngBytes)
|
||||
}
|
||||
|
||||
// HandleGIF handles POST /render/gif
|
||||
func (h *Handlers) HandleGIF(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "failed to read request body")
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var req GIFRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ApplyDefaults()
|
||||
|
||||
if req.Character == nil {
|
||||
writeError(w, http.StatusBadRequest, "character field is required")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.MergeFromJSON(req.Character)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "merge failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
gifBytes, err := render.RenderGIF(result.GLBBytes, result.Atlas, req.Background, req.Frames, req.Width, req.Height, req.Delay)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "render failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "image/gif")
|
||||
w.Write(gifBytes)
|
||||
}
|
||||
|
||||
// HandleHealth handles GET /health
|
||||
func (h *Handlers) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
// HandleOpenAPISpec handles GET /openapi.json
|
||||
func (h *Handlers) HandleOpenAPISpec(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(OpenAPISpec))
|
||||
}
|
||||
|
||||
// HandleSwaggerUI handles GET /docs
|
||||
func (h *Handlers) HandleSwaggerUI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(SwaggerUIHTML))
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(ErrorResponse{Error: message})
|
||||
}
|
||||
257
internal/api/openapi.go
Normal file
257
internal/api/openapi.go
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
package api
|
||||
|
||||
// OpenAPISpec is the OpenAPI 3.0 specification for the BlockyServer API
|
||||
const OpenAPISpec = `{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "BlockyModel Merger API",
|
||||
"description": "HTTP API for rendering Hytale character models as GLB, PNG, or animated GIF.\n\n**BlockyServer** by [devilreef](https://github.com/devilr33f)\n\n**BlockyModel Merger** by [JackGamesFTW](https://github.com/JackGamesFTW)",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "/",
|
||||
"description": "Current server"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/health": {
|
||||
"get": {
|
||||
"summary": "Health check",
|
||||
"operationId": "getHealth",
|
||||
"tags": ["System"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Server is healthy",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/render/glb": {
|
||||
"post": {
|
||||
"summary": "Render character as GLB",
|
||||
"description": "Renders a character and returns GLB binary file.",
|
||||
"operationId": "renderGLB",
|
||||
"tags": ["Render"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CharacterConfig"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "GLB binary file",
|
||||
"content": {
|
||||
"model/gltf-binary": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/render/png": {
|
||||
"post": {
|
||||
"summary": "Render character as PNG",
|
||||
"description": "Renders a character as a PNG image with configurable rotation and background.",
|
||||
"operationId": "renderPNG",
|
||||
"tags": ["Render"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PNGRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PNG image",
|
||||
"content": {
|
||||
"image/png": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/render/gif": {
|
||||
"post": {
|
||||
"summary": "Render character as animated GIF",
|
||||
"description": "Renders a character as an animated rotating GIF.",
|
||||
"operationId": "renderGIF",
|
||||
"tags": ["Render"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GIFRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Animated GIF",
|
||||
"content": {
|
||||
"image/gif": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"CharacterConfig": {
|
||||
"type": "object",
|
||||
"description": "Character appearance configuration. All fields are optional. Format: \"AccessoryId.Color.Variant\"",
|
||||
"properties": {
|
||||
"bodyCharacteristic": {"type": "string", "example": "Default.02", "description": "Body type and skin tone"},
|
||||
"underwear": {"type": "string", "example": "Underwear_Male", "description": "Base underwear"},
|
||||
"face": {"type": "string", "example": "Face_A", "description": "Face shape"},
|
||||
"ears": {"type": "string", "example": "Ears_A", "description": "Ear type"},
|
||||
"mouth": {"type": "string", "example": "Mouth_A", "description": "Mouth shape"},
|
||||
"haircut": {"type": "string", "example": "Scavenger_Hair.PitchBlack", "description": "Hair style and color"},
|
||||
"facialHair": {"type": "string", "example": "Beard_A.Brown", "description": "Beard/mustache"},
|
||||
"eyebrows": {"type": "string", "example": "Eyebrows_A.Black", "description": "Eyebrow style and color"},
|
||||
"eyes": {"type": "string", "example": "Large_Eyes.Pink", "description": "Eye style and color"},
|
||||
"pants": {"type": "string", "example": "Pants_A.Blue", "description": "Lower body clothing"},
|
||||
"overpants": {"type": "string", "description": "Pants overlay (belt, etc.)"},
|
||||
"undertop": {"type": "string", "example": "Shirt_A.White", "description": "Shirt/undershirt"},
|
||||
"overtop": {"type": "string", "example": "Jacket_A.Red", "description": "Jacket/coat"},
|
||||
"shoes": {"type": "string", "example": "Boots_A.Brown", "description": "Footwear"},
|
||||
"headAccessory": {"type": "string", "description": "Hat/helmet"},
|
||||
"faceAccessory": {"type": "string", "description": "Glasses/mask"},
|
||||
"earAccessory": {"type": "string", "description": "Earrings"},
|
||||
"skinFeature": {"type": "string", "description": "Tattoos/markings"},
|
||||
"gloves": {"type": "string", "description": "Hand accessories"},
|
||||
"cape": {"type": "string", "description": "Back cape"}
|
||||
}
|
||||
},
|
||||
"PNGRequest": {
|
||||
"type": "object",
|
||||
"required": ["character"],
|
||||
"properties": {
|
||||
"character": {"$ref": "#/components/schemas/CharacterConfig"},
|
||||
"rotation": {"type": "number", "default": 0, "description": "Rotation in degrees"},
|
||||
"background": {"type": "string", "default": "transparent", "description": "\"transparent\" or hex color \"#RRGGBB\""},
|
||||
"width": {"type": "integer", "default": 512, "description": "Image width in pixels"},
|
||||
"height": {"type": "integer", "default": 512, "description": "Image height in pixels"}
|
||||
}
|
||||
},
|
||||
"GIFRequest": {
|
||||
"type": "object",
|
||||
"required": ["character"],
|
||||
"properties": {
|
||||
"character": {"$ref": "#/components/schemas/CharacterConfig"},
|
||||
"background": {"type": "string", "default": "#FFFFFF", "description": "Hex color (no transparency for GIF)"},
|
||||
"frames": {"type": "integer", "default": 36, "description": "Number of frames (36 = 10° per frame)"},
|
||||
"width": {"type": "integer", "default": 512, "description": "Image width in pixels"},
|
||||
"height": {"type": "integer", "default": 512, "description": "Image height in pixels"},
|
||||
"delay": {"type": "integer", "default": 5, "description": "Centiseconds between frames"}
|
||||
}
|
||||
},
|
||||
"ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {"type": "string", "description": "Error message"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"BadRequest": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/ErrorResponse"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"InternalError": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/ErrorResponse"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
// SwaggerUIHTML returns the Swagger UI HTML page
|
||||
const SwaggerUIHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BlockyServer API</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
window.onload = () => {
|
||||
SwaggerUIBundle({
|
||||
url: '/openapi.json',
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
|
||||
layout: "BaseLayout"
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
34
internal/api/server.go
Normal file
34
internal/api/server.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"blockyserver/internal/service"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// NewServer creates a new HTTP server with all routes configured
|
||||
func NewServer(svc *service.MergeService) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Middleware
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Timeout(60 * time.Second))
|
||||
|
||||
// Create handlers
|
||||
h := NewHandlers(svc)
|
||||
|
||||
// Routes
|
||||
r.Get("/health", h.HandleHealth)
|
||||
r.Get("/openapi.json", h.HandleOpenAPISpec)
|
||||
r.Get("/docs", h.HandleSwaggerUI)
|
||||
r.Post("/render/glb", h.HandleGLB)
|
||||
r.Post("/render/png", h.HandlePNG)
|
||||
r.Post("/render/gif", h.HandleGIF)
|
||||
|
||||
return r
|
||||
}
|
||||
59
internal/api/types.go
Normal file
59
internal/api/types.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package api
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// PNGRequest represents a request to render a character as PNG
|
||||
type PNGRequest struct {
|
||||
Character json.RawMessage `json:"character"`
|
||||
Rotation float64 `json:"rotation"` // degrees, default 0
|
||||
Background string `json:"background"` // "transparent" or hex "#RRGGBB"
|
||||
Width int `json:"width"` // default 512
|
||||
Height int `json:"height"` // default 512
|
||||
}
|
||||
|
||||
// GIFRequest represents a request to render a character as animated GIF
|
||||
type GIFRequest struct {
|
||||
Character json.RawMessage `json:"character"`
|
||||
Background string `json:"background"` // hex color "#RRGGBB"
|
||||
Frames int `json:"frames"` // default 36 (10° per frame)
|
||||
Width int `json:"width"` // default 512
|
||||
Height int `json:"height"` // default 512
|
||||
Delay int `json:"delay"` // centiseconds between frames, default 5
|
||||
}
|
||||
|
||||
// ErrorResponse represents an error returned by the API
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// ApplyDefaults fills in default values for PNGRequest
|
||||
func (r *PNGRequest) ApplyDefaults() {
|
||||
if r.Width == 0 {
|
||||
r.Width = 512
|
||||
}
|
||||
if r.Height == 0 {
|
||||
r.Height = 512
|
||||
}
|
||||
if r.Background == "" {
|
||||
r.Background = "transparent"
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyDefaults fills in default values for GIFRequest
|
||||
func (r *GIFRequest) ApplyDefaults() {
|
||||
if r.Width == 0 {
|
||||
r.Width = 512
|
||||
}
|
||||
if r.Height == 0 {
|
||||
r.Height = 512
|
||||
}
|
||||
if r.Frames == 0 {
|
||||
r.Frames = 36
|
||||
}
|
||||
if r.Delay == 0 {
|
||||
r.Delay = 5
|
||||
}
|
||||
if r.Background == "" {
|
||||
r.Background = "#FFFFFF"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue