Codevault initial commit

This commit is contained in:
2026-08-16 16:23:14 +03:00
commit 6ee80f1c8f
47 changed files with 3177 additions and 0 deletions

4
internal/api/doc.go Normal file
View File

@@ -0,0 +1,4 @@
// Package api contains the OpenAPI-generated GIN strict-server code for CodeVault.
package api
//go:generate oapi-codegen --config=oapi-codegen.yaml openapi.yaml

363
internal/api/generated.go Normal file
View File

@@ -0,0 +1,363 @@
// Package api 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 api
import (
"bytes"
"compress/gzip"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"strings"
"github.com/getkin/kin-openapi/openapi3"
"github.com/gin-gonic/gin"
strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin"
)
const (
BasicAuthScopes = "basicAuth.Scopes"
)
// GetValueRequest defines model for GetValueRequest.
type GetValueRequest struct {
Code string `json:"code"`
}
// GetValueResponse defines model for GetValueResponse.
type GetValueResponse struct {
Value string `json:"value"`
}
// SetValueRequest defines model for SetValueRequest.
type SetValueRequest struct {
Code string `json:"code"`
Value string `json:"value"`
}
// GetValueJSONRequestBody defines body for GetValue for application/json ContentType.
type GetValueJSONRequestBody = GetValueRequest
// SetValueJSONRequestBody defines body for SetValue for application/json ContentType.
type SetValueJSONRequestBody = SetValueRequest
// ServerInterface represents all server handlers.
type ServerInterface interface {
// (POST /api/v1/getValue)
GetValue(c *gin.Context)
// (POST /api/v1/setValue)
SetValue(c *gin.Context)
}
// ServerInterfaceWrapper converts contexts to parameters.
type ServerInterfaceWrapper struct {
Handler ServerInterface
HandlerMiddlewares []MiddlewareFunc
ErrorHandler func(*gin.Context, error, int)
}
type MiddlewareFunc func(c *gin.Context)
// GetValue operation middleware
func (siw *ServerInterfaceWrapper) GetValue(c *gin.Context) {
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
return
}
}
siw.Handler.GetValue(c)
}
// SetValue operation middleware
func (siw *ServerInterfaceWrapper) SetValue(c *gin.Context) {
c.Set(BasicAuthScopes, []string{})
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
return
}
}
siw.Handler.SetValue(c)
}
// GinServerOptions provides options for the Gin server.
type GinServerOptions struct {
BaseURL string
Middlewares []MiddlewareFunc
ErrorHandler func(*gin.Context, error, int)
}
// RegisterHandlers creates http.Handler with routing matching OpenAPI spec.
func RegisterHandlers(router gin.IRouter, si ServerInterface) {
RegisterHandlersWithOptions(router, si, GinServerOptions{})
}
// RegisterHandlersWithOptions creates http.Handler with additional options
func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) {
errorHandler := options.ErrorHandler
if errorHandler == nil {
errorHandler = func(c *gin.Context, err error, statusCode int) {
c.JSON(statusCode, gin.H{"msg": err.Error()})
}
}
wrapper := ServerInterfaceWrapper{
Handler: si,
HandlerMiddlewares: options.Middlewares,
ErrorHandler: errorHandler,
}
router.POST(options.BaseURL+"/api/v1/getValue", wrapper.GetValue)
router.POST(options.BaseURL+"/api/v1/setValue", wrapper.SetValue)
}
type GetValueRequestObject struct {
Body *GetValueJSONRequestBody
}
type GetValueResponseObject interface {
VisitGetValueResponse(w http.ResponseWriter) error
}
type GetValue200JSONResponse GetValueResponse
func (response GetValue200JSONResponse) VisitGetValueResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type GetValue400Response struct {
}
func (response GetValue400Response) VisitGetValueResponse(w http.ResponseWriter) error {
w.WriteHeader(400)
return nil
}
type GetValue404Response struct {
}
func (response GetValue404Response) VisitGetValueResponse(w http.ResponseWriter) error {
w.WriteHeader(404)
return nil
}
type SetValueRequestObject struct {
Body *SetValueJSONRequestBody
}
type SetValueResponseObject interface {
VisitSetValueResponse(w http.ResponseWriter) error
}
type SetValue204Response struct {
}
func (response SetValue204Response) VisitSetValueResponse(w http.ResponseWriter) error {
w.WriteHeader(204)
return nil
}
type SetValue400Response struct {
}
func (response SetValue400Response) VisitSetValueResponse(w http.ResponseWriter) error {
w.WriteHeader(400)
return nil
}
type SetValue401Response struct {
}
func (response SetValue401Response) VisitSetValueResponse(w http.ResponseWriter) error {
w.WriteHeader(401)
return nil
}
// StrictServerInterface represents all server handlers.
type StrictServerInterface interface {
// (POST /api/v1/getValue)
GetValue(ctx context.Context, request GetValueRequestObject) (GetValueResponseObject, error)
// (POST /api/v1/setValue)
SetValue(ctx context.Context, request SetValueRequestObject) (SetValueResponseObject, error)
}
type StrictHandlerFunc = strictgin.StrictGinHandlerFunc
type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc
func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface {
return &strictHandler{ssi: ssi, middlewares: middlewares}
}
type strictHandler struct {
ssi StrictServerInterface
middlewares []StrictMiddlewareFunc
}
// GetValue operation middleware
func (sh *strictHandler) GetValue(ctx *gin.Context) {
var request GetValueRequestObject
var body GetValueJSONRequestBody
if err := ctx.ShouldBindJSON(&body); err != nil {
ctx.Status(http.StatusBadRequest)
ctx.Error(err)
return
}
request.Body = &body
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
return sh.ssi.GetValue(ctx, request.(GetValueRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetValue")
}
response, err := handler(ctx, request)
if err != nil {
ctx.Error(err)
ctx.Status(http.StatusInternalServerError)
} else if validResponse, ok := response.(GetValueResponseObject); ok {
if err := validResponse.VisitGetValueResponse(ctx.Writer); err != nil {
ctx.Error(err)
}
} else if response != nil {
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
}
}
// SetValue operation middleware
func (sh *strictHandler) SetValue(ctx *gin.Context) {
var request SetValueRequestObject
var body SetValueJSONRequestBody
if err := ctx.ShouldBindJSON(&body); err != nil {
ctx.Status(http.StatusBadRequest)
ctx.Error(err)
return
}
request.Body = &body
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
return sh.ssi.SetValue(ctx, request.(SetValueRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "SetValue")
}
response, err := handler(ctx, request)
if err != nil {
ctx.Error(err)
ctx.Status(http.StatusInternalServerError)
} else if validResponse, ok := response.(SetValueResponseObject); ok {
if err := validResponse.VisitSetValueResponse(ctx.Writer); err != nil {
ctx.Error(err)
}
} else if response != nil {
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
}
}
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"H4sIAAAAAAAC/7STwW7bMAyGX8XgdjRiZ+3Jt26HIYcBwwL0EuSgSEzCwhVVkQ4QFH73QZKTLcuKbgV6",
"siH95E9+pJ7B8mNgj14FumcQu8dHk3+/ot6bfsAf+DSgaDoKkQNGJcwCyw7TV48BoQPRSH4H41hDxKeB",
"IjroVkW1rk8q3jygVRjr3/JLYC94bXBI1687FNnfLJZvbKH+V+scX79YwViDoB0i6XGZwBbTjRGyd4Pu",
"z8BTTD6Fc4q9aoAxZSC/5VwJaZ9uvrDDezP0Wt19XyRzjELsoYP5rJ21qXoO6E0g6OBm1s5uoIZgdJ/N",
"GxOoOcyb3YQmM+HCJpExSuwXDrrzfKC0jKKf2R0LNa/oc4gJoSebg5oHSVWcdij9fYy4hQ4+NL+WrJk2",
"rPlzvcZLthoHzAdlOXLtn9r2Heyn7cv+DsVGClp4ZkG15cG7RPW22F9qFv5genLVhKjaJEZZfHstLgk9",
"6ylpNj2NRF4dyfJ9R7J8y0hebFOUU9j/g5tfi7+RCPldxbGiKc5GdOiVTC8XDw261cUTW63HdeL8MwAA",
"//98/iCR6gQAAA==",
}
// GetSwagger returns the content of the embedded swagger specification file
// or error if failed to decode
func decodeSpec() ([]byte, error) {
zipped, err := base64.StdEncoding.DecodeString(strings.Join(swaggerSpec, ""))
if err != nil {
return nil, fmt.Errorf("error base64 decoding spec: %w", err)
}
zr, err := gzip.NewReader(bytes.NewReader(zipped))
if err != nil {
return nil, fmt.Errorf("error decompressing spec: %w", err)
}
var buf bytes.Buffer
_, err = buf.ReadFrom(zr)
if err != nil {
return nil, fmt.Errorf("error decompressing spec: %w", err)
}
return buf.Bytes(), nil
}
var rawSpec = decodeSpecCached()
// a naive cached of a decoded swagger spec
func decodeSpecCached() func() ([]byte, error) {
data, err := decodeSpec()
return func() ([]byte, error) {
return data, err
}
}
// Constructs a synthetic filesystem for resolving external references when loading openapi specifications.
func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) {
res := make(map[string]func() ([]byte, error))
if len(pathToFile) > 0 {
res[pathToFile] = rawSpec
}
return res
}
// GetSwagger returns the Swagger specification corresponding to the generated code
// in this file. The external references of Swagger specification are resolved.
// The logic of resolving external references is tightly connected to "import-mapping" feature.
// Externally referenced files must be embedded in the corresponding golang packages.
// Urls can be supported but this task was out of the scope.
func GetSwagger() (swagger *openapi3.T, err error) {
resolvePath := PathToRawSpec("")
loader := openapi3.NewLoader()
loader.IsExternalRefsAllowed = true
loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) {
pathToFile := url.String()
pathToFile = path.Clean(pathToFile)
getSpec, ok := resolvePath[pathToFile]
if !ok {
err1 := fmt.Errorf("path not found: %s", pathToFile)
return nil, err1
}
return getSpec()
}
var specData []byte
specData, err = rawSpec()
if err != nil {
return
}
swagger, err = loader.LoadFromData(specData)
if err != nil {
return
}
return
}

55
internal/api/handlers.go Normal file
View File

@@ -0,0 +1,55 @@
package api
import (
"context"
"errors"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"github.com/kanootoko/codevault/internal/db"
"github.com/kanootoko/codevault/internal/observability/metrics"
)
// StrictHandler implements StrictServerInterface.
type StrictHandler struct {
Secrets *db.SecretsRepository
Metrics metrics.ValueMetrics
}
func NewHandler(secrets *db.SecretsRepository, m metrics.ValueMetrics) *StrictHandler {
return &StrictHandler{Secrets: secrets, Metrics: m}
}
func (h *StrictHandler) GetValue(ctx context.Context, request GetValueRequestObject) (GetValueResponseObject, error) {
if request.Body == nil || request.Body.Code == "" {
return GetValue400Response{}, nil
}
value, err := h.Secrets.Get(ctx, request.Body.Code)
if errors.Is(err, db.ErrNotFound) {
h.Metrics.GetFailedTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", "not_found")))
return GetValue404Response{}, nil
}
if err != nil {
return nil, err
}
h.Metrics.GetSuccessTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("code", request.Body.Code)))
return GetValue200JSONResponse{Value: value}, nil
}
func (h *StrictHandler) SetValue(ctx context.Context, request SetValueRequestObject) (SetValueResponseObject, error) {
if request.Body == nil || request.Body.Code == "" || request.Body.Value == "" || len(request.Body.Value) > 10*1024*1024 {
h.Metrics.SetFailedTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", "bad_request")))
return SetValue400Response{}, nil
}
if err := h.Secrets.Set(ctx, request.Body.Code, request.Body.Value); err != nil {
return nil, err
}
login, _ := LoginFromContext(ctx)
h.Metrics.SetSuccessTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("user", login)))
return SetValue204Response{}, nil
}

View File

@@ -0,0 +1,81 @@
package api
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"github.com/kanootoko/codevault/internal/db"
"github.com/kanootoko/codevault/internal/observability/metrics"
"github.com/kanootoko/codevault/internal/ratelimit"
)
type contextKey string
const loginContextKey contextKey = "login"
// LoginFromContext returns the Basic Auth login stashed by BasicAuthMiddleware,
// if any. Strict-server handlers only see context.Context, not *gin.Context, so
// this is how they recover the authenticated login.
func LoginFromContext(ctx context.Context) (string, bool) {
login, ok := ctx.Value(loginContextKey).(string)
return login, ok
}
// BasicAuthMiddleware checks the request's HTTP Basic Auth credentials against
// the DB-backed user store.
func BasicAuthMiddleware(users *db.UsersRepository, vm metrics.ValueMetrics) gin.HandlerFunc {
return func(c *gin.Context) {
login, password, ok := c.Request.BasicAuth()
if !ok || users.Verify(c.Request.Context(), login, password) != nil {
vm.SetFailedTotal.Add(c.Request.Context(), 1, metric.WithAttributes(attribute.String("reason", "unauthorized")))
c.Header("WWW-Authenticate", `Basic realm="codevault"`)
c.AbortWithStatus(http.StatusUnauthorized)
return
}
c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), loginContextKey, login))
c.Next()
}
}
// RateLimitMiddleware short-circuits to 404 once the client IP has accumulated
// too many failures, without touching the handler (and thus the database). A
// short-circuited request counts as a failure itself, same as a genuine 404 from
// the handler.
func RateLimitMiddleware(limiter *ratelimit.Limiter, vm metrics.ValueMetrics) gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
if !limiter.Allowed(ip) {
limiter.RecordFailure(ip)
vm.GetFailedTotal.Add(c.Request.Context(), 1, metric.WithAttributes(attribute.String("reason", "rate_limited")))
c.AbortWithStatus(http.StatusNotFound)
return
}
c.Next()
if c.Writer.Status() == http.StatusNotFound {
limiter.RecordFailure(ip)
}
}
}
// InstrumentMiddleware records request count and duration metrics per route.
func InstrumentMiddleware(route string, m metrics.HTTPMetrics) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
duration := time.Since(start).Seconds()
attrs := metric.WithAttributes(
attribute.String("route", route),
attribute.Int("status", c.Writer.Status()),
)
m.RequestsTotal.Add(c.Request.Context(), 1, attrs)
m.RequestDuration.Record(c.Request.Context(), duration, metric.WithAttributes(attribute.String("route", route)))
}
}

View File

@@ -0,0 +1,7 @@
package: api
generate:
gin-server: true
strict-server: true
models: true
embedded-spec: true
output: generated.go

73
internal/api/openapi.yaml Normal file
View File

@@ -0,0 +1,73 @@
openapi: 3.0.3
info:
title: CodeVault API
version: "1.0.0"
paths:
/api/v1/getValue:
post:
operationId: getValue
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/GetValueRequest'
responses:
'200':
description: Value found
content:
application/json:
schema:
$ref: '#/components/schemas/GetValueResponse'
'400':
description: Invalid request body
'404':
description: Value not found
/api/v1/setValue:
post:
operationId: setValue
security:
- basicAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SetValueRequest'
responses:
'204':
description: Value stored
'400':
description: Invalid request body
'401':
description: Missing or invalid credentials
components:
securitySchemes:
basicAuth:
type: http
scheme: basic
schemas:
GetValueRequest:
type: object
required:
- code
properties:
code:
type: string
GetValueResponse:
type: object
required:
- value
properties:
value:
type: string
SetValueRequest:
type: object
required:
- code
- value
properties:
code:
type: string
value:
type: string

35
internal/api/swagger.go Normal file
View File

@@ -0,0 +1,35 @@
package api
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
// RegisterSwaggerHandlers serves the OpenAPI spec and an interactive Swagger UI
// (embedded swagger-ui-dist assets, no CDN) under /api.
func RegisterSwaggerHandlers(router *gin.Engine) error {
spec, err := GetSwagger()
if err != nil {
return fmt.Errorf("error on building swagger specification: %w", err)
}
specBody, err := spec.MarshalJSON()
if err != nil {
return fmt.Errorf("error on marshaling swagger specification: %w", err)
}
// /api/docs/*any (rather than /api/*any) so this wildcard doesn't collide with
// the static /api/openapi.json route at the same tree level.
router.GET("/api/openapi.json", func(c *gin.Context) {
c.Data(http.StatusOK, "application/json", specBody)
})
router.GET("/api", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/api/docs/index.html")
})
router.GET("/api/docs/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, ginSwagger.URL("/api/openapi.json")))
return nil
}