Add service component: api
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
jordan 2026-02-04 08:33:50 +00:00
parent 7c22fab815
commit 27f4187f16
18 changed files with 767 additions and 1 deletions

View File

@ -35,6 +35,33 @@ steps:
event: push
# 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: evolve-test-1770194025/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/evolve-test-1770194025-api api=registry.threesix.ai/evolve-test-1770194025/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:

View File

@ -76,4 +76,7 @@ evolve-test-1770194025/
## Components
<!-- Components will be listed here as they're added -->
| Component | Type | Path |
|-----------|------|------|
| **api** | API service | `services/api/` |

View File

@ -1,2 +1,3 @@
# Local development processes
# Components will be added below as they're created
api: cd services/api && make run

View File

@ -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
View 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/evolve-test-1770194025?sslmode=disable

33
services/api/Dockerfile Normal file
View File

@ -0,0 +1,33 @@
# 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)
# Note: go.work.sum may not exist if no external dependencies have been synced yet
COPY go.work ./
COPY go.work.su[m] ./
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
View 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/evolve-test-1770194025
# 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/

View File

@ -0,0 +1,18 @@
// Package main is the entry point for the api service.
package main
import (
"git.threesix.ai/jordan/evolve-test-1770194025/pkg/app"
"git.threesix.ai/jordan/evolve-test-1770194025/services/api/internal/api"
)
func main() {
// Create application
application := app.New("api", app.WithDefaultPort(8001))
// Register routes
api.RegisterRoutes(application)
// Start server
application.Run()
}

View 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
View File

@ -0,0 +1,8 @@
module git.threesix.ai/jordan/evolve-test-1770194025/services/api
go 1.23
require git.threesix.ai/jordan/evolve-test-1770194025/pkg v0.0.0
// Use local workspace modules (for Docker builds without go.work)
replace git.threesix.ai/jordan/evolve-test-1770194025/pkg => ../../pkg

0
services/api/go.sum Normal file
View File

View File

@ -0,0 +1,203 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"git.threesix.ai/jordan/evolve-test-1770194025/pkg/app"
"git.threesix.ai/jordan/evolve-test-1770194025/pkg/httperror"
"git.threesix.ai/jordan/evolve-test-1770194025/pkg/httpresponse"
"git.threesix.ai/jordan/evolve-test-1770194025/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
}

View 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/evolve-test-1770194025/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)
}
}

View File

@ -0,0 +1,26 @@
package handlers
import (
"net/http"
"git.threesix.ai/jordan/evolve-test-1770194025/pkg/httpresponse"
"git.threesix.ai/jordan/evolve-test-1770194025/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",
})
}

View File

@ -0,0 +1,53 @@
// Package api provides HTTP routing and handlers for the api service.
package api
import (
"git.threesix.ai/jordan/evolve-test-1770194025/pkg/app"
"git.threesix.ai/jordan/evolve-test-1770194025/pkg/auth"
"git.threesix.ai/jordan/evolve-test-1770194025/services/api/internal/api/handlers"
"git.threesix.ai/jordan/evolve-test-1770194025/services/api/internal/config"
)
// RegisterRoutes registers all HTTP routes for the service.
// Routes are mounted under /api/api to match the ingress path routing.
// This allows the monorepo to expose multiple services under a single domain:
// - https://domain/api/api/health
// - https://domain/api/api/examples
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 under /api/{service-name} to match ingress path routing.
// The ingress routes /api/api/* to this service.
application.Route("/api/api", 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: "evolve-test-1770194025",
}),
}))
}
r.Post("/examples", app.Wrap(exampleHandler.Create))
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update))
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
})
})
}

View File

@ -0,0 +1,112 @@
package api
import "git.threesix.ai/jordan/evolve-test-1770194025/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/api/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/api/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/api/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/api/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/api/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/api/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
}

View File

@ -0,0 +1,34 @@
// Package config provides service-specific configuration.
package config
import (
"os"
"strings"
"git.threesix.ai/jordan/evolve-test-1770194025/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"),
}
}

View File