From bc9d0dd8ea071833e2fed802378813ff206d32c7 Mon Sep 17 00:00:00 2001 From: Manuel Ganter Date: Wed, 18 Feb 2026 11:12:14 +0100 Subject: [PATCH] feat: migrate receiver to Fuego framework with OpenAPI generation Replace net/http handlers with Fuego framework for automatic OpenAPI 3.0 spec generation. Add generated Go client package, OpenAPI extraction script, and update Makefile with separate build/run targets for both binaries. Co-Authored-By: Claude Opus 4.6 --- Makefile | 34 +- cmd/receiver/main.go | 65 +- docs/openapi.json | 665 +++++++++++++ go.mod | 33 +- go.sum | 100 +- internal/integration/integration_test.go | 28 +- internal/receiver/handler.go | 297 +++--- internal/receiver/handler_test.go | 90 +- internal/receiver/sizing_test.go | 25 +- pkg/client/client.gen.go | 1096 ++++++++++++++++++++++ scripts/extract-openapi/main.go | 64 ++ 11 files changed, 2245 insertions(+), 252 deletions(-) create mode 100644 docs/openapi.json create mode 100644 pkg/client/client.gen.go create mode 100644 scripts/extract-openapi/main.go diff --git a/Makefile b/Makefile index d1e1543..5adc181 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,10 @@ # ABOUTME: Makefile for forgejo-runner-sizer project. # ABOUTME: Provides targets for building, formatting, linting, and testing. -BINARY_NAME := sizer -CMD_PATH := ./cmd/collector GO := go GOLANGCI_LINT := $(GO) run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.2 GITLEAKS := $(GO) run github.com/zricethezav/gitleaks/v8@v8.30.0 +OAPI_CODEGEN := $(GO) run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest # Build flags LDFLAGS := -s -w @@ -13,18 +12,23 @@ BUILD_FLAGS := -ldflags "$(LDFLAGS)" default: run -.PHONY: all build clean fmt format lint gitleaks test run help vet tidy install-hooks +.PHONY: all build build-collector build-receiver clean fmt format lint gitleaks test run-collector run-receiver help vet tidy install-hooks openapi generate-client # Default target all: fmt vet lint build ## Build targets -build: ## Build the binary - $(GO) build $(BUILD_FLAGS) -o $(BINARY_NAME) $(CMD_PATH) +build: build-collector build-receiver ## Build both binaries + +build-collector: ## Build the collector binary + $(GO) build $(BUILD_FLAGS) -o collector ./cmd/collector + +build-receiver: ## Build the receiver binary + $(GO) build $(BUILD_FLAGS) -o receiver ./cmd/receiver clean: ## Remove build artifacts - rm -f $(BINARY_NAME) coverage.out coverage.html + rm -f collector receiver coverage.out coverage.html $(GO) clean ## Code quality targets @@ -46,6 +50,16 @@ gitleaks: ## Check for secrets in git history gitleaks-all: ## Check for secrets in git history $(GITLEAKS) git . +## OpenAPI / Client Generation + +openapi: ## Generate OpenAPI spec from Fuego routes + $(GO) run scripts/extract-openapi/main.go + +generate-client: openapi ## Generate Go client from OpenAPI spec + rm -rf pkg/client + mkdir -p pkg/client + $(OAPI_CODEGEN) -generate types,client -package client docs/openapi.json > pkg/client/client.gen.go + ## Dependency management tidy: ## Tidy go modules @@ -62,11 +76,11 @@ test-coverage: ## Run tests with coverage ## Run targets -run: build ## Build and run with default settings - ./$(BINARY_NAME) +run-collector: build-collector ## Build and run the collector + ./collector -run-text: build ## Build and run with text output format - ./$(BINARY_NAME) --log-format text --interval 2s +run-receiver: build-receiver ## Build and run the receiver + ./receiver --read-token=secure-read-token --hmac-key=secure-hmac-key ## Git hooks diff --git a/cmd/receiver/main.go b/cmd/receiver/main.go index a7863c7..9067eee 100644 --- a/cmd/receiver/main.go +++ b/cmd/receiver/main.go @@ -1,16 +1,17 @@ +// ABOUTME: Entry point for the metrics receiver service. +// ABOUTME: HTTP service using Fuego framework with automatic OpenAPI 3.0 generation. package main import ( - "context" "flag" "fmt" "log/slog" - "net/http" "os" - "os/signal" - "syscall" "time" + "github.com/getkin/kin-openapi/openapi3" + "github.com/go-fuego/fuego" + "edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver" ) @@ -39,42 +40,44 @@ func main() { defer func() { _ = store.Close() }() handler := receiver.NewHandler(store, logger, *readToken, *hmacKey, *tokenTTL) - mux := http.NewServeMux() - handler.RegisterRoutes(mux) - server := &http.Server{ - Addr: *addr, - Handler: mux, - ReadTimeout: 10 * time.Second, - WriteTimeout: 10 * time.Second, - } + // Create Fuego server with OpenAPI configuration + s := fuego.NewServer( + fuego.WithAddr(*addr), + fuego.WithEngineOptions( + fuego.WithOpenAPIConfig(fuego.OpenAPIConfig{ + PrettyFormatJSON: true, + JSONFilePath: "docs/openapi.json", + SwaggerURL: "/swagger", + Info: &openapi3.Info{ + Title: "Forgejo Runner Resource Collector API", + Version: "1.0.0", + Description: "HTTP service that receives and stores CI/CD resource metrics from collectors, providing query and sizing recommendation APIs.", + Contact: &openapi3.Contact{ + Name: "API Support", + URL: "https://edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer", + }, + License: &openapi3.License{ + Name: "Apache 2.0", + URL: "http://www.apache.org/licenses/LICENSE-2.0.html", + }, + }, + }), + ), + ) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - go func() { - sig := <-sigChan - logger.Info("received signal, shutting down", slog.String("signal", sig.String())) - cancel() - - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer shutdownCancel() - _ = server.Shutdown(shutdownCtx) - }() + // Register routes + handler.RegisterRoutes(s) logger.Info("starting metrics receiver", slog.String("addr", *addr), slog.String("db", *dbPath), + slog.String("swagger", fmt.Sprintf("http://localhost%s/swagger", *addr)), ) - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + // Run server (handles graceful shutdown) + if err := s.Run(); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } - - <-ctx.Done() - logger.Info("receiver stopped gracefully") } diff --git a/docs/openapi.json b/docs/openapi.json new file mode 100644 index 0000000..35c1b9e --- /dev/null +++ b/docs/openapi.json @@ -0,0 +1,665 @@ +{ + "components": { + "schemas": { + "HTTPError": { + "description": "HTTPError schema", + "properties": { + "detail": { + "description": "Human readable error message", + "nullable": true, + "type": "string" + }, + "errors": { + "items": { + "nullable": true, + "properties": { + "more": { + "additionalProperties": { + "description": "Additional information about the error", + "nullable": true + }, + "description": "Additional information about the error", + "nullable": true, + "type": "object" + }, + "name": { + "description": "For example, name of the parameter that caused the error", + "type": "string" + }, + "reason": { + "description": "Human readable error message", + "type": "string" + } + }, + "type": "object" + }, + "nullable": true, + "type": "array" + }, + "instance": { + "nullable": true, + "type": "string" + }, + "status": { + "description": "HTTP status code", + "example": 403, + "nullable": true, + "type": "integer" + }, + "title": { + "description": "Short title of the error", + "nullable": true, + "type": "string" + }, + "type": { + "description": "URL of the error type. Can be used to lookup the error in a documentation", + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, + "HealthResponse": { + "description": "HealthResponse schema", + "properties": { + "status": { + "type": "string" + } + }, + "type": "object" + }, + "MetricCreatedResponse": { + "description": "MetricCreatedResponse schema", + "properties": { + "id": { + "minimum": 0, + "type": "integer" + }, + "status": { + "type": "string" + } + }, + "type": "object" + }, + "MetricResponse": { + "description": "MetricResponse schema", + "properties": { + "id": { + "minimum": 0, + "type": "integer" + }, + "job": { + "type": "string" + }, + "organization": { + "type": "string" + }, + "payload": {}, + "received_at": { + "format": "date-time", + "type": "string" + }, + "repository": { + "type": "string" + }, + "run_id": { + "type": "string" + }, + "workflow": { + "type": "string" + } + }, + "type": "object" + }, + "SizingResponse": { + "description": "SizingResponse schema", + "properties": { + "containers": { + "items": { + "properties": { + "cpu": { + "properties": { + "limit": { + "type": "string" + }, + "request": { + "type": "string" + } + }, + "type": "object" + }, + "memory": { + "properties": { + "limit": { + "type": "string" + }, + "request": { + "type": "string" + } + }, + "type": "object" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "meta": { + "properties": { + "buffer_percent": { + "type": "integer" + }, + "cpu_percentile": { + "type": "string" + }, + "runs_analyzed": { + "type": "integer" + } + }, + "type": "object" + }, + "total": { + "properties": { + "cpu": { + "properties": { + "limit": { + "type": "string" + }, + "request": { + "type": "string" + } + }, + "type": "object" + }, + "memory": { + "properties": { + "limit": { + "type": "string" + }, + "request": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "TokenRequest": { + "description": "TokenRequest schema", + "properties": { + "job": { + "type": "string" + }, + "organization": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "workflow": { + "type": "string" + } + }, + "type": "object" + }, + "TokenResponse": { + "description": "TokenResponse schema", + "properties": { + "token": { + "type": "string" + } + }, + "type": "object" + }, + "unknown-interface": { + "description": "unknown-interface schema" + } + } + }, + "info": { + "contact": { + "name": "API Support", + "url": "https://edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer" + }, + "description": "HTTP service that receives and stores CI/CD resource metrics from collectors, providing query and sizing recommendation APIs.", + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "title": "Forgejo Runner Resource Collector API", + "version": "1.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/api/v1/metrics": { + "post": { + "description": "#### Controller: \n\n`edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver.(*Handler).ReceiveMetrics`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\n", + "operationId": "POST_/api/v1/metrics", + "parameters": [ + { + "in": "header", + "name": "Accept", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetricCreatedResponse" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/MetricCreatedResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + }, + "description": "Bad Request _(validation or deserialization error)_" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + }, + "description": "Internal Server Error _(panics)_" + }, + "default": { + "description": "" + } + }, + "summary": "receive metrics", + "tags": [ + "api/v1" + ] + } + }, + "/api/v1/metrics/repo/{org}/{repo}/{workflow}/{job}": { + "get": { + "description": "#### Controller: \n\n`edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver.(*Handler).GetMetricsByWorkflowJob`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver.(*Handler).requireReadToken`\n\n---\n\n", + "operationId": "GET_/api/v1/metrics/repo/:org/:repo/:workflow/:job", + "parameters": [ + { + "in": "header", + "name": "Accept", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "workflow", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "job", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/MetricResponse" + }, + "type": "array" + } + }, + "application/xml": { + "schema": { + "items": { + "$ref": "#/components/schemas/MetricResponse" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + }, + "description": "Bad Request _(validation or deserialization error)_" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + }, + "description": "Internal Server Error _(panics)_" + }, + "default": { + "description": "" + } + }, + "summary": "get metrics by workflow job", + "tags": [ + "api/v1" + ] + } + }, + "/api/v1/sizing/repo/{org}/{repo}/{workflow}/{job}": { + "get": { + "description": "#### Controller: \n\n`edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver.(*Handler).GetSizing`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver.(*Handler).requireReadToken`\n\n---\n\n", + "operationId": "GET_/api/v1/sizing/repo/:org/:repo/:workflow/:job", + "parameters": [ + { + "in": "header", + "name": "Accept", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "workflow", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "job", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SizingResponse" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/SizingResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + }, + "description": "Bad Request _(validation or deserialization error)_" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + }, + "description": "Internal Server Error _(panics)_" + }, + "default": { + "description": "" + } + }, + "summary": "get sizing", + "tags": [ + "api/v1" + ] + } + }, + "/api/v1/token": { + "post": { + "description": "#### Controller: \n\n`edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver.(*Handler).GenerateToken`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver.(*Handler).requireReadToken`\n\n---\n\n", + "operationId": "POST_/api/v1/token", + "parameters": [ + { + "in": "header", + "name": "Accept", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/TokenRequest" + } + } + }, + "description": "Request body for receiver.TokenRequest", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + }, + "description": "Bad Request _(validation or deserialization error)_" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + }, + "description": "Internal Server Error _(panics)_" + }, + "default": { + "description": "" + } + }, + "summary": "generate token", + "tags": [ + "api/v1" + ] + } + }, + "/health": { + "get": { + "description": "#### Controller: \n\n`edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver.(*Handler).Health`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\n", + "operationId": "GET_/health", + "parameters": [ + { + "in": "header", + "name": "Accept", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + }, + "description": "Bad Request _(validation or deserialization error)_" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + }, + "description": "Internal Server Error _(panics)_" + }, + "default": { + "description": "" + } + }, + "summary": "health" + } + } + } +} \ No newline at end of file diff --git a/go.mod b/go.mod index a51ecd1..13f846b 100644 --- a/go.mod +++ b/go.mod @@ -3,20 +3,45 @@ module edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer go 1.25.6 require ( + github.com/getkin/kin-openapi v0.133.0 github.com/glebarez/sqlite v1.11.0 + github.com/go-fuego/fuego v0.19.0 + github.com/oapi-codegen/runtime v1.1.2 gorm.io/gorm v1.31.1 ) require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.11 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.22.3 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.28.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/schema v1.4.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/mattn/go-isatty v0.0.17 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.7.0 // indirect - golang.org/x/text v0.20.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/woodsbury/decimal128 v1.4.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.31.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect diff --git a/go.sum b/go.sum index 95df11c..a80e80c 100644 --- a/go.sum +++ b/go.sum @@ -1,27 +1,109 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= +github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-fuego/fuego v0.19.0 h1:kxkkBsrbGZP1YnPCAPIdUpMu53nreqN8N86lfi50CJw= +github.com/go-fuego/fuego v0.19.0/go.mod h1:O7CLZbvCCBA9ijhN/q8SnyFTzDdMsqYZjUbR82VDHhA= +github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8= +github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= +github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= -github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= +github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/thejerf/slogassert v0.3.4 h1:VoTsXixRbXMrRSSxDjYTiEDCM4VWbsYPW5rB/hX24kM= +github.com/thejerf/slogassert v0.3.4/go.mod h1:0zn9ISLVKo1aPMTqcGfG1o6dWwt+Rk574GlUxHD4rs8= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= +github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go index 91f532c..915998c 100644 --- a/internal/integration/integration_test.go +++ b/internal/integration/integration_test.go @@ -14,6 +14,8 @@ import ( "testing" "time" + "github.com/go-fuego/fuego" + "edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver" "edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/summary" ) @@ -33,10 +35,17 @@ func setupTestReceiver(t *testing.T) (*receiver.Store, *httptest.Server, func()) } handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), testReadToken, testHMACKey, 0) - mux := http.NewServeMux() - handler.RegisterRoutes(mux) + s := fuego.NewServer( + fuego.WithoutStartupMessages(), + fuego.WithEngineOptions( + fuego.WithOpenAPIConfig(fuego.OpenAPIConfig{ + Disabled: true, + }), + ), + ) + handler.RegisterRoutes(s) - server := httptest.NewServer(mux) + server := httptest.NewServer(s.Mux) cleanup := func() { server.Close() @@ -372,10 +381,17 @@ func setupTestReceiverWithToken(t *testing.T, readToken, hmacKey string) (*recei } handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), readToken, hmacKey, 0) - mux := http.NewServeMux() - handler.RegisterRoutes(mux) + s := fuego.NewServer( + fuego.WithoutStartupMessages(), + fuego.WithEngineOptions( + fuego.WithOpenAPIConfig(fuego.OpenAPIConfig{ + Disabled: true, + }), + ), + ) + handler.RegisterRoutes(s) - server := httptest.NewServer(mux) + server := httptest.NewServer(s.Mux) cleanup := func() { server.Close() diff --git a/internal/receiver/handler.go b/internal/receiver/handler.go index 57c09b5..e42365d 100644 --- a/internal/receiver/handler.go +++ b/internal/receiver/handler.go @@ -1,15 +1,18 @@ -// ABOUTME: HTTP handlers for the metrics receiver service. -// ABOUTME: Provides endpoints for receiving and querying metrics. +// ABOUTME: HTTP handlers for the metrics receiver service using Fuego framework. +// ABOUTME: Provides endpoints for receiving and querying metrics with automatic OpenAPI generation. package receiver import ( "crypto/subtle" "encoding/json" + "errors" "fmt" "log/slog" "net/http" "strings" "time" + + "github.com/go-fuego/fuego" ) // Handler handles HTTP requests for the metrics receiver @@ -32,128 +35,175 @@ func NewHandler(store *Store, logger *slog.Logger, readToken, hmacKey string, to return &Handler{store: store, logger: logger, readToken: readToken, hmacKey: hmacKey, tokenTTL: tokenTTL} } -// RegisterRoutes registers all HTTP routes on the given mux -func (h *Handler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("POST /api/v1/metrics", h.handleReceiveMetrics) - mux.HandleFunc("POST /api/v1/token", h.handleGenerateToken) - mux.HandleFunc("GET /api/v1/metrics/repo/{org}/{repo}/{workflow}/{job}", h.handleGetByWorkflowJob) - mux.HandleFunc("GET /api/v1/sizing/repo/{org}/{repo}/{workflow}/{job}", h.handleGetSizing) - mux.HandleFunc("GET /health", h.handleHealth) +// Common errors +var ( + ErrUnauthorized = errors.New("authorization required") + ErrInvalidToken = errors.New("invalid token") + ErrInvalidFormat = errors.New("invalid authorization format") + ErrMissingHMACKey = errors.New("token generation requires a configured HMAC key") + ErrMissingFields = errors.New("organization, repository, workflow, and job are required") + ErrMissingRunID = errors.New("run_id is required") + ErrInvalidParams = errors.New("org, repo, workflow and job are required") + ErrNoMetrics = errors.New("no metrics found for this workflow/job") + ErrInvalidPercent = errors.New("invalid cpu_percentile: must be one of peak, p99, p95, p75, p50, avg") +) + +// HealthResponse is the response for the health endpoint +type HealthResponse struct { + Status string `json:"status"` } -// validateReadToken checks the Authorization header for a valid Bearer token. -func (h *Handler) validateReadToken(w http.ResponseWriter, r *http.Request) bool { - if h.readToken == "" { - h.logger.Warn("no read-token configured, rejecting request", slog.String("path", r.URL.Path)) - http.Error(w, "authorization required", http.StatusUnauthorized) - return false - } - - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - h.logger.Warn("missing authorization header", slog.String("path", r.URL.Path)) - http.Error(w, "authorization required", http.StatusUnauthorized) - return false - } - - const bearerPrefix = "Bearer " - if !strings.HasPrefix(authHeader, bearerPrefix) { - h.logger.Warn("invalid authorization format", slog.String("path", r.URL.Path)) - http.Error(w, "invalid authorization format", http.StatusUnauthorized) - return false - } - - token := strings.TrimPrefix(authHeader, bearerPrefix) - if subtle.ConstantTimeCompare([]byte(token), []byte(h.readToken)) != 1 { - h.logger.Warn("invalid token", slog.String("path", r.URL.Path)) - http.Error(w, "invalid token", http.StatusUnauthorized) - return false - } - - return true +// MetricCreatedResponse is the response when a metric is successfully created +type MetricCreatedResponse struct { + ID uint `json:"id"` + Status string `json:"status"` } -func (h *Handler) handleGenerateToken(w http.ResponseWriter, r *http.Request) { - if h.hmacKey == "" { - http.Error(w, "token generation requires a configured HMAC key", http.StatusBadRequest) - return - } - - if !h.validateReadToken(w, r) { - return - } - - var req TokenRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid JSON body", http.StatusBadRequest) - return - } - - if req.Organization == "" || req.Repository == "" || req.Workflow == "" || req.Job == "" { - http.Error(w, "organization, repository, workflow, and job are required", http.StatusBadRequest) - return - } - - token := GenerateToken(h.hmacKey, req.Organization, req.Repository, req.Workflow, req.Job) - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(TokenResponse{Token: token}) +// GetMetricsRequest contains path parameters for getting metrics +type GetMetricsRequest struct { + Org string `path:"org"` + Repo string `path:"repo"` + Workflow string `path:"workflow"` + Job string `path:"job"` } -// validatePushToken checks push authentication via scoped HMAC token. -func (h *Handler) validatePushToken(w http.ResponseWriter, r *http.Request, exec ExecutionContext) bool { +// GetSizingRequest contains path and query parameters for sizing endpoint +type GetSizingRequest struct { + Org string `path:"org"` + Repo string `path:"repo"` + Workflow string `path:"workflow"` + Job string `path:"job"` + Runs int `query:"runs" default:"5" validate:"min=1,max=100" description:"Number of recent runs to analyze"` + Buffer int `query:"buffer" default:"20" validate:"min=0,max=100" description:"Buffer percentage to add"` + CPUPercentile string `query:"cpu_percentile" default:"p95" description:"CPU percentile to use (peak, p99, p95, p75, p50, avg)"` +} + +// RegisterRoutes registers all HTTP routes on the Fuego server +func (h *Handler) RegisterRoutes(s *fuego.Server) { + // Health endpoint (no auth) + fuego.Get(s, "/health", h.Health) + + // API group with authentication + api := fuego.Group(s, "/api/v1") + + // Token generation (requires read token) + fuego.Post(api, "/token", h.GenerateToken, fuego.OptionMiddleware(h.requireReadToken)) + + // Metrics endpoints + fuego.Post(api, "/metrics", h.ReceiveMetrics) // Uses push token validated in handler + fuego.Get(api, "/metrics/repo/{org}/{repo}/{workflow}/{job}", h.GetMetricsByWorkflowJob, fuego.OptionMiddleware(h.requireReadToken)) + + // Sizing endpoint + fuego.Get(api, "/sizing/repo/{org}/{repo}/{workflow}/{job}", h.GetSizing, fuego.OptionMiddleware(h.requireReadToken)) +} + +// requireReadToken is middleware that validates the read token +func (h *Handler) requireReadToken(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if h.readToken == "" { + h.logger.Warn("no read-token configured, rejecting request", slog.String("path", r.URL.Path)) + http.Error(w, "authorization required", http.StatusUnauthorized) + return + } + + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + h.logger.Warn("missing authorization header", slog.String("path", r.URL.Path)) + http.Error(w, "authorization required", http.StatusUnauthorized) + return + } + + const bearerPrefix = "Bearer " + if !strings.HasPrefix(authHeader, bearerPrefix) { + h.logger.Warn("invalid authorization format", slog.String("path", r.URL.Path)) + http.Error(w, "invalid authorization format", http.StatusUnauthorized) + return + } + + token := strings.TrimPrefix(authHeader, bearerPrefix) + if subtle.ConstantTimeCompare([]byte(token), []byte(h.readToken)) != 1 { + h.logger.Warn("invalid token", slog.String("path", r.URL.Path)) + http.Error(w, "invalid token", http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) +} + +// validatePushToken checks push authentication via scoped HMAC token +func (h *Handler) validatePushToken(r *http.Request, exec ExecutionContext) error { if h.hmacKey == "" { h.logger.Warn("no HMAC key configured, rejecting push", slog.String("path", r.URL.Path)) - http.Error(w, "authorization required", http.StatusUnauthorized) - return false + return ErrUnauthorized } authHeader := r.Header.Get("Authorization") if authHeader == "" { h.logger.Warn("missing push authorization", slog.String("path", r.URL.Path)) - http.Error(w, "authorization required", http.StatusUnauthorized) - return false + return ErrUnauthorized } const bearerPrefix = "Bearer " if !strings.HasPrefix(authHeader, bearerPrefix) { h.logger.Warn("invalid push authorization format", slog.String("path", r.URL.Path)) - http.Error(w, "invalid authorization format", http.StatusUnauthorized) - return false + return ErrInvalidFormat } token := strings.TrimPrefix(authHeader, bearerPrefix) if !ValidateToken(h.hmacKey, token, exec.Organization, exec.Repository, exec.Workflow, exec.Job, h.tokenTTL) { h.logger.Warn("invalid push token", slog.String("path", r.URL.Path)) - http.Error(w, "invalid token", http.StatusUnauthorized) - return false + return ErrInvalidToken } - return true + return nil } -func (h *Handler) handleReceiveMetrics(w http.ResponseWriter, r *http.Request) { +// Health returns the service health status +func (h *Handler) Health(c fuego.ContextNoBody) (HealthResponse, error) { + return HealthResponse{Status: "ok"}, nil +} + +// GenerateToken generates a scoped HMAC push token for a workflow/job +func (h *Handler) GenerateToken(c fuego.ContextWithBody[TokenRequest]) (TokenResponse, error) { + if h.hmacKey == "" { + return TokenResponse{}, fuego.BadRequestError{Detail: ErrMissingHMACKey.Error()} + } + + req, err := c.Body() + if err != nil { + return TokenResponse{}, fuego.BadRequestError{Detail: "invalid JSON body"} + } + + if req.Organization == "" || req.Repository == "" || req.Workflow == "" || req.Job == "" { + return TokenResponse{}, fuego.BadRequestError{Detail: ErrMissingFields.Error()} + } + + token := GenerateToken(h.hmacKey, req.Organization, req.Repository, req.Workflow, req.Job) + return TokenResponse{Token: token}, nil +} + +// ReceiveMetrics receives and stores metrics from a collector +func (h *Handler) ReceiveMetrics(c fuego.ContextNoBody) (MetricCreatedResponse, error) { var payload MetricsPayload - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil { h.logger.Error("failed to decode payload", slog.String("error", err.Error())) - http.Error(w, "invalid JSON payload", http.StatusBadRequest) - return + return MetricCreatedResponse{}, fuego.BadRequestError{Detail: "invalid JSON payload"} } if payload.Execution.RunID == "" { - http.Error(w, "run_id is required", http.StatusBadRequest) - return + return MetricCreatedResponse{}, fuego.BadRequestError{Detail: ErrMissingRunID.Error()} } - if !h.validatePushToken(w, r, payload.Execution) { - return + // Validate push token + if err := h.validatePushToken(c.Request(), payload.Execution); err != nil { + return MetricCreatedResponse{}, fuego.UnauthorizedError{Detail: err.Error()} } id, err := h.store.SaveMetric(&payload) if err != nil { h.logger.Error("failed to save metric", slog.String("error", err.Error())) - http.Error(w, "failed to save metric", http.StatusInternalServerError) - return + return MetricCreatedResponse{}, fuego.InternalServerError{Detail: "failed to save metric"} } h.logger.Info("metric saved", @@ -162,30 +212,25 @@ func (h *Handler) handleReceiveMetrics(w http.ResponseWriter, r *http.Request) { slog.String("repository", payload.Execution.Repository), ) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "status": "created"}) + c.SetStatus(http.StatusCreated) + return MetricCreatedResponse{ID: id, Status: "created"}, nil } -func (h *Handler) handleGetByWorkflowJob(w http.ResponseWriter, r *http.Request) { - if !h.validateReadToken(w, r) { - return - } +// GetMetricsByWorkflowJob retrieves all metrics for a specific workflow/job +func (h *Handler) GetMetricsByWorkflowJob(c fuego.ContextNoBody) ([]MetricResponse, error) { + org := c.PathParam("org") + repo := c.PathParam("repo") + workflow := c.PathParam("workflow") + job := c.PathParam("job") - org := r.PathValue("org") - repo := r.PathValue("repo") - workflow := r.PathValue("workflow") - job := r.PathValue("job") if org == "" || repo == "" || workflow == "" || job == "" { - http.Error(w, "org, repo, workflow and job are required", http.StatusBadRequest) - return + return nil, fuego.BadRequestError{Detail: ErrInvalidParams.Error()} } metrics, err := h.store.GetMetricsByWorkflowJob(org, repo, workflow, job) if err != nil { h.logger.Error("failed to get metrics", slog.String("error", err.Error())) - http.Error(w, "failed to get metrics", http.StatusInternalServerError) - return + return nil, fuego.InternalServerError{Detail: "failed to get metrics"} } // Convert to response type with Payload as JSON object @@ -194,67 +239,53 @@ func (h *Handler) handleGetByWorkflowJob(w http.ResponseWriter, r *http.Request) response[i] = m.ToResponse() } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) + return response, nil } -func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) -} +// GetSizing computes Kubernetes resource sizing recommendations +func (h *Handler) GetSizing(c fuego.ContextNoBody) (SizingResponse, error) { + org := c.PathParam("org") + repo := c.PathParam("repo") + workflow := c.PathParam("workflow") + job := c.PathParam("job") -func (h *Handler) handleGetSizing(w http.ResponseWriter, r *http.Request) { - if !h.validateReadToken(w, r) { - return - } - - org := r.PathValue("org") - repo := r.PathValue("repo") - workflow := r.PathValue("workflow") - job := r.PathValue("job") if org == "" || repo == "" || workflow == "" || job == "" { - http.Error(w, "org, repo, workflow and job are required", http.StatusBadRequest) - return + return SizingResponse{}, fuego.BadRequestError{Detail: ErrInvalidParams.Error()} } // Parse query parameters with defaults - runs := parseIntQueryParam(r, "runs", 5, 1, 100) - buffer := parseIntQueryParam(r, "buffer", 20, 0, 100) - cpuPercentile := r.URL.Query().Get("cpu_percentile") + runs := parseIntQueryParamFromContext(c, "runs", 5, 1, 100) + buffer := parseIntQueryParamFromContext(c, "buffer", 20, 0, 100) + cpuPercentile := c.QueryParam("cpu_percentile") if cpuPercentile == "" { cpuPercentile = "p95" } if !IsValidPercentile(cpuPercentile) { - http.Error(w, "invalid cpu_percentile: must be one of peak, p99, p95, p75, p50, avg", http.StatusBadRequest) - return + return SizingResponse{}, fuego.BadRequestError{Detail: ErrInvalidPercent.Error()} } metrics, err := h.store.GetRecentMetricsByWorkflowJob(org, repo, workflow, job, runs) if err != nil { h.logger.Error("failed to get metrics", slog.String("error", err.Error())) - http.Error(w, "failed to get metrics", http.StatusInternalServerError) - return + return SizingResponse{}, fuego.InternalServerError{Detail: "failed to get metrics"} } if len(metrics) == 0 { - http.Error(w, "no metrics found for this workflow/job", http.StatusNotFound) - return + return SizingResponse{}, fuego.NotFoundError{Detail: ErrNoMetrics.Error()} } response, err := computeSizing(metrics, buffer, cpuPercentile) if err != nil { h.logger.Error("failed to compute sizing", slog.String("error", err.Error())) - http.Error(w, "failed to compute sizing", http.StatusInternalServerError) - return + return SizingResponse{}, fuego.InternalServerError{Detail: "failed to compute sizing"} } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) + return *response, nil } -// parseIntQueryParam parses an integer query parameter with default, min, and max values -func parseIntQueryParam(r *http.Request, name string, defaultVal, minVal, maxVal int) int { - strVal := r.URL.Query().Get(name) +// parseIntQueryParamFromContext parses an integer query parameter with default, min, and max values +func parseIntQueryParamFromContext(c fuego.ContextNoBody, name string, defaultVal, minVal, maxVal int) int { + strVal := c.QueryParam(name) if strVal == "" { return defaultVal } diff --git a/internal/receiver/handler_test.go b/internal/receiver/handler_test.go index dcf1791..3a2a2d3 100644 --- a/internal/receiver/handler_test.go +++ b/internal/receiver/handler_test.go @@ -11,6 +11,8 @@ import ( "strings" "testing" + "github.com/go-fuego/fuego" + "edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/summary" ) @@ -42,9 +44,8 @@ func TestHandler_ReceiveMetrics(t *testing.T) { req.Header.Set("Authorization", "Bearer "+pushToken) rec := httptest.NewRecorder() - mux := http.NewServeMux() - h.RegisterRoutes(mux) - mux.ServeHTTP(rec, req) + s := newTestServer(h) + s.Mux.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Errorf("status = %d, want %d", rec.Code, http.StatusCreated) @@ -69,9 +70,8 @@ func TestHandler_ReceiveMetrics_InvalidJSON(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics", bytes.NewReader([]byte("not json"))) rec := httptest.NewRecorder() - mux := http.NewServeMux() - h.RegisterRoutes(mux) - mux.ServeHTTP(rec, req) + s := newTestServer(h) + s.Mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) @@ -95,9 +95,8 @@ func TestHandler_ReceiveMetrics_MissingRunID(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics", bytes.NewReader(body)) rec := httptest.NewRecorder() - mux := http.NewServeMux() - h.RegisterRoutes(mux) - mux.ServeHTTP(rec, req) + s := newTestServer(h) + s.Mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) @@ -125,9 +124,8 @@ func TestHandler_GetByWorkflowJob(t *testing.T) { req.Header.Set("Authorization", "Bearer "+readToken) rec := httptest.NewRecorder() - mux := http.NewServeMux() - h.RegisterRoutes(mux) - mux.ServeHTTP(rec, req) + s := newTestServer(h) + s.Mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) @@ -151,9 +149,8 @@ func TestHandler_GetByWorkflowJob_NotFound(t *testing.T) { req.Header.Set("Authorization", "Bearer "+readToken) rec := httptest.NewRecorder() - mux := http.NewServeMux() - h.RegisterRoutes(mux) - mux.ServeHTTP(rec, req) + s := newTestServer(h) + s.Mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) @@ -180,8 +177,7 @@ func TestHandler_GetByWorkflowJob_WithToken(t *testing.T) { t.Fatalf("SaveMetric() error = %v", err) } - mux := http.NewServeMux() - h.RegisterRoutes(mux) + s := newTestServer(h) tests := []struct { name string @@ -201,7 +197,7 @@ func TestHandler_GetByWorkflowJob_WithToken(t *testing.T) { req.Header.Set("Authorization", tt.authHeader) } rec := httptest.NewRecorder() - mux.ServeHTTP(rec, req) + s.Mux.ServeHTTP(rec, req) if rec.Code != tt.wantCode { t.Errorf("status = %d, want %d", rec.Code, tt.wantCode) @@ -217,9 +213,8 @@ func TestHandler_Health(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/health", nil) rec := httptest.NewRecorder() - mux := http.NewServeMux() - h.RegisterRoutes(mux) - mux.ServeHTTP(rec, req) + s := newTestServer(h) + s.Mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) @@ -250,9 +245,8 @@ func TestHandler_GenerateToken(t *testing.T) { req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() - mux := http.NewServeMux() - h.RegisterRoutes(mux) - mux.ServeHTTP(rec, req) + s := newTestServer(h) + s.Mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) @@ -289,9 +283,8 @@ func TestHandler_GenerateToken_NoAuth(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/token", bytes.NewReader(body)) rec := httptest.NewRecorder() - mux := http.NewServeMux() - h.RegisterRoutes(mux) - mux.ServeHTTP(rec, req) + s := newTestServer(h) + s.Mux.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) @@ -314,9 +307,8 @@ func TestHandler_GenerateToken_MissingFields(t *testing.T) { req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() - mux := http.NewServeMux() - h.RegisterRoutes(mux) - mux.ServeHTTP(rec, req) + s := newTestServer(h) + s.Mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) @@ -338,12 +330,12 @@ func TestHandler_GenerateToken_NoReadToken(t *testing.T) { req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() - mux := http.NewServeMux() - h.RegisterRoutes(mux) - mux.ServeHTTP(rec, req) + s := newTestServer(h) + s.Mux.ServeHTTP(rec, req) - if rec.Code != http.StatusBadRequest { - t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + // With no read token, the middleware rejects before we reach the handler + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) } } @@ -352,8 +344,7 @@ func TestHandler_ReceiveMetrics_WithPushToken(t *testing.T) { h, cleanup := newTestHandlerWithToken(t, readToken) defer cleanup() - mux := http.NewServeMux() - h.RegisterRoutes(mux) + s := newTestServer(h) exec := ExecutionContext{ Organization: "org", @@ -391,7 +382,7 @@ func TestHandler_ReceiveMetrics_WithPushToken(t *testing.T) { req.Header.Set("Authorization", tt.authHeader) } rec := httptest.NewRecorder() - mux.ServeHTTP(rec, req) + s.Mux.ServeHTTP(rec, req) if rec.Code != tt.wantCode { t.Errorf("status = %d, want %d", rec.Code, tt.wantCode) @@ -420,9 +411,8 @@ func TestHandler_ReceiveMetrics_RejectsWhenNoReadToken(t *testing.T) { req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() - mux := http.NewServeMux() - h.RegisterRoutes(mux) - mux.ServeHTTP(rec, req) + s := newTestServer(h) + s.Mux.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) @@ -436,15 +426,27 @@ func TestHandler_GetByWorkflowJob_RejectsWhenNoReadToken(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/repo/org/repo/ci.yml/build", nil) rec := httptest.NewRecorder() - mux := http.NewServeMux() - h.RegisterRoutes(mux) - mux.ServeHTTP(rec, req) + s := newTestServer(h) + s.Mux.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) } } +func newTestServer(h *Handler) *fuego.Server { + s := fuego.NewServer( + fuego.WithoutStartupMessages(), + fuego.WithEngineOptions( + fuego.WithOpenAPIConfig(fuego.OpenAPIConfig{ + Disabled: true, + }), + ), + ) + h.RegisterRoutes(s) + return s +} + func newTestHandler(t *testing.T) (*Handler, func()) { t.Helper() dbPath := filepath.Join(t.TempDir(), "test.db") diff --git a/internal/receiver/sizing_test.go b/internal/receiver/sizing_test.go index ce57210..05c3297 100644 --- a/internal/receiver/sizing_test.go +++ b/internal/receiver/sizing_test.go @@ -342,9 +342,8 @@ func TestHandler_GetSizing(t *testing.T) { req.Header.Set("Authorization", "Bearer "+readToken) rec := httptest.NewRecorder() - mux := http.NewServeMux() - h.RegisterRoutes(mux) - mux.ServeHTTP(rec, req) + s := newTestServer(h) + s.Mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) @@ -396,9 +395,8 @@ func TestHandler_GetSizing_CustomParams(t *testing.T) { req.Header.Set("Authorization", "Bearer "+readToken) rec := httptest.NewRecorder() - mux := http.NewServeMux() - h.RegisterRoutes(mux) - mux.ServeHTTP(rec, req) + s := newTestServer(h) + s.Mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) @@ -432,9 +430,8 @@ func TestHandler_GetSizing_NotFound(t *testing.T) { req.Header.Set("Authorization", "Bearer "+readToken) rec := httptest.NewRecorder() - mux := http.NewServeMux() - h.RegisterRoutes(mux) - mux.ServeHTTP(rec, req) + s := newTestServer(h) + s.Mux.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound) @@ -450,9 +447,8 @@ func TestHandler_GetSizing_InvalidPercentile(t *testing.T) { req.Header.Set("Authorization", "Bearer "+readToken) rec := httptest.NewRecorder() - mux := http.NewServeMux() - h.RegisterRoutes(mux) - mux.ServeHTTP(rec, req) + s := newTestServer(h) + s.Mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) @@ -474,8 +470,7 @@ func TestHandler_GetSizing_AuthRequired(t *testing.T) { {"valid token", "Bearer " + readToken, http.StatusNotFound}, // no metrics, but auth works } - mux := http.NewServeMux() - h.RegisterRoutes(mux) + s := newTestServer(h) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -484,7 +479,7 @@ func TestHandler_GetSizing_AuthRequired(t *testing.T) { req.Header.Set("Authorization", tt.authHeader) } rec := httptest.NewRecorder() - mux.ServeHTTP(rec, req) + s.Mux.ServeHTTP(rec, req) if rec.Code != tt.wantCode { t.Errorf("status = %d, want %d", rec.Code, tt.wantCode) diff --git a/pkg/client/client.gen.go b/pkg/client/client.gen.go new file mode 100644 index 0000000..1085246 --- /dev/null +++ b/pkg/client/client.gen.go @@ -0,0 +1,1096 @@ +// Package client provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT. +package client + +import ( + "context" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/oapi-codegen/runtime" +) + +// HTTPError HTTPError schema +type HTTPError struct { + // Detail Human readable error message + Detail *string `json:"detail"` + Errors *[]struct { + // More Additional information about the error + More *map[string]*interface{} `json:"more"` + + // Name For example, name of the parameter that caused the error + Name *string `json:"name,omitempty"` + + // Reason Human readable error message + Reason *string `json:"reason,omitempty"` + } `json:"errors"` + Instance *string `json:"instance"` + + // Status HTTP status code + Status *int `json:"status"` + + // Title Short title of the error + Title *string `json:"title"` + + // Type URL of the error type. Can be used to lookup the error in a documentation + Type *string `json:"type"` +} + +// HealthResponse HealthResponse schema +type HealthResponse struct { + Status *string `json:"status,omitempty"` +} + +// MetricCreatedResponse MetricCreatedResponse schema +type MetricCreatedResponse struct { + Id *int `json:"id,omitempty"` + Status *string `json:"status,omitempty"` +} + +// MetricResponse MetricResponse schema +type MetricResponse struct { + Id *int `json:"id,omitempty"` + Job *string `json:"job,omitempty"` + Organization *string `json:"organization,omitempty"` + Payload interface{} `json:"payload,omitempty"` + ReceivedAt *time.Time `json:"received_at,omitempty"` + Repository *string `json:"repository,omitempty"` + RunId *string `json:"run_id,omitempty"` + Workflow *string `json:"workflow,omitempty"` +} + +// SizingResponse SizingResponse schema +type SizingResponse struct { + Containers *[]struct { + Cpu *struct { + Limit *string `json:"limit,omitempty"` + Request *string `json:"request,omitempty"` + } `json:"cpu,omitempty"` + Memory *struct { + Limit *string `json:"limit,omitempty"` + Request *string `json:"request,omitempty"` + } `json:"memory,omitempty"` + Name *string `json:"name,omitempty"` + } `json:"containers,omitempty"` + Meta *struct { + BufferPercent *int `json:"buffer_percent,omitempty"` + CpuPercentile *string `json:"cpu_percentile,omitempty"` + RunsAnalyzed *int `json:"runs_analyzed,omitempty"` + } `json:"meta,omitempty"` + Total *struct { + Cpu *struct { + Limit *string `json:"limit,omitempty"` + Request *string `json:"request,omitempty"` + } `json:"cpu,omitempty"` + Memory *struct { + Limit *string `json:"limit,omitempty"` + Request *string `json:"request,omitempty"` + } `json:"memory,omitempty"` + } `json:"total,omitempty"` +} + +// TokenRequest TokenRequest schema +type TokenRequest struct { + Job *string `json:"job,omitempty"` + Organization *string `json:"organization,omitempty"` + Repository *string `json:"repository,omitempty"` + Workflow *string `json:"workflow,omitempty"` +} + +// TokenResponse TokenResponse schema +type TokenResponse struct { + Token *string `json:"token,omitempty"` +} + +// POSTapiv1metricsParams defines parameters for POSTapiv1metrics. +type POSTapiv1metricsParams struct { + Accept *string `json:"Accept,omitempty"` +} + +// GETapiv1metricsrepoOrgRepoWorkflowJobParams defines parameters for GETapiv1metricsrepoOrgRepoWorkflowJob. +type GETapiv1metricsrepoOrgRepoWorkflowJobParams struct { + Accept *string `json:"Accept,omitempty"` +} + +// GETapiv1sizingrepoOrgRepoWorkflowJobParams defines parameters for GETapiv1sizingrepoOrgRepoWorkflowJob. +type GETapiv1sizingrepoOrgRepoWorkflowJobParams struct { + Accept *string `json:"Accept,omitempty"` +} + +// POSTapiv1tokenParams defines parameters for POSTapiv1token. +type POSTapiv1tokenParams struct { + Accept *string `json:"Accept,omitempty"` +} + +// GEThealthParams defines parameters for GEThealth. +type GEThealthParams struct { + Accept *string `json:"Accept,omitempty"` +} + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // POSTapiv1metrics request + POSTapiv1metrics(ctx context.Context, params *POSTapiv1metricsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GETapiv1metricsrepoOrgRepoWorkflowJob request + GETapiv1metricsrepoOrgRepoWorkflowJob(ctx context.Context, org string, repo string, workflow string, job string, params *GETapiv1metricsrepoOrgRepoWorkflowJobParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GETapiv1sizingrepoOrgRepoWorkflowJob request + GETapiv1sizingrepoOrgRepoWorkflowJob(ctx context.Context, org string, repo string, workflow string, job string, params *GETapiv1sizingrepoOrgRepoWorkflowJobParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // POSTapiv1tokenWithBody request with any body + POSTapiv1tokenWithBody(ctx context.Context, params *POSTapiv1tokenParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GEThealth request + GEThealth(ctx context.Context, params *GEThealthParams, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) POSTapiv1metrics(ctx context.Context, params *POSTapiv1metricsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPOSTapiv1metricsRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GETapiv1metricsrepoOrgRepoWorkflowJob(ctx context.Context, org string, repo string, workflow string, job string, params *GETapiv1metricsrepoOrgRepoWorkflowJobParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGETapiv1metricsrepoOrgRepoWorkflowJobRequest(c.Server, org, repo, workflow, job, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GETapiv1sizingrepoOrgRepoWorkflowJob(ctx context.Context, org string, repo string, workflow string, job string, params *GETapiv1sizingrepoOrgRepoWorkflowJobParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGETapiv1sizingrepoOrgRepoWorkflowJobRequest(c.Server, org, repo, workflow, job, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) POSTapiv1tokenWithBody(ctx context.Context, params *POSTapiv1tokenParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPOSTapiv1tokenRequestWithBody(c.Server, params, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GEThealth(ctx context.Context, params *GEThealthParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGEThealthRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewPOSTapiv1metricsRequest generates requests for POSTapiv1metrics +func NewPOSTapiv1metricsRequest(server string, params *POSTapiv1metricsParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/metrics") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), nil) + if err != nil { + return nil, err + } + + if params != nil { + + if params.Accept != nil { + var headerParam0 string + + headerParam0, err = runtime.StyleParamWithLocation("simple", false, "Accept", runtime.ParamLocationHeader, *params.Accept) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", headerParam0) + } + + } + + return req, nil +} + +// NewGETapiv1metricsrepoOrgRepoWorkflowJobRequest generates requests for GETapiv1metricsrepoOrgRepoWorkflowJob +func NewGETapiv1metricsrepoOrgRepoWorkflowJobRequest(server string, org string, repo string, workflow string, job string, params *GETapiv1metricsrepoOrgRepoWorkflowJobParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "org", runtime.ParamLocationPath, org) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "repo", runtime.ParamLocationPath, repo) + if err != nil { + return nil, err + } + + var pathParam2 string + + pathParam2, err = runtime.StyleParamWithLocation("simple", false, "workflow", runtime.ParamLocationPath, workflow) + if err != nil { + return nil, err + } + + var pathParam3 string + + pathParam3, err = runtime.StyleParamWithLocation("simple", false, "job", runtime.ParamLocationPath, job) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/metrics/repo/%s/%s/%s/%s", pathParam0, pathParam1, pathParam2, pathParam3) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + if params != nil { + + if params.Accept != nil { + var headerParam0 string + + headerParam0, err = runtime.StyleParamWithLocation("simple", false, "Accept", runtime.ParamLocationHeader, *params.Accept) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", headerParam0) + } + + } + + return req, nil +} + +// NewGETapiv1sizingrepoOrgRepoWorkflowJobRequest generates requests for GETapiv1sizingrepoOrgRepoWorkflowJob +func NewGETapiv1sizingrepoOrgRepoWorkflowJobRequest(server string, org string, repo string, workflow string, job string, params *GETapiv1sizingrepoOrgRepoWorkflowJobParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "org", runtime.ParamLocationPath, org) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "repo", runtime.ParamLocationPath, repo) + if err != nil { + return nil, err + } + + var pathParam2 string + + pathParam2, err = runtime.StyleParamWithLocation("simple", false, "workflow", runtime.ParamLocationPath, workflow) + if err != nil { + return nil, err + } + + var pathParam3 string + + pathParam3, err = runtime.StyleParamWithLocation("simple", false, "job", runtime.ParamLocationPath, job) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/sizing/repo/%s/%s/%s/%s", pathParam0, pathParam1, pathParam2, pathParam3) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + if params != nil { + + if params.Accept != nil { + var headerParam0 string + + headerParam0, err = runtime.StyleParamWithLocation("simple", false, "Accept", runtime.ParamLocationHeader, *params.Accept) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", headerParam0) + } + + } + + return req, nil +} + +// NewPOSTapiv1tokenRequestWithBody generates requests for POSTapiv1token with any type of body +func NewPOSTapiv1tokenRequestWithBody(server string, params *POSTapiv1tokenParams, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/token") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + if params != nil { + + if params.Accept != nil { + var headerParam0 string + + headerParam0, err = runtime.StyleParamWithLocation("simple", false, "Accept", runtime.ParamLocationHeader, *params.Accept) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", headerParam0) + } + + } + + return req, nil +} + +// NewGEThealthRequest generates requests for GEThealth +func NewGEThealthRequest(server string, params *GEThealthParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/health") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + if params != nil { + + if params.Accept != nil { + var headerParam0 string + + headerParam0, err = runtime.StyleParamWithLocation("simple", false, "Accept", runtime.ParamLocationHeader, *params.Accept) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", headerParam0) + } + + } + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // POSTapiv1metricsWithResponse request + POSTapiv1metricsWithResponse(ctx context.Context, params *POSTapiv1metricsParams, reqEditors ...RequestEditorFn) (*POSTapiv1metricsResponse, error) + + // GETapiv1metricsrepoOrgRepoWorkflowJobWithResponse request + GETapiv1metricsrepoOrgRepoWorkflowJobWithResponse(ctx context.Context, org string, repo string, workflow string, job string, params *GETapiv1metricsrepoOrgRepoWorkflowJobParams, reqEditors ...RequestEditorFn) (*GETapiv1metricsrepoOrgRepoWorkflowJobResponse, error) + + // GETapiv1sizingrepoOrgRepoWorkflowJobWithResponse request + GETapiv1sizingrepoOrgRepoWorkflowJobWithResponse(ctx context.Context, org string, repo string, workflow string, job string, params *GETapiv1sizingrepoOrgRepoWorkflowJobParams, reqEditors ...RequestEditorFn) (*GETapiv1sizingrepoOrgRepoWorkflowJobResponse, error) + + // POSTapiv1tokenWithBodyWithResponse request with any body + POSTapiv1tokenWithBodyWithResponse(ctx context.Context, params *POSTapiv1tokenParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*POSTapiv1tokenResponse, error) + + // GEThealthWithResponse request + GEThealthWithResponse(ctx context.Context, params *GEThealthParams, reqEditors ...RequestEditorFn) (*GEThealthResponse, error) +} + +type POSTapiv1metricsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *MetricCreatedResponse + XML200 *MetricCreatedResponse + JSON400 *HTTPError + XML400 *HTTPError + JSON500 *HTTPError + XML500 *HTTPError +} + +// Status returns HTTPResponse.Status +func (r POSTapiv1metricsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r POSTapiv1metricsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GETapiv1metricsrepoOrgRepoWorkflowJobResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *[]MetricResponse + XML200 *[]MetricResponse + JSON400 *HTTPError + XML400 *HTTPError + JSON500 *HTTPError + XML500 *HTTPError +} + +// Status returns HTTPResponse.Status +func (r GETapiv1metricsrepoOrgRepoWorkflowJobResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GETapiv1metricsrepoOrgRepoWorkflowJobResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GETapiv1sizingrepoOrgRepoWorkflowJobResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *SizingResponse + XML200 *SizingResponse + JSON400 *HTTPError + XML400 *HTTPError + JSON500 *HTTPError + XML500 *HTTPError +} + +// Status returns HTTPResponse.Status +func (r GETapiv1sizingrepoOrgRepoWorkflowJobResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GETapiv1sizingrepoOrgRepoWorkflowJobResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type POSTapiv1tokenResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *TokenResponse + XML200 *TokenResponse + JSON400 *HTTPError + XML400 *HTTPError + JSON500 *HTTPError + XML500 *HTTPError +} + +// Status returns HTTPResponse.Status +func (r POSTapiv1tokenResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r POSTapiv1tokenResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GEThealthResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *HealthResponse + XML200 *HealthResponse + JSON400 *HTTPError + XML400 *HTTPError + JSON500 *HTTPError + XML500 *HTTPError +} + +// Status returns HTTPResponse.Status +func (r GEThealthResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GEThealthResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// POSTapiv1metricsWithResponse request returning *POSTapiv1metricsResponse +func (c *ClientWithResponses) POSTapiv1metricsWithResponse(ctx context.Context, params *POSTapiv1metricsParams, reqEditors ...RequestEditorFn) (*POSTapiv1metricsResponse, error) { + rsp, err := c.POSTapiv1metrics(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParsePOSTapiv1metricsResponse(rsp) +} + +// GETapiv1metricsrepoOrgRepoWorkflowJobWithResponse request returning *GETapiv1metricsrepoOrgRepoWorkflowJobResponse +func (c *ClientWithResponses) GETapiv1metricsrepoOrgRepoWorkflowJobWithResponse(ctx context.Context, org string, repo string, workflow string, job string, params *GETapiv1metricsrepoOrgRepoWorkflowJobParams, reqEditors ...RequestEditorFn) (*GETapiv1metricsrepoOrgRepoWorkflowJobResponse, error) { + rsp, err := c.GETapiv1metricsrepoOrgRepoWorkflowJob(ctx, org, repo, workflow, job, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGETapiv1metricsrepoOrgRepoWorkflowJobResponse(rsp) +} + +// GETapiv1sizingrepoOrgRepoWorkflowJobWithResponse request returning *GETapiv1sizingrepoOrgRepoWorkflowJobResponse +func (c *ClientWithResponses) GETapiv1sizingrepoOrgRepoWorkflowJobWithResponse(ctx context.Context, org string, repo string, workflow string, job string, params *GETapiv1sizingrepoOrgRepoWorkflowJobParams, reqEditors ...RequestEditorFn) (*GETapiv1sizingrepoOrgRepoWorkflowJobResponse, error) { + rsp, err := c.GETapiv1sizingrepoOrgRepoWorkflowJob(ctx, org, repo, workflow, job, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGETapiv1sizingrepoOrgRepoWorkflowJobResponse(rsp) +} + +// POSTapiv1tokenWithBodyWithResponse request with arbitrary body returning *POSTapiv1tokenResponse +func (c *ClientWithResponses) POSTapiv1tokenWithBodyWithResponse(ctx context.Context, params *POSTapiv1tokenParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*POSTapiv1tokenResponse, error) { + rsp, err := c.POSTapiv1tokenWithBody(ctx, params, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePOSTapiv1tokenResponse(rsp) +} + +// GEThealthWithResponse request returning *GEThealthResponse +func (c *ClientWithResponses) GEThealthWithResponse(ctx context.Context, params *GEThealthParams, reqEditors ...RequestEditorFn) (*GEThealthResponse, error) { + rsp, err := c.GEThealth(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGEThealthResponse(rsp) +} + +// ParsePOSTapiv1metricsResponse parses an HTTP response from a POSTapiv1metricsWithResponse call +func ParsePOSTapiv1metricsResponse(rsp *http.Response) (*POSTapiv1metricsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &POSTapiv1metricsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest MetricCreatedResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest HTTPError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest HTTPError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "xml") && rsp.StatusCode == 200: + var dest MetricCreatedResponse + if err := xml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.XML200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "xml") && rsp.StatusCode == 400: + var dest HTTPError + if err := xml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.XML400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "xml") && rsp.StatusCode == 500: + var dest HTTPError + if err := xml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.XML500 = &dest + + } + + return response, nil +} + +// ParseGETapiv1metricsrepoOrgRepoWorkflowJobResponse parses an HTTP response from a GETapiv1metricsrepoOrgRepoWorkflowJobWithResponse call +func ParseGETapiv1metricsrepoOrgRepoWorkflowJobResponse(rsp *http.Response) (*GETapiv1metricsrepoOrgRepoWorkflowJobResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GETapiv1metricsrepoOrgRepoWorkflowJobResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest []MetricResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest HTTPError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest HTTPError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "xml") && rsp.StatusCode == 200: + var dest []MetricResponse + if err := xml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.XML200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "xml") && rsp.StatusCode == 400: + var dest HTTPError + if err := xml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.XML400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "xml") && rsp.StatusCode == 500: + var dest HTTPError + if err := xml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.XML500 = &dest + + } + + return response, nil +} + +// ParseGETapiv1sizingrepoOrgRepoWorkflowJobResponse parses an HTTP response from a GETapiv1sizingrepoOrgRepoWorkflowJobWithResponse call +func ParseGETapiv1sizingrepoOrgRepoWorkflowJobResponse(rsp *http.Response) (*GETapiv1sizingrepoOrgRepoWorkflowJobResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GETapiv1sizingrepoOrgRepoWorkflowJobResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest SizingResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest HTTPError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest HTTPError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "xml") && rsp.StatusCode == 200: + var dest SizingResponse + if err := xml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.XML200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "xml") && rsp.StatusCode == 400: + var dest HTTPError + if err := xml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.XML400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "xml") && rsp.StatusCode == 500: + var dest HTTPError + if err := xml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.XML500 = &dest + + } + + return response, nil +} + +// ParsePOSTapiv1tokenResponse parses an HTTP response from a POSTapiv1tokenWithResponse call +func ParsePOSTapiv1tokenResponse(rsp *http.Response) (*POSTapiv1tokenResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &POSTapiv1tokenResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest TokenResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest HTTPError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest HTTPError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "xml") && rsp.StatusCode == 200: + var dest TokenResponse + if err := xml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.XML200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "xml") && rsp.StatusCode == 400: + var dest HTTPError + if err := xml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.XML400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "xml") && rsp.StatusCode == 500: + var dest HTTPError + if err := xml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.XML500 = &dest + + } + + return response, nil +} + +// ParseGEThealthResponse parses an HTTP response from a GEThealthWithResponse call +func ParseGEThealthResponse(rsp *http.Response) (*GEThealthResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GEThealthResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest HealthResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest HTTPError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest HTTPError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "xml") && rsp.StatusCode == 200: + var dest HealthResponse + if err := xml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.XML200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "xml") && rsp.StatusCode == 400: + var dest HTTPError + if err := xml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.XML400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "xml") && rsp.StatusCode == 500: + var dest HTTPError + if err := xml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.XML500 = &dest + + } + + return response, nil +} diff --git a/scripts/extract-openapi/main.go b/scripts/extract-openapi/main.go new file mode 100644 index 0000000..327d967 --- /dev/null +++ b/scripts/extract-openapi/main.go @@ -0,0 +1,64 @@ +//go:build ignore + +// ABOUTME: Extracts OpenAPI spec from Fuego server without running it. +// ABOUTME: Run with: go run scripts/extract-openapi/main.go +package main + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/go-fuego/fuego" + + "edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver" +) + +func main() { + // Create a minimal handler (store is nil, won't be used) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := receiver.NewHandler(nil, logger, "dummy", "dummy", 0) + + // Create Fuego server with OpenAPI config + s := fuego.NewServer( + fuego.WithoutStartupMessages(), + fuego.WithEngineOptions( + fuego.WithOpenAPIConfig(fuego.OpenAPIConfig{ + DisableLocalSave: true, + Info: &openapi3.Info{ + Title: "Forgejo Runner Resource Collector API", + Version: "1.0.0", + Description: "HTTP service that receives and stores CI/CD resource metrics from collectors, providing query and sizing recommendation APIs.", + Contact: &openapi3.Contact{ + Name: "API Support", + URL: "https://edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer", + }, + License: &openapi3.License{ + Name: "Apache 2.0", + URL: "http://www.apache.org/licenses/LICENSE-2.0.html", + }, + }, + }), + ), + ) + + // Register routes to populate OpenAPI spec + handler.RegisterRoutes(s) + + // Output OpenAPI spec as JSON + spec, err := json.MarshalIndent(s.OpenAPI.Description(), "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "Error marshaling OpenAPI spec: %v\n", err) + os.Exit(1) + } + + if err := os.WriteFile("docs/openapi.json", spec, 0644); err != nil { + fmt.Fprintf(os.Stderr, "Error writing docs/openapi.json: %v\n", err) + os.Exit(1) + } + + fmt.Println("Generated docs/openapi.json") +}