From 28fd502bd70f0a20572a47cad67e3ef4bfd29696 Mon Sep 17 00:00:00 2001 From: yusing Date: Thu, 29 Jan 2026 16:30:12 +0800 Subject: [PATCH] feat(api): add route validation endpoint with WebSocket support Adds a new `/route/validate` endpoint that accepts YAML-encoded route configurations for validation. Supports both synchronous HTTP requests and real-time streaming via WebSocket for interactive validation workflows. Changes: - Implement Validate handler with YAML binding in route/validate.go - Add WebSocket manager for streaming validation results - Register GET/POST routes in handler.go - Regenerate Swagger documentation --- internal/api/handler.go | 2 + internal/api/v1/docs/swagger.json | 343 ++++++++++-------------------- internal/api/v1/docs/swagger.yaml | 206 +++++++----------- internal/api/v1/file/validate.go | 2 +- internal/api/v1/route/validate.go | 69 ++++++ 5 files changed, 271 insertions(+), 351 deletions(-) create mode 100644 internal/api/v1/route/validate.go diff --git a/internal/api/handler.go b/internal/api/handler.go index b1eb1b00..5878db6b 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -86,6 +86,8 @@ func NewHandler(requireAuth bool) *gin.Engine { route.GET("/providers", routeApi.Providers) route.GET("/by_provider", routeApi.ByProvider) route.POST("/playground", routeApi.Playground) + route.GET("/validate", routeApi.Validate) // websocket + route.POST("/validate", routeApi.Validate) } file := v1.Group("/file") diff --git a/internal/api/v1/docs/swagger.json b/internal/api/v1/docs/swagger.json index ff08028c..dd6544c7 100644 --- a/internal/api/v1/docs/swagger.json +++ b/internal/api/v1/docs/swagger.json @@ -1087,7 +1087,7 @@ "post": { "description": "Validate file", "consumes": [ - "text/plain" + "application/yaml" ], "produces": [ "application/json" @@ -3026,6 +3026,122 @@ "operationId": "providers" } }, + "/route/validate": { + "get": { + "description": "Validate route,", + "consumes": [ + "application/yaml" + ], + "produces": [ + "application/json" + ], + "tags": [ + "route", + "websocket" + ], + "summary": "Validate route", + "parameters": [ + { + "description": "Route", + "name": "route", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Route" + } + } + ], + "responses": { + "200": { + "description": "Route validated", + "schema": { + "$ref": "#/definitions/SuccessResponse" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "417": { + "description": "Validation failed", + "schema": {} + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "validate", + "operationId": "validate" + }, + "post": { + "description": "Validate route,", + "consumes": [ + "application/yaml" + ], + "produces": [ + "application/json" + ], + "tags": [ + "route", + "websocket" + ], + "summary": "Validate route", + "parameters": [ + { + "description": "Route", + "name": "route", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Route" + } + } + ], + "responses": { + "200": { + "description": "Route validated", + "schema": { + "$ref": "#/definitions/SuccessResponse" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "417": { + "description": "Validation failed", + "schema": {} + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "validate", + "operationId": "validate" + } + }, "/route/{which}": { "get": { "description": "List route", @@ -6745,229 +6861,6 @@ "x-nullable": false, "x-omitempty": false }, - "route.Route": { - "type": "object", - "properties": { - "access_log": { - "allOf": [ - { - "$ref": "#/definitions/RequestLoggerConfig" - } - ], - "x-nullable": true - }, - "agent": { - "type": "string", - "x-nullable": false, - "x-omitempty": false - }, - "alias": { - "type": "string", - "x-nullable": false, - "x-omitempty": false - }, - "bind": { - "description": "for TCP and UDP routes, bind address to listen on", - "type": "string", - "x-nullable": true - }, - "container": { - "description": "Docker only", - "allOf": [ - { - "$ref": "#/definitions/Container" - } - ], - "x-nullable": true - }, - "disable_compression": { - "type": "boolean", - "x-nullable": false, - "x-omitempty": false - }, - "excluded": { - "type": "boolean", - "x-nullable": true - }, - "excluded_reason": { - "type": "string", - "x-nullable": true - }, - "health": { - "description": "for swagger", - "allOf": [ - { - "$ref": "#/definitions/HealthJSON" - } - ], - "x-nullable": false, - "x-omitempty": false - }, - "healthcheck": { - "description": "null on load-balancer routes", - "allOf": [ - { - "$ref": "#/definitions/HealthCheckConfig" - } - ], - "x-nullable": true - }, - "homepage": { - "$ref": "#/definitions/HomepageItemConfig", - "x-nullable": false, - "x-omitempty": false - }, - "host": { - "type": "string", - "x-nullable": false, - "x-omitempty": false - }, - "idlewatcher": { - "allOf": [ - { - "$ref": "#/definitions/IdlewatcherConfig" - } - ], - "x-nullable": true - }, - "index": { - "description": "Index file to serve for single-page app mode", - "type": "string", - "x-nullable": false, - "x-omitempty": false - }, - "load_balance": { - "allOf": [ - { - "$ref": "#/definitions/LoadBalancerConfig" - } - ], - "x-nullable": true - }, - "lurl": { - "description": "private fields", - "type": "string", - "x-nullable": true - }, - "middlewares": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/types.LabelMap" - }, - "x-nullable": true - }, - "no_tls_verify": { - "type": "boolean", - "x-nullable": false, - "x-omitempty": false - }, - "path_patterns": { - "type": "array", - "items": { - "type": "string" - }, - "x-nullable": true - }, - "port": { - "$ref": "#/definitions/Port", - "x-nullable": false, - "x-omitempty": false - }, - "provider": { - "description": "for backward compatibility", - "type": "string", - "x-nullable": true - }, - "proxmox": { - "allOf": [ - { - "$ref": "#/definitions/ProxmoxNodeConfig" - } - ], - "x-nullable": true - }, - "purl": { - "type": "string", - "x-nullable": false, - "x-omitempty": false - }, - "response_header_timeout": { - "type": "integer", - "x-nullable": false, - "x-omitempty": false - }, - "root": { - "type": "string", - "x-nullable": false, - "x-omitempty": false - }, - "rule_file": { - "type": "string", - "x-nullable": true - }, - "rules": { - "type": "array", - "items": { - "$ref": "#/definitions/rules.Rule" - }, - "x-nullable": true - }, - "scheme": { - "type": "string", - "enum": [ - "http", - "https", - "h2c", - "tcp", - "udp", - "fileserver" - ], - "x-nullable": false, - "x-omitempty": false - }, - "spa": { - "description": "Single-page app mode: serves index for non-existent paths", - "type": "boolean", - "x-nullable": false, - "x-omitempty": false - }, - "ssl_certificate": { - "description": "Path to client certificate", - "type": "string", - "x-nullable": false, - "x-omitempty": false - }, - "ssl_certificate_key": { - "description": "Path to client certificate key", - "type": "string", - "x-nullable": false, - "x-omitempty": false - }, - "ssl_protocols": { - "description": "Allowed TLS protocols", - "type": "array", - "items": { - "type": "string" - }, - "x-nullable": false, - "x-omitempty": false - }, - "ssl_server_name": { - "description": "SSL/TLS proxy options (nginx-like)", - "type": "string", - "x-nullable": false, - "x-omitempty": false - }, - "ssl_trusted_certificate": { - "description": "Path to trusted CA certificates", - "type": "string", - "x-nullable": false, - "x-omitempty": false - } - }, - "x-nullable": false, - "x-omitempty": false - }, "routeApi.RawRule": { "type": "object", "properties": { @@ -6995,7 +6888,7 @@ "additionalProperties": { "type": "array", "items": { - "$ref": "#/definitions/route.Route" + "$ref": "#/definitions/Route" } }, "x-nullable": false, diff --git a/internal/api/v1/docs/swagger.yaml b/internal/api/v1/docs/swagger.yaml index 5492e1f7..0bacaada 100644 --- a/internal/api/v1/docs/swagger.yaml +++ b/internal/api/v1/docs/swagger.yaml @@ -1807,12 +1807,12 @@ definitions: type: string kernel_version: type: string + load_avg_15m: + type: string load_avg_1m: type: string load_avg_5m: type: string - load_avg_15m: - type: string mem_pct: type: string mem_total: @@ -1830,127 +1830,6 @@ definitions: uptime: type: string type: object - route.Route: - properties: - access_log: - allOf: - - $ref: '#/definitions/RequestLoggerConfig' - x-nullable: true - agent: - type: string - alias: - type: string - bind: - description: for TCP and UDP routes, bind address to listen on - type: string - x-nullable: true - container: - allOf: - - $ref: '#/definitions/Container' - description: Docker only - x-nullable: true - disable_compression: - type: boolean - excluded: - type: boolean - x-nullable: true - excluded_reason: - type: string - x-nullable: true - health: - allOf: - - $ref: '#/definitions/HealthJSON' - description: for swagger - healthcheck: - allOf: - - $ref: '#/definitions/HealthCheckConfig' - description: null on load-balancer routes - x-nullable: true - homepage: - $ref: '#/definitions/HomepageItemConfig' - host: - type: string - idlewatcher: - allOf: - - $ref: '#/definitions/IdlewatcherConfig' - x-nullable: true - index: - description: Index file to serve for single-page app mode - type: string - load_balance: - allOf: - - $ref: '#/definitions/LoadBalancerConfig' - x-nullable: true - lurl: - description: private fields - type: string - x-nullable: true - middlewares: - additionalProperties: - $ref: '#/definitions/types.LabelMap' - type: object - x-nullable: true - no_tls_verify: - type: boolean - path_patterns: - items: - type: string - type: array - x-nullable: true - port: - $ref: '#/definitions/Port' - provider: - description: for backward compatibility - type: string - x-nullable: true - proxmox: - allOf: - - $ref: '#/definitions/ProxmoxNodeConfig' - x-nullable: true - purl: - type: string - response_header_timeout: - type: integer - root: - type: string - rule_file: - type: string - x-nullable: true - rules: - items: - $ref: '#/definitions/rules.Rule' - type: array - x-nullable: true - scheme: - enum: - - http - - https - - h2c - - tcp - - udp - - fileserver - type: string - spa: - description: 'Single-page app mode: serves index for non-existent paths' - type: boolean - ssl_certificate: - description: Path to client certificate - type: string - ssl_certificate_key: - description: Path to client certificate key - type: string - ssl_protocols: - description: Allowed TLS protocols - items: - type: string - type: array - ssl_server_name: - description: SSL/TLS proxy options (nginx-like) - type: string - ssl_trusted_certificate: - description: Path to trusted CA certificates - type: string - type: object routeApi.RawRule: properties: do: @@ -1963,7 +1842,7 @@ definitions: routeApi.RoutesByProvider: additionalProperties: items: - $ref: '#/definitions/route.Route' + $ref: '#/definitions/Route' type: array type: object rules.Rule: @@ -2741,7 +2620,7 @@ paths: /file/validate: post: consumes: - - text/plain + - application/yaml description: Validate file parameters: - description: Type @@ -4079,6 +3958,83 @@ paths: - route - websocket x-id: providers + /route/validate: + get: + consumes: + - application/yaml + description: Validate route, + parameters: + - description: Route + in: body + name: route + required: true + schema: + $ref: '#/definitions/Route' + produces: + - application/json + responses: + "200": + description: Route validated + schema: + $ref: '#/definitions/SuccessResponse' + "400": + description: Bad request + schema: + $ref: '#/definitions/ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "417": + description: Validation failed + schema: {} + "500": + description: Internal server error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Validate route + tags: + - route + - websocket + x-id: validate + post: + consumes: + - application/yaml + description: Validate route, + parameters: + - description: Route + in: body + name: route + required: true + schema: + $ref: '#/definitions/Route' + produces: + - application/json + responses: + "200": + description: Route validated + schema: + $ref: '#/definitions/SuccessResponse' + "400": + description: Bad request + schema: + $ref: '#/definitions/ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/ErrorResponse' + "417": + description: Validation failed + schema: {} + "500": + description: Internal server error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Validate route + tags: + - route + - websocket + x-id: validate /stats: get: consumes: diff --git a/internal/api/v1/file/validate.go b/internal/api/v1/file/validate.go index dab99324..e2fa07dc 100644 --- a/internal/api/v1/file/validate.go +++ b/internal/api/v1/file/validate.go @@ -20,7 +20,7 @@ type ValidateFileRequest struct { // @Summary Validate file // @Description Validate file // @Tags file -// @Accept json +// @Accept application/yaml // @Produce json // @Param type query FileType true "Type" // @Param file body string true "File content" diff --git a/internal/api/v1/route/validate.go b/internal/api/v1/route/validate.go new file mode 100644 index 00000000..ddda19e0 --- /dev/null +++ b/internal/api/v1/route/validate.go @@ -0,0 +1,69 @@ +package routeApi + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/goccy/go-yaml" + "github.com/yusing/godoxy/internal/route" + "github.com/yusing/godoxy/internal/serialization" + apitypes "github.com/yusing/goutils/apitypes" + "github.com/yusing/goutils/http/httpheaders" + "github.com/yusing/goutils/http/websocket" +) + +type _ = route.Route + +// @x-id "validate" +// @BasePath /api/v1 +// @Summary Validate route +// @Description Validate route, +// @Tags route,websocket +// @Accept application/yaml +// @Produce json +// @Param route body route.Route true "Route" +// @Success 200 {object} apitypes.SuccessResponse "Route validated" +// @Failure 400 {object} apitypes.ErrorResponse "Bad request" +// @Failure 403 {object} apitypes.ErrorResponse "Forbidden" +// @Failure 417 {object} any "Validation failed" +// @Failure 500 {object} apitypes.ErrorResponse "Internal server error" +// @Router /route/validate [get] +// @Router /route/validate [post] +func Validate(c *gin.Context) { + if httpheaders.IsWebsocket(c.Request.Header) { + ValidateWS(c) + return + } + var request route.Route + if err := c.ShouldBindWith(&request, serialization.GinYAMLBinding{}); err != nil { + c.JSON(http.StatusExpectationFailed, err) + return + } + c.JSON(http.StatusOK, apitypes.Success("route validated")) +} + +func ValidateWS(c *gin.Context) { + manager, err := websocket.NewManagerWithUpgrade(c) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to upgrade to websocket")) + return + } + defer manager.Close() + + const writeTimeout = 5 * time.Second + + for { + select { + case <-manager.Done(): + return + case msg := <-manager.ReadCh(): + var request route.Route + if err := serialization.UnmarshalValidate(msg, &request, yaml.Unmarshal); err != nil { + manager.WriteJSON(gin.H{"error": err}, writeTimeout) + continue + } + manager.WriteJSON(gin.H{"message": "route validated"}, writeTimeout) + } + } +}