Add service component: api
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
17beb43e18
commit
7110399914
@ -36,6 +36,33 @@ steps:
|
|||||||
|
|
||||||
# COMPONENT_STEPS_BELOW
|
# 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: route-test-1770185086/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/route-test-1770185086-api api=registry.threesix.ai/route-test-1770185086/api:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping"
|
||||||
|
when:
|
||||||
|
branch: main
|
||||||
|
event: push
|
||||||
|
|
||||||
# Woodpecker CI step for web React app
|
# Woodpecker CI step for web React app
|
||||||
# Add this step to your .woodpecker.yml
|
# Add this step to your .woodpecker.yml
|
||||||
|
|
||||||
|
|||||||
@ -79,4 +79,5 @@ route-test-1770185086/
|
|||||||
| Component | Type | Path |
|
| Component | Type | Path |
|
||||||
|-----------|------|------|
|
|-----------|------|------|
|
||||||
| **web** | React app | `apps/web/` |
|
| **web** | React app | `apps/web/` |
|
||||||
|
| **api** | API service | `services/api/` |
|
||||||
|
|
||||||
|
|||||||
1
Procfile
1
Procfile
@ -1,3 +1,4 @@
|
|||||||
# Local development processes
|
# Local development processes
|
||||||
# Components will be added below as they're created
|
# Components will be added below as they're created
|
||||||
web: cd apps/web && npm run dev
|
web: cd apps/web && npm run dev
|
||||||
|
api: cd services/api && make run
|
||||||
|
|||||||
1
go.work
1
go.work
@ -1,4 +1,5 @@
|
|||||||
go 1.23
|
go 1.23
|
||||||
|
|
||||||
use ./pkg
|
use ./pkg
|
||||||
|
use ./services/api
|
||||||
// Component modules will be added below
|
// 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/route-test-1770185086?sslmode=disable
|
||||||
33
services/api/Dockerfile
Normal file
33
services/api/Dockerfile
Normal 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
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/route-test-1770185086
|
||||||
|
|
||||||
|
# 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/route-test-1770185086/pkg/app"
|
||||||
|
"git.threesix.ai/jordan/route-test-1770185086/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/route-test-1770185086/services/api
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require git.threesix.ai/jordan/route-test-1770185086/pkg v0.0.0
|
||||||
|
|
||||||
|
// Use local workspace modules (for Docker builds without go.work)
|
||||||
|
replace git.threesix.ai/jordan/route-test-1770185086/pkg => ../../pkg
|
||||||
0
services/api/go.sum
Normal file
0
services/api/go.sum
Normal file
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/route-test-1770185086/pkg/app"
|
||||||
|
"git.threesix.ai/jordan/route-test-1770185086/pkg/httperror"
|
||||||
|
"git.threesix.ai/jordan/route-test-1770185086/pkg/httpresponse"
|
||||||
|
"git.threesix.ai/jordan/route-test-1770185086/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/route-test-1770185086/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/route-test-1770185086/pkg/httpresponse"
|
||||||
|
"git.threesix.ai/jordan/route-test-1770185086/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",
|
||||||
|
})
|
||||||
|
}
|
||||||
53
services/api/internal/api/routes.go
Normal file
53
services/api/internal/api/routes.go
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
// Package api provides HTTP routing and handlers for the api service.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.threesix.ai/jordan/route-test-1770185086/pkg/app"
|
||||||
|
"git.threesix.ai/jordan/route-test-1770185086/pkg/auth"
|
||||||
|
"git.threesix.ai/jordan/route-test-1770185086/services/api/internal/api/handlers"
|
||||||
|
"git.threesix.ai/jordan/route-test-1770185086/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: "route-test-1770185086",
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
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/route-test-1770185086/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
|
||||||
|
}
|
||||||
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/route-test-1770185086/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