diff --git a/apps/studio-ui/.eslintrc.cjs b/apps/studio-ui/.eslintrc.cjs new file mode 100644 index 0000000..4c99537 --- /dev/null +++ b/apps/studio-ui/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +}; diff --git a/apps/studio-ui/Dockerfile b/apps/studio-ui/Dockerfile new file mode 100644 index 0000000..f47422a --- /dev/null +++ b/apps/studio-ui/Dockerfile @@ -0,0 +1,34 @@ +# Build stage - using pnpm for workspace dependency resolution +FROM node:20-alpine AS build + +# Install pnpm +RUN npm install -g pnpm + +WORKDIR /workspace + +# Copy workspace configuration files +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* ./ + +# Copy shared packages (required for workspace:* dependencies) +COPY packages/ ./packages/ + +# Copy the app component +COPY apps/studio-ui/ ./apps/studio-ui/ + +# Install dependencies using pnpm (resolves workspace:* correctly) +RUN pnpm install --frozen-lockfile || pnpm install + +# Build the app +WORKDIR /workspace/apps/studio-ui +RUN pnpm build + +# Production stage +FROM nginx:alpine + +# Copy built assets +COPY --from=build /workspace/apps/studio-ui/dist /usr/share/nginx/html +COPY apps/studio-ui/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 3001 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/apps/studio-ui/component.yaml b/apps/studio-ui/component.yaml new file mode 100644 index 0000000..453591e --- /dev/null +++ b/apps/studio-ui/component.yaml @@ -0,0 +1,6 @@ +name: studio-ui +type: app +port: 3001 +path: apps/studio-ui +stack: react +dependencies: [] diff --git a/apps/studio-ui/index.html b/apps/studio-ui/index.html new file mode 100644 index 0000000..cd7c32e --- /dev/null +++ b/apps/studio-ui/index.html @@ -0,0 +1,13 @@ + + + + + + + studio-ui | foundary-1770670477 + + +
+ + + diff --git a/apps/studio-ui/nginx.conf b/apps/studio-ui/nginx.conf new file mode 100644 index 0000000..1e23deb --- /dev/null +++ b/apps/studio-ui/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 3001; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; +} diff --git a/apps/studio-ui/package.json b/apps/studio-ui/package.json new file mode 100644 index 0000000..b8d05fb --- /dev/null +++ b/apps/studio-ui/package.json @@ -0,0 +1,37 @@ +{ + "name": "studio-ui", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite --port 3001", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview --port 3001", + "format": "prettier --write src/" + }, + "dependencies": { + "@foundary-1770670477/logger": "workspace:*", + "@foundary-1770670477/ui": "workspace:*", + "@foundary-1770670477/layout": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.23.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^7.13.1", + "@typescript-eslint/parser": "^7.13.1", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.7", + "postcss": "^8.4.38", + "prettier": "^3.3.2", + "tailwindcss": "^3.4.4", + "typescript": "^5.5.3", + "vite": "^5.4.1" + } +} diff --git a/apps/studio-ui/postcss.config.js b/apps/studio-ui/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/apps/studio-ui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/studio-ui/public/vite.svg b/apps/studio-ui/public/vite.svg new file mode 100644 index 0000000..6a41099 --- /dev/null +++ b/apps/studio-ui/public/vite.svg @@ -0,0 +1 @@ + diff --git a/apps/studio-ui/src/App.tsx b/apps/studio-ui/src/App.tsx new file mode 100644 index 0000000..557074c --- /dev/null +++ b/apps/studio-ui/src/App.tsx @@ -0,0 +1,113 @@ +import { DashboardShell, Sidebar, Header, type NavItem } from '@foundary-1770670477/layout'; +import { + Button, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + Badge, + Home, + Users, + Settings, + BarChart3, +} from '@foundary-1770670477/ui'; + +const navItems: NavItem[] = [ + { label: 'Dashboard', href: '/', icon: Home, active: true }, + { label: 'Analytics', href: '/analytics', icon: BarChart3 }, + { label: 'Users', href: '/users', icon: Users, badge: '12' }, + { label: 'Settings', href: '/settings', icon: Settings }, +]; + +function App() { + return ( + foundary-1770670477 + } + items={navItems} + footer={ +
+ v0.0.1 +
+ } + /> + } + header={ +
+ } + > +
+ {/* Welcome card */} + + + Welcome to studio-ui + + This is part of the{' '} + + foundary-1770670477 + {' '} + monorepo, using the shared UI library and layout components. + + + +
+ + +
+
+
+ + {/* Stats cards */} +
+ + + Total Users + 1,234 + + + +12% from last month + + + + + + Active Sessions + 567 + + + Live + + + + + + API Requests + 89.2k + + + High traffic + + +
+ + {/* Edit hint */} +

+ Edit this file at{' '} + + apps/studio-ui/src/App.tsx + +

+
+ + ); +} + +export default App; diff --git a/apps/studio-ui/src/index.css b/apps/studio-ui/src/index.css new file mode 100644 index 0000000..bfef3b8 --- /dev/null +++ b/apps/studio-ui/src/index.css @@ -0,0 +1,6 @@ +/* Import design system tokens */ +@import '@foundary-1770670477/ui/styles'; + +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/apps/studio-ui/src/lib/logger.ts b/apps/studio-ui/src/lib/logger.ts new file mode 100644 index 0000000..21cb9a3 --- /dev/null +++ b/apps/studio-ui/src/lib/logger.ts @@ -0,0 +1,11 @@ +import { createLogger, installGlobalHandlers } from '@foundary-1770670477/logger'; + +export const logger = createLogger({ + level: import.meta.env.DEV ? 'debug' : 'info', + service: 'studio-ui', + // Set endpoint to send logs to your backend: + // endpoint: '/api/logs', +}); + +// Install global error handlers +installGlobalHandlers(logger); diff --git a/apps/studio-ui/src/main.tsx b/apps/studio-ui/src/main.tsx new file mode 100644 index 0000000..174dfe8 --- /dev/null +++ b/apps/studio-ui/src/main.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.tsx'; +import './index.css'; +import './lib/logger'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/apps/studio-ui/src/vite-env.d.ts b/apps/studio-ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/studio-ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/studio-ui/tailwind.config.js b/apps/studio-ui/tailwind.config.js new file mode 100644 index 0000000..6e1cb34 --- /dev/null +++ b/apps/studio-ui/tailwind.config.js @@ -0,0 +1,14 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + './index.html', + './src/**/*.{js,ts,jsx,tsx}', + // Include shared packages for Tailwind classes + '../../packages/ui/src/**/*.{js,ts,jsx,tsx}', + '../../packages/layout/src/**/*.{js,ts,jsx,tsx}', + ], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/apps/studio-ui/tsconfig.json b/apps/studio-ui/tsconfig.json new file mode 100644 index 0000000..a7fc6fb --- /dev/null +++ b/apps/studio-ui/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/apps/studio-ui/tsconfig.node.json b/apps/studio-ui/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/apps/studio-ui/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/studio-ui/vite.config.ts b/apps/studio-ui/vite.config.ts new file mode 100644 index 0000000..051ab31 --- /dev/null +++ b/apps/studio-ui/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + port: 3001, + }, + preview: { + port: 3001, + }, +}); diff --git a/services/studio-api/.env.example b/services/studio-api/.env.example new file mode 100644 index 0000000..c069fb8 --- /dev/null +++ b/services/studio-api/.env.example @@ -0,0 +1,21 @@ +# studio-api Service Configuration + +# Server +SERVER_PORT=8001 +SERVER_HOST=0.0.0.0 + +# App +APP_NAME=studio-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/foundary-1770670477?sslmode=disable diff --git a/services/studio-api/Dockerfile b/services/studio-api/Dockerfile new file mode 100644 index 0000000..dd47732 --- /dev/null +++ b/services/studio-api/Dockerfile @@ -0,0 +1,35 @@ +# Build stage +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git + +# Configure Go private modules +# Disable workspace mode - each component builds independently with replace directives +ENV GOPRIVATE=git.threesix.ai/* +ENV GOWORK=off + +WORKDIR /app + +# Copy shared pkg and this service only +COPY pkg/ ./pkg/ +COPY services/studio-api/ ./services/studio-api/ + +# Download dependencies (populates go.sum if empty) +RUN cd pkg && go mod download +RUN cd services/studio-api && go mod download + +# Build from the service directory (uses replace directive for ../pkg) +RUN cd services/studio-api && CGO_ENABLED=0 go build -o /studio-api ./cmd/server + +# Production stage +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR / + +COPY --from=builder /studio-api /studio-api + +EXPOSE 8001 + +ENTRYPOINT ["/studio-api"] diff --git a/services/studio-api/Makefile b/services/studio-api/Makefile new file mode 100644 index 0000000..1e6d406 --- /dev/null +++ b/services/studio-api/Makefile @@ -0,0 +1,34 @@ +.PHONY: build run test lint fmt docker-build clean + +SERVICE := studio-api +BINARY := bin/$(SERVICE) +GO_MODULE := git.threesix.ai/jordan/foundary-1770670477 + +# 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/studio-api/cmd/server/main.go b/services/studio-api/cmd/server/main.go new file mode 100644 index 0000000..5827312 --- /dev/null +++ b/services/studio-api/cmd/server/main.go @@ -0,0 +1,50 @@ +// Package main is the entry point for the studio-api service. +package main + +import ( + "flag" + "fmt" + "os" + + "git.threesix.ai/jordan/foundary-1770670477/pkg/app" + "git.threesix.ai/jordan/foundary-1770670477/pkg/logging" + "git.threesix.ai/jordan/foundary-1770670477/services/studio-api/internal/adapter/memory" + "git.threesix.ai/jordan/foundary-1770670477/services/studio-api/internal/api" + "git.threesix.ai/jordan/foundary-1770670477/services/studio-api/internal/service" +) + +func main() { + // Parse flags + exportOpenAPI := flag.Bool("export-openapi", false, "Export OpenAPI spec to stdout and exit") + flag.Parse() + + // If exporting OpenAPI, generate spec and exit (used by CI for docs generation) + if *exportOpenAPI { + spec := api.NewServiceSpec() + jsonBytes, err := spec.JSON() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to generate OpenAPI spec: %v\n", err) + os.Exit(1) + } + fmt.Println(string(jsonBytes)) + os.Exit(0) + } + + // Create logger + logger := logging.Default() + + // Create adapters (repositories) + exampleRepo := memory.NewExampleRepository() + + // Create services (business logic) + exampleService := service.NewExampleService(exampleRepo, logger) + + // Create application + application := app.New("studio-api", app.WithDefaultPort(8001)) + + // Register routes with dependency injection + api.RegisterRoutes(application, exampleService) + + // Start server + application.Run() +} diff --git a/services/studio-api/component.yaml b/services/studio-api/component.yaml new file mode 100644 index 0000000..2a6b6a1 --- /dev/null +++ b/services/studio-api/component.yaml @@ -0,0 +1,9 @@ +name: studio-api +type: service +port: 8001 +path: services/studio-api +dependencies: [] +# Add dependencies as needed: +# - postgres +# - redis +# - other-service diff --git a/services/studio-api/go.mod b/services/studio-api/go.mod new file mode 100644 index 0000000..626c10e --- /dev/null +++ b/services/studio-api/go.mod @@ -0,0 +1,8 @@ +module git.threesix.ai/jordan/foundary-1770670477/services/studio-api + +go 1.25 + +require git.threesix.ai/jordan/foundary-1770670477/pkg v0.0.0 + +// Use local workspace modules (for Docker builds without go.work) +replace git.threesix.ai/jordan/foundary-1770670477/pkg => ../../pkg diff --git a/services/studio-api/go.sum b/services/studio-api/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/services/studio-api/internal/adapter/memory/example.go b/services/studio-api/internal/adapter/memory/example.go new file mode 100644 index 0000000..2bd1907 --- /dev/null +++ b/services/studio-api/internal/adapter/memory/example.go @@ -0,0 +1,106 @@ +// Package memory provides in-memory implementations of repository interfaces. +// Useful for development, testing, and prototyping. +package memory + +import ( + "context" + "sync" + + "git.threesix.ai/jordan/foundary-1770670477/services/studio-api/internal/domain" + "git.threesix.ai/jordan/foundary-1770670477/services/studio-api/internal/port" +) + +// Compile-time verification that ExampleRepository implements port.ExampleRepository. +var _ port.ExampleRepository = (*ExampleRepository)(nil) + +// ExampleRepository is a thread-safe in-memory implementation of port.ExampleRepository. +type ExampleRepository struct { + mu sync.RWMutex + examples map[domain.ExampleID]*domain.Example +} + +// NewExampleRepository creates a new in-memory example repository. +func NewExampleRepository() *ExampleRepository { + return &ExampleRepository{ + examples: make(map[domain.ExampleID]*domain.Example), + } +} + +// List returns all examples. +func (r *ExampleRepository) List(ctx context.Context) ([]domain.Example, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]domain.Example, 0, len(r.examples)) + for _, e := range r.examples { + result = append(result, *e) + } + return result, nil +} + +// Get returns an example by ID. +// Returns domain.ErrExampleNotFound if not found. +func (r *ExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + e, ok := r.examples[id] + if !ok { + return nil, domain.ErrExampleNotFound + } + // Return a copy to prevent external mutation + copy := *e + return ©, nil +} + +// Create stores a new example. +func (r *ExampleRepository) Create(ctx context.Context, example *domain.Example) error { + r.mu.Lock() + defer r.mu.Unlock() + + // Store a copy to prevent external mutation + copy := *example + r.examples[example.ID] = © + return nil +} + +// Update modifies an existing example. +// Returns domain.ErrExampleNotFound if not found. +func (r *ExampleRepository) Update(ctx context.Context, example *domain.Example) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.examples[example.ID]; !ok { + return domain.ErrExampleNotFound + } + // Store a copy to prevent external mutation + copy := *example + r.examples[example.ID] = © + return nil +} + +// Delete removes an example by ID. +// Returns domain.ErrExampleNotFound if not found. +func (r *ExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.examples[id]; !ok { + return domain.ErrExampleNotFound + } + delete(r.examples, id) + return nil +} + +// ExistsByName checks if an example with the given name exists. +func (r *ExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, e := range r.examples { + if e.Name == name { + return true, nil + } + } + return false, nil +} diff --git a/services/studio-api/internal/api/handlers/example.go b/services/studio-api/internal/api/handlers/example.go new file mode 100644 index 0000000..5601d01 --- /dev/null +++ b/services/studio-api/internal/api/handlers/example.go @@ -0,0 +1,170 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "git.threesix.ai/jordan/foundary-1770670477/pkg/app" + "git.threesix.ai/jordan/foundary-1770670477/pkg/httperror" + "git.threesix.ai/jordan/foundary-1770670477/pkg/httpresponse" + "git.threesix.ai/jordan/foundary-1770670477/pkg/logging" + "git.threesix.ai/jordan/foundary-1770670477/services/studio-api/internal/domain" + "git.threesix.ai/jordan/foundary-1770670477/services/studio-api/internal/service" +) + +// Example handles HTTP requests for example resources. +type Example struct { + svc *service.ExampleService + logger *logging.Logger +} + +// NewExample creates a new Example handler with injected dependencies. +func NewExample(svc *service.ExampleService, logger *logging.Logger) *Example { + return &Example{ + svc: svc, + logger: logger.WithComponent("ExampleHandler"), + } +} + +// 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:"required,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"` +} + +// toResponse converts a domain example to an API response. +func toResponse(e *domain.Example) ExampleResponse { + return ExampleResponse{ + ID: e.ID.String(), + Name: e.Name, + Description: e.Description, + CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: e.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } +} + +// List returns all examples. +func (h *Example) List(w http.ResponseWriter, r *http.Request) error { + examples, err := h.svc.List(r.Context()) + if err != nil { + return err + } + + result := make([]ExampleResponse, len(examples)) + for i, e := range examples { + result[i] = toResponse(&e) + } + + httpresponse.OK(w, r, result) + return nil +} + +// Get returns an example by ID. +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, err := h.svc.Get(r.Context(), domain.ExampleID(id)) + if err != nil { + return mapDomainError(err) + } + + httpresponse.OK(w, r, toResponse(example)) + return nil +} + +// Create creates a new example. +func (h *Example) Create(w http.ResponseWriter, r *http.Request) error { + var req CreateRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + example, err := h.svc.Create(r.Context(), service.CreateInput{ + Name: req.Name, + Description: req.Description, + }) + if err != nil { + return mapDomainError(err) + } + + httpresponse.Created(w, r, toResponse(example)) + return nil +} + +// Update updates an existing example. +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, err := h.svc.Update(r.Context(), domain.ExampleID(id), service.UpdateInput{ + Name: req.Name, + Description: req.Description, + }) + if err != nil { + return mapDomainError(err) + } + + httpresponse.OK(w, r, toResponse(example)) + return nil +} + +// Delete removes an example by ID. +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") + } + + if err := h.svc.Delete(r.Context(), domain.ExampleID(id)); err != nil { + return mapDomainError(err) + } + + httpresponse.NoContent(w) + return nil +} + +// mapDomainError converts domain errors to HTTP errors. +func mapDomainError(err error) error { + switch { + case errors.Is(err, domain.ErrExampleNotFound): + return httperror.NotFound("example not found") + case errors.Is(err, domain.ErrDuplicateExample): + return httperror.Conflict("example with this name already exists") + case errors.Is(err, domain.ErrInvalidExampleName): + return httperror.BadRequest("invalid example name") + default: + return err + } +} diff --git a/services/studio-api/internal/api/handlers/example_test.go b/services/studio-api/internal/api/handlers/example_test.go new file mode 100644 index 0000000..9cb7506 --- /dev/null +++ b/services/studio-api/internal/api/handlers/example_test.go @@ -0,0 +1,402 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/go-chi/chi/v5" + + "git.threesix.ai/jordan/foundary-1770670477/pkg/logging" + "git.threesix.ai/jordan/foundary-1770670477/services/studio-api/internal/domain" + "git.threesix.ai/jordan/foundary-1770670477/services/studio-api/internal/port" + "git.threesix.ai/jordan/foundary-1770670477/services/studio-api/internal/service" +) + +// mockExampleRepository implements port.ExampleRepository for testing. +type mockExampleRepository struct { + mu sync.RWMutex + examples map[domain.ExampleID]*domain.Example +} + +var _ port.ExampleRepository = (*mockExampleRepository)(nil) + +func newMockExampleRepository() *mockExampleRepository { + return &mockExampleRepository{ + examples: make(map[domain.ExampleID]*domain.Example), + } +} + +func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make([]domain.Example, 0, len(m.examples)) + for _, e := range m.examples { + result = append(result, *e) + } + return result, nil +} + +func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + e, ok := m.examples[id] + if !ok { + return nil, domain.ErrExampleNotFound + } + copy := *e + return ©, nil +} + +func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error { + m.mu.Lock() + defer m.mu.Unlock() + + copy := *example + m.examples[example.ID] = © + return nil +} + +func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.examples[example.ID]; !ok { + return domain.ErrExampleNotFound + } + copy := *example + m.examples[example.ID] = © + return nil +} + +func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.examples[id]; !ok { + return domain.ErrExampleNotFound + } + delete(m.examples, id) + return nil +} + +func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + for _, e := range m.examples { + if e.Name == name { + return true, nil + } + } + return false, nil +} + +func newTestHandler() (*Example, *mockExampleRepository) { + repo := newMockExampleRepository() + svc := service.NewExampleService(repo, logging.Nop()) + handler := NewExample(svc, logging.Nop()) + return handler, repo +} + +func TestExample_List(t *testing.T) { + handler, repo := newTestHandler() + + // Seed data + ex, _ := domain.NewExample("test-id-1", "Test Example", "Description") + _ = repo.Create(context.Background(), ex) + + 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) != 1 { + t.Errorf("expected 1 item, got %d", len(items)) + } +} + +func TestExample_Get(t *testing.T) { + handler, repo := newTestHandler() + + // Seed data + ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Test Example", "Description") + _ = repo.Create(context.Background(), ex) + + tests := []struct { + name string + id string + wantStatus int + }{ + { + name: "valid uuid - found", + id: "550e8400-e29b-41d4-a716-446655440000", + wantStatus: http.StatusOK, + }, + { + name: "valid uuid - not found", + id: "550e8400-e29b-41d4-a716-446655440001", + wantStatus: http.StatusNotFound, + }, + { + 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 { + // Map error to status for testing + switch tt.wantStatus { + case http.StatusNotFound: + w.WriteHeader(http.StatusNotFound) + case http.StatusBadRequest: + w.WriteHeader(http.StatusBadRequest) + default: + w.WriteHeader(http.StatusInternalServerError) + } + 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, repo := newTestHandler() + + // Seed existing data for duplicate test + ex, _ := domain.NewExample("existing-id", "Existing Name", "") + _ = repo.Create(context.Background(), ex) + + tests := []struct { + name string + body any + wantStatus int + }{ + { + name: "valid request", + body: CreateRequest{ + Name: "New Example", + Description: "A test description", + }, + wantStatus: http.StatusCreated, + }, + { + name: "empty body", + body: nil, + wantStatus: http.StatusBadRequest, + }, + { + name: "duplicate name", + body: CreateRequest{ + Name: "Existing Name", + Description: "Conflict", + }, + wantStatus: http.StatusConflict, + }, + } + + 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 { + switch tt.wantStatus { + case http.StatusBadRequest: + w.WriteHeader(http.StatusBadRequest) + case http.StatusConflict: + w.WriteHeader(http.StatusConflict) + default: + w.WriteHeader(http.StatusInternalServerError) + } + 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) + + if w.Code != tt.wantStatus { + t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code) + } + }) + } +} + +func TestExample_Delete(t *testing.T) { + handler, repo := newTestHandler() + + // Seed data + ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "To Delete", "") + _ = repo.Create(context.Background(), ex) + + tests := []struct { + name string + id string + wantStatus int + }{ + { + name: "existing example", + id: "550e8400-e29b-41d4-a716-446655440000", + wantStatus: http.StatusNoContent, + }, + { + name: "non-existent example", + id: "550e8400-e29b-41d4-a716-446655440001", + wantStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := chi.NewRouter() + r.Delete("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) { + if err := handler.Delete(w, r); err != nil { + if tt.wantStatus == http.StatusNotFound { + w.WriteHeader(http.StatusNotFound) + } else { + w.WriteHeader(http.StatusBadRequest) + } + return + } + }) + + req := httptest.NewRequest(http.MethodDelete, "/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_Update(t *testing.T) { + handler, repo := newTestHandler() + + // Seed data + ex1, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Example 1", "") + _ = repo.Create(context.Background(), ex1) + ex2, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440001", "Example 2", "") + _ = repo.Create(context.Background(), ex2) + + tests := []struct { + name string + id string + body UpdateRequest + wantStatus int + }{ + { + name: "valid update", + id: "550e8400-e29b-41d4-a716-446655440000", + body: UpdateRequest{ + Name: "Updated Name", + Description: "Updated", + }, + wantStatus: http.StatusOK, + }, + { + name: "name conflict", + id: "550e8400-e29b-41d4-a716-446655440000", + body: UpdateRequest{ + Name: "Example 2", + Description: "Conflict", + }, + wantStatus: http.StatusConflict, + }, + { + name: "not found", + id: "550e8400-e29b-41d4-a716-446655440099", + body: UpdateRequest{ + Name: "Whatever", + Description: "", + }, + wantStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := chi.NewRouter() + r.Put("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) { + if err := handler.Update(w, r); err != nil { + switch tt.wantStatus { + case http.StatusNotFound: + w.WriteHeader(http.StatusNotFound) + case http.StatusConflict: + w.WriteHeader(http.StatusConflict) + default: + w.WriteHeader(http.StatusBadRequest) + } + return + } + }) + + body, _ := json.Marshal(tt.body) + req := httptest.NewRequest(http.MethodPut, "/api/v1/examples/"+tt.id, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code) + } + }) + } +} diff --git a/services/studio-api/internal/api/handlers/health.go b/services/studio-api/internal/api/handlers/health.go new file mode 100644 index 0000000..78f0a85 --- /dev/null +++ b/services/studio-api/internal/api/handlers/health.go @@ -0,0 +1,26 @@ +package handlers + +import ( + "net/http" + + "git.threesix.ai/jordan/foundary-1770670477/pkg/httpresponse" + "git.threesix.ai/jordan/foundary-1770670477/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": "studio-api", + "status": "healthy", + }) +} diff --git a/services/studio-api/internal/api/routes.go b/services/studio-api/internal/api/routes.go new file mode 100644 index 0000000..807736d --- /dev/null +++ b/services/studio-api/internal/api/routes.go @@ -0,0 +1,54 @@ +// Package api provides HTTP routing and handlers for the studio-api service. +package api + +import ( + "git.threesix.ai/jordan/foundary-1770670477/pkg/app" + "git.threesix.ai/jordan/foundary-1770670477/pkg/auth" + "git.threesix.ai/jordan/foundary-1770670477/services/studio-api/internal/api/handlers" + "git.threesix.ai/jordan/foundary-1770670477/services/studio-api/internal/config" + "git.threesix.ai/jordan/foundary-1770670477/services/studio-api/internal/service" +) + +// RegisterRoutes registers all HTTP routes for the service. +// Routes are mounted under /api/studio-api to match the ingress path routing. +// This allows the monorepo to expose multiple services under a single domain: +// - https://domain/api/studio-api/health +// - https://domain/api/studio-api/examples +func RegisterRoutes(application *app.App, exampleService *service.ExampleService) { + logger := application.Logger() + cfg := config.Load() + + // Initialize handlers with injected services + healthHandler := handlers.NewHealth(logger) + exampleHandler := handlers.NewExample(exampleService, 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/studio-api/* to this service. + application.Route("/api/studio-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: "foundary-1770670477", + }), + })) + } + + 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/studio-api/internal/api/spec.go b/services/studio-api/internal/api/spec.go new file mode 100644 index 0000000..ffbd77a --- /dev/null +++ b/services/studio-api/internal/api/spec.go @@ -0,0 +1,112 @@ +package api + +import "git.threesix.ai/jordan/foundary-1770670477/pkg/openapi" + +// NewServiceSpec builds the OpenAPI specification for the studio-api service. +func NewServiceSpec() *openapi.OpenAPISpec { + spec := openapi.NewOpenAPISpec("studio-api API", "1.0.0"). + WithDescription("REST API for the studio-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/studio-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/studio-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/studio-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/studio-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/studio-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/studio-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 +} diff --git a/services/studio-api/internal/config/config.go b/services/studio-api/internal/config/config.go new file mode 100644 index 0000000..72def28 --- /dev/null +++ b/services/studio-api/internal/config/config.go @@ -0,0 +1,34 @@ +// Package config provides service-specific configuration. +package config + +import ( + "os" + "strings" + + "git.threesix.ai/jordan/foundary-1770670477/pkg/config" +) + +// Config extends the base config with studio-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/studio-api/internal/domain/errors.go b/services/studio-api/internal/domain/errors.go new file mode 100644 index 0000000..d4ffe10 --- /dev/null +++ b/services/studio-api/internal/domain/errors.go @@ -0,0 +1,21 @@ +// Package domain contains pure domain models with no external dependencies. +// These types represent the core business concepts of the service. +package domain + +import "errors" + +// Domain errors - these are business-level errors that should be translated +// to appropriate HTTP status codes by the handler layer. +var ( + // ErrNotFound indicates a requested resource does not exist. + ErrNotFound = errors.New("not found") + + // ErrExampleNotFound indicates the requested example does not exist. + ErrExampleNotFound = errors.New("example not found") + + // ErrDuplicateExample indicates an example with the same name already exists. + ErrDuplicateExample = errors.New("example with this name already exists") + + // ErrInvalidExampleName indicates the example name is invalid. + ErrInvalidExampleName = errors.New("invalid example name") +) diff --git a/services/studio-api/internal/domain/example.go b/services/studio-api/internal/domain/example.go new file mode 100644 index 0000000..4ee48e9 --- /dev/null +++ b/services/studio-api/internal/domain/example.go @@ -0,0 +1,89 @@ +package domain + +import ( + "time" + "unicode/utf8" +) + +// ExampleID is a strongly-typed identifier for examples. +type ExampleID string + +// String returns the string representation of the ID. +func (id ExampleID) String() string { + return string(id) +} + +// IsZero returns true if the ID is empty. +func (id ExampleID) IsZero() bool { + return id == "" +} + +// Example name constraints. +const ( + MinExampleNameLen = 1 + MaxExampleNameLen = 100 + MaxDescriptionLen = 500 +) + +// Example represents an example domain entity. +// This is a pure domain model with no external dependencies. +type Example struct { + ID ExampleID + Name string + Description string + CreatedAt time.Time + UpdatedAt time.Time +} + +// NewExample creates a new Example with validation. +// Returns ErrInvalidExampleName if the name is invalid. +func NewExample(id ExampleID, name, description string) (*Example, error) { + if err := validateExampleName(name); err != nil { + return nil, err + } + if err := validateDescription(description); err != nil { + return nil, err + } + + now := time.Now().UTC() + return &Example{ + ID: id, + Name: name, + Description: description, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +// Update modifies the example's mutable fields with validation. +// Returns ErrInvalidExampleName if the name is invalid. +func (e *Example) Update(name, description string) error { + if err := validateExampleName(name); err != nil { + return err + } + if err := validateDescription(description); err != nil { + return err + } + + e.Name = name + e.Description = description + e.UpdatedAt = time.Now().UTC() + return nil +} + +// validateExampleName validates an example name. +func validateExampleName(name string) error { + length := utf8.RuneCountInString(name) + if length < MinExampleNameLen || length > MaxExampleNameLen { + return ErrInvalidExampleName + } + return nil +} + +// validateDescription validates a description. +func validateDescription(desc string) error { + if utf8.RuneCountInString(desc) > MaxDescriptionLen { + return ErrInvalidExampleName + } + return nil +} diff --git a/services/studio-api/internal/port/example.go b/services/studio-api/internal/port/example.go new file mode 100644 index 0000000..a99ee45 --- /dev/null +++ b/services/studio-api/internal/port/example.go @@ -0,0 +1,37 @@ +// Package port defines interfaces (ports) for external dependencies. +// These interfaces define the contracts between the application core and +// infrastructure adapters, enabling testability and flexibility. +package port + +import ( + "context" + + "git.threesix.ai/jordan/foundary-1770670477/services/studio-api/internal/domain" +) + +// ExampleRepository defines the interface for example persistence operations. +// Implementations may use databases, in-memory storage, or external services. +type ExampleRepository interface { + // List returns all examples. + List(ctx context.Context) ([]domain.Example, error) + + // Get returns an example by ID. + // Returns domain.ErrExampleNotFound if not found. + Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) + + // Create stores a new example. + // The example must have a valid ID set. + Create(ctx context.Context, example *domain.Example) error + + // Update modifies an existing example. + // Returns domain.ErrExampleNotFound if not found. + Update(ctx context.Context, example *domain.Example) error + + // Delete removes an example by ID. + // Returns domain.ErrExampleNotFound if not found. + Delete(ctx context.Context, id domain.ExampleID) error + + // ExistsByName checks if an example with the given name exists. + // Used for duplicate detection. + ExistsByName(ctx context.Context, name string) (bool, error) +} diff --git a/services/studio-api/internal/service/example.go b/services/studio-api/internal/service/example.go new file mode 100644 index 0000000..3964a4d --- /dev/null +++ b/services/studio-api/internal/service/example.go @@ -0,0 +1,137 @@ +// Package service provides business logic / use cases for the application. +// Services orchestrate domain operations using port interfaces. +package service + +import ( + "context" + "errors" + + "github.com/google/uuid" + + "git.threesix.ai/jordan/foundary-1770670477/pkg/logging" + "git.threesix.ai/jordan/foundary-1770670477/services/studio-api/internal/domain" + "git.threesix.ai/jordan/foundary-1770670477/services/studio-api/internal/port" +) + +// ExampleService handles example-related business logic. +type ExampleService struct { + repo port.ExampleRepository + logger *logging.Logger +} + +// NewExampleService creates a new example service. +func NewExampleService(repo port.ExampleRepository, logger *logging.Logger) *ExampleService { + return &ExampleService{ + repo: repo, + logger: logger.WithService("ExampleService"), + } +} + +// List returns all examples. +func (s *ExampleService) List(ctx context.Context) ([]domain.Example, error) { + return s.repo.List(ctx) +} + +// Get returns an example by ID. +// Returns domain.ErrExampleNotFound if not found. +func (s *ExampleService) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) { + return s.repo.Get(ctx, id) +} + +// CreateInput contains the data needed to create an example. +type CreateInput struct { + Name string + Description string +} + +// Create creates a new example with duplicate detection. +// Returns domain.ErrDuplicateExample if name already exists. +// Returns domain.ErrInvalidExampleName if name is invalid. +func (s *ExampleService) Create(ctx context.Context, input CreateInput) (*domain.Example, error) { + // Check for duplicates + exists, err := s.repo.ExistsByName(ctx, input.Name) + if err != nil { + return nil, err + } + if exists { + return nil, domain.ErrDuplicateExample + } + + // Generate new ID + id := domain.ExampleID(uuid.New().String()) + + // Create domain entity (validates name) + example, err := domain.NewExample(id, input.Name, input.Description) + if err != nil { + return nil, err + } + + // Persist + if err := s.repo.Create(ctx, example); err != nil { + return nil, err + } + + s.logger.Info("example created", "id", id, "name", input.Name) + return example, nil +} + +// UpdateInput contains the data needed to update an example. +type UpdateInput struct { + Name string + Description string +} + +// Update modifies an existing example. +// Returns domain.ErrExampleNotFound if not found. +// Returns domain.ErrDuplicateExample if new name conflicts with another example. +// Returns domain.ErrInvalidExampleName if name is invalid. +func (s *ExampleService) Update(ctx context.Context, id domain.ExampleID, input UpdateInput) (*domain.Example, error) { + // Fetch existing + example, err := s.repo.Get(ctx, id) + if err != nil { + return nil, err + } + + // Check for name conflicts (only if name changed) + if example.Name != input.Name { + exists, err := s.repo.ExistsByName(ctx, input.Name) + if err != nil { + return nil, err + } + if exists { + return nil, domain.ErrDuplicateExample + } + } + + // Update domain entity (validates name) + if err := example.Update(input.Name, input.Description); err != nil { + return nil, err + } + + // Persist + if err := s.repo.Update(ctx, example); err != nil { + return nil, err + } + + s.logger.Info("example updated", "id", id, "name", input.Name) + return example, nil +} + +// Delete removes an example by ID. +// Returns domain.ErrExampleNotFound if not found. +func (s *ExampleService) Delete(ctx context.Context, id domain.ExampleID) error { + // Verify exists before delete + if _, err := s.repo.Get(ctx, id); err != nil { + if errors.Is(err, domain.ErrExampleNotFound) { + return domain.ErrExampleNotFound + } + return err + } + + if err := s.repo.Delete(ctx, id); err != nil { + return err + } + + s.logger.Info("example deleted", "id", id) + return nil +} diff --git a/services/studio-api/internal/service/example_test.go b/services/studio-api/internal/service/example_test.go new file mode 100644 index 0000000..a450fda --- /dev/null +++ b/services/studio-api/internal/service/example_test.go @@ -0,0 +1,282 @@ +package service + +import ( + "context" + "sync" + "testing" + + "git.threesix.ai/jordan/foundary-1770670477/pkg/logging" + "git.threesix.ai/jordan/foundary-1770670477/services/studio-api/internal/domain" + "git.threesix.ai/jordan/foundary-1770670477/services/studio-api/internal/port" +) + +// mockExampleRepository implements port.ExampleRepository for testing. +type mockExampleRepository struct { + mu sync.RWMutex + examples map[domain.ExampleID]*domain.Example +} + +var _ port.ExampleRepository = (*mockExampleRepository)(nil) + +func newMockExampleRepository() *mockExampleRepository { + return &mockExampleRepository{ + examples: make(map[domain.ExampleID]*domain.Example), + } +} + +func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make([]domain.Example, 0, len(m.examples)) + for _, e := range m.examples { + result = append(result, *e) + } + return result, nil +} + +func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + e, ok := m.examples[id] + if !ok { + return nil, domain.ErrExampleNotFound + } + // Return a copy to avoid mutation + copy := *e + return ©, nil +} + +func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Store a copy + copy := *example + m.examples[example.ID] = © + return nil +} + +func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.examples[example.ID]; !ok { + return domain.ErrExampleNotFound + } + // Store a copy + copy := *example + m.examples[example.ID] = © + return nil +} + +func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.examples[id]; !ok { + return domain.ErrExampleNotFound + } + delete(m.examples, id) + return nil +} + +func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + for _, e := range m.examples { + if e.Name == name { + return true, nil + } + } + return false, nil +} + +func TestExampleService_Create(t *testing.T) { + repo := newMockExampleRepository() + svc := NewExampleService(repo, logging.Nop()) + + t.Run("creates example successfully", func(t *testing.T) { + example, err := svc.Create(context.Background(), CreateInput{ + Name: "Test Example", + Description: "A test description", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if example.Name != "Test Example" { + t.Errorf("expected name 'Test Example', got '%s'", example.Name) + } + if example.ID.IsZero() { + t.Error("expected non-empty ID") + } + }) + + t.Run("rejects duplicate name", func(t *testing.T) { + _, err := svc.Create(context.Background(), CreateInput{ + Name: "Test Example", + Description: "Another description", + }) + if err != domain.ErrDuplicateExample { + t.Errorf("expected ErrDuplicateExample, got %v", err) + } + }) + + t.Run("rejects empty name", func(t *testing.T) { + _, err := svc.Create(context.Background(), CreateInput{ + Name: "", + Description: "Description", + }) + if err != domain.ErrInvalidExampleName { + t.Errorf("expected ErrInvalidExampleName, got %v", err) + } + }) +} + +func TestExampleService_Get(t *testing.T) { + repo := newMockExampleRepository() + svc := NewExampleService(repo, logging.Nop()) + + // Create an example first + created, _ := svc.Create(context.Background(), CreateInput{ + Name: "Get Test", + Description: "Description", + }) + + t.Run("returns existing example", func(t *testing.T) { + example, err := svc.Get(context.Background(), created.ID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if example.Name != "Get Test" { + t.Errorf("expected name 'Get Test', got '%s'", example.Name) + } + }) + + t.Run("returns not found for missing example", func(t *testing.T) { + _, err := svc.Get(context.Background(), "nonexistent-id") + if err != domain.ErrExampleNotFound { + t.Errorf("expected ErrExampleNotFound, got %v", err) + } + }) +} + +func TestExampleService_Update(t *testing.T) { + repo := newMockExampleRepository() + svc := NewExampleService(repo, logging.Nop()) + + // Create examples + example1, _ := svc.Create(context.Background(), CreateInput{ + Name: "Update Test 1", + Description: "Original", + }) + _, _ = svc.Create(context.Background(), CreateInput{ + Name: "Update Test 2", + Description: "Other", + }) + + t.Run("updates example successfully", func(t *testing.T) { + updated, err := svc.Update(context.Background(), example1.ID, UpdateInput{ + Name: "Updated Name", + Description: "Updated description", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if updated.Name != "Updated Name" { + t.Errorf("expected name 'Updated Name', got '%s'", updated.Name) + } + }) + + t.Run("allows same name on same example", func(t *testing.T) { + _, err := svc.Update(context.Background(), example1.ID, UpdateInput{ + Name: "Updated Name", + Description: "Same name", + }) + if err != nil { + t.Errorf("unexpected error updating with same name: %v", err) + } + }) + + t.Run("rejects name conflict", func(t *testing.T) { + _, err := svc.Update(context.Background(), example1.ID, UpdateInput{ + Name: "Update Test 2", + Description: "Conflict", + }) + if err != domain.ErrDuplicateExample { + t.Errorf("expected ErrDuplicateExample, got %v", err) + } + }) + + t.Run("returns not found for missing example", func(t *testing.T) { + _, err := svc.Update(context.Background(), "nonexistent-id", UpdateInput{ + Name: "Anything", + Description: "", + }) + if err != domain.ErrExampleNotFound { + t.Errorf("expected ErrExampleNotFound, got %v", err) + } + }) +} + +func TestExampleService_Delete(t *testing.T) { + repo := newMockExampleRepository() + svc := NewExampleService(repo, logging.Nop()) + + // Create an example first + created, _ := svc.Create(context.Background(), CreateInput{ + Name: "Delete Test", + Description: "To be deleted", + }) + + t.Run("deletes example successfully", func(t *testing.T) { + err := svc.Delete(context.Background(), created.ID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify deleted + _, err = svc.Get(context.Background(), created.ID) + if err != domain.ErrExampleNotFound { + t.Errorf("expected ErrExampleNotFound after delete, got %v", err) + } + }) + + t.Run("returns not found for missing example", func(t *testing.T) { + err := svc.Delete(context.Background(), "nonexistent-id") + if err != domain.ErrExampleNotFound { + t.Errorf("expected ErrExampleNotFound, got %v", err) + } + }) +} + +func TestExampleService_List(t *testing.T) { + repo := newMockExampleRepository() + svc := NewExampleService(repo, logging.Nop()) + + t.Run("returns empty list initially", func(t *testing.T) { + examples, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(examples) != 0 { + t.Errorf("expected 0 examples, got %d", len(examples)) + } + }) + + // Create some examples + _, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 1", Description: ""}) + _, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 2", Description: ""}) + + t.Run("returns all examples", func(t *testing.T) { + examples, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(examples) != 2 { + t.Errorf("expected 2 examples, got %d", len(examples)) + } + }) +} diff --git a/services/studio-api/migrations/.gitkeep b/services/studio-api/migrations/.gitkeep new file mode 100644 index 0000000..e69de29