diff --git a/.woodpecker.yml b/.woodpecker.yml index d724f66..5ac534a 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -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-e2e2/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-e2e2-api api=registry.threesix.ai/feat-dev-e2e2/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: diff --git a/CLAUDE.md b/CLAUDE.md index 1e2b3a7..f17fb55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,4 +76,7 @@ feat-dev-e2e2/ ## Components - +| Component | Type | Path | +|-----------|------|------| +| **api** | API service | `services/api/` | + diff --git a/Procfile b/Procfile index 8e897c6..60fb4f9 100644 --- a/Procfile +++ b/Procfile @@ -1,2 +1,3 @@ # Local development processes # Components will be added below as they're created +api: cd services/api && make run diff --git a/go.work b/go.work index 9ffbefe..9b16450 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,5 @@ go 1.23 use ./pkg +use ./services/api // Component modules will be added below diff --git a/services/api/.env.example b/services/api/.env.example new file mode 100644 index 0000000..4a95805 --- /dev/null +++ b/services/api/.env.example @@ -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-e2e2?sslmode=disable diff --git a/services/api/Dockerfile b/services/api/Dockerfile new file mode 100644 index 0000000..3f6749a --- /dev/null +++ b/services/api/Dockerfile @@ -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"] diff --git a/services/api/Makefile b/services/api/Makefile new file mode 100644 index 0000000..38c818a --- /dev/null +++ b/services/api/Makefile @@ -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-e2e2 + +# 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/ diff --git a/services/api/cmd/server/main.go b/services/api/cmd/server/main.go new file mode 100644 index 0000000..444c539 --- /dev/null +++ b/services/api/cmd/server/main.go @@ -0,0 +1,18 @@ +// Package main is the entry point for the api service. +package main + +import ( + "git.threesix.ai/jordan/feat-dev-e2e2/pkg/app" + "git.threesix.ai/jordan/feat-dev-e2e2/services/api/internal/api" +) + +func main() { + // Create application + application := app.New("api", app.WithDefaultPort(8001)) + + // Register routes + api.RegisterRoutes(application) + + // Start server + application.Run() +} diff --git a/services/api/component.yaml b/services/api/component.yaml new file mode 100644 index 0000000..95172bd --- /dev/null +++ b/services/api/component.yaml @@ -0,0 +1,9 @@ +name: api +type: service +port: 8001 +path: services/api +dependencies: [] +# Add dependencies as needed: +# - postgres +# - redis +# - other-service diff --git a/services/api/go.mod b/services/api/go.mod new file mode 100644 index 0000000..ebd5ebd --- /dev/null +++ b/services/api/go.mod @@ -0,0 +1,8 @@ +module git.threesix.ai/jordan/feat-dev-e2e2/services/api + +go 1.23 + +require git.threesix.ai/jordan/feat-dev-e2e2/pkg v0.0.0 + +// Use local workspace modules (for Docker builds without go.work) +replace git.threesix.ai/jordan/feat-dev-e2e2/pkg => ../../pkg diff --git a/services/api/internal/api/handlers/example.go b/services/api/internal/api/handlers/example.go new file mode 100644 index 0000000..86e4df0 --- /dev/null +++ b/services/api/internal/api/handlers/example.go @@ -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-e2e2/pkg/app" + "git.threesix.ai/jordan/feat-dev-e2e2/pkg/httperror" + "git.threesix.ai/jordan/feat-dev-e2e2/pkg/httpresponse" + "git.threesix.ai/jordan/feat-dev-e2e2/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 +} diff --git a/services/api/internal/api/handlers/example_test.go b/services/api/internal/api/handlers/example_test.go new file mode 100644 index 0000000..5e66cf8 --- /dev/null +++ b/services/api/internal/api/handlers/example_test.go @@ -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-e2e2/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) + } +} diff --git a/services/api/internal/api/handlers/health.go b/services/api/internal/api/handlers/health.go new file mode 100644 index 0000000..fbebb0a --- /dev/null +++ b/services/api/internal/api/handlers/health.go @@ -0,0 +1,26 @@ +package handlers + +import ( + "net/http" + + "git.threesix.ai/jordan/feat-dev-e2e2/pkg/httpresponse" + "git.threesix.ai/jordan/feat-dev-e2e2/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", + }) +} diff --git a/services/api/internal/api/routes.go b/services/api/internal/api/routes.go new file mode 100644 index 0000000..b3f30bb --- /dev/null +++ b/services/api/internal/api/routes.go @@ -0,0 +1,48 @@ +// Package api provides HTTP routing and handlers for the api service. +package api + +import ( + "git.threesix.ai/jordan/feat-dev-e2e2/pkg/app" + "git.threesix.ai/jordan/feat-dev-e2e2/pkg/auth" + "git.threesix.ai/jordan/feat-dev-e2e2/services/api/internal/api/handlers" + "git.threesix.ai/jordan/feat-dev-e2e2/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-e2e2", + }), + })) + } + + r.Post("/examples", app.Wrap(exampleHandler.Create)) + r.Put("/examples/{id}", app.Wrap(exampleHandler.Update)) + r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete)) + }) + }) +} diff --git a/services/api/internal/api/spec.go b/services/api/internal/api/spec.go new file mode 100644 index 0000000..bd88fb3 --- /dev/null +++ b/services/api/internal/api/spec.go @@ -0,0 +1,112 @@ +package api + +import "git.threesix.ai/jordan/feat-dev-e2e2/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 +} diff --git a/services/api/internal/config/config.go b/services/api/internal/config/config.go new file mode 100644 index 0000000..4f42f6c --- /dev/null +++ b/services/api/internal/config/config.go @@ -0,0 +1,34 @@ +// Package config provides service-specific configuration. +package config + +import ( + "os" + "strings" + + "git.threesix.ai/jordan/feat-dev-e2e2/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"), + } +} diff --git a/services/api/migrations/.gitkeep b/services/api/migrations/.gitkeep new file mode 100644 index 0000000..e69de29