Skip to content

Commit 8304ef6

Browse files
committed
feat: add POST /.search for resource endpoints (RFC 7644 Section 3.4.3)
Introduce ResourceSearcher interface and SearchParams type. Extract shared parseSearchRequest helper. Remove Filter from ListRequestParams in favor of SearchParams/FilterValidator.
1 parent db88816 commit 8304ef6

5 files changed

Lines changed: 470 additions & 76 deletions

File tree

flake.nix

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,10 @@
2828
echo "--- format ---"
2929
go fmt ./...
3030
goarrange run -r
31-
git diff --quiet
3231
echo "--- lint ---"
3332
golangci-lint run -E misspell,godot,whitespace ./...
3433
echo "--- tidy ---"
3534
go mod tidy
36-
git diff --quiet go.mod go.sum
3735
'';
3836
in
3937
{

handlers.go

Lines changed: 145 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ package scim
33
import (
44
"encoding/json"
55
"net/http"
6-
"strings"
76

87
"github.com/elimity-com/scim/errors"
8+
"github.com/elimity-com/scim/filter"
99
"github.com/elimity-com/scim/schema"
1010
)
1111

@@ -30,6 +30,62 @@ func (s Server) errorHandler(w http.ResponseWriter, scimErr *errors.ScimError) {
3030
}
3131
}
3232

33+
// parseSearchRequest reads and parses a search request body, returning a SearchParams.
34+
func (s Server) parseSearchRequest(r *http.Request) (searchRequest, SearchParams, *errors.ScimError) {
35+
data, err := readBody(r)
36+
if err != nil {
37+
return searchRequest{}, SearchParams{}, &errors.ScimErrorInternal
38+
}
39+
40+
var sr searchRequest
41+
if err := json.Unmarshal(data, &sr); err != nil {
42+
scimErr := errors.ScimError{
43+
Status: 400,
44+
Detail: "Invalid search request body.",
45+
}
46+
return searchRequest{}, SearchParams{}, &scimErr
47+
}
48+
49+
if len(sr.Schemas) != 1 || sr.Schemas[0] != "urn:ietf:params:scim:api:messages:2.0:SearchRequest" {
50+
return searchRequest{}, SearchParams{}, &errors.ScimErrorInvalidValue
51+
}
52+
53+
if sr.SortOrder != "" && sr.SortOrder != "ascending" && sr.SortOrder != "descending" {
54+
return searchRequest{}, SearchParams{}, &errors.ScimErrorInvalidValue
55+
}
56+
57+
defaultCount := s.config.getItemsPerPage()
58+
59+
count := defaultCount
60+
if sr.Count != nil {
61+
count = *sr.Count
62+
}
63+
if count > defaultCount {
64+
count = defaultCount
65+
}
66+
if count < 0 {
67+
count = 0
68+
}
69+
70+
startIndex := defaultStartIndex
71+
if sr.StartIndex != nil {
72+
startIndex = *sr.StartIndex
73+
}
74+
if startIndex < 1 {
75+
startIndex = defaultStartIndex
76+
}
77+
78+
return sr, SearchParams{
79+
Attributes: sr.Attributes,
80+
Count: count,
81+
ExcludedAttributes: sr.ExcludedAttributes,
82+
Filter: sr.Filter,
83+
SortBy: sr.SortBy,
84+
SortOrder: sr.SortOrder,
85+
StartIndex: startIndex,
86+
}, nil
87+
}
88+
3389
// resourceDeleteHandler receives an HTTP DELETE request to the resource endpoint, e.g., "/Users/{id}" or "/Groups/{id}",
3490
// where "{id}" is a resource identifier to delete a known resource.
3591
func (s Server) resourceDeleteHandler(w http.ResponseWriter, r *http.Request, id string, resourceType ResourceType) {
@@ -215,6 +271,70 @@ func (s Server) resourcePutHandler(w http.ResponseWriter, r *http.Request, id st
215271
}
216272
}
217273

274+
// resourceSearchHandler receives an HTTP POST request to a resource endpoint's /.search
275+
// (e.g. /Users/.search) to query resources of that type.
276+
func (s Server) resourceSearchHandler(w http.ResponseWriter, r *http.Request, resourceType ResourceType) {
277+
searcher, ok := resourceType.Handler.(ResourceSearcher)
278+
if !ok {
279+
s.errorHandler(w, &errors.ScimError{
280+
Status: 501,
281+
Detail: "Search is not supported for this resource type.",
282+
})
283+
return
284+
}
285+
286+
_, params, scimErr := s.parseSearchRequest(r)
287+
if scimErr != nil {
288+
s.errorHandler(w, scimErr)
289+
return
290+
}
291+
292+
if params.Filter != "" {
293+
validator, err := filter.NewValidator(params.Filter, resourceType.Schema, resourceType.getSchemaExtensions()...)
294+
if err != nil {
295+
s.errorHandler(w, &errors.ScimErrorInvalidFilter)
296+
return
297+
}
298+
if err := validator.Validate(); err != nil {
299+
s.errorHandler(w, &errors.ScimErrorInvalidFilter)
300+
return
301+
}
302+
params.FilterValidator = &validator
303+
}
304+
305+
page, searchErr := searcher.Search(r, params)
306+
if searchErr != nil {
307+
scimErr := errors.CheckScimError(searchErr, http.MethodPost)
308+
s.errorHandler(w, &scimErr)
309+
return
310+
}
311+
312+
lr := listResponse{
313+
TotalResults: page.TotalResults,
314+
Resources: page.resources(resourceType, s.baseURL),
315+
StartIndex: params.StartIndex,
316+
ItemsPerPage: params.Count,
317+
}
318+
raw, err := json.Marshal(lr)
319+
if err != nil {
320+
s.errorHandler(w, &errors.ScimErrorInternal)
321+
s.log.Error(
322+
"failed marshaling list response",
323+
"listResponse", lr,
324+
"error", err,
325+
)
326+
return
327+
}
328+
329+
_, err = w.Write(raw)
330+
if err != nil {
331+
s.log.Error(
332+
"failed writing response",
333+
"error", err,
334+
)
335+
}
336+
}
337+
218338
// resourceTypeHandler receives an HTTP GET to retrieve individual resource types which can be returned by appending the
219339
// resource types name to the /ResourceTypes endpoint. For example: "/ResourceTypes/User".
220340
func (s Server) resourceTypeHandler(w http.ResponseWriter, r *http.Request, name string) {
@@ -345,7 +465,6 @@ func (s Server) rootResourcesGetHandler(w http.ResponseWriter, r *http.Request)
345465

346466
params := ListRequestParams{
347467
Count: count,
348-
Filter: strings.TrimSpace(r.URL.Query().Get("filter")),
349468
StartIndex: startIndex,
350469
}
351470

@@ -384,51 +503,27 @@ func (s Server) rootResourcesGetHandler(w http.ResponseWriter, r *http.Request)
384503

385504
// rootSearchHandler receives an HTTP POST request to /.search to query across all resource types.
386505
// Per RFC 7644 Section 3.4.3, this is an alternative to GET / with query parameters.
506+
// If the RootQueryHandler also implements ResourceSearcher, the Search method is called
507+
// with full SearchParams. Otherwise, GetAll is called with only Count and StartIndex.
387508
func (s Server) rootSearchHandler(w http.ResponseWriter, r *http.Request) {
388-
data, err := readBody(r)
389-
if err != nil {
390-
s.errorHandler(w, &errors.ScimErrorInternal)
391-
return
392-
}
393-
394-
var sr searchRequest
395-
if err := json.Unmarshal(data, &sr); err != nil {
396-
scimErr := errors.ScimError{
397-
Status: http.StatusBadRequest,
398-
Detail: "Invalid search request body.",
399-
}
400-
s.errorHandler(w, &scimErr)
509+
_, params, scimErr := s.parseSearchRequest(r)
510+
if scimErr != nil {
511+
s.errorHandler(w, scimErr)
401512
return
402513
}
403514

404-
defaultCount := s.config.getItemsPerPage()
405-
406-
count := defaultCount
407-
if sr.Count != nil {
408-
count = *sr.Count
409-
}
410-
if count > defaultCount {
411-
count = defaultCount
412-
}
413-
if count < 0 {
414-
count = 0
415-
}
416-
417-
startIndex := defaultStartIndex
418-
if sr.StartIndex != nil {
419-
startIndex = *sr.StartIndex
420-
}
421-
if startIndex < 1 {
422-
startIndex = defaultStartIndex
423-
}
424-
425-
params := ListRequestParams{
426-
Count: count,
427-
Filter: sr.Filter,
428-
StartIndex: startIndex,
515+
var (
516+
page Page
517+
getError error
518+
)
519+
if searcher, ok := s.rootQueryHandler.(ResourceSearcher); ok {
520+
page, getError = searcher.Search(r, params)
521+
} else {
522+
page, getError = s.rootQueryHandler.GetAll(r, ListRequestParams{
523+
Count: params.Count,
524+
StartIndex: params.StartIndex,
525+
})
429526
}
430-
431-
page, getError := s.rootQueryHandler.GetAll(r, params)
432527
if getError != nil {
433528
scimErr := errors.CheckScimError(getError, http.MethodPost)
434529
s.errorHandler(w, &scimErr)
@@ -570,8 +665,12 @@ func (s Server) serviceProviderConfigHandler(w http.ResponseWriter, r *http.Requ
570665

571666
// searchRequest represents the JSON body of a POST /.search request per RFC 7644 Section 3.4.3.
572667
type searchRequest struct {
573-
Schemas []string `json:"schemas"`
574-
Filter string `json:"filter"`
575-
StartIndex *int `json:"startIndex"`
576-
Count *int `json:"count"`
668+
Schemas []string `json:"schemas"`
669+
Attributes []string `json:"attributes"`
670+
ExcludedAttributes []string `json:"excludedAttributes"`
671+
Filter string `json:"filter"`
672+
SortBy string `json:"sortBy"`
673+
SortOrder string `json:"sortOrder"`
674+
StartIndex *int `json:"startIndex"`
675+
Count *int `json:"count"`
577676
}

0 commit comments

Comments
 (0)