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..4aa8254
--- /dev/null
+++ b/apps/studio-ui/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ studio-ui | foundary-1770666514
+
+
+
+
+
+
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..9500371
--- /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-1770666514/logger": "workspace:*",
+ "@foundary-1770666514/ui": "workspace:*",
+ "@foundary-1770666514/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..a9cb3cf
--- /dev/null
+++ b/apps/studio-ui/src/App.tsx
@@ -0,0 +1,113 @@
+import { DashboardShell, Sidebar, Header, type NavItem } from '@foundary-1770666514/layout';
+import {
+ Button,
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ Badge,
+ Home,
+ Users,
+ Settings,
+ BarChart3,
+} from '@foundary-1770666514/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-1770666514
+ }
+ items={navItems}
+ footer={
+
+ v0.0.1
+
+ }
+ />
+ }
+ header={
+
+ }
+ >
+
+ {/* Welcome card */}
+
+
+ Welcome to studio-ui
+
+ This is part of the{' '}
+
+ foundary-1770666514
+ {' '}
+ monorepo, using the shared UI library and layout components.
+
+
+
+
+ Get Started
+ Documentation
+
+
+
+
+ {/* 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..529ad2f
--- /dev/null
+++ b/apps/studio-ui/src/index.css
@@ -0,0 +1,6 @@
+/* Import design system tokens */
+@import '@foundary-1770666514/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..2b97e61
--- /dev/null
+++ b/apps/studio-ui/src/lib/logger.ts
@@ -0,0 +1,11 @@
+import { createLogger, installGlobalHandlers } from '@foundary-1770666514/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..f5bf159
--- /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-1770666514?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..a7de023
--- /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-1770666514
+
+# 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..88222f6
--- /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-1770666514/pkg/app"
+ "git.threesix.ai/jordan/foundary-1770666514/pkg/logging"
+ "git.threesix.ai/jordan/foundary-1770666514/services/studio-api/internal/adapter/memory"
+ "git.threesix.ai/jordan/foundary-1770666514/services/studio-api/internal/api"
+ "git.threesix.ai/jordan/foundary-1770666514/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..d220b76
--- /dev/null
+++ b/services/studio-api/go.mod
@@ -0,0 +1,8 @@
+module git.threesix.ai/jordan/foundary-1770666514/services/studio-api
+
+go 1.25
+
+require git.threesix.ai/jordan/foundary-1770666514/pkg v0.0.0
+
+// Use local workspace modules (for Docker builds without go.work)
+replace git.threesix.ai/jordan/foundary-1770666514/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..bfe3403
--- /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-1770666514/services/studio-api/internal/domain"
+ "git.threesix.ai/jordan/foundary-1770666514/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..b872145
--- /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-1770666514/pkg/app"
+ "git.threesix.ai/jordan/foundary-1770666514/pkg/httperror"
+ "git.threesix.ai/jordan/foundary-1770666514/pkg/httpresponse"
+ "git.threesix.ai/jordan/foundary-1770666514/pkg/logging"
+ "git.threesix.ai/jordan/foundary-1770666514/services/studio-api/internal/domain"
+ "git.threesix.ai/jordan/foundary-1770666514/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..bb06854
--- /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-1770666514/pkg/logging"
+ "git.threesix.ai/jordan/foundary-1770666514/services/studio-api/internal/domain"
+ "git.threesix.ai/jordan/foundary-1770666514/services/studio-api/internal/port"
+ "git.threesix.ai/jordan/foundary-1770666514/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..62cbd9e
--- /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-1770666514/pkg/httpresponse"
+ "git.threesix.ai/jordan/foundary-1770666514/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..ece2ab9
--- /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-1770666514/pkg/app"
+ "git.threesix.ai/jordan/foundary-1770666514/pkg/auth"
+ "git.threesix.ai/jordan/foundary-1770666514/services/studio-api/internal/api/handlers"
+ "git.threesix.ai/jordan/foundary-1770666514/services/studio-api/internal/config"
+ "git.threesix.ai/jordan/foundary-1770666514/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-1770666514",
+ }),
+ }))
+ }
+
+ 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..4dc0154
--- /dev/null
+++ b/services/studio-api/internal/api/spec.go
@@ -0,0 +1,112 @@
+package api
+
+import "git.threesix.ai/jordan/foundary-1770666514/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..9592f0e
--- /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-1770666514/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..0bb5762
--- /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-1770666514/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..84cc593
--- /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-1770666514/pkg/logging"
+ "git.threesix.ai/jordan/foundary-1770666514/services/studio-api/internal/domain"
+ "git.threesix.ai/jordan/foundary-1770666514/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..6dfa060
--- /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-1770666514/pkg/logging"
+ "git.threesix.ai/jordan/foundary-1770666514/services/studio-api/internal/domain"
+ "git.threesix.ai/jordan/foundary-1770666514/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