Add a minimal HTTP API

This commit is contained in:
2026-05-04 21:19:29 -05:00
parent 526ee463f2
commit eac6b69217
6 changed files with 507 additions and 8 deletions

View File

@@ -0,0 +1,66 @@
package httpadapter
import (
"time"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
type runRequestDTO struct {
ProfileID string `json:"profile_id"`
ProfileVersion string `json:"profile_version,omitempty"`
Inputs map[string]inputRefDTO `json:"inputs"`
Vars map[string]string `json:"vars,omitempty"`
Model *modelOverrideRequestDTO `json:"model,omitempty"`
}
type inputRefDTO struct {
Type string `json:"type"`
URI string `json:"uri,omitempty"`
Body string `json:"body,omitempty"`
}
type modelOverrideRequestDTO struct {
Endpoint string `json:"endpoint,omitempty"`
Model string `json:"model,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
TopP float64 `json:"top_p,omitempty"`
}
type runResponseDTO struct {
Artifact artifactDTO `json:"artifact"`
Validation domain.ValidationResult `json:"validation"`
Metadata metadataDTO `json:"metadata"`
RawModelOutput string `json:"raw_model_output"`
}
type artifactDTO struct {
Name string `json:"name"`
ContentType string `json:"content_type"`
Body string `json:"body"`
URI string `json:"uri,omitempty"`
Size int64 `json:"size"`
Hash string `json:"hash"`
}
type metadataDTO struct {
ProfileID string `json:"profile_id"`
ProfileVersion string `json:"profile_version"`
ModelName string `json:"model_name"`
Endpoint string `json:"endpoint"`
InputHashes map[string]string `json:"input_hashes"`
PromptHash string `json:"prompt_hash"`
Usage domain.TokenUsage `json:"usage"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
}
type errorResponse struct {
Error errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}

View File

@@ -0,0 +1,145 @@
package httpadapter
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
"gitea.maximumdirect.net/eric/scriptorium/internal/usecase"
)
type Runner interface {
Run(ctx context.Context, req domain.RunRequest) (*domain.RunResult, error)
}
type Handler struct {
runner Runner
}
func NewHandler(runner Runner) *Handler {
return &Handler{runner: runner}
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/runs" {
writeError(w, http.StatusNotFound, "not_found", "route not found")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
var req runRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", fmt.Sprintf("invalid JSON request: %v", err))
return
}
if strings.TrimSpace(req.ProfileID) == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "profile_id is required")
return
}
if len(req.Inputs) == 0 {
writeError(w, http.StatusBadRequest, "invalid_request", "inputs is required")
return
}
mappedInputs := make(map[string]domain.ArtifactRef, len(req.Inputs))
for name, in := range req.Inputs {
mappedInputs[name] = domain.ArtifactRef{
Type: domain.ArtifactRefType(in.Type),
URI: in.URI,
Body: in.Body,
}
}
var model *domain.ModelTarget
if req.Model != nil {
model = &domain.ModelTarget{
Endpoint: req.Model.Endpoint,
Model: req.Model.Model,
Temperature: req.Model.Temperature,
MaxTokens: req.Model.MaxTokens,
TopP: req.Model.TopP,
}
}
res, err := h.runner.Run(r.Context(), domain.RunRequest{
ProfileID: req.ProfileID,
ProfileVersion: req.ProfileVersion,
Inputs: mappedInputs,
Vars: req.Vars,
Model: model,
})
if err != nil {
status, code := mapRunError(err)
writeError(w, status, code, err.Error())
return
}
writeJSON(w, http.StatusOK, runResponseDTO{
Artifact: artifactDTO{
Name: res.Artifact.Name,
ContentType: res.Artifact.ContentType,
Body: string(res.Artifact.Body),
URI: res.Artifact.URI,
Size: res.Artifact.Size,
Hash: res.Artifact.Hash,
},
Validation: res.Validation,
Metadata: metadataDTO{
ProfileID: res.ProfileID,
ProfileVersion: res.ProfileVersion,
ModelName: res.ModelName,
Endpoint: res.Endpoint,
InputHashes: res.InputHashes,
PromptHash: res.PromptHash,
Usage: res.Usage,
StartTime: res.StartTime,
EndTime: res.EndTime,
},
RawModelOutput: res.RawOutput,
})
}
func mapRunError(err error) (int, string) {
switch {
case errors.Is(err, profile.ErrProfileNotFound):
return http.StatusNotFound, "profile_not_found"
case errors.Is(err, usecase.ErrInvalidRequest):
return http.StatusBadRequest, "invalid_request"
case errors.Is(err, usecase.ErrProfileLoad):
return http.StatusBadRequest, "profile_load_failed"
case errors.Is(err, usecase.ErrArtifactLoad):
return http.StatusBadRequest, "artifact_read_failed"
case errors.Is(err, usecase.ErrPromptRender):
return http.StatusBadRequest, "prompt_render_failed"
case errors.Is(err, usecase.ErrLLMGenerate):
return http.StatusBadGateway, "llm_failed"
case errors.Is(err, usecase.ErrValidation):
return http.StatusInternalServerError, "validation_runtime_failed"
default:
return http.StatusInternalServerError, "internal_error"
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorResponse{
Error: errorBody{
Code: code,
Message: message,
},
})
}

View File

@@ -0,0 +1,179 @@
package httpadapter
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
"gitea.maximumdirect.net/eric/scriptorium/internal/usecase"
)
type fakeRunner struct {
result *domain.RunResult
err error
last domain.RunRequest
}
func (f *fakeRunner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunResult, error) {
f.last = req
if f.err != nil {
return nil, f.err
}
return f.result, nil
}
func TestHandlerPostRunsSuccess(t *testing.T) {
start := time.Now().UTC()
end := start.Add(2 * time.Second)
r := &fakeRunner{result: &domain.RunResult{
Artifact: domain.Artifact{
Name: "output",
ContentType: "text/plain",
Body: []byte("hello"),
Size: 5,
Hash: "abc",
},
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
ProfileID: "p1",
ProfileVersion: "1.0.0",
ModelName: "m1",
Endpoint: "http://llm/v1",
InputHashes: map[string]string{"transcript": "h1"},
PromptHash: "ph",
Usage: domain.TokenUsage{PromptTokens: 1, CompletionTokens: 2, TotalTokens: 3},
StartTime: start,
EndTime: end,
RawOutput: "hello",
}}
h := NewHandler(r)
body := []byte(`{
"profile_id": "p1",
"inputs": {
"transcript": {"type": "file", "uri": "./t.md"}
},
"vars": {"k": "v"},
"model": {"model": "gpt-x"}
}`)
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewReader(body))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
artifact := resp["artifact"].(map[string]any)
if artifact["body"] != "hello" {
t.Fatalf("expected artifact body hello, got %#v", artifact["body"])
}
if resp["raw_model_output"] != "hello" {
t.Fatalf("expected raw model output hello, got %#v", resp["raw_model_output"])
}
if r.last.ProfileID != "p1" {
t.Fatalf("expected request profile_id p1, got %q", r.last.ProfileID)
}
if r.last.Model == nil || r.last.Model.Model != "gpt-x" {
t.Fatalf("expected model override, got %#v", r.last.Model)
}
}
func TestHandlerInvalidJSON(t *testing.T) {
h := NewHandler(&fakeRunner{})
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString("{"))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestHandlerMissingProfileID(t *testing.T) {
h := NewHandler(&fakeRunner{})
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"inputs":{"x":{"type":"file","uri":"a"}}}`))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestHandlerUsecaseErrorMapping(t *testing.T) {
tests := []struct {
name string
err error
status int
}{
{name: "profile not found", err: wrap(usecase.ErrProfileLoad, profile.ErrProfileNotFound), status: http.StatusNotFound},
{name: "artifact", err: wrap(usecase.ErrArtifactLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest},
{name: "prompt", err: wrap(usecase.ErrPromptRender, fmt.Errorf("render failed")), status: http.StatusBadRequest},
{name: "llm", err: wrap(usecase.ErrLLMGenerate, fmt.Errorf("llm failed")), status: http.StatusBadGateway},
{name: "validation runtime", err: wrap(usecase.ErrValidation, fmt.Errorf("validator broke")), status: http.StatusInternalServerError},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
h := NewHandler(&fakeRunner{err: tc.err})
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"profile_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != tc.status {
t.Fatalf("expected %d, got %d body=%s", tc.status, w.Code, w.Body.String())
}
})
}
}
func TestHandlerValidationFailureStillSuccess(t *testing.T) {
h := NewHandler(&fakeRunner{result: &domain.RunResult{
Artifact: domain.Artifact{Body: []byte("bad json")},
RawOutput: "bad json",
Validation: domain.ValidationResult{
Status: domain.ValidationFailed,
Mode: domain.ValidationJSON,
Errors: []string{"invalid JSON"},
},
}})
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"profile_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
validation := resp["validation"].(map[string]any)
if status, ok := validation["Status"].(string); !ok || status != "failed" {
t.Fatalf("expected validation Status=failed, got %#v", validation["Status"])
}
}
func wrap(stage error, cause error) error {
return fmt.Errorf("%w: %w", stage, cause)
}