Merged in feature/chatbot1 (pull request #223)
Chatbot functionality * baseline working * missing test file * more tests
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user