72de690894
Chatbot functionality * baseline working * missing test file * more tests
275 lines
9.5 KiB
Go
275 lines
9.5 KiB
Go
// Package queryapi: handler implementations for the BotService user
|
|
// routes. M2 covers session create/get/list and scope patch. Plan §3
|
|
// mandates singular `/client/{clientId}/...` routes; the OpenAPI spec
|
|
// declares them that way and the generated ServerInterface wires the
|
|
// path params into these handlers.
|
|
//
|
|
// Every handler:
|
|
// 1. Extracts the authenticated Cognito subject via cognitoauth.GetUserSubject.
|
|
// Returns 401 when the subject is missing.
|
|
// 2. Calls the bot.Service method with the (clientID, actor) tuple.
|
|
// 3. Maps the bot.Err* sentinels onto HTTP status codes via mapBotError.
|
|
// 4. Translates the service-layer Session into the generated
|
|
// BotSessionResponse / BotSessionListResponse.
|
|
package queryapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"queryorchestration/internal/bot"
|
|
"queryorchestration/internal/cognitoauth"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/oapi-codegen/nullable"
|
|
openapi_types "github.com/oapi-codegen/runtime/types"
|
|
)
|
|
|
|
const (
|
|
defaultBotListLimit = 20
|
|
defaultBotListOffset = 0
|
|
)
|
|
|
|
// CreateBotSession is POST /client/{clientId}/bot/sessions.
|
|
//
|
|
// Body is an empty CreateBotSessionRequest object (placeholder for
|
|
// future fields). The service mints the session id and timestamps;
|
|
// title remains empty until M4's first turn arrives.
|
|
func (s *Controllers) CreateBotSession(ctx echo.Context, clientId BotClientID) error {
|
|
actor, ok := cognitoauth.GetUserSubject(ctx)
|
|
if !ok {
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
|
|
}
|
|
session, err := s.svc.Bot.CreateSession(ctx.Request().Context(), clientId, actor)
|
|
if err != nil {
|
|
return mapBotError(err)
|
|
}
|
|
resp, err := sessionToResponse(session)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode session response")
|
|
}
|
|
return ctx.JSON(http.StatusCreated, resp)
|
|
}
|
|
|
|
// GetBotSession is GET /client/{clientId}/bot/sessions/{sessionId}.
|
|
// Owner-scoped read: returns 404 when the session does not match
|
|
// (clientId, actor, is_deleted=false).
|
|
func (s *Controllers) GetBotSession(ctx echo.Context, clientId BotClientID, sessionId BotSessionID) error {
|
|
actor, ok := cognitoauth.GetUserSubject(ctx)
|
|
if !ok {
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
|
|
}
|
|
session, err := s.svc.Bot.GetSessionForOwner(ctx.Request().Context(), uuid.UUID(sessionId), clientId, actor)
|
|
if err != nil {
|
|
return mapBotError(err)
|
|
}
|
|
resp, err := sessionToResponse(session)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode session response")
|
|
}
|
|
return ctx.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// ListBotSessions is GET /client/{clientId}/bot/sessions.
|
|
//
|
|
// Owner-scoped list, paged by limit/offset. Each item carries up to
|
|
// MinTurnLimit(limit, cfg.RecentTurns) preview turns.
|
|
func (s *Controllers) ListBotSessions(ctx echo.Context, clientId BotClientID, params ListBotSessionsParams) error {
|
|
actor, ok := cognitoauth.GetUserSubject(ctx)
|
|
if !ok {
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
|
|
}
|
|
limit, offset, err := resolveListBotSessionParams(params.Limit, params.Offset)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sessions, err := s.svc.Bot.ListSessionsForOwner(ctx.Request().Context(), clientId, actor, bot.ListSessionsInput{
|
|
Limit: limit,
|
|
Offset: offset,
|
|
})
|
|
if err != nil {
|
|
return mapBotError(err)
|
|
}
|
|
return ctx.JSON(http.StatusOK, sessionsToListResponse(sessions))
|
|
}
|
|
|
|
// PatchBotSessionScope is PATCH /client/{clientId}/bot/sessions/{sessionId}/scope.
|
|
func (s *Controllers) PatchBotSessionScope(ctx echo.Context, clientId BotClientID, sessionId BotSessionID) error {
|
|
actor, ok := cognitoauth.GetUserSubject(ctx)
|
|
if !ok {
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
|
|
}
|
|
var req BotSessionScopePatchRequest
|
|
if err := ctx.Bind(&req); err != nil {
|
|
slog.Error("failed to parse scope patch request", "error", err)
|
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
|
|
}
|
|
input := bot.PatchScopeInput{
|
|
AddDocuments: derefUUIDList(req.AddDocuments),
|
|
RemoveDocuments: derefUUIDList(req.RemoveDocuments),
|
|
AddFolders: derefUUIDList(req.AddFolders),
|
|
RemoveFolders: derefUUIDList(req.RemoveFolders),
|
|
}
|
|
session, err := s.svc.Bot.PatchScope(ctx.Request().Context(), uuid.UUID(sessionId), clientId, actor, input)
|
|
if err != nil {
|
|
return mapBotError(err)
|
|
}
|
|
resp, err := sessionToResponse(session)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode session response")
|
|
}
|
|
return ctx.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// resolveListBotSessionParams normalises and validates the optional
|
|
// limit/offset query params. Plan §3 mandates limit in [1, 200] and
|
|
// offset >= 0; this is the gate before the service layer.
|
|
func resolveListBotSessionParams(limitPtr, offsetPtr *int32) (int, int, error) {
|
|
limit := defaultBotListLimit
|
|
offset := defaultBotListOffset
|
|
if limitPtr != nil {
|
|
limit = int(*limitPtr)
|
|
}
|
|
if offsetPtr != nil {
|
|
offset = int(*offsetPtr)
|
|
}
|
|
if limit < 1 || limit > 200 {
|
|
return 0, 0, echo.NewHTTPError(http.StatusBadRequest, "limit must be in [1, 200]")
|
|
}
|
|
if offset < 0 {
|
|
return 0, 0, echo.NewHTTPError(http.StatusBadRequest, "offset must be >= 0")
|
|
}
|
|
return limit, offset, nil
|
|
}
|
|
|
|
// derefUUIDList unpacks the optional pointer-to-slice that oapi-codegen
|
|
// emits for nullable arrays into a plain slice. nil input returns a
|
|
// non-nil empty slice so service-layer dedup helpers see a consistent
|
|
// shape.
|
|
func derefUUIDList(in *[]openapi_types.UUID) []uuid.UUID {
|
|
if in == nil {
|
|
return []uuid.UUID{}
|
|
}
|
|
out := make([]uuid.UUID, 0, len(*in))
|
|
for _, id := range *in {
|
|
out = append(out, uuid.UUID(id))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mapBotError translates internal/bot sentinel errors into HTTP errors.
|
|
// M4 sentinels live in mapBotTurnError so the M2/M3 cases below stay
|
|
// short. Any unrecognized error is logged and surfaced as 500.
|
|
func mapBotError(err error) error {
|
|
if turnMapped := mapBotTurnError(err); turnMapped != nil {
|
|
return turnMapped
|
|
}
|
|
switch {
|
|
case errors.Is(err, bot.ErrSessionNotFound):
|
|
return echo.NewHTTPError(http.StatusNotFound, "session not found")
|
|
case errors.Is(err, bot.ErrAddRemoveConflict):
|
|
return echo.NewHTTPError(http.StatusBadRequest, "add_remove_conflict")
|
|
case errors.Is(err, bot.ErrDocumentCrossClient):
|
|
return echo.NewHTTPError(http.StatusBadRequest, "document_cross_client")
|
|
case errors.Is(err, bot.ErrFolderCrossClient):
|
|
return echo.NewHTTPError(http.StatusBadRequest, "folder_cross_client")
|
|
case errors.Is(err, bot.ErrInvalidLimit):
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
case errors.Is(err, bot.ErrInvalidOffset):
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
case errors.Is(err, bot.ErrMissingClientID):
|
|
return echo.NewHTTPError(http.StatusBadRequest, "clientId is required")
|
|
}
|
|
slog.Error("bot handler internal error", "error", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "internal server error")
|
|
}
|
|
|
|
// sessionToResponse converts the service-layer Session into the
|
|
// generated BotSessionResponse type. State is unmarshalled into a
|
|
// map[string]interface{} so the wire shape is a JSON object, not a
|
|
// base64 string. recentTurns is omitted from single-session reads
|
|
// (where the service did not populate them).
|
|
func sessionToResponse(s *bot.Session) (BotSessionResponse, error) {
|
|
stateMap := map[string]interface{}{}
|
|
if len(s.State) > 0 {
|
|
if err := json.Unmarshal(s.State, &stateMap); err != nil {
|
|
return BotSessionResponse{}, err
|
|
}
|
|
}
|
|
docs := make([]openapi_types.UUID, 0, len(s.Scope.DocumentIDs))
|
|
for _, id := range s.Scope.DocumentIDs {
|
|
docs = append(docs, openapi_types.UUID(id))
|
|
}
|
|
folders := make([]openapi_types.UUID, 0, len(s.Scope.FolderIDs))
|
|
for _, id := range s.Scope.FolderIDs {
|
|
folders = append(folders, openapi_types.UUID(id))
|
|
}
|
|
previews := previewsToResponse(s.RecentTurns)
|
|
if previews == nil {
|
|
previews = []BotTurnPreview{}
|
|
}
|
|
resp := BotSessionResponse{
|
|
Id: openapi_types.UUID(s.ID),
|
|
ClientId: s.ClientID,
|
|
CreatedBy: s.CreatedBy,
|
|
Title: s.Title,
|
|
State: stateMap,
|
|
LastTurn: s.LastTurn,
|
|
CreatedAt: s.CreatedAt,
|
|
UpdatedAt: s.UpdatedAt,
|
|
Scope: BotSessionScope{
|
|
DocumentIds: docs,
|
|
FolderIds: folders,
|
|
},
|
|
RecentTurns: previews,
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// previewsToResponse converts service-layer turn previews into the
|
|
// generated BotTurnPreview slice. Returns nil for empty input so
|
|
// callers can decide whether to substitute an empty slice.
|
|
func previewsToResponse(in []bot.TurnPreview) []BotTurnPreview {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]BotTurnPreview, 0, len(in))
|
|
for _, p := range in {
|
|
row := BotTurnPreview{
|
|
Ordinal: p.Ordinal,
|
|
Prompt: p.Prompt,
|
|
Status: BotTurnPreviewStatus(p.Status),
|
|
CreatedAt: p.CreatedAt,
|
|
}
|
|
if p.Completion != nil {
|
|
row.Completion = nullable.NewNullableWithValue(*p.Completion)
|
|
} else {
|
|
row.Completion = nullable.NewNullNullable[string]()
|
|
}
|
|
out = append(out, row)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sessionsToListResponse builds the wire shape for list endpoints. The
|
|
// list always emits a non-nil array even when empty.
|
|
func sessionsToListResponse(in []*bot.Session) BotSessionListResponse {
|
|
out := BotSessionListResponse{
|
|
Sessions: make([]BotSessionResponse, 0, len(in)),
|
|
}
|
|
for _, s := range in {
|
|
resp, err := sessionToResponse(s)
|
|
if err != nil {
|
|
// json.Marshal of an in-memory map[string]interface{}
|
|
// effectively cannot fail here; log and keep going.
|
|
slog.Error("encode session in list", "error", err, "session_id", s.ID)
|
|
continue
|
|
}
|
|
out.Sessions = append(out.Sessions, resp)
|
|
}
|
|
return out
|
|
}
|