package service import ( "fmt" "net/url" "strings" "github.com/krow/krow-backend/go-api/internal/authctx" "github.com/krow/krow-backend/go-api/internal/definition" "github.com/krow/krow-backend/go-api/internal/domain" "github.com/krow/krow-backend/go-api/internal/owliver" ) // allowedSuggestionParams names the accepted query parameters, on the pattern // of allowedDefinitionFilters: anything else is a caller mistake worth saying // out loud rather than a filter to ignore. It also keeps the endpoint from // quietly accepting a `role`, `org` or `user` parameter should one ever be // added by a client — who is asking is read from the session and nowhere else. var allowedSuggestionParams = map[string]bool{ "page": true, "query": true, } // maxEchoedPage bounds how much of a rejected page value is quoted back. Long // enough to name every real surface, short enough that the error cannot be used // to reflect a payload. const maxEchoedPage = 64 // SuggestionQuery is a validated suggestion request. // // Page is canonical: aliases are resolved here so nothing downstream has to // know that `hired` and `hired-history` are the same surface. type SuggestionQuery struct { Page string Query string } // SuggestionsService answers "what could I usefully ask on this page?". // // It holds no pool, opens no transaction and reads no table. That is not an // omission — the panel calls it while the user types, and everything it needs // is the static catalogue in internal/owliver plus the caller's role. It is a // service rather than a function in the handler so that validation and // authorization sit where every other endpoint's do. type SuggestionsService struct{} // NewSuggestions builds the suggestion service. func NewSuggestions() *SuggestionsService { return &SuggestionsService{} } // ParseParams validates the query string. // // `page` is required and must name a real surface — the same closed vocabulary // internal/definition validates a definition's `pages:` against, so there is // one answer to "is that a page" in this process. `query` is optional: an // absent or too-short one is not an error, it is a request that has nothing to // rank yet, and Suggest answers it with an empty list. func (s *SuggestionsService) ParseParams(q url.Values) (SuggestionQuery, error) { var out SuggestionQuery for name := range q { if !allowedSuggestionParams[name] { return out, domain.Invalid(fmt.Sprintf("unknown parameter %q", name)) } } raw := strings.TrimSpace(q.Get("page")) if raw == "" { return out, domain.Invalid("page is required") } page := definition.CanonicalPage(raw) if page == "" { echoed := raw if len(echoed) > maxEchoedPage { echoed = echoed[:maxEchoedPage] } return out, domain.Invalid(fmt.Sprintf( "Unsupported page: %s. Supported pages: %s.", echoed, strings.Join(definition.SupportedPages, ", "))) } out.Page = page out.Query = q.Get("query") return out, nil } // Suggest ranks the page's readings for this caller. // // The role comes off the session-resolved identity, exactly as Server.authorize // reads it, and an unrecognised role is offered nothing — the same deny-by- // default the policy table applies. Nothing else about the caller is consulted: // there is no branch here on organization, account type or anything a request // could set. // // No error case beyond parsing. A page with no readings for this caller, and a // query that matches none of them, both answer with an empty list — an empty // result is an answer, not a failure. func (s *SuggestionsService) Suggest(ident authctx.Identity, q SuggestionQuery) []owliver.Suggestion { role, known := domain.ParseRole(ident.Role) if !known { return []owliver.Suggestion{} } return owliver.Suggest(q.Page, q.Query, role) }