diff --git a/go.mod b/go.mod index 739be562..240187e7 100644 --- a/go.mod +++ b/go.mod @@ -58,6 +58,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/peterhellberg/link v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_golang v1.16.0 // indirect github.com/prometheus/client_model v0.4.0 // indirect diff --git a/go.sum b/go.sum index 6b3a8dbd..b501aed9 100644 --- a/go.sum +++ b/go.sum @@ -106,6 +106,8 @@ github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4 github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/peterhellberg/link v1.2.0 h1:UA5pg3Gp/E0F2WdX7GERiNrPQrM1K6CVJUUWfHa4t6c= +github.com/peterhellberg/link v1.2.0/go.mod h1:gYfAh+oJgQu2SrZHg5hROVRQe1ICoK0/HHJTcE0edxc= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/internal/cmd/server/start_deployment_manager_server.go b/internal/cmd/server/start_deployment_manager_server.go index 902d2144..b0811ad9 100644 --- a/internal/cmd/server/start_deployment_manager_server.go +++ b/internal/cmd/server/start_deployment_manager_server.go @@ -80,6 +80,11 @@ func DeploymentManagerServer() *cobra.Command { []string{}, "Extension to add to deployment managers.", ) + _ = flags.String( + externalAddressFlagName, + "", + "External address.", + ) return result } @@ -244,6 +249,21 @@ func (c *DeploymentManagerServerCommand) run(cmd *cobra.Command, argv []string) slog.Any("extensions", extensions), ) + // Get the external address: + externalAddress, err := flags.GetString(externalAddressFlagName) + if err != nil { + logger.Error( + "Failed to get external address flag", + slog.String("flag", externalAddressFlagName), + slog.String("error", err.Error()), + ) + return exit.Error(1) + } + logger.Info( + "External address", + slog.String("value", externalAddress), + ) + // Create the logging wrapper: loggingWrapper, err := logging.NewTransportWrapper(). SetLogger(logger). diff --git a/internal/service/adapter.go b/internal/service/adapter.go index c4590df5..43126b21 100644 --- a/internal/service/adapter.go +++ b/internal/service/adapter.go @@ -15,17 +15,24 @@ License. package service import ( + "bytes" "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" "errors" "fmt" "log/slog" "mime" "net/http" + neturl "net/url" "slices" "strings" "github.com/gorilla/mux" jsoniter "github.com/json-iterator/go" + "github.com/peterhellberg/link" "github.com/openshift-kni/oran-o2ims/internal/data" "github.com/openshift-kni/oran-o2ims/internal/logging" @@ -36,33 +43,41 @@ import ( // AdapterBuilder contains the data and logic needed to create adapters. Don't create instances of // this type directly, use the NewAdapter function instead. type AdapterBuilder struct { - logger *slog.Logger - pathVariables []string - handler any - includeFields []string - excludeFields []string + logger *slog.Logger + pathVariables []string + handler any + includeFields []string + excludeFields []string + externalAddress string + nextPageMarkerKey []byte + nextPageMarkerNonce []byte } // Adapter knows how to translate an HTTP request into a request for a collection of objects. Don't // create instances of this type directly, use the NewAdapter function instead. type Adapter struct { - logger *slog.Logger - pathVariables []string - listHandler ListHandler - getHandler GetHandler - addHandler AddHandler - deleteHandler DeleteHandler - includeFields []search.Path - excludeFields []search.Path - pathsParser *search.PathsParser - selectorParser *search.SelectorParser - projectorEvaluator *search.ProjectorEvaluator - jsonAPI jsoniter.API + logger *slog.Logger + pathVariables []string + listHandler ListHandler + getHandler GetHandler + addHandler AddHandler + deleteHandler DeleteHandler + includeFields []search.Path + excludeFields []search.Path + pathsParser *search.PathsParser + selectorParser *search.SelectorParser + projectorEvaluator *search.ProjectorEvaluator + externalAddress *neturl.URL + nextPageMarkerCipher cipher.AEAD + nextPageMarkerNonce []byte + jsonAPI jsoniter.API } // NewAdapter creates a builder that can be used to configure and create an adatper. func NewAdapter() *AdapterBuilder { - return &AdapterBuilder{} + return &AdapterBuilder{ + nextPageMarkerKey: slices.Clone(defaultAdapdterNextPageMarkerKey[:]), + } } // SetLogger sets the logger that the adapter will use to write to the log. This is mandatory. @@ -105,6 +120,32 @@ func (b *AdapterBuilder) SetExcludeFields(values ...string) *AdapterBuilder { return b } +// SetExternalAddress set the URL of the service as seen by external users. +func (b *AdapterBuilder) SetExternalAddress(value string) *AdapterBuilder { + b.externalAddress = value + return b +} + +// SetNextPageMarkerKey sets the key that is used to encrypt and decrypt the next page opaque +// tokens. The purpose of this encryption is to discourage clients from assuming the format of the +// marker. By default a hard coded key is used. That key is effectively public because it is part +// of the source code. There is usually no reason to change it, but you can use this method if you +// really need. +func (b *AdapterBuilder) SetNextPageMarkerKey(value []byte) *AdapterBuilder { + b.nextPageMarkerKey = slices.Clone(value) + return b +} + +// SetNextPageMarkerNonce sets the nonce that is used to encrypt and decrypt the next page opaque +// tokens. The purpose of this encryption is to discourage clients from assuming the format of the +// marker. By default a random nonce is usually each time that a marker is encrypted, but for unit +// tests it is convenient to be able to explicitly specify it, so that the resulting encrypted +// marker will be predictable. +func (b *AdapterBuilder) SetNextPageMarkerNonce(value []byte) *AdapterBuilder { + b.nextPageMarkerNonce = slices.Clone(value) + return b +} + // Build uses the data stored in the builder to create and configure a new adapter. func (b *AdapterBuilder) Build() (result *Adapter, err error) { // Check parameters: @@ -116,6 +157,20 @@ func (b *AdapterBuilder) Build() (result *Adapter, err error) { err = errors.New("handler is mandatory") return } + if b.nextPageMarkerKey == nil { + err = errors.New("next page marker key is mandatory") + return + } + + // Parse the external address once, so that when we need to use it later we only + // need to copy it. + var externalAddress *neturl.URL + if b.externalAddress != "" { + externalAddress, err = neturl.Parse(b.externalAddress) + if err != nil { + return + } + } // Check that the handler implements at least one of the handler interfaces: listHandler, _ := b.handler.(ListHandler) @@ -188,6 +243,12 @@ func (b *AdapterBuilder) Build() (result *Adapter, err error) { return } + // Create the cipher for the next page markers: + nextPageMarkerCipher, err := b.createNextPageMarkerCipher() + if err != nil { + return + } + // Prepare the JSON iterator API: jsonConfig := jsoniter.Config{ IndentionStep: 2, @@ -196,19 +257,54 @@ func (b *AdapterBuilder) Build() (result *Adapter, err error) { // Create and populate the object: result = &Adapter{ - logger: b.logger, - pathVariables: variables, - pathsParser: pathsParser, - listHandler: listHandler, - getHandler: getHandler, - addHandler: addHandler, - deleteHandler: deleteHandler, - includeFields: includePaths, - excludeFields: excludePaths, - selectorParser: selectorParser, - projectorEvaluator: projectorEvaluator, - jsonAPI: jsonAPI, + logger: b.logger, + pathVariables: variables, + pathsParser: pathsParser, + listHandler: listHandler, + getHandler: getHandler, + addHandler: addHandler, + deleteHandler: deleteHandler, + includeFields: includePaths, + excludeFields: excludePaths, + selectorParser: selectorParser, + projectorEvaluator: projectorEvaluator, + externalAddress: externalAddress, + nextPageMarkerCipher: nextPageMarkerCipher, + nextPageMarkerNonce: slices.Clone(b.nextPageMarkerNonce), + jsonAPI: jsonAPI, + } + return +} + +// createNextPageMarkerCipher creates the cipher that will be used to encrypt the next page +// markers. Currently this creates an AES cipher with Galois counter mode. +func (b *AdapterBuilder) createNextPageMarkerCipher() (result cipher.AEAD, err error) { + // Create the cipher: + blockCipher, err := aes.NewCipher(b.nextPageMarkerKey) + if err != nil { + return + } + gcmCipher, err := cipher.NewGCM(blockCipher) + if err != nil { + return + } + + // If the nonce has been explicitly specified then check that it has the required size: + if b.nextPageMarkerNonce != nil { + requiredSize := gcmCipher.NonceSize() + actualSize := len(b.nextPageMarkerNonce) + if actualSize != requiredSize { + err = fmt.Errorf( + "nonce has been explicitly specified, and it is %d bytes long, "+ + "but the cipher requires %d", + actualSize, requiredSize, + ) + return + } } + + // Return the cipher: + result = gcmCipher return } @@ -347,7 +443,7 @@ func (a *Adapter) serveList(w http.ResponseWriter, r *http.Request, pathVariable Variables: pathVariables, } - // Try to extract the selector and projector: + // Try to extract, projector and next page marker: var ok bool request.Selector, ok = a.extractSelector(w, r) if !ok { @@ -357,6 +453,10 @@ func (a *Adapter) serveList(w http.ResponseWriter, r *http.Request, pathVariable if !ok { return } + request.NextPageMarker, ok = a.extractNextPageMarker(w, r) + if !ok { + return + } // Call the handler: response, err := a.listHandler.List(ctx, request) @@ -385,6 +485,12 @@ func (a *Adapter) serveList(w http.ResponseWriter, r *http.Request, pathVariable ) } + // Set the next page marker: + ok = a.addNextPageMarker(w, r, response.NextPageMarker) + if !ok { + return + } + a.sendItems(ctx, w, items) } @@ -603,6 +709,183 @@ func (a *Adapter) extractProjector(w http.ResponseWriter, return } +// extractNextPageMarker tries to extract the next page marker from the `nextpage_opaque_marker` +// query parameter. It returns the marker and a flag indicating if it is okay to continue +// processing the request. When this flag is false the error response was already sent to the +// client, and request processing should stop. +func (a *Adapter) extractNextPageMarker(w http.ResponseWriter, r *http.Request) (result []byte, + ok bool) { + // Get the context: + ctx := r.Context() + + // Get the marker text: + text := r.URL.Query().Get(adapterNextPageOpaqueMarkerQueryParameterName) + if text == "" { + ok = true + return + } + + // Decrypt the marker: + data, err := a.decryptNextPageMarker(text) + if err != nil { + a.logger.ErrorContext( + ctx, + "Failed to decrypt next page marker", + slog.String("text", text), + slog.String("error", err.Error()), + ) + SendError( + w, + http.StatusBadRequest, + "Failed to decrypt next page marker '%s'", + text, + ) + ok = false + return + } + + // Return the value: + result = data + ok = true + return +} + +// setNextPageMarker generates the next page marker data and adds it to the response header as a +// link. It returns a flag indicating if it is okay to continue processing the request. When this +// flag is false the error response has already been sent to the client, and the request processing +// should stop. +func (a *Adapter) addNextPageMarker(w http.ResponseWriter, r *http.Request, value []byte) bool { + // Get the context: + ctx := r.Context() + + // Do nothing if there is no marker: + if value == nil { + return true + } + + // Encrypt the marker: + text, err := a.encryptNextPageMarker(value) + if err != nil { + a.logger.ErrorContext( + ctx, + "Failed to encrypt next page marker", + slog.Any("value", value), + slog.String("error", err.Error()), + ) + SendError( + w, + http.StatusInternalServerError, + "Failed to encrypt next page marker", + ) + return false + } + + // Generate the complete link, preserving existing query parameters and replacing any + // possible existing next page marker with the one that we just generated. + url := *r.URL + if a.externalAddress != nil { + url.Scheme = a.externalAddress.Scheme + url.Host = a.externalAddress.Host + } + url.Path = r.URL.Path + query := neturl.Values{} + for name, values := range r.URL.Query() { + if name != adapterNextPageOpaqueMarkerQueryParameterName { + query[name] = slices.Clone(values) + } + } + query.Set(adapterNextPageOpaqueMarkerQueryParameterName, text) + url.RawQuery = query.Encode() + + // Add the link to the header: + header := w.Header() + links := link.ParseHeader(header) + if links == nil { + links = link.Group{} + } + next, ok := links["next"] + if ok { + a.logger.WarnContext( + r.Context(), + "Next page link is already set, will replace it", + "link", next.String(), + "text", text, + ) + } + links["next"] = &link.Link{ + URI: url.String(), + Rel: "next", + } + buffer := &bytes.Buffer{} + i := 0 + for _, current := range links { + if i > 0 { + buffer.WriteString(", ") + } + fmt.Fprintf(buffer, `<%s>; rel="%s"`, current.URI, current.Rel) + for extraName, extraValue := range current.Extra { + buffer.WriteString(";") + fmt.Fprintf(buffer, `%s="%s"`, extraName, extraValue) + } + i++ + } + header.Set("Link", buffer.String()) + return true +} + +func (a *Adapter) encryptNextPageMarker(value []byte) (result string, err error) { + // If a nonce has been explicitly provided then use it, otherwise generate a random one: + var nonce []byte + if a.nextPageMarkerNonce != nil { + nonce = a.nextPageMarkerNonce + } else { + nonce = make([]byte, a.nextPageMarkerCipher.NonceSize()) + _, err = rand.Read(nonce) + if err != nil { + return + } + } + + // Encrypt the marker: + data := a.nextPageMarkerCipher.Seal(nil, nonce, value, nil) + + // We will need the nonce to decrypt, so the marker will contain the result of + // concatenating it with the encrypted data. + data = append(nonce, data...) + + // Encode the as text safe for use in a query parameter: + result = base64.RawURLEncoding.EncodeToString(data) + return +} + +func (a *Adapter) decryptNextPageMarker(text string) (result []byte, err error) { + // Decode the text: + data, err := base64.RawURLEncoding.DecodeString(text) + if err != nil { + return + } + + // The data should contain the nonce contatenated with the encrypted data, so it needs + // to be at least as long as the nonce size required by the cipher. + dataSize := len(data) + nonceSize := a.nextPageMarkerCipher.NonceSize() + if dataSize < nonceSize { + err = fmt.Errorf( + "marker size (%d bytes) is smaller than nonce size (%d bytes)", + dataSize, nonceSize, + ) + return + } + + // Separate the nonce and the encrypted data: + nonce := data[0:nonceSize] + data = data[nonceSize:] + + // Decrypt the data: + result, err = a.nextPageMarkerCipher.Open(nil, nonce, data, nil) + return +} + func (a *Adapter) sendItems(ctx context.Context, w http.ResponseWriter, items data.Stream) { w.Header().Set("Content-Type", "application/json") @@ -676,3 +959,18 @@ func (a *Adapter) sendObject(ctx context.Context, w http.ResponseWriter, ) } } + +// Names of query parameters: +const ( + adapterNextPageOpaqueMarkerQueryParameterName = "nextpage_opaque_marker" +) + +// defaultAdapterNextPageMarkerKey is the key used to encrypt and decrypt next page markers by +// default. It is OK to have it in clear text here because its only purpose is to discourage +// users from messing with the contents. +var defaultAdapdterNextPageMarkerKey = [...]byte{ + 0x44, 0x48, 0xb5, 0x2c, 0xa5, 0x11, 0x4e, 0xea, + 0x7e, 0x37, 0xcf, 0x85, 0x34, 0x5b, 0x5f, 0xd7, + 0x04, 0xc7, 0x37, 0x3a, 0x5b, 0x9f, 0x49, 0x63, + 0x54, 0xa9, 0xb0, 0xbd, 0x2d, 0x5e, 0xcc, 0x37, +} diff --git a/internal/service/adapter_test.go b/internal/service/adapter_test.go index 7c5391d2..7ebea36f 100644 --- a/internal/service/adapter_test.go +++ b/internal/service/adapter_test.go @@ -20,12 +20,14 @@ import ( "io" "net/http" "net/http/httptest" + neturl "net/url" "strings" "github.com/gorilla/mux" . "github.com/onsi/ginkgo/v2/dsl/core" . "github.com/onsi/ginkgo/v2/dsl/table" . "github.com/onsi/gomega" + "github.com/peterhellberg/link" "go.uber.org/mock/gomock" "github.com/openshift-kni/oran-o2ims/internal/data" @@ -678,6 +680,248 @@ var _ = Describe("Adapter", func() { }) }) + Describe("Paging", func() { + It("Adds next page marker link", func() { + // Prepare the handler: + body := func(ctx context.Context, + request *ListRequest) (response *ListResponse, err error) { + response = &ListResponse{ + Items: data.Pour(), + NextPageMarker: []byte("mymarker"), + } + return + } + handler := NewMockHandler(ctrl) + handler.EXPECT().List(gomock.Any(), gomock.Any()).DoAndReturn(body) + + // Create the adapter: + nonce := make([]byte, 12) + adapter, err := NewAdapter(). + SetLogger(logger). + SetPathVariables("id"). + SetHandler(handler). + SetNextPageMarkerNonce(nonce). + Build() + Expect(err).ToNot(HaveOccurred()) + + // Send the request: + request := httptest.NewRequest( + http.MethodGet, + "/mycollection", + nil, + ) + recorder := httptest.NewRecorder() + adapter.ServeHTTP(recorder, request) + + // Verify the response: + links := link.ParseHeader(recorder.Header()) + Expect(links).ToNot(BeEmpty()) + next := links["next"] + Expect(next).ToNot(BeNil()) + url, err := neturl.Parse(next.URI) + Expect(err).ToNot(HaveOccurred()) + query := url.Query() + text := query.Get("nextpage_opaque_marker") + Expect(text).To(Equal( + "AAAAAAAAAAAAAAAAft-pCX6aW3oPQfuRD8TT4mspJeJ9hXNc", + )) + }) + + It("Preserves existing links", func() { + // Prepare the handler: + body := func(ctx context.Context, + request *ListRequest) (response *ListResponse, err error) { + response = &ListResponse{ + Items: data.Pour(), + NextPageMarker: []byte("mymarker"), + } + return + } + handler := NewMockHandler(ctrl) + handler.EXPECT().List(gomock.Any(), gomock.Any()).DoAndReturn(body) + + // Create the adapter: + nonce := make([]byte, 12) + adapter, err := NewAdapter(). + SetLogger(logger). + SetPathVariables("id"). + SetHandler(handler). + SetNextPageMarkerNonce(nonce). + Build() + Expect(err).ToNot(HaveOccurred()) + + // Prepare an HTTP handler that adds a link to the header before calling + // the adapter: + linker := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Link", `; rel="icon"; color="red"`) + adapter.ServeHTTP(w, r) + }) + + // Send the request: + request := httptest.NewRequest( + http.MethodGet, + "/mycollection", + nil, + ) + recorder := httptest.NewRecorder() + linker.ServeHTTP(recorder, request) + + // Verify that the response contains both the new `next` link and the + // previous `icon` link: + links := link.ParseHeader(recorder.Header()) + Expect(links).ToNot(BeEmpty()) + Expect(links).To(HaveKey("next")) + Expect(links).To(HaveKey("icon")) + + // Verify that the previous `icon` link hasn't been modified: + icon := links["icon"] + Expect(icon.URI).To(Equal("http://icons.com/myicon")) + Expect(icon.Rel).To(Equal("icon")) + Expect(icon.Extra).To(HaveLen(1)) + Expect(icon.Extra).To(HaveKeyWithValue("color", "red")) + }) + + It("Uses provided external address", func() { + // Prepare the handler: + body := func(ctx context.Context, + request *ListRequest) (response *ListResponse, err error) { + response = &ListResponse{ + Items: data.Pour(), + NextPageMarker: []byte("mymarker"), + } + return + } + handler := NewMockHandler(ctrl) + handler.EXPECT().List(gomock.Any(), gomock.Any()).DoAndReturn(body) + + // Create the adapter: + adapter, err := NewAdapter(). + SetLogger(logger). + SetPathVariables("id"). + SetHandler(handler). + SetExternalAddress("https://myserver.com"). + Build() + Expect(err).ToNot(HaveOccurred()) + + // Send the request: + request := httptest.NewRequest( + http.MethodGet, + "/mycollection", + nil, + ) + recorder := httptest.NewRecorder() + adapter.ServeHTTP(recorder, request) + + // Verify that the link uses the external address: + links := link.ParseHeader(recorder.Header()) + next := links["next"] + Expect(next).ToNot(BeNil()) + url, err := neturl.Parse(next.URI) + Expect(err).ToNot(HaveOccurred()) + Expect(url.Scheme).To(Equal("https")) + Expect(url.Host).To(Equal("myserver.com")) + }) + + It("Doesn't add next page marker if not set", func() { + // Prepare the handler: + body := func(ctx context.Context, + request *ListRequest) (response *ListResponse, err error) { + response = &ListResponse{ + Items: data.Pour(), + } + return + } + handler := NewMockHandler(ctrl) + handler.EXPECT().List(gomock.Any(), gomock.Any()).DoAndReturn(body) + + // Create the adapter: + adapter, err := NewAdapter(). + SetLogger(logger). + SetPathVariables("id"). + SetHandler(handler). + Build() + Expect(err).ToNot(HaveOccurred()) + + // Send the request: + request := httptest.NewRequest( + http.MethodGet, + "/mycollection", + nil, + ) + recorder := httptest.NewRecorder() + adapter.ServeHTTP(recorder, request) + + // Verify that there is no `next` link: + links := link.ParseHeader(recorder.Header()) + Expect(links).ToNot(HaveKey("next")) + }) + + It("Extracts next page marker", func() { + // Prepare the handler: + body := func(ctx context.Context, + request *ListRequest) (response *ListResponse, err error) { + Expect(request.NextPageMarker).To(Equal([]byte("mymarker"))) + response = &ListResponse{ + Items: data.Pour(), + } + return + } + handler := NewMockHandler(ctrl) + handler.EXPECT().List(gomock.Any(), gomock.Any()).DoAndReturn(body) + + // Create the adapter: + nonce := make([]byte, 12) + adapter, err := NewAdapter(). + SetLogger(logger). + SetPathVariables("id"). + SetHandler(handler). + SetNextPageMarkerNonce(nonce). + Build() + Expect(err).ToNot(HaveOccurred()) + + // Send the request: + request := httptest.NewRequest( + http.MethodGet, + "/mycollection?nextpage_opaque_marker="+ + "AAAAAAAAAAAAAAAAft-pCX6aW3oPQfuRD8TT4mspJeJ9hXNc", + nil, + ) + recorder := httptest.NewRecorder() + adapter.ServeHTTP(recorder, request) + }) + + It("Doesn't extract next page marker if not set", func() { + // Prepare the handler: + body := func(ctx context.Context, + request *ListRequest) (response *ListResponse, err error) { + Expect(request.NextPageMarker).To(BeNil()) + response = &ListResponse{ + Items: data.Pour(), + } + return + } + handler := NewMockHandler(ctrl) + handler.EXPECT().List(gomock.Any(), gomock.Any()).DoAndReturn(body) + + // Create the adapter: + adapter, err := NewAdapter(). + SetLogger(logger). + SetPathVariables("id"). + SetHandler(handler). + Build() + Expect(err).ToNot(HaveOccurred()) + + // Send the request: + request := httptest.NewRequest( + http.MethodGet, + "/mycollection", + nil, + ) + recorder := httptest.NewRecorder() + adapter.ServeHTTP(recorder, request) + }) + }) + Describe("Object projection", func() { It("Accepts projector with one field", func() { // Prepare the handler: diff --git a/internal/service/handlers.go b/internal/service/handlers.go index 6c7ba111..6c0e606c 100644 --- a/internal/service/handlers.go +++ b/internal/service/handlers.go @@ -47,11 +47,20 @@ type ListRequest struct { // Projector is the list of field paths to return. Projector *search.Projector + + // NextPageMarker contains the next page marker extracted from the `nextpage_opaque_marker` + // of the request. + NextPageMarker []byte } // ListResponse represents the response to the request to get the list of items of a collection. type ListResponse struct { + // Items is the stream of results. Items data.Stream + + // NextPageMarker is the information that will be added to the next page marker link. It + // encrypted and added to the `next` link in the response header. + NextPageMarker []byte } // ListHandler is the interface implemented by objects that know how to get list