This commit is contained in:
parent
a4980a5bd1
commit
98935d75a7
@ -9,6 +9,33 @@ clone:
|
||||
|
||||
steps:
|
||||
# COMPONENT_STEPS_BELOW
|
||||
|
||||
# Woodpecker CI step for api service
|
||||
# Add this step to your .woodpecker.yml
|
||||
|
||||
build-api:
|
||||
image: woodpeckerci/plugin-kaniko
|
||||
settings:
|
||||
registry: registry.threesix.ai
|
||||
repo: feat-dev-e2e/api
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_SHA:0:8}
|
||||
context: .
|
||||
dockerfile: services/api/Dockerfile
|
||||
cache: true
|
||||
skip-tls-verify: true
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
|
||||
deploy-api:
|
||||
image: bitnami/kubectl:latest
|
||||
commands:
|
||||
- kubectl set image deployment/feat-dev-e2e-api api=registry.threesix.ai/feat-dev-e2e/api:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping"
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
# Do not remove the marker above - component steps are inserted here
|
||||
|
||||
verify:
|
||||
|
||||
@ -76,4 +76,7 @@ feat-dev-e2e/
|
||||
|
||||
## Components
|
||||
|
||||
<!-- Components will be listed here as they're added -->
|
||||
| Component | Type | Path |
|
||||
|-----------|------|------|
|
||||
| **api** | API service | `services/api/` |
|
||||
|
||||
|
||||
1
Procfile
1
Procfile
@ -1,2 +1,3 @@
|
||||
# Local development processes
|
||||
# Components will be added below as they're created
|
||||
api: cd services/api && make run
|
||||
|
||||
1
go.work
1
go.work
@ -1,4 +1,5 @@
|
||||
go 1.23
|
||||
|
||||
use ./pkg
|
||||
use ./services/api
|
||||
// Component modules will be added below
|
||||
|
||||
21
services/api/.env.example
Normal file
21
services/api/.env.example
Normal file
@ -0,0 +1,21 @@
|
||||
# api Service Configuration
|
||||
|
||||
# Server
|
||||
SERVER_PORT=8001
|
||||
SERVER_HOST=0.0.0.0
|
||||
|
||||
# App
|
||||
APP_NAME=api
|
||||
APP_ENVIRONMENT=development
|
||||
APP_DEBUG=true
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=debug
|
||||
LOG_FORMAT=text
|
||||
|
||||
# Auth (set AUTH_ENABLED=true to require JWT for protected routes)
|
||||
AUTH_ENABLED=false
|
||||
JWT_SECRET=dev-secret-change-in-production
|
||||
|
||||
# Database (if needed)
|
||||
DATABASE_URL=postgres://dev:dev@localhost:5432/feat-dev-e2e?sslmode=disable
|
||||
31
services/api/Dockerfile
Normal file
31
services/api/Dockerfile
Normal file
@ -0,0 +1,31 @@
|
||||
# Build stage
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Configure Go workspace and private modules
|
||||
ENV GOPRIVATE=git.threesix.ai/*
|
||||
ENV GOWORK=/app/go.work
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go workspace and all source (workspace deps are local)
|
||||
COPY go.work go.work.sum* ./
|
||||
COPY pkg/ ./pkg/
|
||||
COPY services/api/ ./services/api/
|
||||
|
||||
# Build from workspace root
|
||||
RUN CGO_ENABLED=0 go build -o /api ./services/api/cmd/server
|
||||
|
||||
# Production stage
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /
|
||||
|
||||
COPY --from=builder /api /api
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
ENTRYPOINT ["/api"]
|
||||
34
services/api/Makefile
Normal file
34
services/api/Makefile
Normal file
@ -0,0 +1,34 @@
|
||||
.PHONY: build run test lint fmt docker-build clean
|
||||
|
||||
SERVICE := api
|
||||
BINARY := bin/$(SERVICE)
|
||||
GO_MODULE := git.threesix.ai/jordan/feat-dev-e2e
|
||||
|
||||
# Build the service binary
|
||||
build:
|
||||
go build -o $(BINARY) ./cmd/server
|
||||
|
||||
# Run the service locally
|
||||
run:
|
||||
go run ./cmd/server
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
# Run linter
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
# Format code
|
||||
fmt:
|
||||
gofmt -w .
|
||||
goimports -w -local $(GO_MODULE) .
|
||||
|
||||
# Build Docker image (run from monorepo root)
|
||||
docker-build:
|
||||
docker build -t $(SERVICE):latest -f Dockerfile ../..
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf bin/
|
||||
18
services/api/cmd/server/main.go
Normal file
18
services/api/cmd/server/main.go
Normal file
@ -0,0 +1,18 @@
|
||||
// Package main is the entry point for the api service.
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.threesix.ai/jordan/feat-dev-e2e/pkg/app"
|
||||
"git.threesix.ai/jordan/feat-dev-e2e/services/api/internal/api"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create application
|
||||
application := app.New("api", app.WithDefaultPort(8001))
|
||||
|
||||
// Register routes
|
||||
api.RegisterRoutes(application)
|
||||
|
||||
// Start server
|
||||
application.Run()
|
||||
}
|
||||
9
services/api/component.yaml
Normal file
9
services/api/component.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
name: api
|
||||
type: service
|
||||
port: 8001
|
||||
path: services/api
|
||||
dependencies: []
|
||||
# Add dependencies as needed:
|
||||
# - postgres
|
||||
# - redis
|
||||
# - other-service
|
||||
8
services/api/go.mod
Normal file
8
services/api/go.mod
Normal file
@ -0,0 +1,8 @@
|
||||
module git.threesix.ai/jordan/feat-dev-e2e/services/api
|
||||
|
||||
go 1.23
|
||||
|
||||
require git.threesix.ai/jordan/feat-dev-e2e/pkg v0.0.0
|
||||
|
||||
// Use local workspace modules (for Docker builds without go.work)
|
||||
replace git.threesix.ai/jordan/feat-dev-e2e/pkg => ../../pkg
|
||||
203
services/api/internal/api/handlers/example.go
Normal file
203
services/api/internal/api/handlers/example.go
Normal file
@ -0,0 +1,203 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.threesix.ai/jordan/feat-dev-e2e/pkg/app"
|
||||
"git.threesix.ai/jordan/feat-dev-e2e/pkg/httperror"
|
||||
"git.threesix.ai/jordan/feat-dev-e2e/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/feat-dev-e2e/pkg/logging"
|
||||
)
|
||||
|
||||
// Example demonstrates the Wrap pattern for error-returning handlers.
|
||||
type Example struct {
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewExample creates a new Example handler.
|
||||
func NewExample(logger *logging.Logger) *Example {
|
||||
return &Example{logger: logger}
|
||||
}
|
||||
|
||||
// CreateRequest is the request body for creating an example.
|
||||
type CreateRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
Description string `json:"description" validate:"max=500"`
|
||||
}
|
||||
|
||||
// UpdateRequest is the request body for updating an example.
|
||||
type UpdateRequest struct {
|
||||
Name string `json:"name" validate:"omitempty,min=1,max=100"`
|
||||
Description string `json:"description" validate:"max=500"`
|
||||
}
|
||||
|
||||
// ExampleResponse is the response for an example resource.
|
||||
type ExampleResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// List returns a paginated list of examples.
|
||||
// Demonstrates pagination query params and list responses.
|
||||
func (h *Example) List(w http.ResponseWriter, r *http.Request) error {
|
||||
// Example: Parse pagination query params
|
||||
// page := r.URL.Query().Get("page")
|
||||
// perPage := r.URL.Query().Get("per_page")
|
||||
|
||||
// Example: Fetch from database
|
||||
// items, total, err := h.repo.List(r.Context(), page, perPage)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
|
||||
// Placeholder response
|
||||
items := []ExampleResponse{
|
||||
{
|
||||
ID: "550e8400-e29b-41d4-a716-446655440000",
|
||||
Name: "Example Item 1",
|
||||
Description: "First example item",
|
||||
CreatedAt: "2024-01-15T10:30:00Z",
|
||||
UpdatedAt: "2024-01-15T10:30:00Z",
|
||||
},
|
||||
{
|
||||
ID: "550e8400-e29b-41d4-a716-446655440001",
|
||||
Name: "Example Item 2",
|
||||
Description: "Second example item",
|
||||
CreatedAt: "2024-01-16T12:00:00Z",
|
||||
UpdatedAt: "2024-01-16T12:00:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, items)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns an example by ID.
|
||||
// Demonstrates returning HTTPErrors for common error cases.
|
||||
func (h *Example) Get(w http.ResponseWriter, r *http.Request) error {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
// Validate UUID format
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return httperror.BadRequest("invalid id format")
|
||||
}
|
||||
|
||||
// Example: Fetch from database
|
||||
// item, err := h.repo.Get(r.Context(), id)
|
||||
// if err != nil {
|
||||
// if errors.Is(err, ErrNotFound) {
|
||||
// return httperror.NotFoundf("example %s not found", id)
|
||||
// }
|
||||
// return err
|
||||
// }
|
||||
|
||||
// Placeholder response
|
||||
httpresponse.OK(w, r, ExampleResponse{
|
||||
ID: id,
|
||||
Name: "Example Item",
|
||||
Description: "This is an example item",
|
||||
CreatedAt: "2024-01-15T10:30:00Z",
|
||||
UpdatedAt: "2024-01-15T10:30:00Z",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create creates a new example.
|
||||
// Demonstrates using BindAndValidate for request parsing and validation.
|
||||
func (h *Example) Create(w http.ResponseWriter, r *http.Request) error {
|
||||
var req CreateRequest
|
||||
|
||||
// Bind and validate request body
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Example: Check for duplicates
|
||||
// if exists, _ := h.repo.GetByName(r.Context(), req.Name); exists != nil {
|
||||
// return httperror.Conflict("example with this name already exists")
|
||||
// }
|
||||
|
||||
// Example: Create in database
|
||||
// item, err := h.repo.Create(r.Context(), req)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
|
||||
// Example: Access authenticated user
|
||||
// user := auth.GetUser(r.Context())
|
||||
// h.logger.Info("example created", "by", user.ID, "name", req.Name)
|
||||
|
||||
id := uuid.New().String()
|
||||
|
||||
httpresponse.Created(w, r, ExampleResponse{
|
||||
ID: id,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
CreatedAt: "2024-01-15T10:30:00Z",
|
||||
UpdatedAt: "2024-01-15T10:30:00Z",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates an existing example.
|
||||
// Demonstrates partial updates with BindAndValidate.
|
||||
func (h *Example) Update(w http.ResponseWriter, r *http.Request) error {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return httperror.BadRequest("invalid id format")
|
||||
}
|
||||
|
||||
var req UpdateRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Example: Fetch existing, apply updates, save
|
||||
// item, err := h.repo.Get(r.Context(), id)
|
||||
// if err != nil {
|
||||
// if errors.Is(err, ErrNotFound) {
|
||||
// return httperror.NotFoundf("example %s not found", id)
|
||||
// }
|
||||
// return err
|
||||
// }
|
||||
// if err := h.repo.Update(r.Context(), id, req); err != nil {
|
||||
// return err
|
||||
// }
|
||||
|
||||
httpresponse.OK(w, r, ExampleResponse{
|
||||
ID: id,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
CreatedAt: "2024-01-15T10:30:00Z",
|
||||
UpdatedAt: "2024-01-16T14:00:00Z",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete deletes an example by ID.
|
||||
// Demonstrates no-content response.
|
||||
func (h *Example) Delete(w http.ResponseWriter, r *http.Request) error {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return httperror.BadRequest("invalid id format")
|
||||
}
|
||||
|
||||
// Example: Delete from database
|
||||
// if err := h.repo.Delete(r.Context(), id); err != nil {
|
||||
// if errors.Is(err, ErrNotFound) {
|
||||
// return httperror.NotFoundf("example %s not found", id)
|
||||
// }
|
||||
// return err
|
||||
// }
|
||||
|
||||
httpresponse.NoContent(w)
|
||||
return nil
|
||||
}
|
||||
183
services/api/internal/api/handlers/example_test.go
Normal file
183
services/api/internal/api/handlers/example_test.go
Normal file
@ -0,0 +1,183 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.threesix.ai/jordan/feat-dev-e2e/pkg/logging"
|
||||
)
|
||||
|
||||
func newTestLogger() *logging.Logger {
|
||||
return logging.New(logging.Config{
|
||||
Level: logging.LevelDebug,
|
||||
Format: logging.FormatText,
|
||||
})
|
||||
}
|
||||
|
||||
func TestExample_List(t *testing.T) {
|
||||
handler := NewExample(newTestLogger())
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.List(w, r); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/examples", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
data, ok := resp["data"]
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' field in response")
|
||||
}
|
||||
|
||||
items, ok := data.([]any)
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' to be an array")
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
t.Error("expected at least one item in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExample_Get(t *testing.T) {
|
||||
handler := NewExample(newTestLogger())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "valid uuid",
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "invalid uuid",
|
||||
id: "not-a-uuid",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Get(w, r); err != nil {
|
||||
// Error-returning handler: convert error to status
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/examples/"+tt.id, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExample_Create(t *testing.T) {
|
||||
handler := NewExample(newTestLogger())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body any
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "valid request",
|
||||
body: CreateRequest{
|
||||
Name: "Test Example",
|
||||
Description: "A test description",
|
||||
},
|
||||
wantStatus: http.StatusCreated,
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: nil,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "missing required name",
|
||||
body: map[string]string{
|
||||
"description": "no name provided",
|
||||
},
|
||||
wantStatus: http.StatusUnprocessableEntity,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Create(w, r); err != nil {
|
||||
// Simulate Wrap behavior for tests
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
var body []byte
|
||||
if tt.body != nil {
|
||||
var err error
|
||||
body, err = json.Marshal(tt.body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/examples", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// For the valid case, check 201
|
||||
if tt.name == "valid request" && w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status %d, got %d", http.StatusCreated, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExample_Delete(t *testing.T) {
|
||||
handler := NewExample(newTestLogger())
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Delete("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Delete(w, r); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/examples/550e8400-e29b-41d4-a716-446655440000", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("expected status 204, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
26
services/api/internal/api/handlers/health.go
Normal file
26
services/api/internal/api/handlers/health.go
Normal file
@ -0,0 +1,26 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.threesix.ai/jordan/feat-dev-e2e/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/feat-dev-e2e/pkg/logging"
|
||||
)
|
||||
|
||||
// Health handles health check endpoints.
|
||||
type Health struct {
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewHealth creates a new Health handler.
|
||||
func NewHealth(logger *logging.Logger) *Health {
|
||||
return &Health{logger: logger}
|
||||
}
|
||||
|
||||
// Check returns the service health status.
|
||||
func (h *Health) Check(w http.ResponseWriter, r *http.Request) {
|
||||
httpresponse.OK(w, r, map[string]string{
|
||||
"service": "api",
|
||||
"status": "healthy",
|
||||
})
|
||||
}
|
||||
48
services/api/internal/api/routes.go
Normal file
48
services/api/internal/api/routes.go
Normal file
@ -0,0 +1,48 @@
|
||||
// Package api provides HTTP routing and handlers for the api service.
|
||||
package api
|
||||
|
||||
import (
|
||||
"git.threesix.ai/jordan/feat-dev-e2e/pkg/app"
|
||||
"git.threesix.ai/jordan/feat-dev-e2e/pkg/auth"
|
||||
"git.threesix.ai/jordan/feat-dev-e2e/services/api/internal/api/handlers"
|
||||
"git.threesix.ai/jordan/feat-dev-e2e/services/api/internal/config"
|
||||
)
|
||||
|
||||
// RegisterRoutes registers all HTTP routes for the service.
|
||||
func RegisterRoutes(application *app.App) {
|
||||
logger := application.Logger()
|
||||
cfg := config.Load()
|
||||
|
||||
// Initialize handlers
|
||||
healthHandler := handlers.NewHealth(logger)
|
||||
exampleHandler := handlers.NewExample(logger)
|
||||
|
||||
// Build and mount OpenAPI spec
|
||||
spec := NewServiceSpec()
|
||||
application.EnableDocs(spec)
|
||||
|
||||
// Register API routes
|
||||
application.Route("/api/v1", func(r app.Router) {
|
||||
r.Get("/health", healthHandler.Check)
|
||||
|
||||
// Public routes (no auth required)
|
||||
r.Get("/examples", app.Wrap(exampleHandler.List))
|
||||
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
|
||||
|
||||
// Protected routes (auth required when enabled)
|
||||
r.Group(func(r app.Router) {
|
||||
if cfg.AuthEnabled {
|
||||
r.Use(auth.Middleware(auth.MiddlewareConfig{
|
||||
Validator: auth.NewJWTValidator(auth.JWTConfig{
|
||||
Secret: []byte(cfg.JWTSecret),
|
||||
Issuer: "feat-dev-e2e",
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
r.Post("/examples", app.Wrap(exampleHandler.Create))
|
||||
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update))
|
||||
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
|
||||
})
|
||||
})
|
||||
}
|
||||
112
services/api/internal/api/spec.go
Normal file
112
services/api/internal/api/spec.go
Normal file
@ -0,0 +1,112 @@
|
||||
package api
|
||||
|
||||
import "git.threesix.ai/jordan/feat-dev-e2e/pkg/openapi"
|
||||
|
||||
// NewServiceSpec builds the OpenAPI specification for the api service.
|
||||
func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
spec := openapi.NewOpenAPISpec("api API", "1.0.0").
|
||||
WithDescription("REST API for the api service").
|
||||
WithBearerSecurity("bearer", "JWT authentication token").
|
||||
WithTag("Health", "Service health endpoints").
|
||||
WithTag("Examples", "Example CRUD endpoints")
|
||||
|
||||
// Define reusable schemas
|
||||
spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{
|
||||
"id": openapi.UUID().WithDescription("Unique identifier"),
|
||||
"name": openapi.String().WithDescription("Name of the example").WithExample("My Example"),
|
||||
"description": openapi.String().WithDescription("Optional description").WithExample("A description"),
|
||||
"created_at": openapi.DateTime().WithDescription("Creation timestamp"),
|
||||
"updated_at": openapi.DateTime().WithDescription("Last update timestamp"),
|
||||
}, "id", "name"))
|
||||
|
||||
spec.WithSchema("CreateExampleRequest", openapi.Object(map[string]openapi.Schema{
|
||||
"name": openapi.StringWithMinMax(1, 100).WithDescription("Name of the example"),
|
||||
"description": openapi.StringWithMinMax(0, 500).WithDescription("Optional description"),
|
||||
}, "name"))
|
||||
|
||||
spec.WithSchema("UpdateExampleRequest", openapi.Object(map[string]openapi.Schema{
|
||||
"name": openapi.StringWithMinMax(1, 100).WithDescription("Updated name"),
|
||||
"description": openapi.StringWithMinMax(0, 500).WithDescription("Updated description"),
|
||||
}))
|
||||
|
||||
// Health
|
||||
spec.AddPath("/api/v1/health", "get", map[string]any{
|
||||
"summary": "Health check",
|
||||
"tags": []string{"Health"},
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Service is healthy", openapi.Object(map[string]openapi.Schema{
|
||||
"service": openapi.String(),
|
||||
"status": openapi.String(),
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
// List examples
|
||||
spec.AddPath("/api/v1/examples", "get", map[string]any{
|
||||
"summary": "List examples",
|
||||
"description": "Returns a paginated list of examples.",
|
||||
"tags": []string{"Examples"},
|
||||
"parameters": []any{openapi.PageParam(), openapi.PerPageParam()},
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.RefArray("Example"))),
|
||||
},
|
||||
})
|
||||
|
||||
// Get example
|
||||
spec.AddPath("/api/v1/examples/{id}", "get", map[string]any{
|
||||
"summary": "Get example by ID",
|
||||
"tags": []string{"Examples"},
|
||||
"parameters": []any{openapi.IDParam()},
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Example"))),
|
||||
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// Create example
|
||||
spec.AddPath("/api/v1/examples", "post", map[string]any{
|
||||
"summary": "Create example",
|
||||
"description": "Creates a new example. Requires authentication.",
|
||||
"tags": []string{"Examples"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"requestBody": openapi.RequestBody(openapi.Ref("CreateExampleRequest"), true),
|
||||
"responses": map[string]any{
|
||||
"201": openapi.OpResponse("Created", openapi.ResponseSchema(openapi.Ref("Example"))),
|
||||
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
|
||||
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||
"422": openapi.OpResponse("Validation error", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// Update example
|
||||
spec.AddPath("/api/v1/examples/{id}", "put", map[string]any{
|
||||
"summary": "Update example",
|
||||
"description": "Updates an existing example. Requires authentication.",
|
||||
"tags": []string{"Examples"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"parameters": []any{openapi.IDParam()},
|
||||
"requestBody": openapi.RequestBody(openapi.Ref("UpdateExampleRequest"), true),
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("Example"))),
|
||||
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
|
||||
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// Delete example
|
||||
spec.AddPath("/api/v1/examples/{id}", "delete", map[string]any{
|
||||
"summary": "Delete example",
|
||||
"description": "Deletes an example by ID. Requires authentication.",
|
||||
"tags": []string{"Examples"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"parameters": []any{openapi.IDParam()},
|
||||
"responses": map[string]any{
|
||||
"204": openapi.OpResponseNoContent(),
|
||||
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
return spec
|
||||
}
|
||||
34
services/api/internal/config/config.go
Normal file
34
services/api/internal/config/config.go
Normal file
@ -0,0 +1,34 @@
|
||||
// Package config provides service-specific configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.threesix.ai/jordan/feat-dev-e2e/pkg/config"
|
||||
)
|
||||
|
||||
// Config extends the base config with api-specific settings.
|
||||
type Config struct {
|
||||
config.AppConfig
|
||||
Server config.ServerConfig
|
||||
Database config.DatabaseConfig
|
||||
Logging config.LoggingConfig
|
||||
|
||||
// Auth
|
||||
AuthEnabled bool
|
||||
JWTSecret string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables.
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
AppConfig: config.ReadAppConfig(),
|
||||
Server: config.ReadServerConfig(),
|
||||
Database: config.ReadDatabaseConfig(),
|
||||
Logging: config.ReadLoggingConfig(),
|
||||
|
||||
AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
}
|
||||
}
|
||||
0
services/api/migrations/.gitkeep
Normal file
0
services/api/migrations/.gitkeep
Normal file
Loading…
Reference in New Issue
Block a user