diff --git a/.openapi-generator/FILES b/.openapi-generator/FILES index 26f3192..a3c05a2 100644 --- a/.openapi-generator/FILES +++ b/.openapi-generator/FILES @@ -68,6 +68,8 @@ docs/RelationshipCondition.md docs/SourceInfo.md docs/Status.md docs/Store.md +docs/StreamResultOfStreamedListObjectsResponse.md +docs/StreamedListObjectsResponse.md docs/Tuple.md docs/TupleChange.md docs/TupleKey.md @@ -157,6 +159,8 @@ model_relationship_condition.go model_source_info.go model_status.go model_store.go +model_stream_result_of_streamed_list_objects_response.go +model_streamed_list_objects_response.go model_tuple.go model_tuple_change.go model_tuple_key.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 46c2039..83e982d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,10 @@ - feat: add generic `ToPtr[T any](v T) *T` function for creating pointers to any type - deprecation: `PtrBool`, `PtrInt`, `PtrInt32`, `PtrInt64`, `PtrFloat32`, `PtrFloat64`, `PtrString`, and `PtrTime` are now deprecated in favor of the generic `ToPtr` function - feat: add a top-level makefile in go-sdk to simplify running tests and linters: (#250) - +- feat: add support for StreamedListObjects endpoint (#252) ## v0.7.3 -### [0.7.3](https://github.com/openfga/go-sdk/compare/v0.7.2...v0.7.3) +### [0.7.3](https://github.com/openfga/go-sdk/compare/v0.7.2...v0.7.3) (2025-10-08) - feat: add support for custom headers per request. See [documentation](https://github.com/openfga/go-sdk#custom-headers). - feat: add support for conflict options for Write operations**: (#229) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb0c695..e2e23f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ Reading and following these guidelines will help us make the contribution proces * [Getting Started](#getting-started) * [Making Changes](#making-changes) * [Opening Issues](#opening-issues) - * [Submitting Pull Requests](#submitting-pull-requests) [Note: We are not accepting Pull Requests at this time!] + * [Submitting Pull Requests](#submitting-pull-requests) * [Getting in Touch](#getting-in-touch) * [Have a question or problem?](#have-a-question-or-problem) * [Vulnerability Reporting](#vulnerability-reporting) diff --git a/README.md b/README.md index c81e159..103f257 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ This is an autogenerated Go SDK for OpenFGA. It provides a wrapper around the [O - [Batch Check](#batch-check) - [Expand](#expand) - [List Objects](#list-objects) + - [Streamed List Objects](#streamed-list-objects) - [List Relations](#list-relations) - [List Users](#list-users) - [Assertions](#assertions) @@ -912,6 +913,50 @@ data, err := fgaClient.ListObjects(context.Background()). // data.Objects = ["document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a"] ``` +##### Streamed List Objects + +List objects of a particular type that the user has access to, using the streaming API. + +The Streamed ListObjects API is very similar to the ListObjects API, with two key differences: +1. **Streaming Results**: Instead of collecting all objects before returning a response, it streams them to the client as they are collected. +2. **No Pagination Limit**: Returns all results without the 1000-object limit of the standard ListObjects API. + +This is particularly useful when querying **computed relations** that may return large result sets. + +[API Documentation](https://openfga.dev/api/service#/Relationship%20Queries/StreamedListObjects) + +```golang +options := ClientStreamedListObjectsOptions{ + // You can rely on the model id set in the configuration or override it for this specific request + AuthorizationModelId: openfga.PtrString("01GAHCE4YVKPQEKZQHT2R89MQV"), +} + +body := ClientStreamedListObjectsRequest{ + User: "user:anne", + Relation: "can_read", + Type: "document", +} + +response, err := fgaClient.StreamedListObjects(context.Background()).Body(body).Options(options).Execute() +if err != nil { + // .. Handle error +} +defer response.Close() + +// Consume objects from the stream +var objects []string +for obj := range response.Objects { + objects = append(objects, obj.Object) +} + +// Check for any errors during streaming +if err := <-response.Errors; err != nil { + // .. Handle streaming error +} + +// objects = ["document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a"] +``` + #### List Relations List the relations a user has on an object. @@ -1101,6 +1146,7 @@ Class | Method | HTTP request | Description *OpenFgaApi* | [**ReadAuthorizationModel**](docs/OpenFgaApi.md#readauthorizationmodel) | **Get** /stores/{store_id}/authorization-models/{id} | Return a particular version of an authorization model *OpenFgaApi* | [**ReadAuthorizationModels**](docs/OpenFgaApi.md#readauthorizationmodels) | **Get** /stores/{store_id}/authorization-models | Return all the authorization models for a particular store *OpenFgaApi* | [**ReadChanges**](docs/OpenFgaApi.md#readchanges) | **Get** /stores/{store_id}/changes | Return a list of all the tuple changes +*OpenFgaApi* | [**StreamedListObjects**](docs/OpenFgaApi.md#streamedlistobjects) | **Post** /stores/{store_id}/streamed-list-objects | Stream all objects of the given type that the user has a relation with *OpenFgaApi* | [**Write**](docs/OpenFgaApi.md#write) | **Post** /stores/{store_id}/write | Add or delete tuples from the store *OpenFgaApi* | [**WriteAssertions**](docs/OpenFgaApi.md#writeassertions) | **Put** /stores/{store_id}/assertions/{authorization_model_id} | Upsert assertions for an authorization model ID *OpenFgaApi* | [**WriteAuthorizationModel**](docs/OpenFgaApi.md#writeauthorizationmodel) | **Post** /stores/{store_id}/authorization-models | Create a new authorization model @@ -1166,6 +1212,8 @@ Class | Method | HTTP request | Description - [SourceInfo](docs/SourceInfo.md) - [Status](docs/Status.md) - [Store](docs/Store.md) + - [StreamResultOfStreamedListObjectsResponse](docs/StreamResultOfStreamedListObjectsResponse.md) + - [StreamedListObjectsResponse](docs/StreamedListObjectsResponse.md) - [Tuple](docs/Tuple.md) - [TupleChange](docs/TupleChange.md) - [TupleKey](docs/TupleKey.md) diff --git a/api_client.go b/api_client.go index eec5cdf..51b891f 100644 --- a/api_client.go +++ b/api_client.go @@ -1,493 +1,499 @@ package openfga import ( - "bytes" - "context" - "encoding/json" - "encoding/xml" - "errors" - "fmt" - "io" - "log" - "net/http" - "net/http/httputil" - "net/url" - "os" - "reflect" - "regexp" - "strings" - "time" - "unicode/utf8" - - "github.com/openfga/go-sdk/internal/utils/retryutils" - "github.com/openfga/go-sdk/telemetry" + "bytes" + "context" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "reflect" + "regexp" + "strings" + "time" + "unicode/utf8" + + "github.com/openfga/go-sdk/internal/utils/retryutils" + "github.com/openfga/go-sdk/telemetry" ) var ( - jsonCheck = regexp.MustCompile(`(?i:(?:application|text)/(?:vnd\.[^;]+\+)?json)`) - xmlCheck = regexp.MustCompile(`(?i:(?:application|text)/xml)`) + jsonCheck = regexp.MustCompile(`(?i:(?:application|text)/(?:vnd\.[^;]+\+)?json)`) + xmlCheck = regexp.MustCompile(`(?i:(?:application|text)/xml)`) ) // ErrorResponse defines the error that will be asserted by FGA API. // This will only be used for error that is not defined type ErrorResponse struct { - Code string `json:"code"` - Message string `json:"message"` + Code string `json:"code"` + Message string `json:"message"` } // APIClient manages communication with the OpenFGA API v1.x // In most cases there should be only one, shared, APIClient. type APIClient struct { - cfg *Configuration - common service // Reuse a single struct instead of allocating one for each service on the heap. + cfg *Configuration + common service // Reuse a single struct instead of allocating one for each service on the heap. - // API Services + // API Services - OpenFgaApi OpenFgaApi + OpenFgaApi OpenFgaApi } type service struct { - client *APIClient - RetryParams *RetryParams + client *APIClient + RetryParams *RetryParams } // NewAPIClient creates a new API client. Requires a userAgent string describing your application. // optionally a custom http.Client to allow for advanced features such as caching. func NewAPIClient(cfg *Configuration) *APIClient { - if cfg.Telemetry == nil { - cfg.Telemetry = telemetry.DefaultTelemetryConfiguration() - } - if cfg.HTTPClient == nil { - if cfg.Credentials == nil { - cfg.HTTPClient = http.DefaultClient - } else { - cfg.Credentials.Context = context.Background() - telemetry.Bind(cfg.Credentials.Context, telemetry.Get(telemetry.TelemetryFactoryParameters{Configuration: cfg.Telemetry})) - var httpClient, headers = cfg.Credentials.GetHttpClientAndHeaderOverrides(retryutils.GetRetryParamsOrDefault(cfg.RetryParams), cfg.Debug) - if len(headers) > 0 { - for idx := range headers { - cfg.AddDefaultHeader(headers[idx].Key, headers[idx].Value) - } - } - if httpClient != nil { - cfg.HTTPClient = httpClient - } - } - } - - c := &APIClient{} - c.cfg = cfg - c.common.client = c - c.common.RetryParams = cfg.RetryParams - - // API Services - c.OpenFgaApi = (*OpenFgaApiService)(&c.common) - - return c + if cfg.Telemetry == nil { + cfg.Telemetry = telemetry.DefaultTelemetryConfiguration() + } + if cfg.HTTPClient == nil { + if cfg.Credentials == nil { + cfg.HTTPClient = http.DefaultClient + } else { + cfg.Credentials.Context = context.Background() + telemetry.Bind(cfg.Credentials.Context, telemetry.Get(telemetry.TelemetryFactoryParameters{Configuration: cfg.Telemetry})) + var httpClient, headers = cfg.Credentials.GetHttpClientAndHeaderOverrides(retryutils.GetRetryParamsOrDefault(cfg.RetryParams), cfg.Debug) + if len(headers) > 0 { + for idx := range headers { + cfg.AddDefaultHeader(headers[idx].Key, headers[idx].Value) + } + } + if httpClient != nil { + cfg.HTTPClient = httpClient + } + } + } + + c := &APIClient{} + c.cfg = cfg + c.common.client = c + c.common.RetryParams = cfg.RetryParams + + // API Services + c.OpenFgaApi = (*OpenFgaApiService)(&c.common) + + return c } // selectHeaderContentType select a content type from the available list. func selectHeaderContentType(contentTypes []string) string { - if len(contentTypes) == 0 { - return "" - } - if contains(contentTypes, "application/json") { - return "application/json" - } - return contentTypes[0] // use the first content type specified in 'consumes' + if len(contentTypes) == 0 { + return "" + } + if contains(contentTypes, "application/json") { + return "application/json" + } + return contentTypes[0] // use the first content type specified in 'consumes' } // selectHeaderAccept join all accept types and return func selectHeaderAccept(accepts []string) string { - if len(accepts) == 0 { - return "" - } + if len(accepts) == 0 { + return "" + } - if contains(accepts, "application/json") { - return "application/json" - } + if contains(accepts, "application/json") { + return "application/json" + } - return strings.Join(accepts, ",") + return strings.Join(accepts, ",") } // contains is a case insensitive match, finding needle in a haystack func contains(haystack []string, needle string) bool { - loweredNeedle := strings.ToLower(needle) - for _, a := range haystack { - if strings.ToLower(a) == loweredNeedle { - return true - } - } - return false + loweredNeedle := strings.ToLower(needle) + for _, a := range haystack { + if strings.ToLower(a) == loweredNeedle { + return true + } + } + return false } // Verify optional parameters are of the correct type. func typeCheckParameter(obj interface{}, expected string, name string) error { - // Make sure there is an object. - if obj == nil { - return nil - } - - // Check the type is as expected. - if reflect.TypeOf(obj).String() != expected { - return fmt.Errorf("expected %s to be of type %s but received %s", name, expected, reflect.TypeOf(obj).String()) - } - return nil + // Make sure there is an object. + if obj == nil { + return nil + } + + // Check the type is as expected. + if reflect.TypeOf(obj).String() != expected { + return fmt.Errorf("expected %s to be of type %s but received %s", name, expected, reflect.TypeOf(obj).String()) + } + return nil } // parameterToString convert interface{} parameters to string, using a delimiter if format is provided. func parameterToString(obj interface{}, collectionFormat string) string { - var delimiter string - - switch collectionFormat { - case "pipes": - delimiter = "|" - case "ssv": - delimiter = " " - case "tsv": - delimiter = "\t" - case "csv": - delimiter = "," - } - - if reflect.TypeOf(obj).Kind() == reflect.Slice { - return strings.Trim(strings.ReplaceAll(fmt.Sprint(obj), " ", delimiter), "[]") - } else if t, ok := obj.(time.Time); ok { - return t.Format(time.RFC3339) - } - - return fmt.Sprintf("%v", obj) + var delimiter string + + switch collectionFormat { + case "pipes": + delimiter = "|" + case "ssv": + delimiter = " " + case "tsv": + delimiter = "\t" + case "csv": + delimiter = "," + } + + if reflect.TypeOf(obj).Kind() == reflect.Slice { + return strings.Trim(strings.ReplaceAll(fmt.Sprint(obj), " ", delimiter), "[]") + } else if t, ok := obj.(time.Time); ok { + return t.Format(time.RFC3339) + } + + return fmt.Sprintf("%v", obj) } // helper for converting interface{} parameters to json strings func parameterToJson(obj interface{}) (string, error) { - jsonBuf, err := json.Marshal(obj) - if err != nil { - return "", err - } - return string(jsonBuf), err + jsonBuf, err := json.Marshal(obj) + if err != nil { + return "", err + } + return string(jsonBuf), err } // callAPI do the request. func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { - if c.cfg.Debug { - dump, err := httputil.DumpRequestOut(request, true) - if err != nil { - return nil, err - } - log.Printf("\n%s\n", string(dump)) - } - - resp, err := c.cfg.HTTPClient.Do(request) - if err != nil { - if resp != nil && resp.Request == nil { - resp.Request = request - } - - return resp, err - } - - if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) - if err != nil { - return resp, err - } - log.Printf("\n%s\n", string(dump)) - } - - if resp.Request == nil { - resp.Request = request - } - - return resp, err + if c.cfg.Debug { + dump, err := httputil.DumpRequestOut(request, true) + if err != nil { + return nil, err + } + log.Printf("\n%s\n", string(dump)) + } + + resp, err := c.cfg.HTTPClient.Do(request) + if err != nil { + if resp != nil && resp.Request == nil { + resp.Request = request + } + + return resp, err + } + + if c.cfg.Debug { + // for debugging, don't dump the body for streaming resp. as it would buffer the entire response + // only dump headers + isStreamingResponse := resp.Header.Get("Content-Type") == "application/x-ndjson" + dump, err := httputil.DumpResponse(resp, !isStreamingResponse) + if err != nil { + return resp, err + } + log.Printf("\n%s\n", string(dump)) + if isStreamingResponse { + log.Printf("Streaming response body - not dumped to preserve streaming\n") + } + } + + if resp.Request == nil { + resp.Request = request + } + + return resp, err } // Allow modification of underlying config for alternate implementations and testing // Caution: modifying the configuration while live can cause data races and potentially unwanted behavior func (c *APIClient) GetConfig() *Configuration { - return c.cfg + return c.cfg } // prepareRequest build the request func (c *APIClient) prepareRequest( - ctx context.Context, - path string, method string, - postBody interface{}, - headerParams map[string]string, - queryParams url.Values) (localVarRequest *http.Request, err error) { - - var body *bytes.Buffer - - // Detect postBody type and post. - if postBody != nil { - contentType := headerParams["Content-Type"] - if contentType == "" { - contentType = detectContentType(postBody) - headerParams["Content-Type"] = contentType - } - - body, err = setBody(postBody, contentType) - if err != nil { - return nil, err - } - } - - // Setup path and query parameters - uri, err := url.Parse(c.cfg.ApiUrl + path) - if err != nil { - return nil, err - } - - // Adding Query Param - query := uri.Query() - for k, v := range queryParams { - for _, iv := range v { - query.Add(k, iv) - } - } - - // Encode the parameters. - uri.RawQuery = query.Encode() - - // Generate a new request - if body != nil { - localVarRequest, err = http.NewRequest(method, uri.String(), body) - } else { - localVarRequest, err = http.NewRequest(method, uri.String(), nil) - } - if err != nil { - return nil, err - } - - // add header parameters, if any - if len(headerParams) > 0 { - headers := http.Header{} - for h, v := range headerParams { - headers.Set(h, v) - } - localVarRequest.Header = headers - } - - // Add the user agent to the request. - localVarRequest.Header.Set("User-Agent", c.cfg.UserAgent) - - for header, value := range c.cfg.DefaultHeaders { - if localVarRequest.Header.Get(header) == "" { - localVarRequest.Header.Set(header, value) - } - } - - if ctx != nil { - // add context to the request - localVarRequest = localVarRequest.WithContext(ctx) - } - - return localVarRequest, nil + ctx context.Context, + path string, method string, + postBody interface{}, + headerParams map[string]string, + queryParams url.Values) (localVarRequest *http.Request, err error) { + + var body *bytes.Buffer + + // Detect postBody type and post. + if postBody != nil { + contentType := headerParams["Content-Type"] + if contentType == "" { + contentType = detectContentType(postBody) + headerParams["Content-Type"] = contentType + } + + body, err = setBody(postBody, contentType) + if err != nil { + return nil, err + } + } + + // Setup path and query parameters + uri, err := url.Parse(c.cfg.ApiUrl + path) + if err != nil { + return nil, err + } + + // Adding Query Param + query := uri.Query() + for k, v := range queryParams { + for _, iv := range v { + query.Add(k, iv) + } + } + + // Encode the parameters. + uri.RawQuery = query.Encode() + + // Generate a new request + if body != nil { + localVarRequest, err = http.NewRequest(method, uri.String(), body) + } else { + localVarRequest, err = http.NewRequest(method, uri.String(), nil) + } + if err != nil { + return nil, err + } + + // add header parameters, if any + if len(headerParams) > 0 { + headers := http.Header{} + for h, v := range headerParams { + headers.Set(h, v) + } + localVarRequest.Header = headers + } + + // Add the user agent to the request. + localVarRequest.Header.Set("User-Agent", c.cfg.UserAgent) + + for header, value := range c.cfg.DefaultHeaders { + if localVarRequest.Header.Get(header) == "" { + localVarRequest.Header.Set(header, value) + } + } + + if ctx != nil { + // add context to the request + localVarRequest = localVarRequest.WithContext(ctx) + } + + return localVarRequest, nil } func (c *APIClient) decode(v interface{}, b []byte, contentType string) (err error) { - if len(b) == 0 { - return nil - } - if s, ok := v.(*string); ok { - *s = string(b) - return nil - } - if xmlCheck.MatchString(contentType) { - if err = xml.Unmarshal(b, v); err != nil { - return err - } - return nil - } - if jsonCheck.MatchString(contentType) { - if actualObj, ok := v.(interface{ GetActualInstance() interface{} }); ok { // oneOf, anyOf schemas - if unmarshalObj, ok := actualObj.(interface{ UnmarshalJSON([]byte) error }); ok { // make sure it has UnmarshalJSON defined - if err = unmarshalObj.UnmarshalJSON(b); err != nil { - return err - } - } else { - return errors.New("unknown type with GetActualInstance but no unmarshalObj.UnmarshalJSON defined") - } - } else if err = json.Unmarshal(b, v); err != nil { // simple model - return err - } - return nil - } - return errors.New("undefined response type") + if len(b) == 0 { + return nil + } + if s, ok := v.(*string); ok { + *s = string(b) + return nil + } + if xmlCheck.MatchString(contentType) { + if err = xml.Unmarshal(b, v); err != nil { + return err + } + return nil + } + if jsonCheck.MatchString(contentType) { + if actualObj, ok := v.(interface{ GetActualInstance() interface{} }); ok { // oneOf, anyOf schemas + if unmarshalObj, ok := actualObj.(interface{ UnmarshalJSON([]byte) error }); ok { // make sure it has UnmarshalJSON defined + if err = unmarshalObj.UnmarshalJSON(b); err != nil { + return err + } + } else { + return errors.New("unknown type with GetActualInstance but no unmarshalObj.UnmarshalJSON defined") + } + } else if err = json.Unmarshal(b, v); err != nil { // simple model + return err + } + return nil + } + return errors.New("undefined response type") } func (c *APIClient) handleAPIError(httpResponse *http.Response, responseBody []byte, requestBody interface{}, operationName string, storeId string) error { - switch httpResponse.StatusCode { - case http.StatusBadRequest, http.StatusUnprocessableEntity: - err := NewFgaApiValidationError(operationName, requestBody, httpResponse, responseBody, storeId) - var v ValidationErrorMessageResponse - errBody := c.decode(&v, responseBody, httpResponse.Header.Get("Content-Type")) - if errBody != nil { - err.modelDecodeError = err - return err - } - err.model = v - err.responseCode = v.GetCode() - err.error += " with error code " + string(v.GetCode()) + " error message: " + v.GetMessage() - return err - - case http.StatusUnauthorized, http.StatusForbidden: - return NewFgaApiAuthenticationError(operationName, requestBody, httpResponse, responseBody, storeId) - - case http.StatusNotFound: - err := NewFgaApiNotFoundError(operationName, requestBody, httpResponse, responseBody, storeId) - var v PathUnknownErrorMessageResponse - errBody := c.decode(&v, responseBody, httpResponse.Header.Get("Content-Type")) - if errBody != nil { - err.modelDecodeError = err - return err - } - err.model = v - err.responseCode = v.GetCode() - err.error += " with error code " + string(v.GetCode()) + " error message: " + v.GetMessage() - return err - - case http.StatusTooManyRequests: - return NewFgaApiRateLimitExceededError(operationName, requestBody, httpResponse, responseBody, storeId) - - default: - if httpResponse.StatusCode >= http.StatusInternalServerError { - err := NewFgaApiInternalError(operationName, requestBody, httpResponse, responseBody, storeId) - var v InternalErrorMessageResponse - errBody := c.decode(&v, responseBody, httpResponse.Header.Get("Content-Type")) - if errBody != nil { - err.modelDecodeError = err - return err - } - err.model = v - err.responseCode = v.GetCode() - err.error += " with error code " + string(v.GetCode()) + " error message: " + v.GetMessage() - return err - } - - err := NewFgaApiError(operationName, requestBody, httpResponse, responseBody, storeId) - var v ErrorResponse - errBody := c.decode(&v, responseBody, httpResponse.Header.Get("Content-Type")) - if errBody != nil { - err.modelDecodeError = err - return err - } - err.model = v - err.responseCode = v.Code - err.error += " with error code " + string(v.Code) + " error message: " + v.Message - return err - } + switch httpResponse.StatusCode { + case http.StatusBadRequest, http.StatusUnprocessableEntity: + err := NewFgaApiValidationError(operationName, requestBody, httpResponse, responseBody, storeId) + var v ValidationErrorMessageResponse + errBody := c.decode(&v, responseBody, httpResponse.Header.Get("Content-Type")) + if errBody != nil { + err.modelDecodeError = err + return err + } + err.model = v + err.responseCode = v.GetCode() + err.error += " with error code " + string(v.GetCode()) + " error message: " + v.GetMessage() + return err + + case http.StatusUnauthorized, http.StatusForbidden: + return NewFgaApiAuthenticationError(operationName, requestBody, httpResponse, responseBody, storeId) + + case http.StatusNotFound: + err := NewFgaApiNotFoundError(operationName, requestBody, httpResponse, responseBody, storeId) + var v PathUnknownErrorMessageResponse + errBody := c.decode(&v, responseBody, httpResponse.Header.Get("Content-Type")) + if errBody != nil { + err.modelDecodeError = err + return err + } + err.model = v + err.responseCode = v.GetCode() + err.error += " with error code " + string(v.GetCode()) + " error message: " + v.GetMessage() + return err + + case http.StatusTooManyRequests: + return NewFgaApiRateLimitExceededError(operationName, requestBody, httpResponse, responseBody, storeId) + + default: + if httpResponse.StatusCode >= http.StatusInternalServerError { + err := NewFgaApiInternalError(operationName, requestBody, httpResponse, responseBody, storeId) + var v InternalErrorMessageResponse + errBody := c.decode(&v, responseBody, httpResponse.Header.Get("Content-Type")) + if errBody != nil { + err.modelDecodeError = err + return err + } + err.model = v + err.responseCode = v.GetCode() + err.error += " with error code " + string(v.GetCode()) + " error message: " + v.GetMessage() + return err + } + + err := NewFgaApiError(operationName, requestBody, httpResponse, responseBody, storeId) + var v ErrorResponse + errBody := c.decode(&v, responseBody, httpResponse.Header.Get("Content-Type")) + if errBody != nil { + err.modelDecodeError = err + return err + } + err.model = v + err.responseCode = v.Code + err.error += " with error code " + string(v.Code) + " error message: " + v.Message + return err + } } // Prevent trying to import "fmt" func reportError(format string, a ...interface{}) error { - return fmt.Errorf(format, a...) + return fmt.Errorf(format, a...) } // Set request body from an interface{} func setBody(body interface{}, contentType string) (bodyBuf *bytes.Buffer, err error) { - if bodyBuf == nil { - bodyBuf = &bytes.Buffer{} - } - - if reader, ok := body.(io.Reader); ok { - _, err = bodyBuf.ReadFrom(reader) - } else if fp, ok := body.(**os.File); ok { - _, err = bodyBuf.ReadFrom(*fp) - } else if b, ok := body.([]byte); ok { - _, err = bodyBuf.Write(b) - } else if s, ok := body.(string); ok { - _, err = bodyBuf.WriteString(s) - } else if s, ok := body.(*string); ok { - _, err = bodyBuf.WriteString(*s) - } else if jsonCheck.MatchString(contentType) { - err = json.NewEncoder(bodyBuf).Encode(body) - } else if xmlCheck.MatchString(contentType) { - err = xml.NewEncoder(bodyBuf).Encode(body) - } - - if err != nil { - return nil, err - } - - if bodyBuf.Len() == 0 { - err = fmt.Errorf("invalid body type %s", contentType) - return nil, err - } - return bodyBuf, nil + if bodyBuf == nil { + bodyBuf = &bytes.Buffer{} + } + + if reader, ok := body.(io.Reader); ok { + _, err = bodyBuf.ReadFrom(reader) + } else if fp, ok := body.(**os.File); ok { + _, err = bodyBuf.ReadFrom(*fp) + } else if b, ok := body.([]byte); ok { + _, err = bodyBuf.Write(b) + } else if s, ok := body.(string); ok { + _, err = bodyBuf.WriteString(s) + } else if s, ok := body.(*string); ok { + _, err = bodyBuf.WriteString(*s) + } else if jsonCheck.MatchString(contentType) { + err = json.NewEncoder(bodyBuf).Encode(body) + } else if xmlCheck.MatchString(contentType) { + err = xml.NewEncoder(bodyBuf).Encode(body) + } + + if err != nil { + return nil, err + } + + if bodyBuf.Len() == 0 { + err = fmt.Errorf("invalid body type %s", contentType) + return nil, err + } + return bodyBuf, nil } // detectContentType method is used to figure out `Request.Body` content type for request header func detectContentType(body interface{}) string { - contentType := "text/plain; charset=utf-8" - kind := reflect.TypeOf(body).Kind() - - switch kind { - case reflect.Struct, reflect.Map, reflect.Ptr: - contentType = "application/json; charset=utf-8" - case reflect.String: - contentType = "text/plain; charset=utf-8" - default: - if b, ok := body.([]byte); ok { - contentType = http.DetectContentType(b) - } else if kind == reflect.Slice { - contentType = "application/json; charset=utf-8" - } - } - - return contentType + contentType := "text/plain; charset=utf-8" + kind := reflect.TypeOf(body).Kind() + + switch kind { + case reflect.Struct, reflect.Map, reflect.Ptr: + contentType = "application/json; charset=utf-8" + case reflect.String: + contentType = "text/plain; charset=utf-8" + default: + if b, ok := body.([]byte); ok { + contentType = http.DetectContentType(b) + } else if kind == reflect.Slice { + contentType = "application/json; charset=utf-8" + } + } + + return contentType } // Ripped from https://github.com/gregjones/httpcache/blob/master/httpcache.go type cacheControl map[string]string func parseCacheControl(headers http.Header) cacheControl { - cc := cacheControl{} - ccHeader := headers.Get("Cache-Control") - for _, part := range strings.Split(ccHeader, ",") { - part = strings.Trim(part, " ") - if part == "" { - continue - } - if strings.ContainsRune(part, '=') { - keyval := strings.Split(part, "=") - cc[strings.Trim(keyval[0], " ")] = strings.Trim(keyval[1], ",") - } else { - cc[part] = "" - } - } - return cc + cc := cacheControl{} + ccHeader := headers.Get("Cache-Control") + for _, part := range strings.Split(ccHeader, ",") { + part = strings.Trim(part, " ") + if part == "" { + continue + } + if strings.ContainsRune(part, '=') { + keyval := strings.Split(part, "=") + cc[strings.Trim(keyval[0], " ")] = strings.Trim(keyval[1], ",") + } else { + cc[part] = "" + } + } + return cc } // CacheExpires helper function to determine remaining time before repeating a request. func CacheExpires(r *http.Response) time.Time { - // Figure out when the cache expires. - var expires time.Time - now, err := time.Parse(time.RFC1123, r.Header.Get("date")) - if err != nil { - return time.Now() - } - respCacheControl := parseCacheControl(r.Header) - - if maxAge, ok := respCacheControl["max-age"]; ok { - lifetime, err := time.ParseDuration(maxAge + "s") - if err != nil { - expires = now - } else { - expires = now.Add(lifetime) - } - } else { - expiresHeader := r.Header.Get("Expires") - if expiresHeader != "" { - expires, err = time.Parse(time.RFC1123, expiresHeader) - if err != nil { - expires = now - } - } - } - return expires + // Figure out when the cache expires. + var expires time.Time + now, err := time.Parse(time.RFC1123, r.Header.Get("date")) + if err != nil { + return time.Now() + } + respCacheControl := parseCacheControl(r.Header) + + if maxAge, ok := respCacheControl["max-age"]; ok { + lifetime, err := time.ParseDuration(maxAge + "s") + if err != nil { + expires = now + } else { + expires = now.Add(lifetime) + } + } else { + expiresHeader := r.Header.Get("Expires") + if expiresHeader != "" { + expires, err = time.Parse(time.RFC1123, expiresHeader) + if err != nil { + expires = now + } + } + } + return expires } func strlen(s string) int { - return utf8.RuneCountInString(s) + return utf8.RuneCountInString(s) } diff --git a/api_open_fga.go b/api_open_fga.go index 309b182..d2f17f2 100644 --- a/api_open_fga.go +++ b/api_open_fga.go @@ -39,62 +39,62 @@ type RequestOptions struct { type OpenFgaApi interface { /* - * BatchCheck Send a list of `check` operations in a single request - * The `BatchCheck` API functions nearly identically to `Check`, but instead of checking a single user-object relationship BatchCheck accepts a list of relationships to check and returns a map containing `BatchCheckItem` response for each check it received. - - An associated `correlation_id` is required for each check in the batch. This ID is used to correlate a check to the appropriate response. It is a string consisting of only alphanumeric characters or hyphens with a maximum length of 36 characters. This `correlation_id` is used to map the result of each check to the item which was checked, so it must be unique for each item in the batch. We recommend using a UUID or ULID as the `correlation_id`, but you can use whatever unique identifier you need as long as it matches this regex pattern: `^[\w\d-]{1,36}$` - - NOTE: The maximum number of checks that can be passed in the `BatchCheck` API is configurable via the [OPENFGA_MAX_CHECKS_PER_BATCH_CHECK](https://openfga.dev/docs/getting-started/setup-openfga/configuration#OPENFGA_MAX_CHECKS_PER_BATCH_CHECK) environment variable. If `BatchCheck` is called using the SDK, the SDK can split the batch check requests for you. - - For more details on how `Check` functions, see the docs for `/check`. - - ### Examples - #### A BatchCheckRequest - ```json - { - "checks": [ - { - "tuple_key": { - "object": "document:2021-budget" - "relation": "reader", - "user": "user:anne", - }, - "contextual_tuples": {...} - "context": {} - "correlation_id": "01JA8PM3QM7VBPGB8KMPK8SBD5" - }, - { - "tuple_key": { - "object": "document:2021-budget" - "relation": "reader", - "user": "user:bob", - }, - "contextual_tuples": {...} - "context": {} - "correlation_id": "01JA8PMM6A90NV5ET0F28CYSZQ" - } - ] - } - ``` - - Below is a possible response to the above request. Note that the result map's keys are the `correlation_id` values from the checked items in the request: - ```json - { - "result": { - "01JA8PMM6A90NV5ET0F28CYSZQ": { - "allowed": false, - "error": {"message": ""} - }, - "01JA8PM3QM7VBPGB8KMPK8SBD5": { - "allowed": true, - "error": {"message": ""} - } - } - ``` + * BatchCheck Send a list of `check` operations in a single request + * The `BatchCheck` API functions nearly identically to `Check`, but instead of checking a single user-object relationship BatchCheck accepts a list of relationships to check and returns a map containing `BatchCheckItem` response for each check it received. + + An associated `correlation_id` is required for each check in the batch. This ID is used to correlate a check to the appropriate response. It is a string consisting of only alphanumeric characters or hyphens with a maximum length of 36 characters. This `correlation_id` is used to map the result of each check to the item which was checked, so it must be unique for each item in the batch. We recommend using a UUID or ULID as the `correlation_id`, but you can use whatever unique identifier you need as long as it matches this regex pattern: `^[\w\d-]{1,36}$` + + NOTE: The maximum number of checks that can be passed in the `BatchCheck` API is configurable via the [OPENFGA_MAX_CHECKS_PER_BATCH_CHECK](https://openfga.dev/docs/getting-started/setup-openfga/configuration#OPENFGA_MAX_CHECKS_PER_BATCH_CHECK) environment variable. If `BatchCheck` is called using the SDK, the SDK can split the batch check requests for you. + + For more details on how `Check` functions, see the docs for `/check`. + + ### Examples + #### A BatchCheckRequest + ```json + { + "checks": [ + { + "tuple_key": { + "object": "document:2021-budget" + "relation": "reader", + "user": "user:anne", + }, + "contextual_tuples": {...} + "context": {} + "correlation_id": "01JA8PM3QM7VBPGB8KMPK8SBD5" + }, + { + "tuple_key": { + "object": "document:2021-budget" + "relation": "reader", + "user": "user:bob", + }, + "contextual_tuples": {...} + "context": {} + "correlation_id": "01JA8PMM6A90NV5ET0F28CYSZQ" + } + ] + } + ``` + + Below is a possible response to the above request. Note that the result map's keys are the `correlation_id` values from the checked items in the request: + ```json + { + "result": { + "01JA8PMM6A90NV5ET0F28CYSZQ": { + "allowed": false, + "error": {"message": ""} + }, + "01JA8PM3QM7VBPGB8KMPK8SBD5": { + "allowed": true, + "error": {"message": ""} + } + } + ``` - * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). - * @param storeId - * @return ApiBatchCheckRequest + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @param storeId + * @return ApiBatchCheckRequest */ BatchCheck(ctx context.Context, storeId string) ApiBatchCheckRequest @@ -105,129 +105,129 @@ type OpenFgaApi interface { BatchCheckExecute(r ApiBatchCheckRequest) (BatchCheckResponse, *http.Response, error) /* - * Check Check whether a user is authorized to access an object - * The Check API returns whether a given user has a relationship with a given object in a given store. - The `user` field of the request can be a specific target, such as `user:anne`, or a userset (set of users) such as `group:marketing#member` or a type-bound public access `user:*`. - To arrive at a result, the API uses: an authorization model, explicit tuples written through the Write API, contextual tuples present in the request, and implicit tuples that exist by virtue of applying set theory (such as `document:2021-budget#viewer@document:2021-budget#viewer`; the set of users who are viewers of `document:2021-budget` are the set of users who are the viewers of `document:2021-budget`). - A `contextual_tuples` object may also be included in the body of the request. This object contains one field `tuple_keys`, which is an array of tuple keys. Each of these tuples may have an associated `condition`. - You may also provide an `authorization_model_id` in the body. This will be used to assert that the input `tuple_key` is valid for the model specified. If not specified, the assertion will be made against the latest authorization model ID. It is strongly recommended to specify authorization model id for better performance. - You may also provide a `context` object that will be used to evaluate the conditioned tuples in the system. It is strongly recommended to provide a value for all the input parameters of all the conditions, to ensure that all tuples be evaluated correctly. - By default, the Check API caches results for a short time to optimize performance. You may specify a value of `HIGHER_CONSISTENCY` for the optional `consistency` parameter in the body to inform the server that higher conisistency is preferred at the expense of increased latency. Consideration should be given to the increased latency if requesting higher consistency. - The response will return whether the relationship exists in the field `allowed`. - - Some exceptions apply, but in general, if a Check API responds with `{allowed: true}`, then you can expect the equivalent ListObjects query to return the object, and viceversa. - For example, if `Check(user:anne, reader, document:2021-budget)` responds with `{allowed: true}`, then `ListObjects(user:anne, reader, document)` may include `document:2021-budget` in the response. - ## Examples - ### Querying with contextual tuples - In order to check if user `user:anne` of type `user` has a `reader` relationship with object `document:2021-budget` given the following contextual tuple - ```json - { - "user": "user:anne", - "relation": "member", - "object": "time_slot:office_hours" - } - ``` - the Check API can be used with the following request body: - ```json - { - "tuple_key": { - "user": "user:anne", - "relation": "reader", - "object": "document:2021-budget" - }, - "contextual_tuples": { - "tuple_keys": [ - { - "user": "user:anne", - "relation": "member", - "object": "time_slot:office_hours" - } - ] - }, - "authorization_model_id": "01G50QVV17PECNVAHX1GG4Y5NC" - } - ``` - ### Querying usersets - Some Checks will always return `true`, even without any tuples. For example, for the following authorization model - ```python - model - schema 1.1 - type user - type document - relations - define reader: [user] - ``` - the following query - ```json - { - "tuple_key": { - "user": "document:2021-budget#reader", - "relation": "reader", - "object": "document:2021-budget" - } - } - ``` - will always return `{ "allowed": true }`. This is because usersets are self-defining: the userset `document:2021-budget#reader` will always have the `reader` relation with `document:2021-budget`. - ### Querying usersets with difference in the model - A Check for a userset can yield results that must be treated carefully if the model involves difference. For example, for the following authorization model - ```python - model - schema 1.1 - type user - type group - relations - define member: [user] - type document - relations - define blocked: [user] - define reader: [group#member] but not blocked - ``` - the following query - ```json - { - "tuple_key": { - "user": "group:finance#member", - "relation": "reader", - "object": "document:2021-budget" - }, - "contextual_tuples": { - "tuple_keys": [ - { - "user": "user:anne", - "relation": "member", - "object": "group:finance" - }, - { - "user": "group:finance#member", - "relation": "reader", - "object": "document:2021-budget" - }, - { - "user": "user:anne", - "relation": "blocked", - "object": "document:2021-budget" - } - ] - }, - } - ``` - will return `{ "allowed": true }`, even though a specific user of the userset `group:finance#member` does not have the `reader` relationship with the given object. - ### Requesting higher consistency - By default, the Check API caches results for a short time to optimize performance. You may request higher consistency to inform the server that higher consistency should be preferred at the expense of increased latency. Care should be taken when requesting higher consistency due to the increased latency. - ```json - { - "tuple_key": { - "user": "group:finance#member", - "relation": "reader", - "object": "document:2021-budget" - }, - "consistency": "HIGHER_CONSISTENCY" - } - ``` + * Check Check whether a user is authorized to access an object + * The Check API returns whether a given user has a relationship with a given object in a given store. + The `user` field of the request can be a specific target, such as `user:anne`, or a userset (set of users) such as `group:marketing#member` or a type-bound public access `user:*`. + To arrive at a result, the API uses: an authorization model, explicit tuples written through the Write API, contextual tuples present in the request, and implicit tuples that exist by virtue of applying set theory (such as `document:2021-budget#viewer@document:2021-budget#viewer`; the set of users who are viewers of `document:2021-budget` are the set of users who are the viewers of `document:2021-budget`). + A `contextual_tuples` object may also be included in the body of the request. This object contains one field `tuple_keys`, which is an array of tuple keys. Each of these tuples may have an associated `condition`. + You may also provide an `authorization_model_id` in the body. This will be used to assert that the input `tuple_key` is valid for the model specified. If not specified, the assertion will be made against the latest authorization model ID. It is strongly recommended to specify authorization model id for better performance. + You may also provide a `context` object that will be used to evaluate the conditioned tuples in the system. It is strongly recommended to provide a value for all the input parameters of all the conditions, to ensure that all tuples be evaluated correctly. + By default, the Check API caches results for a short time to optimize performance. You may specify a value of `HIGHER_CONSISTENCY` for the optional `consistency` parameter in the body to inform the server that higher conisistency is preferred at the expense of increased latency. Consideration should be given to the increased latency if requesting higher consistency. + The response will return whether the relationship exists in the field `allowed`. + + Some exceptions apply, but in general, if a Check API responds with `{allowed: true}`, then you can expect the equivalent ListObjects query to return the object, and viceversa. + For example, if `Check(user:anne, reader, document:2021-budget)` responds with `{allowed: true}`, then `ListObjects(user:anne, reader, document)` may include `document:2021-budget` in the response. + ## Examples + ### Querying with contextual tuples + In order to check if user `user:anne` of type `user` has a `reader` relationship with object `document:2021-budget` given the following contextual tuple + ```json + { + "user": "user:anne", + "relation": "member", + "object": "time_slot:office_hours" + } + ``` + the Check API can be used with the following request body: + ```json + { + "tuple_key": { + "user": "user:anne", + "relation": "reader", + "object": "document:2021-budget" + }, + "contextual_tuples": { + "tuple_keys": [ + { + "user": "user:anne", + "relation": "member", + "object": "time_slot:office_hours" + } + ] + }, + "authorization_model_id": "01G50QVV17PECNVAHX1GG4Y5NC" + } + ``` + ### Querying usersets + Some Checks will always return `true`, even without any tuples. For example, for the following authorization model + ```python + model + schema 1.1 + type user + type document + relations + define reader: [user] + ``` + the following query + ```json + { + "tuple_key": { + "user": "document:2021-budget#reader", + "relation": "reader", + "object": "document:2021-budget" + } + } + ``` + will always return `{ "allowed": true }`. This is because usersets are self-defining: the userset `document:2021-budget#reader` will always have the `reader` relation with `document:2021-budget`. + ### Querying usersets with difference in the model + A Check for a userset can yield results that must be treated carefully if the model involves difference. For example, for the following authorization model + ```python + model + schema 1.1 + type user + type group + relations + define member: [user] + type document + relations + define blocked: [user] + define reader: [group#member] but not blocked + ``` + the following query + ```json + { + "tuple_key": { + "user": "group:finance#member", + "relation": "reader", + "object": "document:2021-budget" + }, + "contextual_tuples": { + "tuple_keys": [ + { + "user": "user:anne", + "relation": "member", + "object": "group:finance" + }, + { + "user": "group:finance#member", + "relation": "reader", + "object": "document:2021-budget" + }, + { + "user": "user:anne", + "relation": "blocked", + "object": "document:2021-budget" + } + ] + }, + } + ``` + will return `{ "allowed": true }`, even though a specific user of the userset `group:finance#member` does not have the `reader` relationship with the given object. + ### Requesting higher consistency + By default, the Check API caches results for a short time to optimize performance. You may request higher consistency to inform the server that higher consistency should be preferred at the expense of increased latency. Care should be taken when requesting higher consistency due to the increased latency. + ```json + { + "tuple_key": { + "user": "group:finance#member", + "relation": "reader", + "object": "document:2021-budget" + }, + "consistency": "HIGHER_CONSISTENCY" + } + ``` - * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). - * @param storeId - * @return ApiCheckRequest + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @param storeId + * @return ApiCheckRequest */ Check(ctx context.Context, storeId string) ApiCheckRequest @@ -266,169 +266,169 @@ type OpenFgaApi interface { DeleteStoreExecute(r ApiDeleteStoreRequest) (*http.Response, error) /* - * Expand Expand all relationships in userset tree format, and following userset rewrite rules. Useful to reason about and debug a certain relationship - * The Expand API will return all users and usersets that have certain relationship with an object in a certain store. - This is different from the `/stores/{store_id}/read` API in that both users and computed usersets are returned. - Body parameters `tuple_key.object` and `tuple_key.relation` are all required. - A `contextual_tuples` object may also be included in the body of the request. This object contains one field `tuple_keys`, which is an array of tuple keys. Each of these tuples may have an associated `condition`. - The response will return a tree whose leaves are the specific users and usersets. Union, intersection and difference operator are located in the intermediate nodes. - - ## Example - To expand all users that have the `reader` relationship with object `document:2021-budget`, use the Expand API with the following request body - ```json - { - "tuple_key": { - "object": "document:2021-budget", - "relation": "reader" - }, - "authorization_model_id": "01G50QVV17PECNVAHX1GG4Y5NC" - } - ``` - OpenFGA's response will be a userset tree of the users and usersets that have read access to the document. - ```json - { - "tree":{ - "root":{ - "type":"document:2021-budget#reader", - "union":{ - "nodes":[ - { - "type":"document:2021-budget#reader", - "leaf":{ - "users":{ - "users":[ - "user:bob" - ] - } - } - }, - { - "type":"document:2021-budget#reader", - "leaf":{ - "computed":{ - "userset":"document:2021-budget#writer" - } - } - } - ] - } - } - } - } - ``` - The caller can then call expand API for the `writer` relationship for the `document:2021-budget`. - ### Expand Request with Contextual Tuples - - Given the model - ```python - model - schema 1.1 - - type user - - type folder - relations - define owner: [user] - - type document - relations - define parent: [folder] - define viewer: [user] or writer - define writer: [user] or owner from parent - ``` - and the initial tuples - ```json - [{ - "user": "user:bob", - "relation": "owner", - "object": "folder:1" - }] - ``` - - To expand all `writers` of `document:1` when `document:1` is put in `folder:1`, the first call could be - - ```json - { - "tuple_key": { - "object": "document:1", - "relation": "writer" - }, - "contextual_tuples": { - "tuple_keys": [ - { - "user": "folder:1", - "relation": "parent", - "object": "document:1" - } - ] - } - } - ``` - this returns: - ```json - { - "tree": { - "root": { - "name": "document:1#writer", - "union": { - "nodes": [ - { - "name": "document:1#writer", - "leaf": { - "users": { - "users": [] - } - } - }, - { - "name": "document:1#writer", - "leaf": { - "tupleToUserset": { - "tupleset": "document:1#parent", - "computed": [ - { - "userset": "folder:1#owner" - } - ] - } - } - } - ] - } - } - } - } - ``` - This tells us that the `owner` of `folder:1` may also be a writer. So our next call could be to find the `owners` of `folder:1` - ```json - { - "tuple_key": { - "object": "folder:1", - "relation": "owner" - } - } - ``` - which gives - ```json - { - "tree": { - "root": { - "name": "folder:1#owner", - "leaf": { - "users": { - "users": [ - "user:bob" - ] - } - } - } - } - } - ``` + * Expand Expand all relationships in userset tree format, and following userset rewrite rules. Useful to reason about and debug a certain relationship + * The Expand API will return all users and usersets that have certain relationship with an object in a certain store. + This is different from the `/stores/{store_id}/read` API in that both users and computed usersets are returned. + Body parameters `tuple_key.object` and `tuple_key.relation` are all required. + A `contextual_tuples` object may also be included in the body of the request. This object contains one field `tuple_keys`, which is an array of tuple keys. Each of these tuples may have an associated `condition`. + The response will return a tree whose leaves are the specific users and usersets. Union, intersection and difference operator are located in the intermediate nodes. + + ## Example + To expand all users that have the `reader` relationship with object `document:2021-budget`, use the Expand API with the following request body + ```json + { + "tuple_key": { + "object": "document:2021-budget", + "relation": "reader" + }, + "authorization_model_id": "01G50QVV17PECNVAHX1GG4Y5NC" + } + ``` + OpenFGA's response will be a userset tree of the users and usersets that have read access to the document. + ```json + { + "tree":{ + "root":{ + "type":"document:2021-budget#reader", + "union":{ + "nodes":[ + { + "type":"document:2021-budget#reader", + "leaf":{ + "users":{ + "users":[ + "user:bob" + ] + } + } + }, + { + "type":"document:2021-budget#reader", + "leaf":{ + "computed":{ + "userset":"document:2021-budget#writer" + } + } + } + ] + } + } + } + } + ``` + The caller can then call expand API for the `writer` relationship for the `document:2021-budget`. + ### Expand Request with Contextual Tuples + + Given the model + ```python + model + schema 1.1 + + type user + + type folder + relations + define owner: [user] + + type document + relations + define parent: [folder] + define viewer: [user] or writer + define writer: [user] or owner from parent + ``` + and the initial tuples + ```json + [{ + "user": "user:bob", + "relation": "owner", + "object": "folder:1" + }] + ``` + + To expand all `writers` of `document:1` when `document:1` is put in `folder:1`, the first call could be + + ```json + { + "tuple_key": { + "object": "document:1", + "relation": "writer" + }, + "contextual_tuples": { + "tuple_keys": [ + { + "user": "folder:1", + "relation": "parent", + "object": "document:1" + } + ] + } + } + ``` + this returns: + ```json + { + "tree": { + "root": { + "name": "document:1#writer", + "union": { + "nodes": [ + { + "name": "document:1#writer", + "leaf": { + "users": { + "users": [] + } + } + }, + { + "name": "document:1#writer", + "leaf": { + "tupleToUserset": { + "tupleset": "document:1#parent", + "computed": [ + { + "userset": "folder:1#owner" + } + ] + } + } + } + ] + } + } + } + } + ``` + This tells us that the `owner` of `folder:1` may also be a writer. So our next call could be to find the `owners` of `folder:1` + ```json + { + "tuple_key": { + "object": "folder:1", + "relation": "owner" + } + } + ``` + which gives + ```json + { + "tree": { + "root": { + "name": "folder:1#owner", + "leaf": { + "users": { + "users": [ + "user:bob" + ] + } + } + } + } + } + ``` - * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). - * @param storeId - * @return ApiExpandRequest + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @param storeId + * @return ApiExpandRequest */ Expand(ctx context.Context, storeId string) ApiExpandRequest @@ -454,19 +454,19 @@ type OpenFgaApi interface { GetStoreExecute(r ApiGetStoreRequest) (GetStoreResponse, *http.Response, error) /* - * ListObjects List all objects of the given type that the user has a relation with - * The ListObjects API returns a list of all the objects of the given type that the user has a relation with. - To arrive at a result, the API uses: an authorization model, explicit tuples written through the Write API, contextual tuples present in the request, and implicit tuples that exist by virtue of applying set theory (such as `document:2021-budget#viewer@document:2021-budget#viewer`; the set of users who are viewers of `document:2021-budget` are the set of users who are the viewers of `document:2021-budget`). - An `authorization_model_id` may be specified in the body. If it is not specified, the latest authorization model ID will be used. It is strongly recommended to specify authorization model id for better performance. - You may also specify `contextual_tuples` that will be treated as regular tuples. Each of these tuples may have an associated `condition`. - You may also provide a `context` object that will be used to evaluate the conditioned tuples in the system. It is strongly recommended to provide a value for all the input parameters of all the conditions, to ensure that all tuples be evaluated correctly. - By default, the Check API caches results for a short time to optimize performance. You may specify a value of `HIGHER_CONSISTENCY` for the optional `consistency` parameter in the body to inform the server that higher conisistency is preferred at the expense of increased latency. Consideration should be given to the increased latency if requesting higher consistency. - The response will contain the related objects in an array in the "objects" field of the response and they will be strings in the object format `:` (e.g. "document:roadmap"). - The number of objects in the response array will be limited by the execution timeout specified in the flag OPENFGA_LIST_OBJECTS_DEADLINE and by the upper bound specified in the flag OPENFGA_LIST_OBJECTS_MAX_RESULTS, whichever is hit first. - The objects given will not be sorted, and therefore two identical calls can give a given different set of objects. - * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). - * @param storeId - * @return ApiListObjectsRequest + * ListObjects List all objects of the given type that the user has a relation with + * The ListObjects API returns a list of all the objects of the given type that the user has a relation with. + To arrive at a result, the API uses: an authorization model, explicit tuples written through the Write API, contextual tuples present in the request, and implicit tuples that exist by virtue of applying set theory (such as `document:2021-budget#viewer@document:2021-budget#viewer`; the set of users who are viewers of `document:2021-budget` are the set of users who are the viewers of `document:2021-budget`). + An `authorization_model_id` may be specified in the body. If it is not specified, the latest authorization model ID will be used. It is strongly recommended to specify authorization model id for better performance. + You may also specify `contextual_tuples` that will be treated as regular tuples. Each of these tuples may have an associated `condition`. + You may also provide a `context` object that will be used to evaluate the conditioned tuples in the system. It is strongly recommended to provide a value for all the input parameters of all the conditions, to ensure that all tuples be evaluated correctly. + By default, the Check API caches results for a short time to optimize performance. You may specify a value of `HIGHER_CONSISTENCY` for the optional `consistency` parameter in the body to inform the server that higher conisistency is preferred at the expense of increased latency. Consideration should be given to the increased latency if requesting higher consistency. + The response will contain the related objects in an array in the "objects" field of the response and they will be strings in the object format `:` (e.g. "document:roadmap"). + The number of objects in the response array will be limited by the execution timeout specified in the flag OPENFGA_LIST_OBJECTS_DEADLINE and by the upper bound specified in the flag OPENFGA_LIST_OBJECTS_MAX_RESULTS, whichever is hit first. + The objects given will not be sorted, and therefore two identical calls can give a given different set of objects. + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @param storeId + * @return ApiListObjectsRequest */ ListObjects(ctx context.Context, storeId string) ApiListObjectsRequest @@ -477,12 +477,12 @@ type OpenFgaApi interface { ListObjectsExecute(r ApiListObjectsRequest) (ListObjectsResponse, *http.Response, error) /* - * ListStores List all stores - * Returns a paginated list of OpenFGA stores and a continuation token to get additional stores. - The continuation token will be empty if there are no more stores. + * ListStores List all stores + * Returns a paginated list of OpenFGA stores and a continuation token to get additional stores. + The continuation token will be empty if there are no more stores. - * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). - * @return ApiListStoresRequest + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @return ApiListStoresRequest */ ListStores(ctx context.Context) ApiListStoresRequest @@ -493,20 +493,20 @@ type OpenFgaApi interface { ListStoresExecute(r ApiListStoresRequest) (ListStoresResponse, *http.Response, error) /* - * ListUsers List the users matching the provided filter who have a certain relation to a particular type. - * The ListUsers API returns a list of all the users of a specific type that have a relation to a given object. - To arrive at a result, the API uses: an authorization model, explicit tuples written through the Write API, contextual tuples present in the request, and implicit tuples that exist by virtue of applying set theory (such as `document:2021-budget#viewer@document:2021-budget#viewer`; the set of users who are viewers of `document:2021-budget` are the set of users who are the viewers of `document:2021-budget`). - An `authorization_model_id` may be specified in the body. If it is not specified, the latest authorization model ID will be used. It is strongly recommended to specify authorization model id for better performance. - You may also specify `contextual_tuples` that will be treated as regular tuples. Each of these tuples may have an associated `condition`. - You may also provide a `context` object that will be used to evaluate the conditioned tuples in the system. It is strongly recommended to provide a value for all the input parameters of all the conditions, to ensure that all tuples be evaluated correctly. - The response will contain the related users in an array in the "users" field of the response. These results may include specific objects, usersets - or type-bound public access. Each of these types of results is encoded in its own type and not represented as a string.In cases where a type-bound public access result is returned (e.g. `user:*`), it cannot be inferred that all subjects - of that type have a relation to the object; it is possible that negations exist and checks should still be queried - on individual subjects to ensure access to that document.The number of users in the response array will be limited by the execution timeout specified in the flag OPENFGA_LIST_USERS_DEADLINE and by the upper bound specified in the flag OPENFGA_LIST_USERS_MAX_RESULTS, whichever is hit first. - The returned users will not be sorted, and therefore two identical calls may yield different sets of users. - * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). - * @param storeId - * @return ApiListUsersRequest + * ListUsers List the users matching the provided filter who have a certain relation to a particular type. + * The ListUsers API returns a list of all the users of a specific type that have a relation to a given object. + To arrive at a result, the API uses: an authorization model, explicit tuples written through the Write API, contextual tuples present in the request, and implicit tuples that exist by virtue of applying set theory (such as `document:2021-budget#viewer@document:2021-budget#viewer`; the set of users who are viewers of `document:2021-budget` are the set of users who are the viewers of `document:2021-budget`). + An `authorization_model_id` may be specified in the body. If it is not specified, the latest authorization model ID will be used. It is strongly recommended to specify authorization model id for better performance. + You may also specify `contextual_tuples` that will be treated as regular tuples. Each of these tuples may have an associated `condition`. + You may also provide a `context` object that will be used to evaluate the conditioned tuples in the system. It is strongly recommended to provide a value for all the input parameters of all the conditions, to ensure that all tuples be evaluated correctly. + The response will contain the related users in an array in the "users" field of the response. These results may include specific objects, usersets + or type-bound public access. Each of these types of results is encoded in its own type and not represented as a string.In cases where a type-bound public access result is returned (e.g. `user:*`), it cannot be inferred that all subjects + of that type have a relation to the object; it is possible that negations exist and checks should still be queried + on individual subjects to ensure access to that document.The number of users in the response array will be limited by the execution timeout specified in the flag OPENFGA_LIST_USERS_DEADLINE and by the upper bound specified in the flag OPENFGA_LIST_USERS_MAX_RESULTS, whichever is hit first. + The returned users will not be sorted, and therefore two identical calls may yield different sets of users. + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @param storeId + * @return ApiListUsersRequest */ ListUsers(ctx context.Context, storeId string) ApiListUsersRequest @@ -517,109 +517,109 @@ type OpenFgaApi interface { ListUsersExecute(r ApiListUsersRequest) (ListUsersResponse, *http.Response, error) /* - * Read Get tuples from the store that matches a query, without following userset rewrite rules - * The Read API will return the tuples for a certain store that match a query filter specified in the body of the request. - The API doesn't guarantee order by any field. - It is different from the `/stores/{store_id}/expand` API in that it only returns relationship tuples that are stored in the system and satisfy the query. - In the body: - 1. `tuple_key` is optional. If not specified, it will return all tuples in the store. - 2. `tuple_key.object` is mandatory if `tuple_key` is specified. It can be a full object (e.g., `type:object_id`) or type only (e.g., `type:`). - 3. `tuple_key.user` is mandatory if tuple_key is specified in the case the `tuple_key.object` is a type only. If tuple_key.user is specified, it needs to be a full object (e.g., `type:user_id`). - ## Examples - ### Query for all objects in a type definition - To query for all objects that `user:bob` has `reader` relationship in the `document` type definition, call read API with body of - ```json - { - "tuple_key": { - "user": "user:bob", - "relation": "reader", - "object": "document:" - } - } - ``` - The API will return tuples and a continuation token, something like - ```json - { - "tuples": [ - { - "key": { - "user": "user:bob", - "relation": "reader", - "object": "document:2021-budget" - }, - "timestamp": "2021-10-06T15:32:11.128Z" - } - ], - "continuation_token": "eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==" - } - ``` - This means that `user:bob` has a `reader` relationship with 1 document `document:2021-budget`. Note that this API, unlike the List Objects API, does not evaluate the tuples in the store. - The continuation token will be empty if there are no more tuples to query. - ### Query for all stored relationship tuples that have a particular relation and object - To query for all users that have `reader` relationship with `document:2021-budget`, call read API with body of - ```json - { - "tuple_key": { - "object": "document:2021-budget", - "relation": "reader" - } - } - ``` - The API will return something like - ```json - { - "tuples": [ - { - "key": { - "user": "user:bob", - "relation": "reader", - "object": "document:2021-budget" - }, - "timestamp": "2021-10-06T15:32:11.128Z" - } - ], - "continuation_token": "eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==" - } - ``` - This means that `document:2021-budget` has 1 `reader` (`user:bob`). Note that, even if the model said that all `writers` are also `readers`, the API will not return writers such as `user:anne` because it only returns tuples and does not evaluate them. - ### Query for all users with all relationships for a particular document - To query for all users that have any relationship with `document:2021-budget`, call read API with body of - ```json - { - "tuple_key": { - "object": "document:2021-budget" - } - } - ``` - The API will return something like - ```json - { - "tuples": [ - { - "key": { - "user": "user:anne", - "relation": "writer", - "object": "document:2021-budget" - }, - "timestamp": "2021-10-05T13:42:12.356Z" - }, - { - "key": { - "user": "user:bob", - "relation": "reader", - "object": "document:2021-budget" - }, - "timestamp": "2021-10-06T15:32:11.128Z" - } - ], - "continuation_token": "eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==" - } - ``` - This means that `document:2021-budget` has 1 `reader` (`user:bob`) and 1 `writer` (`user:anne`). + * Read Get tuples from the store that matches a query, without following userset rewrite rules + * The Read API will return the tuples for a certain store that match a query filter specified in the body of the request. + The API doesn't guarantee order by any field. + It is different from the `/stores/{store_id}/expand` API in that it only returns relationship tuples that are stored in the system and satisfy the query. + In the body: + 1. `tuple_key` is optional. If not specified, it will return all tuples in the store. + 2. `tuple_key.object` is mandatory if `tuple_key` is specified. It can be a full object (e.g., `type:object_id`) or type only (e.g., `type:`). + 3. `tuple_key.user` is mandatory if tuple_key is specified in the case the `tuple_key.object` is a type only. If tuple_key.user is specified, it needs to be a full object (e.g., `type:user_id`). + ## Examples + ### Query for all objects in a type definition + To query for all objects that `user:bob` has `reader` relationship in the `document` type definition, call read API with body of + ```json + { + "tuple_key": { + "user": "user:bob", + "relation": "reader", + "object": "document:" + } + } + ``` + The API will return tuples and a continuation token, something like + ```json + { + "tuples": [ + { + "key": { + "user": "user:bob", + "relation": "reader", + "object": "document:2021-budget" + }, + "timestamp": "2021-10-06T15:32:11.128Z" + } + ], + "continuation_token": "eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==" + } + ``` + This means that `user:bob` has a `reader` relationship with 1 document `document:2021-budget`. Note that this API, unlike the List Objects API, does not evaluate the tuples in the store. + The continuation token will be empty if there are no more tuples to query. + ### Query for all stored relationship tuples that have a particular relation and object + To query for all users that have `reader` relationship with `document:2021-budget`, call read API with body of + ```json + { + "tuple_key": { + "object": "document:2021-budget", + "relation": "reader" + } + } + ``` + The API will return something like + ```json + { + "tuples": [ + { + "key": { + "user": "user:bob", + "relation": "reader", + "object": "document:2021-budget" + }, + "timestamp": "2021-10-06T15:32:11.128Z" + } + ], + "continuation_token": "eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==" + } + ``` + This means that `document:2021-budget` has 1 `reader` (`user:bob`). Note that, even if the model said that all `writers` are also `readers`, the API will not return writers such as `user:anne` because it only returns tuples and does not evaluate them. + ### Query for all users with all relationships for a particular document + To query for all users that have any relationship with `document:2021-budget`, call read API with body of + ```json + { + "tuple_key": { + "object": "document:2021-budget" + } + } + ``` + The API will return something like + ```json + { + "tuples": [ + { + "key": { + "user": "user:anne", + "relation": "writer", + "object": "document:2021-budget" + }, + "timestamp": "2021-10-05T13:42:12.356Z" + }, + { + "key": { + "user": "user:bob", + "relation": "reader", + "object": "document:2021-budget" + }, + "timestamp": "2021-10-06T15:32:11.128Z" + } + ], + "continuation_token": "eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==" + } + ``` + This means that `document:2021-budget` has 1 `reader` (`user:bob`) and 1 `writer` (`user:anne`). - * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). - * @param storeId - * @return ApiReadRequest + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @param storeId + * @return ApiReadRequest */ Read(ctx context.Context, storeId string) ApiReadRequest @@ -646,103 +646,103 @@ type OpenFgaApi interface { ReadAssertionsExecute(r ApiReadAssertionsRequest) (ReadAssertionsResponse, *http.Response, error) /* - * ReadAuthorizationModel Return a particular version of an authorization model - * The ReadAuthorizationModel API returns an authorization model by its identifier. - The response will return the authorization model for the particular version. - - ## Example - To retrieve the authorization model with ID `01G5JAVJ41T49E9TT3SKVS7X1J` for the store, call the GET authorization-models by ID API with `01G5JAVJ41T49E9TT3SKVS7X1J` as the `id` path parameter. The API will return: - ```json - { - "authorization_model":{ - "id":"01G5JAVJ41T49E9TT3SKVS7X1J", - "type_definitions":[ - { - "type":"user" - }, - { - "type":"document", - "relations":{ - "reader":{ - "union":{ - "child":[ - { - "this":{} - }, - { - "computedUserset":{ - "object":"", - "relation":"writer" - } - } - ] - } - }, - "writer":{ - "this":{} - } - } - } - ] - } - } - ``` - In the above example, there are 2 types (`user` and `document`). The `document` type has 2 relations (`writer` and `reader`). - * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). - * @param storeId - * @param id - * @return ApiReadAuthorizationModelRequest - */ - ReadAuthorizationModel(ctx context.Context, storeId string, id string) ApiReadAuthorizationModelRequest - - /* - * ReadAuthorizationModelExecute executes the request - * @return ReadAuthorizationModelResponse - */ - ReadAuthorizationModelExecute(r ApiReadAuthorizationModelRequest) (ReadAuthorizationModelResponse, *http.Response, error) - - /* - * ReadAuthorizationModels Return all the authorization models for a particular store - * The ReadAuthorizationModels API will return all the authorization models for a certain store. - OpenFGA's response will contain an array of all authorization models, sorted in descending order of creation. - - ## Example - Assume that a store's authorization model has been configured twice. To get all the authorization models that have been created in this store, call GET authorization-models. The API will return a response that looks like: - ```json - { - "authorization_models": [ - { - "id": "01G50QVV17PECNVAHX1GG4Y5NC", - "type_definitions": [...] - }, - { - "id": "01G4ZW8F4A07AKQ8RHSVG9RW04", - "type_definitions": [...] - }, - ], - "continuation_token": "eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==" - } - ``` - If there are no more authorization models available, the `continuation_token` field will be empty - ```json - { - "authorization_models": [ - { - "id": "01G50QVV17PECNVAHX1GG4Y5NC", - "type_definitions": [...] - }, - { - "id": "01G4ZW8F4A07AKQ8RHSVG9RW04", - "type_definitions": [...] - }, - ], - "continuation_token": "" - } - ``` + * ReadAuthorizationModel Return a particular version of an authorization model + * The ReadAuthorizationModel API returns an authorization model by its identifier. + The response will return the authorization model for the particular version. + + ## Example + To retrieve the authorization model with ID `01G5JAVJ41T49E9TT3SKVS7X1J` for the store, call the GET authorization-models by ID API with `01G5JAVJ41T49E9TT3SKVS7X1J` as the `id` path parameter. The API will return: + ```json + { + "authorization_model":{ + "id":"01G5JAVJ41T49E9TT3SKVS7X1J", + "type_definitions":[ + { + "type":"user" + }, + { + "type":"document", + "relations":{ + "reader":{ + "union":{ + "child":[ + { + "this":{} + }, + { + "computedUserset":{ + "object":"", + "relation":"writer" + } + } + ] + } + }, + "writer":{ + "this":{} + } + } + } + ] + } + } + ``` + In the above example, there are 2 types (`user` and `document`). The `document` type has 2 relations (`writer` and `reader`). + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @param storeId + * @param id + * @return ApiReadAuthorizationModelRequest + */ + ReadAuthorizationModel(ctx context.Context, storeId string, id string) ApiReadAuthorizationModelRequest + + /* + * ReadAuthorizationModelExecute executes the request + * @return ReadAuthorizationModelResponse + */ + ReadAuthorizationModelExecute(r ApiReadAuthorizationModelRequest) (ReadAuthorizationModelResponse, *http.Response, error) - * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). - * @param storeId - * @return ApiReadAuthorizationModelsRequest + /* + * ReadAuthorizationModels Return all the authorization models for a particular store + * The ReadAuthorizationModels API will return all the authorization models for a certain store. + OpenFGA's response will contain an array of all authorization models, sorted in descending order of creation. + + ## Example + Assume that a store's authorization model has been configured twice. To get all the authorization models that have been created in this store, call GET authorization-models. The API will return a response that looks like: + ```json + { + "authorization_models": [ + { + "id": "01G50QVV17PECNVAHX1GG4Y5NC", + "type_definitions": [...] + }, + { + "id": "01G4ZW8F4A07AKQ8RHSVG9RW04", + "type_definitions": [...] + }, + ], + "continuation_token": "eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==" + } + ``` + If there are no more authorization models available, the `continuation_token` field will be empty + ```json + { + "authorization_models": [ + { + "id": "01G50QVV17PECNVAHX1GG4Y5NC", + "type_definitions": [...] + }, + { + "id": "01G4ZW8F4A07AKQ8RHSVG9RW04", + "type_definitions": [...] + }, + ], + "continuation_token": "" + } + ``` + + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @param storeId + * @return ApiReadAuthorizationModelsRequest */ ReadAuthorizationModels(ctx context.Context, storeId string) ApiReadAuthorizationModelsRequest @@ -753,15 +753,15 @@ type OpenFgaApi interface { ReadAuthorizationModelsExecute(r ApiReadAuthorizationModelsRequest) (ReadAuthorizationModelsResponse, *http.Response, error) /* - * ReadChanges Return a list of all the tuple changes - * The ReadChanges API will return a paginated list of tuple changes (additions and deletions) that occurred in a given store, sorted by ascending time. The response will include a continuation token that is used to get the next set of changes. If there are no changes after the provided continuation token, the same token will be returned in order for it to be used when new changes are recorded. If the store never had any tuples added or removed, this token will be empty. - You can use the `type` parameter to only get the list of tuple changes that affect objects of that type. - When reading a write tuple change, if it was conditioned, the condition will be returned. - When reading a delete tuple change, the condition will NOT be returned regardless of whether it was originally conditioned or not. - - * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). - * @param storeId - * @return ApiReadChangesRequest + * ReadChanges Return a list of all the tuple changes + * The ReadChanges API will return a paginated list of tuple changes (additions and deletions) that occurred in a given store, sorted by ascending time. The response will include a continuation token that is used to get the next set of changes. If there are no changes after the provided continuation token, the same token will be returned in order for it to be used when new changes are recorded. If the store never had any tuples added or removed, this token will be empty. + You can use the `type` parameter to only get the list of tuple changes that affect objects of that type. + When reading a write tuple change, if it was conditioned, the condition will be returned. + When reading a delete tuple change, the condition will NOT be returned regardless of whether it was originally conditioned or not. + + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @param storeId + * @return ApiReadChangesRequest */ ReadChanges(ctx context.Context, storeId string) ApiReadChangesRequest @@ -772,53 +772,71 @@ type OpenFgaApi interface { ReadChangesExecute(r ApiReadChangesRequest) (ReadChangesResponse, *http.Response, error) /* - * Write Add or delete tuples from the store - * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. - In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. - The API is not idempotent by default: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. - To allow writes when an identical tuple already exists in the database, set `"on_duplicate": "ignore"` on the `writes` object. - To allow deletes when a tuple was already removed from the database, set `"on_missing": "ignore"` on the `deletes` object. - If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. This gives developers explicit control over the atomicity of the requests. - The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. - An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. - ## Example - ### Adding relationships - To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following - ```json - { - "writes": { - "tuple_keys": [ - { - "user": "user:anne", - "relation": "writer", - "object": "document:2021-budget" - } - ], - "on_duplicate": "ignore" - }, - "authorization_model_id": "01G50QVV17PECNVAHX1GG4Y5NC" - } - ``` - ### Removing relationships - To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following - ```json - { - "deletes": { - "tuple_keys": [ - { - "user": "user:bob", - "relation": "reader", - "object": "document:2021-budget" - } - ], - "on_missing": "ignore" - } - } - ``` + * StreamedListObjects Stream all objects of the given type that the user has a relation with + * The Streamed ListObjects API is very similar to the the ListObjects API, with two differences: + 1. Instead of collecting all objects before returning a response, it streams them to the client as they are collected. + 2. The number of results returned is only limited by the execution timeout specified in the flag OPENFGA_LIST_OBJECTS_DEADLINE. + + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @param storeId + * @return ApiStreamedListObjectsRequest + */ + StreamedListObjects(ctx context.Context, storeId string) ApiStreamedListObjectsRequest + + /* + * StreamedListObjectsExecute executes the request + * @return StreamResultOfStreamedListObjectsResponse + */ + StreamedListObjectsExecute(r ApiStreamedListObjectsRequest) (StreamResultOfStreamedListObjectsResponse, *http.Response, error) + + /* + * Write Add or delete tuples from the store + * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. + In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. + The API is not idempotent by default: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. + To allow writes when an identical tuple already exists in the database, set `"on_duplicate": "ignore"` on the `writes` object. + To allow deletes when a tuple was already removed from the database, set `"on_missing": "ignore"` on the `deletes` object. + If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. This gives developers explicit control over the atomicity of the requests. + The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. + An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. + ## Example + ### Adding relationships + To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following + ```json + { + "writes": { + "tuple_keys": [ + { + "user": "user:anne", + "relation": "writer", + "object": "document:2021-budget" + } + ], + "on_duplicate": "ignore" + }, + "authorization_model_id": "01G50QVV17PECNVAHX1GG4Y5NC" + } + ``` + ### Removing relationships + To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following + ```json + { + "deletes": { + "tuple_keys": [ + { + "user": "user:bob", + "relation": "reader", + "object": "document:2021-budget" + } + ], + "on_missing": "ignore" + } + } + ``` - * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). - * @param storeId - * @return ApiWriteRequest + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @param storeId + * @return ApiWriteRequest */ Write(ctx context.Context, storeId string) ApiWriteRequest @@ -844,53 +862,53 @@ type OpenFgaApi interface { WriteAssertionsExecute(r ApiWriteAssertionsRequest) (*http.Response, error) /* - * WriteAuthorizationModel Create a new authorization model - * The WriteAuthorizationModel API will add a new authorization model to a store. - Each item in the `type_definitions` array is a type definition as specified in the field `type_definition`. - The response will return the authorization model's ID in the `id` field. - - ## Example - To add an authorization model with `user` and `document` type definitions, call POST authorization-models API with the body: - ```json - { - "type_definitions":[ - { - "type":"user" - }, - { - "type":"document", - "relations":{ - "reader":{ - "union":{ - "child":[ - { - "this":{} - }, - { - "computedUserset":{ - "object":"", - "relation":"writer" - } - } - ] - } - }, - "writer":{ - "this":{} - } - } - } - ] - } - ``` - OpenFGA's response will include the version id for this authorization model, which will look like - ``` - {"authorization_model_id": "01G50QVV17PECNVAHX1GG4Y5NC"} - ``` - - * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). - * @param storeId - * @return ApiWriteAuthorizationModelRequest + * WriteAuthorizationModel Create a new authorization model + * The WriteAuthorizationModel API will add a new authorization model to a store. + Each item in the `type_definitions` array is a type definition as specified in the field `type_definition`. + The response will return the authorization model's ID in the `id` field. + + ## Example + To add an authorization model with `user` and `document` type definitions, call POST authorization-models API with the body: + ```json + { + "type_definitions":[ + { + "type":"user" + }, + { + "type":"document", + "relations":{ + "reader":{ + "union":{ + "child":[ + { + "this":{} + }, + { + "computedUserset":{ + "object":"", + "relation":"writer" + } + } + ] + } + }, + "writer":{ + "this":{} + } + } + } + ] + } + ``` + OpenFGA's response will include the version id for this authorization model, which will look like + ``` + {"authorization_model_id": "01G50QVV17PECNVAHX1GG4Y5NC"} + ``` + + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @param storeId + * @return ApiWriteAuthorizationModelRequest */ WriteAuthorizationModel(ctx context.Context, storeId string) ApiWriteAuthorizationModelRequest @@ -4170,6 +4188,202 @@ func (a *OpenFgaApiService) ReadChangesExecute(r ApiReadChangesRequest) (ReadCha return returnValue, nil, reportError("Error not handled properly") } +type ApiStreamedListObjectsRequest struct { + ctx context.Context + ApiService OpenFgaApi + storeId string + body *ListObjectsRequest + options RequestOptions +} + +func (r ApiStreamedListObjectsRequest) Body(body ListObjectsRequest) ApiStreamedListObjectsRequest { + r.body = &body + return r +} + +func (r ApiStreamedListObjectsRequest) Options(options RequestOptions) ApiStreamedListObjectsRequest { + r.options = options + return r +} + +func (r ApiStreamedListObjectsRequest) Execute() (StreamResultOfStreamedListObjectsResponse, *http.Response, error) { + return r.ApiService.StreamedListObjectsExecute(r) +} + +/* + - StreamedListObjects Stream all objects of the given type that the user has a relation with + - The Streamed ListObjects API is very similar to the the ListObjects API, with two differences: + +1. Instead of collecting all objects before returning a response, it streams them to the client as they are collected. +2. The number of results returned is only limited by the execution timeout specified in the flag OPENFGA_LIST_OBJECTS_DEADLINE. + + - @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + - @param storeId + - @return ApiStreamedListObjectsRequest +*/ +func (a *OpenFgaApiService) StreamedListObjects(ctx context.Context, storeId string) ApiStreamedListObjectsRequest { + return ApiStreamedListObjectsRequest{ + ApiService: a, + ctx: ctx, + storeId: storeId, + } +} + +/* + * Execute executes the request + * @return StreamResultOfStreamedListObjectsResponse + */ +func (a *OpenFgaApiService) StreamedListObjectsExecute(r ApiStreamedListObjectsRequest) (StreamResultOfStreamedListObjectsResponse, *http.Response, error) { + const ( + operationName = "StreamedListObjects" + httpMethod = http.MethodPost + ) + var ( + requestStarted = time.Now() + requestBody interface{} + returnValue StreamResultOfStreamedListObjectsResponse + ) + + path := "/stores/{store_id}/streamed-list-objects" + if r.storeId == "" { + return returnValue, nil, reportError("storeId is required and must be specified") + } + + path = strings.ReplaceAll(path, "{"+"store_id"+"}", url.PathEscape(parameterToString(r.storeId, ""))) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + if r.body == nil { + return returnValue, nil, reportError("body is required and must be specified") + } + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + // body params + requestBody = r.body + + // if any override headers were in the options, set them now + for header, val := range r.options.Headers { + localVarHeaderParams[header] = val + } + + retryParams := a.client.cfg.RetryParams + for i := 0; i < retryParams.MaxRetry+1; i++ { + req, err := a.client.prepareRequest(r.ctx, path, httpMethod, requestBody, localVarHeaderParams, localVarQueryParams) + if err != nil { + return returnValue, nil, err + } + + httpResponse, err := a.client.callAPI(req) + if err != nil || httpResponse == nil { + if i < retryParams.MaxRetry { + timeToWait := retryutils.GetTimeToWait(i, retryParams.MaxRetry, retryParams.MinWaitInMs, http.Header{}, operationName) + if timeToWait > 0 { + if a.client.cfg.Debug { + log.Printf("\nWaiting %v to retry %v (%v %v) due to network error (error=%v) on attempt %v. Request body: %v\n", timeToWait, operationName, req.Method, req.URL, err, i, requestBody) + } + time.Sleep(timeToWait) + continue + } + } + return returnValue, httpResponse, err + } + + responseBody, err := io.ReadAll(httpResponse.Body) + _ = httpResponse.Body.Close() + httpResponse.Body = io.NopCloser(bytes.NewBuffer(responseBody)) + if err != nil { + if i < retryParams.MaxRetry { + timeToWait := retryutils.GetTimeToWait(i, retryParams.MaxRetry, retryParams.MinWaitInMs, httpResponse.Header, operationName) + if timeToWait > 0 { + if a.client.cfg.Debug { + log.Printf("\nWaiting %v to retry %v (%v %v) due to error parsing response body (err=%v) on attempt %v. Request body: %v\n", timeToWait, operationName, req.Method, req.URL, err, i, requestBody) + } + time.Sleep(timeToWait) + continue + } + } + return returnValue, httpResponse, err + } + + if httpResponse.StatusCode >= http.StatusMultipleChoices { + err := a.client.handleAPIError(httpResponse, responseBody, requestBody, operationName, r.storeId) + if err != nil && i < retryParams.MaxRetry { + timeToWait := time.Duration(0) + var fgaApiRateLimitExceededError FgaApiRateLimitExceededError + var fgaApiInternalError FgaApiInternalError + switch { + case errors.As(err, &fgaApiRateLimitExceededError): + timeToWait = err.(FgaApiRateLimitExceededError).GetTimeToWait(i, *retryParams) + case errors.As(err, &fgaApiInternalError): + timeToWait = err.(FgaApiInternalError).GetTimeToWait(i, *retryParams) + } + + if timeToWait > 0 { + if a.client.cfg.Debug { + log.Printf("\nWaiting %v to retry %v (%v %v) due to api retryable error (status code %v, error=%v) on attempt %v. Request body: %v\n", timeToWait, operationName, req.Method, req.URL, httpResponse.StatusCode, err, i, requestBody) + } + time.Sleep(timeToWait) + continue + } + } + + return returnValue, httpResponse, err + } + + err = a.client.decode(&returnValue, responseBody, httpResponse.Header.Get("Content-Type")) + if err != nil { + newErr := GenericOpenAPIError{ + body: responseBody, + error: err.Error(), + } + return returnValue, httpResponse, newErr + } + + metrics := telemetry.GetMetrics(telemetry.TelemetryFactoryParameters{Configuration: a.client.cfg.Telemetry}) + + var attrs, queryDuration, requestDuration, _ = metrics.BuildTelemetryAttributes( + operationName, + map[string]interface{}{ + "storeId": r.storeId, + "body": requestBody, + }, + req, + httpResponse, + requestStarted, + i, + ) + + if requestDuration > 0 { + _, _ = metrics.RequestDuration(requestDuration, attrs) + } + + if queryDuration > 0 { + _, _ = metrics.QueryDuration(queryDuration, attrs) + } + + return returnValue, httpResponse, nil + } + + // should never have reached this + return returnValue, nil, reportError("Error not handled properly") +} + type ApiWriteRequest struct { ctx context.Context ApiService OpenFgaApi diff --git a/client/client.go b/client/client.go index 0a9ccff..ed9d93e 100644 --- a/client/client.go +++ b/client/client.go @@ -439,6 +439,19 @@ type SdkClient interface { */ ListUsersExecute(r SdkClientListUsersRequestInterface) (*ClientListUsersResponse, error) + /* + * StreamedListObjects Stream all objects of the given type that the user has a relation with + * @param ctx _context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @return SdkClientStreamedListObjectsRequestInterface + */ + StreamedListObjects(ctx _context.Context) SdkClientStreamedListObjectsRequestInterface + + /* + * StreamedListObjectsExecute executes the StreamedListObjects request and returns a channel + * @return *ClientStreamedListObjectsResponse + */ + StreamedListObjectsExecute(request SdkClientStreamedListObjectsRequestInterface) (*ClientStreamedListObjectsResponse, error) + /* Assertions */ /* @@ -3282,3 +3295,159 @@ func (client *OpenFgaClient) WriteAssertionsExecute(request SdkClientWriteAssert } return &ClientWriteAssertionsResponse{}, nil } + +type SdkClientStreamedListObjectsRequest struct { + ctx _context.Context + Client *OpenFgaClient + + body *ClientStreamedListObjectsRequest + options *ClientStreamedListObjectsOptions +} + +type SdkClientStreamedListObjectsRequestInterface interface { + Options(options ClientStreamedListObjectsOptions) SdkClientStreamedListObjectsRequestInterface + Body(body ClientStreamedListObjectsRequest) SdkClientStreamedListObjectsRequestInterface + Execute() (*ClientStreamedListObjectsResponse, error) + GetAuthorizationModelIdOverride() *string + GetStoreIdOverride() *string + + GetContext() _context.Context + GetBody() *ClientStreamedListObjectsRequest + GetOptions() *ClientStreamedListObjectsOptions +} + +type ClientStreamedListObjectsRequest struct { + User string `json:"user,omitempty"` + Relation string `json:"relation,omitempty"` + Type string `json:"type,omitempty"` + Context *map[string]interface{} `json:"context,omitempty"` + ContextualTuples []ClientContextualTupleKey `json:"contextual_tuples,omitempty"` +} + +type ClientStreamedListObjectsOptions struct { + RequestOptions + + AuthorizationModelId *string `json:"authorization_model_id,omitempty"` + StoreId *string `json:"store_id,omitempty"` + Consistency *fgaSdk.ConsistencyPreference `json:"consistency,omitempty"` + // StreamBufferSize configures the buffer size for streaming response channels. + // A larger buffer improves throughput for high-volume streams but increases memory usage. + // A smaller buffer reduces memory usage but may decrease throughput. + // Defaults to 10 if not specified or if set to 0. + StreamBufferSize *int `json:"stream_buffer_size,omitempty"` +} + +type ClientStreamedListObjectsResponse struct { + Objects <-chan fgaSdk.StreamedListObjectsResponse + Errors <-chan error + close func() +} + +func (r *ClientStreamedListObjectsResponse) Close() { + if r.close != nil { + r.close() + } +} + +func (client *OpenFgaClient) StreamedListObjects(ctx _context.Context) SdkClientStreamedListObjectsRequestInterface { + return &SdkClientStreamedListObjectsRequest{ + Client: client, + ctx: ctx, + } +} + +func (request *SdkClientStreamedListObjectsRequest) Options(options ClientStreamedListObjectsOptions) SdkClientStreamedListObjectsRequestInterface { + request.options = &options + return request +} + +func (request *SdkClientStreamedListObjectsRequest) GetAuthorizationModelIdOverride() *string { + if request.options == nil { + return nil + } + return request.options.AuthorizationModelId +} + +func (request *SdkClientStreamedListObjectsRequest) GetStoreIdOverride() *string { + if request.options == nil { + return nil + } + return request.options.StoreId +} + +func (request *SdkClientStreamedListObjectsRequest) Body(body ClientStreamedListObjectsRequest) SdkClientStreamedListObjectsRequestInterface { + request.body = &body + return request +} + +func (request *SdkClientStreamedListObjectsRequest) Execute() (*ClientStreamedListObjectsResponse, error) { + return request.Client.StreamedListObjectsExecute(request) +} + +func (request *SdkClientStreamedListObjectsRequest) GetContext() _context.Context { + return request.ctx +} + +func (request *SdkClientStreamedListObjectsRequest) GetBody() *ClientStreamedListObjectsRequest { + return request.body +} + +func (request *SdkClientStreamedListObjectsRequest) GetOptions() *ClientStreamedListObjectsOptions { + return request.options +} + +func (client *OpenFgaClient) StreamedListObjectsExecute(request SdkClientStreamedListObjectsRequestInterface) (*ClientStreamedListObjectsResponse, error) { + if request.GetBody() == nil { + return nil, FgaRequiredParamError{param: "body"} + } + var contextualTuples []ClientContextualTupleKey + if request.GetBody().ContextualTuples != nil { + for index := 0; index < len(request.GetBody().ContextualTuples); index++ { + contextualTuples = append(contextualTuples, (request.GetBody().ContextualTuples)[index]) + } + } + authorizationModelId, err := client.getAuthorizationModelId(request.GetAuthorizationModelIdOverride()) + if err != nil { + return nil, err + } + storeId, err := client.getStoreId(request.GetStoreIdOverride()) + if err != nil { + return nil, err + } + body := fgaSdk.ListObjectsRequest{ + User: request.GetBody().User, + Relation: request.GetBody().Relation, + Type: request.GetBody().Type, + ContextualTuples: fgaSdk.NewContextualTupleKeys(contextualTuples), + Context: request.GetBody().Context, + AuthorizationModelId: authorizationModelId, + } + requestOptions := RequestOptions{} + bufferSize := 0 + if request.GetOptions() != nil { + requestOptions = request.GetOptions().RequestOptions + body.Consistency = request.GetOptions().Consistency + if request.GetOptions().StreamBufferSize != nil { + bufferSize = *request.GetOptions().StreamBufferSize + } + } + + channel, err := fgaSdk.ExecuteStreamedListObjectsWithBufferSize( + &client.APIClient, + request.GetContext(), + *storeId, + body, + requestOptions, + bufferSize, + ) + + if err != nil { + return nil, err + } + + return &ClientStreamedListObjectsResponse{ + Objects: channel.Objects, + Errors: channel.Errors, + close: channel.Close, + }, nil +} diff --git a/client/streaming_test.go b/client/streaming_test.go new file mode 100644 index 0000000..272037f --- /dev/null +++ b/client/streaming_test.go @@ -0,0 +1,329 @@ +/** + * Go SDK for OpenFGA + * + * API version: 1.x + * Website: https://openfga.dev + * Documentation: https://openfga.dev/docs + * Support: https://openfga.dev/community + * License: [Apache-2.0](https://github.com/openfga/go-sdk/blob/main/LICENSE) + * + * NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. + */ + +package client + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + openfga "github.com/openfga/go-sdk" +) + +func TestClientStreamedListObjects_Success(t *testing.T) { + objects := []string{"document:1", "document:2", "document:3"} + expectedResults := []string{} + for _, obj := range objects { + expectedResults = append(expectedResults, `{"result":{"object":"`+obj+`"}}`) + } + responseBody := strings.Join(expectedResults, "\n") + + storeId := "01ARZ3NDEKTSV4RRFFQ69G5FAV" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedPath := "/stores/" + storeId + "/streamed-list-objects" + if r.URL.Path != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) + } + + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + w.Write([]byte(responseBody)) + })) + defer server.Close() + + config := ClientConfiguration{ + ApiUrl: server.URL, + StoreId: storeId, + } + + client, err := NewSdkClient(&config) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + ctx := context.Background() + + response, err := client.StreamedListObjects(ctx). + Body(ClientStreamedListObjectsRequest{ + Type: "document", + Relation: "viewer", + User: "user:anne", + }). + Execute() + + if err != nil { + t.Fatalf("StreamedListObjects failed: %v", err) + } + + defer response.Close() + + receivedObjects := []string{} + for obj := range response.Objects { + receivedObjects = append(receivedObjects, obj.Object) + } + + if err := <-response.Errors; err != nil { + t.Fatalf("Received error from response: %v", err) + } + + if len(receivedObjects) != len(objects) { + t.Fatalf("Expected %d objects, got %d", len(objects), len(receivedObjects)) + } + + for i, expected := range objects { + if receivedObjects[i] != expected { + t.Errorf("Expected object %s at index %d, got %s", expected, i, receivedObjects[i]) + } + } +} + +func TestClientStreamedListObjects_WithOptions(t *testing.T) { + authModelId := "01ARZ3NDEKTSV4RRFFQ69G5FAV" + objects := []string{"document:1"} + responseBody := `{"result":{"object":"document:1"}}` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + w.Write([]byte(responseBody)) + })) + defer server.Close() + + config := ClientConfiguration{ + ApiUrl: server.URL, + StoreId: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + } + + client, err := NewSdkClient(&config) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + ctx := context.Background() + consistency := openfga.CONSISTENCYPREFERENCE_HIGHER_CONSISTENCY + + response, err := client.StreamedListObjects(ctx). + Body(ClientStreamedListObjectsRequest{ + Type: "document", + Relation: "viewer", + User: "user:anne", + }). + Options(ClientStreamedListObjectsOptions{ + AuthorizationModelId: &authModelId, + Consistency: &consistency, + }). + Execute() + + if err != nil { + t.Fatalf("StreamedListObjects with options failed: %v", err) + } + + defer response.Close() + + receivedObjects := []string{} + for obj := range response.Objects { + receivedObjects = append(receivedObjects, obj.Object) + } + + if len(receivedObjects) != len(objects) { + t.Fatalf("Expected %d objects, got %d", len(objects), len(receivedObjects)) + } +} + +func TestClientStreamedListObjects_ErrorHandling(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"code":"internal_error","message":"Internal server error"}`)) + })) + defer server.Close() + + config := ClientConfiguration{ + ApiUrl: server.URL, + StoreId: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + } + + client, err := NewSdkClient(&config) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + ctx := context.Background() + + _, err = client.StreamedListObjects(ctx). + Body(ClientStreamedListObjectsRequest{ + Type: "document", + Relation: "viewer", + User: "user:anne", + }). + Execute() + + if err == nil { + t.Fatal("Expected error from StreamedListObjects, got nil") + } +} + +func TestClientStreamedListObjects_NoStoreId(t *testing.T) { + config := ClientConfiguration{ + ApiUrl: "http://localhost:8080", + } + + client, err := NewSdkClient(&config) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + ctx := context.Background() + + _, err = client.StreamedListObjects(ctx). + Body(ClientStreamedListObjectsRequest{ + Type: "document", + Relation: "viewer", + User: "user:anne", + }). + Execute() + + if err == nil { + t.Fatal("Expected error for missing store ID, got nil") + } +} + +func TestClientStreamedListObjects_CustomBufferSize(t *testing.T) { + objects := []string{"document:1", "document:2", "document:3", "document:4", "document:5"} + expectedResults := []string{} + for _, obj := range objects { + expectedResults = append(expectedResults, `{"result":{"object":"`+obj+`"}}`) + } + responseBody := strings.Join(expectedResults, "\n") + + storeId := "01ARZ3NDEKTSV4RRFFQ69G5FAV" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + w.Write([]byte(responseBody)) + })) + defer server.Close() + + config := ClientConfiguration{ + ApiUrl: server.URL, + StoreId: storeId, + } + + client, err := NewSdkClient(&config) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + ctx := context.Background() + customBufferSize := 50 + + response, err := client.StreamedListObjects(ctx). + Body(ClientStreamedListObjectsRequest{ + Type: "document", + Relation: "viewer", + User: "user:anne", + }). + Options(ClientStreamedListObjectsOptions{ + StreamBufferSize: &customBufferSize, + }). + Execute() + + if err != nil { + t.Fatalf("StreamedListObjects with custom buffer size failed: %v", err) + } + + defer response.Close() + + receivedObjects := []string{} + for obj := range response.Objects { + receivedObjects = append(receivedObjects, obj.Object) + } + + if err := <-response.Errors; err != nil { + t.Fatalf("Received error from response: %v", err) + } + + if len(receivedObjects) != len(objects) { + t.Fatalf("Expected %d objects, got %d", len(objects), len(receivedObjects)) + } + + for i, expected := range objects { + if receivedObjects[i] != expected { + t.Errorf("Expected object %s at index %d, got %s", expected, i, receivedObjects[i]) + } + } +} + +func TestClientStreamedListObjects_DefaultBufferSize(t *testing.T) { + objects := []string{"document:1", "document:2"} + expectedResults := []string{} + for _, obj := range objects { + expectedResults = append(expectedResults, `{"result":{"object":"`+obj+`"}}`) + } + responseBody := strings.Join(expectedResults, "\n") + + storeId := "01ARZ3NDEKTSV4RRFFQ69G5FAV" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + w.Write([]byte(responseBody)) + })) + defer server.Close() + + config := ClientConfiguration{ + ApiUrl: server.URL, + StoreId: storeId, + } + + client, err := NewSdkClient(&config) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + ctx := context.Background() + zeroBufferSize := 0 + + response, err := client.StreamedListObjects(ctx). + Body(ClientStreamedListObjectsRequest{ + Type: "document", + Relation: "viewer", + User: "user:anne", + }). + Options(ClientStreamedListObjectsOptions{ + StreamBufferSize: &zeroBufferSize, + }). + Execute() + + if err != nil { + t.Fatalf("StreamedListObjects with default buffer size failed: %v", err) + } + + defer response.Close() + + receivedObjects := []string{} + for obj := range response.Objects { + receivedObjects = append(receivedObjects, obj.Object) + } + + if err := <-response.Errors; err != nil { + t.Fatalf("Received error from response: %v", err) + } + + if len(receivedObjects) != len(objects) { + t.Fatalf("Expected %d objects, got %d", len(objects), len(receivedObjects)) + } +} diff --git a/docs/OpenFgaApi.md b/docs/OpenFgaApi.md index 60b61e7..16f41f0 100644 --- a/docs/OpenFgaApi.md +++ b/docs/OpenFgaApi.md @@ -18,6 +18,7 @@ Method | HTTP request | Description [**ReadAuthorizationModel**](OpenFgaApi.md#ReadAuthorizationModel) | **Get** /stores/{store_id}/authorization-models/{id} | Return a particular version of an authorization model [**ReadAuthorizationModels**](OpenFgaApi.md#ReadAuthorizationModels) | **Get** /stores/{store_id}/authorization-models | Return all the authorization models for a particular store [**ReadChanges**](OpenFgaApi.md#ReadChanges) | **Get** /stores/{store_id}/changes | Return a list of all the tuple changes +[**StreamedListObjects**](OpenFgaApi.md#StreamedListObjects) | **Post** /stores/{store_id}/streamed-list-objects | Stream all objects of the given type that the user has a relation with [**Write**](OpenFgaApi.md#Write) | **Post** /stores/{store_id}/write | Add or delete tuples from the store [**WriteAssertions**](OpenFgaApi.md#WriteAssertions) | **Put** /stores/{store_id}/assertions/{authorization_model_id} | Upsert assertions for an authorization model ID [**WriteAuthorizationModel**](OpenFgaApi.md#WriteAuthorizationModel) | **Post** /stores/{store_id}/authorization-models | Create a new authorization model @@ -1325,6 +1326,99 @@ No authorization required [[Back to README]](../README.md) +## StreamedListObjects + +> StreamResultOfStreamedListObjectsResponse StreamedListObjects(ctx).Body(body).Execute() + +Stream all objects of the given type that the user has a relation with + + + +### Example + +```go +package main + +import ( + "context" + "fmt" + "os" + openfga "github.com/openfga/go-sdk" +) + +func main() { + + body := *openapiclient.NewListObjectsRequest("document", "reader", "user:anne") // ListObjectsRequest | + + configuration, err := openfga.NewConfiguration(openfga.Configuration{ + ApiUrl: os.Getenv("FGA_API_URL"), // required, e.g. https://api.fga.example + StoreId: os.Getenv("OPENFGA_STORE_ID"), // not needed when calling `CreateStore` or `ListStores` + }) + + if err != nil { + // .. Handle error + } + + apiClient := openfga.NewAPIClient(configuration) + + resp, r, err := apiClient.OpenFgaApi.StreamedListObjects(context.Background()).Body(body).Execute() + if err != nil { + fmt.Fprintf(os.Stderr, "Error when calling `OpenFgaApi.StreamedListObjects``: %v\n", err) + fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r) + switch v := err.(type) { + case FgaApiAuthenticationError: + // Handle authentication error + case FgaApiValidationError: + // Handle parameter validation error + case FgaApiNotFoundError: + // Handle not found error + case FgaApiInternalError: + // Handle API internal error + case FgaApiRateLimitError: + // Exponential backoff in handling rate limit error + default: + // Handle unknown/undefined error + } + } + // response from `StreamedListObjects`: StreamResultOfStreamedListObjectsResponse + fmt.Fprintf(os.Stdout, "Response from `OpenFgaApi.StreamedListObjects`: %v\n", resp) +} +``` + +### Path Parameters + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- +**ctx** | **context.Context** | context for authentication, logging, cancellation, deadlines, tracing, etc. + +### Other Parameters + +Other parameters are passed through a pointer to a apiStreamedListObjectsRequest struct via the builder pattern + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- +**body** | [**ListObjectsRequest**](ListObjectsRequest.md) | | + +### Return type + +[**StreamResultOfStreamedListObjectsResponse**](StreamResultOfStreamedListObjectsResponse.md) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: application/json +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) +[[Back to Model list]](../README.md#documentation-for-models) +[[Back to README]](../README.md) + + ## Write > map[string]interface{} Write(ctx).Body(body).Execute() diff --git a/docs/StreamResultOfStreamedListObjectsResponse.md b/docs/StreamResultOfStreamedListObjectsResponse.md new file mode 100644 index 0000000..4804b36 --- /dev/null +++ b/docs/StreamResultOfStreamedListObjectsResponse.md @@ -0,0 +1,82 @@ +# StreamResultOfStreamedListObjectsResponse + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Result** | Pointer to [**StreamedListObjectsResponse**](StreamedListObjectsResponse.md) | | [optional] +**Error** | Pointer to [**Status**](Status.md) | | [optional] + +## Methods + +### NewStreamResultOfStreamedListObjectsResponse + +`func NewStreamResultOfStreamedListObjectsResponse() *StreamResultOfStreamedListObjectsResponse` + +NewStreamResultOfStreamedListObjectsResponse instantiates a new StreamResultOfStreamedListObjectsResponse object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewStreamResultOfStreamedListObjectsResponseWithDefaults + +`func NewStreamResultOfStreamedListObjectsResponseWithDefaults() *StreamResultOfStreamedListObjectsResponse` + +NewStreamResultOfStreamedListObjectsResponseWithDefaults instantiates a new StreamResultOfStreamedListObjectsResponse object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetResult + +`func (o *StreamResultOfStreamedListObjectsResponse) GetResult() StreamedListObjectsResponse` + +GetResult returns the Result field if non-nil, zero value otherwise. + +### GetResultOk + +`func (o *StreamResultOfStreamedListObjectsResponse) GetResultOk() (*StreamedListObjectsResponse, bool)` + +GetResultOk returns a tuple with the Result field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetResult + +`func (o *StreamResultOfStreamedListObjectsResponse) SetResult(v StreamedListObjectsResponse)` + +SetResult sets Result field to given value. + +### HasResult + +`func (o *StreamResultOfStreamedListObjectsResponse) HasResult() bool` + +HasResult returns a boolean if a field has been set. + +### GetError + +`func (o *StreamResultOfStreamedListObjectsResponse) GetError() Status` + +GetError returns the Error field if non-nil, zero value otherwise. + +### GetErrorOk + +`func (o *StreamResultOfStreamedListObjectsResponse) GetErrorOk() (*Status, bool)` + +GetErrorOk returns a tuple with the Error field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetError + +`func (o *StreamResultOfStreamedListObjectsResponse) SetError(v Status)` + +SetError sets Error field to given value. + +### HasError + +`func (o *StreamResultOfStreamedListObjectsResponse) HasError() bool` + +HasError returns a boolean if a field has been set. + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/docs/StreamedListObjectsResponse.md b/docs/StreamedListObjectsResponse.md new file mode 100644 index 0000000..7c5f894 --- /dev/null +++ b/docs/StreamedListObjectsResponse.md @@ -0,0 +1,51 @@ +# StreamedListObjectsResponse + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Object** | **string** | | + +## Methods + +### NewStreamedListObjectsResponse + +`func NewStreamedListObjectsResponse(object string, ) *StreamedListObjectsResponse` + +NewStreamedListObjectsResponse instantiates a new StreamedListObjectsResponse object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewStreamedListObjectsResponseWithDefaults + +`func NewStreamedListObjectsResponseWithDefaults() *StreamedListObjectsResponse` + +NewStreamedListObjectsResponseWithDefaults instantiates a new StreamedListObjectsResponse object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetObject + +`func (o *StreamedListObjectsResponse) GetObject() string` + +GetObject returns the Object field if non-nil, zero value otherwise. + +### GetObjectOk + +`func (o *StreamedListObjectsResponse) GetObjectOk() (*string, bool)` + +GetObjectOk returns a tuple with the Object field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetObject + +`func (o *StreamedListObjectsResponse) SetObject(v string)` + +SetObject sets Object field to given value. + + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/example/README.md b/example/README.md index f488e5b..97386b0 100644 --- a/example/README.md +++ b/example/README.md @@ -6,6 +6,9 @@ A set of examples on how to call the OpenFGA Go SDK Example 1: A bare bones example. It creates a store, and runs a set of calls against it including creating a model, writing tuples and checking for access. +**StreamedListObjects Example:** +Demonstrates how to use the `StreamedListObjects` API with both synchronous and asynchronous consumption patterns. +Includes support for configurable buffer sizes to optimize throughput vs memory usage. ### Running the Examples diff --git a/example/streamed_list_objects/README.md b/example/streamed_list_objects/README.md new file mode 100644 index 0000000..050013f --- /dev/null +++ b/example/streamed_list_objects/README.md @@ -0,0 +1,140 @@ +# Streamed List Objects Example + +Demonstrates using `StreamedListObjects` to retrieve objects via the streaming API in the Go SDK. + +## What is StreamedListObjects? + +The Streamed ListObjects API is very similar to the ListObjects API, with two key differences: + +1. **Streaming Results**: Instead of collecting all objects before returning a response, it streams them to the client as they are collected. +2. **No Pagination Limit**: Returns all results without the 1000-object limit of the standard ListObjects API. + +This makes it ideal for scenarios where you need to retrieve large numbers of objects, especially when querying computed relations. + +## Prerequisites + +- OpenFGA server running on `http://localhost:8080` (or set `FGA_API_URL`) + +## Running + +```bash +# From the example directory +cd example/streamed_list_objects +go run . +``` + +## What it does + +- Creates a temporary store +- Writes an authorization model with **computed relations** +- Adds 2000 tuples (1000 owners + 1000 viewers) +- Queries the **computed `can_read` relation** via `StreamedListObjects` +- Shows all 2000 results (demonstrating computed relations) +- Shows progress (first 3 objects and every 500th) +- Cleans up the store + +## Authorization Model + +The example demonstrates OpenFGA's **computed relations**: + +``` +type user + +type document + relations + define owner: [user] + define viewer: [user] + define can_read: owner or viewer +``` + +**Why this matters:** +- We write tuples to `owner` and `viewer` (base permissions) +- We query `can_read` (computed from owner OR viewer) + +**Example flow:** +1. Write: `user:anne owner document:1-1000` +2. Write: `user:anne viewer document:1001-2000` +3. Query: `StreamedListObjects(user:anne, relation:can_read, type:document)` +4. Result: All 2000 documents (because `can_read = owner OR viewer`) + +## Key Features Demonstrated + +### Channel-based Streaming Pattern + +The `StreamedListObjects` method returns a response with channels, which is the idiomatic Go way to handle streaming data: + +```go +response, err := fgaClient.StreamedListObjects(ctx).Body(request).Execute() +if err != nil { + log.Fatal(err) +} +defer response.Close() + +for obj := range response.Objects { + fmt.Printf("Received: %s\n", obj.Object) +} + +// Check for errors +if err := <-response.Errors; err != nil { + log.Fatal(err) +} +``` + +### Early Break and Cleanup + +The streaming implementation properly handles early termination: + +```go +for obj := range response.Objects { + fmt.Println(obj.Object) + if someCondition { + break // Stream is automatically cleaned up via defer response.Close() + } +} +``` + +### Context Cancellation Support + +Full support for `context.Context`: + +```go +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +defer cancel() + +response, err := fgaClient.StreamedListObjects(ctx).Body(request).Execute() +if err != nil { + log.Fatal(err) +} +defer response.Close() + +for obj := range response.Objects { + fmt.Println(obj.Object) +} + +if err := <-response.Errors; err != nil && err != context.Canceled { + log.Fatal(err) +} +``` + +## Benefits Over ListObjects + +- **No Pagination**: Retrieve all objects in a single streaming request +- **Lower Memory**: Objects are processed as they arrive, not held in memory +- **Early Termination**: Can stop streaming at any point without wasting resources +- **Better for Large Results**: Ideal when expecting hundreds or thousands of objects + +## Performance Considerations + +- Streaming starts immediately - no need to wait for all results +- HTTP connection remains open during streaming +- Properly handles cleanup if consumer stops early +- Supports all the same options as `ListObjects` (consistency, contextual tuples, etc.) + +## Error Handling + +The example includes robust error handling that: +- Catches configuration errors +- Detects connection issues +- Avoids logging sensitive data +- Provides helpful messages for common issues + diff --git a/example/streamed_list_objects/go.mod b/example/streamed_list_objects/go.mod new file mode 100644 index 0000000..bad2670 --- /dev/null +++ b/example/streamed_list_objects/go.mod @@ -0,0 +1,36 @@ +module streamed_list_objects + +go 1.25.1 + +require github.com/openfga/go-sdk v0.7.3 + +require ( + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/openfga/api/proto v0.0.0-20240905181937-3583905f61a6 // indirect + github.com/openfga/language/pkg/go v0.2.0-beta.2 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect + golang.org/x/net v0.29.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/grpc v1.66.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/openfga/go-sdk => ../.. // added this to point to local module diff --git a/example/streamed_list_objects/main.go b/example/streamed_list_objects/main.go new file mode 100644 index 0000000..fe19dba --- /dev/null +++ b/example/streamed_list_objects/main.go @@ -0,0 +1,201 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + openfga "github.com/openfga/go-sdk" + "github.com/openfga/go-sdk/client" + "github.com/openfga/language/pkg/go/transformer" +) + +func main() { + ctx := context.Background() + + // Get API URL from environment or use default + apiUrl := os.Getenv("FGA_API_URL") + if apiUrl == "" { + apiUrl = "http://localhost:8080" + } + + // Create initial client for store creation + config := client.ClientConfiguration{ + ApiUrl: apiUrl, + } + fgaClient, err := client.NewSdkClient(&config) + if err != nil { + handleError(err) + return + } + + fmt.Println("Creating temporary store") + store, err := fgaClient.CreateStore(ctx).Body(client.ClientCreateStoreRequest{ + Name: "streamed-list-objects", + }).Execute() + if err != nil { + handleError(err) + return + } + + // Create client with store ID + clientWithStore, err := client.NewSdkClient(&client.ClientConfiguration{ + ApiUrl: apiUrl, + StoreId: store.Id, + }) + if err != nil { + handleError(err) + return + } + + fmt.Println("Writing authorization model") + authModel, err := writeAuthorizationModel(ctx, clientWithStore) + if err != nil { + handleError(err) + return + } + + // Create final client with store ID and authorization model ID + fga, err := client.NewSdkClient(&client.ClientConfiguration{ + ApiUrl: apiUrl, + StoreId: store.Id, + AuthorizationModelId: authModel.AuthorizationModelId, + }) + if err != nil { + handleError(err) + return + } + + fmt.Println("Writing tuples (1000 as owner, 1000 as viewer)") + if err := writeTuples(ctx, fga); err != nil { + handleError(err) + return + } + + fmt.Println("Streaming objects via computed 'can_read' relation...") + if err := streamObjects(ctx, fga); err != nil { + handleError(err) + return + } + + fmt.Println("Cleaning up...") + if _, err := fga.DeleteStore(ctx).Execute(); err != nil { + fmt.Printf("Failed to delete store: %v\n", err) + } + + fmt.Println("Done") +} + +func writeAuthorizationModel(ctx context.Context, fgaClient *client.OpenFgaClient) (*client.ClientWriteAuthorizationModelResponse, error) { + // Define the authorization model using OpenFGA DSL + dslString := `model + schema 1.1 + +type user + +type document + relations + define owner: [user] + define viewer: [user] + define can_read: owner or viewer` + + // Transform DSL to JSON string + modelJSON, err := transformer.TransformDSLToJSON(dslString) + if err != nil { + return nil, fmt.Errorf("failed to transform DSL to JSON: %w", err) + } + + // Parse the JSON into the authorization model request + var authModel openfga.AuthorizationModel + if err := json.Unmarshal([]byte(modelJSON), &authModel); err != nil { + return nil, fmt.Errorf("failed to unmarshal authorization model: %w", err) + } + + return fgaClient.WriteAuthorizationModel(ctx).Body(openfga.WriteAuthorizationModelRequest{ + SchemaVersion: authModel.SchemaVersion, + TypeDefinitions: authModel.TypeDefinitions, + }).Execute() +} + +func writeTuples(ctx context.Context, fga *client.OpenFgaClient) error { + const batchSize = 100 + totalWritten := 0 + + // Write 1000 documents where anne is the owner + for batch := 0; batch < 10; batch++ { + tuples := make([]client.ClientTupleKey, 0, batchSize) + for i := 1; i <= batchSize; i++ { + tuples = append(tuples, client.ClientTupleKey{ + User: "user:anne", + Relation: "owner", + Object: fmt.Sprintf("document:%d", batch*batchSize+i), + }) + } + if _, err := fga.WriteTuples(ctx).Body(tuples).Execute(); err != nil { + return fmt.Errorf("failed to write owner tuples: %w", err) + } + totalWritten += len(tuples) + } + + // Write 1000 documents where anne is a viewer + for batch := 0; batch < 10; batch++ { + tuples := make([]client.ClientTupleKey, 0, batchSize) + for i := 1; i <= batchSize; i++ { + tuples = append(tuples, client.ClientTupleKey{ + User: "user:anne", + Relation: "viewer", + Object: fmt.Sprintf("document:%d", 1000+batch*batchSize+i), + }) + } + if _, err := fga.WriteTuples(ctx).Body(tuples).Execute(); err != nil { + return fmt.Errorf("failed to write viewer tuples: %w", err) + } + totalWritten += len(tuples) + } + + fmt.Printf("Wrote %d tuples\n", totalWritten) + return nil +} + +func streamObjects(ctx context.Context, fga *client.OpenFgaClient) error { + consistencyPreference := openfga.CONSISTENCYPREFERENCE_HIGHER_CONSISTENCY + + response, err := fga.StreamedListObjects(ctx).Body(client.ClientStreamedListObjectsRequest{ + User: "user:anne", + Relation: "can_read", // Computed: owner OR viewer + Type: "document", + }).Options(client.ClientStreamedListObjectsOptions{ + Consistency: &consistencyPreference, + }).Execute() + if err != nil { + return fmt.Errorf("StreamedListObjects failed: %w", err) + } + defer response.Close() + + count := 0 + for obj := range response.Objects { + count++ + if count <= 3 || count%500 == 0 { + fmt.Printf("- %s\n", obj.Object) + } + } + + // Check for streaming errors + if err := <-response.Errors; err != nil { + return fmt.Errorf("error during streaming: %w", err) + } + + fmt.Printf("✓ Streamed %d objects\n", count) + return nil +} + +func handleError(err error) { + // Avoid logging sensitive data; only display generic info + if err.Error() == "connection refused" { + fmt.Fprintln(os.Stderr, "Is OpenFGA server running? Check FGA_API_URL environment variable or default http://localhost:8080") + } else { + fmt.Fprintf(os.Stderr, "An error occurred. [%T]\n", err) + } + os.Exit(1) +} diff --git a/model_stream_result_of_streamed_list_objects_response.go b/model_stream_result_of_streamed_list_objects_response.go new file mode 100644 index 0000000..cc7ef37 --- /dev/null +++ b/model_stream_result_of_streamed_list_objects_response.go @@ -0,0 +1,160 @@ +/** + * Go SDK for OpenFGA + * + * API version: 1.x + * Website: https://openfga.dev + * Documentation: https://openfga.dev/docs + * Support: https://openfga.dev/community + * License: [Apache-2.0](https://github.com/openfga/go-sdk/blob/main/LICENSE) + * + * NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. + */ + +package openfga + +import ( + "bytes" + + "encoding/json" +) + +// StreamResultOfStreamedListObjectsResponse struct for StreamResultOfStreamedListObjectsResponse +type StreamResultOfStreamedListObjectsResponse struct { + Result *StreamedListObjectsResponse `json:"result,omitempty" yaml:"result,omitempty"` + Error *Status `json:"error,omitempty" yaml:"error,omitempty"` +} + +// NewStreamResultOfStreamedListObjectsResponse instantiates a new StreamResultOfStreamedListObjectsResponse object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewStreamResultOfStreamedListObjectsResponse() *StreamResultOfStreamedListObjectsResponse { + this := StreamResultOfStreamedListObjectsResponse{} + return &this +} + +// NewStreamResultOfStreamedListObjectsResponseWithDefaults instantiates a new StreamResultOfStreamedListObjectsResponse object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewStreamResultOfStreamedListObjectsResponseWithDefaults() *StreamResultOfStreamedListObjectsResponse { + this := StreamResultOfStreamedListObjectsResponse{} + return &this +} + +// GetResult returns the Result field value if set, zero value otherwise. +func (o *StreamResultOfStreamedListObjectsResponse) GetResult() StreamedListObjectsResponse { + if o == nil || o.Result == nil { + var ret StreamedListObjectsResponse + return ret + } + return *o.Result +} + +// GetResultOk returns a tuple with the Result field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *StreamResultOfStreamedListObjectsResponse) GetResultOk() (*StreamedListObjectsResponse, bool) { + if o == nil || o.Result == nil { + return nil, false + } + return o.Result, true +} + +// HasResult returns a boolean if a field has been set. +func (o *StreamResultOfStreamedListObjectsResponse) HasResult() bool { + if o != nil && o.Result != nil { + return true + } + + return false +} + +// SetResult gets a reference to the given StreamedListObjectsResponse and assigns it to the Result field. +func (o *StreamResultOfStreamedListObjectsResponse) SetResult(v StreamedListObjectsResponse) { + o.Result = &v +} + +// GetError returns the Error field value if set, zero value otherwise. +func (o *StreamResultOfStreamedListObjectsResponse) GetError() Status { + if o == nil || o.Error == nil { + var ret Status + return ret + } + return *o.Error +} + +// GetErrorOk returns a tuple with the Error field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *StreamResultOfStreamedListObjectsResponse) GetErrorOk() (*Status, bool) { + if o == nil || o.Error == nil { + return nil, false + } + return o.Error, true +} + +// HasError returns a boolean if a field has been set. +func (o *StreamResultOfStreamedListObjectsResponse) HasError() bool { + if o != nil && o.Error != nil { + return true + } + + return false +} + +// SetError gets a reference to the given Status and assigns it to the Error field. +func (o *StreamResultOfStreamedListObjectsResponse) SetError(v Status) { + o.Error = &v +} + +func (o StreamResultOfStreamedListObjectsResponse) MarshalJSON() ([]byte, error) { + toSerialize := map[string]interface{}{} + if o.Result != nil { + toSerialize["result"] = o.Result + } + if o.Error != nil { + toSerialize["error"] = o.Error + } + var b bytes.Buffer + enc := json.NewEncoder(&b) + enc.SetEscapeHTML(false) + err := enc.Encode(toSerialize) + if err != nil { + return nil, err + } + return b.Bytes(), nil +} + +type NullableStreamResultOfStreamedListObjectsResponse struct { + value *StreamResultOfStreamedListObjectsResponse + isSet bool +} + +func (v NullableStreamResultOfStreamedListObjectsResponse) Get() *StreamResultOfStreamedListObjectsResponse { + return v.value +} + +func (v *NullableStreamResultOfStreamedListObjectsResponse) Set(val *StreamResultOfStreamedListObjectsResponse) { + v.value = val + v.isSet = true +} + +func (v NullableStreamResultOfStreamedListObjectsResponse) IsSet() bool { + return v.isSet +} + +func (v *NullableStreamResultOfStreamedListObjectsResponse) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableStreamResultOfStreamedListObjectsResponse(val *StreamResultOfStreamedListObjectsResponse) *NullableStreamResultOfStreamedListObjectsResponse { + return &NullableStreamResultOfStreamedListObjectsResponse{value: val, isSet: true} +} + +func (v NullableStreamResultOfStreamedListObjectsResponse) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableStreamResultOfStreamedListObjectsResponse) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/model_streamed_list_objects_response.go b/model_streamed_list_objects_response.go new file mode 100644 index 0000000..c32820e --- /dev/null +++ b/model_streamed_list_objects_response.go @@ -0,0 +1,115 @@ +/** + * Go SDK for OpenFGA + * + * API version: 1.x + * Website: https://openfga.dev + * Documentation: https://openfga.dev/docs + * Support: https://openfga.dev/community + * License: [Apache-2.0](https://github.com/openfga/go-sdk/blob/main/LICENSE) + * + * NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. + */ + +package openfga + +import ( + "bytes" + + "encoding/json" +) + +// StreamedListObjectsResponse The response for a StreamedListObjects RPC. +type StreamedListObjectsResponse struct { + Object string `json:"object" yaml:"object"` +} + +// NewStreamedListObjectsResponse instantiates a new StreamedListObjectsResponse object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewStreamedListObjectsResponse(object string) *StreamedListObjectsResponse { + this := StreamedListObjectsResponse{} + this.Object = object + return &this +} + +// NewStreamedListObjectsResponseWithDefaults instantiates a new StreamedListObjectsResponse object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewStreamedListObjectsResponseWithDefaults() *StreamedListObjectsResponse { + this := StreamedListObjectsResponse{} + return &this +} + +// GetObject returns the Object field value +func (o *StreamedListObjectsResponse) GetObject() string { + if o == nil { + var ret string + return ret + } + + return o.Object +} + +// GetObjectOk returns a tuple with the Object field value +// and a boolean to check if the value has been set. +func (o *StreamedListObjectsResponse) GetObjectOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Object, true +} + +// SetObject sets field value +func (o *StreamedListObjectsResponse) SetObject(v string) { + o.Object = v +} + +func (o StreamedListObjectsResponse) MarshalJSON() ([]byte, error) { + toSerialize := map[string]interface{}{} + toSerialize["object"] = o.Object + var b bytes.Buffer + enc := json.NewEncoder(&b) + enc.SetEscapeHTML(false) + err := enc.Encode(toSerialize) + if err != nil { + return nil, err + } + return b.Bytes(), nil +} + +type NullableStreamedListObjectsResponse struct { + value *StreamedListObjectsResponse + isSet bool +} + +func (v NullableStreamedListObjectsResponse) Get() *StreamedListObjectsResponse { + return v.value +} + +func (v *NullableStreamedListObjectsResponse) Set(val *StreamedListObjectsResponse) { + v.value = val + v.isSet = true +} + +func (v NullableStreamedListObjectsResponse) IsSet() bool { + return v.isSet +} + +func (v *NullableStreamedListObjectsResponse) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableStreamedListObjectsResponse(val *StreamedListObjectsResponse) *NullableStreamedListObjectsResponse { + return &NullableStreamedListObjectsResponse{value: val, isSet: true} +} + +func (v NullableStreamedListObjectsResponse) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableStreamedListObjectsResponse) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/streaming.go b/streaming.go new file mode 100644 index 0000000..acc5b30 --- /dev/null +++ b/streaming.go @@ -0,0 +1,161 @@ +/** + * Go SDK for OpenFGA + * + * API version: 1.x + * Website: https://openfga.dev + * Documentation: https://openfga.dev/docs + * Support: https://openfga.dev/community + * License: [Apache-2.0](https://github.com/openfga/go-sdk/blob/main/LICENSE) + * + * NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. + */ + +package openfga + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" +) + +type StreamedListObjectsChannel struct { + Objects chan StreamedListObjectsResponse + Errors chan error + cancel context.CancelFunc +} + +func (s *StreamedListObjectsChannel) Close() { + if s.cancel != nil { + s.cancel() + } +} + +func ProcessStreamedListObjectsResponse(ctx context.Context, httpResponse *http.Response, bufferSize int) (*StreamedListObjectsChannel, error) { + streamCtx, cancel := context.WithCancel(ctx) + + // Use default buffer size of 10 if not specified or invalid + if bufferSize <= 0 { + bufferSize = 10 + } + + channel := &StreamedListObjectsChannel{ + Objects: make(chan StreamedListObjectsResponse, bufferSize), + Errors: make(chan error, 1), + cancel: cancel, + } + + if httpResponse == nil || httpResponse.Body == nil { + cancel() + return nil, errors.New("response or response body is nil") + } + + go func() { + defer close(channel.Objects) + defer close(channel.Errors) + defer cancel() + defer httpResponse.Body.Close() + + scanner := bufio.NewScanner(httpResponse.Body) + // Allow large NDJSON entries (up to 10MB). Tune as needed. + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 10*1024*1024) + for scanner.Scan() { + select { + case <-streamCtx.Done(): + channel.Errors <- streamCtx.Err() + return + default: + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var streamResult StreamResultOfStreamedListObjectsResponse + if err := json.Unmarshal(line, &streamResult); err != nil { + channel.Errors <- err + return + } + + if streamResult.Error != nil { + msg := "stream error" + if streamResult.Error.Message != nil { + msg = *streamResult.Error.Message + } + channel.Errors <- errors.New(msg) + return + } + + if streamResult.Result != nil { + select { + case <-streamCtx.Done(): + channel.Errors <- streamCtx.Err() + return + case channel.Objects <- *streamResult.Result: + } + } + } + } + + if err := scanner.Err(); err != nil { + // Prefer context error if we were canceled to avoid surfacing net/http "use of closed network connection". + if streamCtx.Err() != nil { + channel.Errors <- streamCtx.Err() + return + } + channel.Errors <- err + } + }() + + return channel, nil +} + +func ExecuteStreamedListObjects(client *APIClient, ctx context.Context, storeId string, body ListObjectsRequest, options RequestOptions) (*StreamedListObjectsChannel, error) { + return ExecuteStreamedListObjectsWithBufferSize(client, ctx, storeId, body, options, 0) +} + +func ExecuteStreamedListObjectsWithBufferSize(client *APIClient, ctx context.Context, storeId string, body ListObjectsRequest, options RequestOptions, bufferSize int) (*StreamedListObjectsChannel, error) { + path := "/stores/{store_id}/streamed-list-objects" + if storeId == "" { + return nil, reportError("storeId is required and must be specified") + } + + path = strings.ReplaceAll(path, "{"+"store_id"+"}", url.PathEscape(parameterToString(storeId, ""))) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + + localVarHTTPContentType := "application/json" + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + localVarHeaderParams["Accept"] = "application/x-ndjson" + + for header, val := range options.Headers { + localVarHeaderParams[header] = val + } + + req, err := client.prepareRequest(ctx, path, http.MethodPost, body, localVarHeaderParams, localVarQueryParams) + if err != nil { + return nil, err + } + + httpResponse, err := client.callAPI(req) + if err != nil || httpResponse == nil { + return nil, err + } + + if httpResponse.StatusCode >= http.StatusMultipleChoices { + responseBody, readErr := io.ReadAll(httpResponse.Body) + _ = httpResponse.Body.Close() + if readErr != nil { + return nil, readErr + } + err = client.handleAPIError(httpResponse, responseBody, body, "StreamedListObjects", storeId) + return nil, err + } + + return ProcessStreamedListObjectsResponse(ctx, httpResponse, bufferSize) +} diff --git a/streaming_test.go b/streaming_test.go new file mode 100644 index 0000000..66ad213 --- /dev/null +++ b/streaming_test.go @@ -0,0 +1,493 @@ +/** + * Go SDK for OpenFGA + * + * API version: 1.x + * Website: https://openfga.dev + * Documentation: https://openfga.dev/docs + * Support: https://openfga.dev/community + * License: [Apache-2.0](https://github.com/openfga/go-sdk/blob/main/LICENSE) + * + * NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. + */ + +package openfga + +import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +func TestStreamedListObjectsChannel_Close(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + channel := &StreamedListObjectsChannel{ + Objects: make(chan StreamedListObjectsResponse), + Errors: make(chan error), + cancel: cancel, + } + + channel.Close() + + select { + case <-ctx.Done(): + case <-time.After(100 * time.Millisecond): + t.Error("Context was not cancelled") + } +} + +func TestStreamedListObjectsWithChannel_Success(t *testing.T) { + objects := []string{"document:1", "document:2", "document:3"} + expectedResults := []string{} + for _, obj := range objects { + expectedResults = append(expectedResults, `{"result":{"object":"`+obj+`"}}`) + } + responseBody := strings.Join(expectedResults, "\n") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/stores/test-store/streamed-list-objects" { + t.Errorf("Expected path /stores/test-store/streamed-list-objects, got %s", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Errorf("Expected POST method, got %s", r.Method) + } + + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + w.Write([]byte(responseBody)) + })) + defer server.Close() + + config, err := NewConfiguration(Configuration{ + ApiUrl: server.URL, + }) + if err != nil { + t.Fatalf("Failed to create configuration: %v", err) + } + + client := NewAPIClient(config) + ctx := context.Background() + + request := ListObjectsRequest{ + Type: "document", + Relation: "viewer", + User: "user:anne", + } + + channel, err := ExecuteStreamedListObjects(client, ctx, "test-store", request, RequestOptions{}) + + if err != nil { + t.Fatalf("ExecuteStreamedListObjects failed: %v", err) + } + + defer channel.Close() + + receivedObjects := []string{} + for obj := range channel.Objects { + receivedObjects = append(receivedObjects, obj.Object) + } + + if err := <-channel.Errors; err != nil { + t.Fatalf("Received error from channel: %v", err) + } + + if len(receivedObjects) != len(objects) { + t.Fatalf("Expected %d objects, got %d", len(objects), len(receivedObjects)) + } + + for i, expected := range objects { + if receivedObjects[i] != expected { + t.Errorf("Expected object %s at index %d, got %s", expected, i, receivedObjects[i]) + } + } +} + +func TestStreamedListObjectsWithChannel_EmptyLines(t *testing.T) { + responseBody := `{"result":{"object":"document:1"}} + +{"result":{"object":"document:2"}} + +{"result":{"object":"document:3"}}` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + w.Write([]byte(responseBody)) + })) + defer server.Close() + + config, err := NewConfiguration(Configuration{ + ApiUrl: server.URL, + }) + if err != nil { + t.Fatalf("Failed to create configuration: %v", err) + } + + client := NewAPIClient(config) + ctx := context.Background() + + request := ListObjectsRequest{ + Type: "document", + Relation: "viewer", + User: "user:anne", + } + + channel, err := ExecuteStreamedListObjects(client, ctx, "test-store", request, RequestOptions{}) + + if err != nil { + t.Fatalf("ExecuteStreamedListObjects failed: %v", err) + } + + defer channel.Close() + + receivedObjects := []string{} + for obj := range channel.Objects { + receivedObjects = append(receivedObjects, obj.Object) + } + + if len(receivedObjects) != 3 { + t.Fatalf("Expected 3 objects, got %d", len(receivedObjects)) + } +} + +func TestStreamedListObjectsWithChannel_ErrorInStream(t *testing.T) { + responseBody := `{"result":{"object":"document:1"}} +{"error":{"code":500,"message":"Internal error"}}` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + w.Write([]byte(responseBody)) + })) + defer server.Close() + + config, err := NewConfiguration(Configuration{ + ApiUrl: server.URL, + }) + if err != nil { + t.Fatalf("Failed to create configuration: %v", err) + } + + client := NewAPIClient(config) + ctx := context.Background() + + request := ListObjectsRequest{ + Type: "document", + Relation: "viewer", + User: "user:anne", + } + + channel, err := ExecuteStreamedListObjects(client, ctx, "test-store", request, RequestOptions{}) + + if err != nil { + t.Fatalf("ExecuteStreamedListObjects failed: %v", err) + } + + defer channel.Close() + + receivedObjects := []string{} + for obj := range channel.Objects { + receivedObjects = append(receivedObjects, obj.Object) + } + + err = <-channel.Errors + if err == nil { + t.Fatal("Expected error from channel, got nil") + } + + if !strings.Contains(err.Error(), "Internal error") { + t.Errorf("Expected error message to contain 'Internal error', got %s", err.Error()) + } + + if len(receivedObjects) != 1 { + t.Fatalf("Expected 1 object before error, got %d", len(receivedObjects)) + } +} + +func TestStreamedListObjectsWithChannel_InvalidJSON(t *testing.T) { + responseBody := `{"result":{"object":"document:1"}} +invalid json` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + w.Write([]byte(responseBody)) + })) + defer server.Close() + + config, err := NewConfiguration(Configuration{ + ApiUrl: server.URL, + }) + if err != nil { + t.Fatalf("Failed to create configuration: %v", err) + } + + client := NewAPIClient(config) + ctx := context.Background() + + request := ListObjectsRequest{ + Type: "document", + Relation: "viewer", + User: "user:anne", + } + + channel, err := ExecuteStreamedListObjects(client, ctx, "test-store", request, RequestOptions{}) + + if err != nil { + t.Fatalf("ExecuteStreamedListObjects failed: %v", err) + } + + defer channel.Close() + + receivedObjects := []string{} + for obj := range channel.Objects { + receivedObjects = append(receivedObjects, obj.Object) + } + + err = <-channel.Errors + if err == nil { + t.Fatal("Expected error from channel for invalid JSON, got nil") + } + + if len(receivedObjects) != 1 { + t.Fatalf("Expected 1 object before error, got %d", len(receivedObjects)) + } +} + +func TestStreamedListObjectsWithChannel_ContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + for i := 0; i < 100; i++ { + w.Write([]byte(`{"result":{"object":"document:` + strconv.Itoa(i) + `"}}` + "\n")) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + time.Sleep(10 * time.Millisecond) + } + })) + defer server.Close() + + config, err := NewConfiguration(Configuration{ + ApiUrl: server.URL, + }) + if err != nil { + t.Fatalf("Failed to create configuration: %v", err) + } + + client := NewAPIClient(config) + ctx, cancel := context.WithCancel(context.Background()) + + request := ListObjectsRequest{ + Type: "document", + Relation: "viewer", + User: "user:anne", + } + + channel, err := ExecuteStreamedListObjects(client, ctx, "test-store", request, RequestOptions{}) + + if err != nil { + t.Fatalf("ExecuteStreamedListObjects failed: %v", err) + } + + defer channel.Close() + + receivedObjects := []string{} + for i := 0; i < 5; i++ { + obj := <-channel.Objects + receivedObjects = append(receivedObjects, obj.Object) + } + + cancel() + + time.Sleep(100 * time.Millisecond) + + remaining := 0 + for range channel.Objects { + remaining++ + } + + if len(receivedObjects) < 5 { + t.Fatalf("Expected at least 5 objects, got %d", len(receivedObjects)) + } +} + +func TestStreamedListObjectsWithChannel_CustomBufferSize(t *testing.T) { + objects := []string{"document:1", "document:2", "document:3", "document:4", "document:5"} + expectedResults := []string{} + for _, obj := range objects { + expectedResults = append(expectedResults, `{"result":{"object":"`+obj+`"}}`) + } + responseBody := strings.Join(expectedResults, "\n") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + w.Write([]byte(responseBody)) + })) + defer server.Close() + + config, err := NewConfiguration(Configuration{ + ApiUrl: server.URL, + }) + if err != nil { + t.Fatalf("Failed to create configuration: %v", err) + } + + client := NewAPIClient(config) + ctx := context.Background() + + request := ListObjectsRequest{ + Type: "document", + Relation: "viewer", + User: "user:anne", + } + + // Test with custom buffer size using the new function + channel, err := ExecuteStreamedListObjectsWithBufferSize(client, ctx, "test-store", request, RequestOptions{}, 50) + + if err != nil { + t.Fatalf("ExecuteStreamedListObjects failed: %v", err) + } + + defer channel.Close() + + receivedObjects := []string{} + for obj := range channel.Objects { + receivedObjects = append(receivedObjects, obj.Object) + } + + if err := <-channel.Errors; err != nil { + t.Fatalf("Received error from channel: %v", err) + } + + if len(receivedObjects) != len(objects) { + t.Fatalf("Expected %d objects, got %d", len(objects), len(receivedObjects)) + } + + for i, expected := range objects { + if receivedObjects[i] != expected { + t.Errorf("Expected object %s at index %d, got %s", expected, i, receivedObjects[i]) + } + } +} + +func TestStreamedListObjectsWithChannel_DefaultBufferSize(t *testing.T) { + objects := []string{"document:1", "document:2"} + expectedResults := []string{} + for _, obj := range objects { + expectedResults = append(expectedResults, `{"result":{"object":"`+obj+`"}}`) + } + responseBody := strings.Join(expectedResults, "\n") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + w.Write([]byte(responseBody)) + })) + defer server.Close() + + config, err := NewConfiguration(Configuration{ + ApiUrl: server.URL, + }) + if err != nil { + t.Fatalf("Failed to create configuration: %v", err) + } + + client := NewAPIClient(config) + ctx := context.Background() + + request := ListObjectsRequest{ + Type: "document", + Relation: "viewer", + User: "user:anne", + } + + // Test with default buffer size (0 uses default of 10) + channel, err := ExecuteStreamedListObjectsWithBufferSize(client, ctx, "test-store", request, RequestOptions{}, 0) + + if err != nil { + t.Fatalf("ExecuteStreamedListObjects failed: %v", err) + } + + defer channel.Close() + + receivedObjects := []string{} + for obj := range channel.Objects { + receivedObjects = append(receivedObjects, obj.Object) + } + + if err := <-channel.Errors; err != nil { + t.Fatalf("Received error from channel: %v", err) + } + + if len(receivedObjects) != len(objects) { + t.Fatalf("Expected %d objects, got %d", len(objects), len(receivedObjects)) + } +} + +func TestStreamedListObjectsWithChannel_ProperNumericStrings(t *testing.T) { + // Test that document IDs are generated correctly for values >= 10 + // This verifies the fix: using strconv.Itoa(i) instead of string(rune('0'+i%10)) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + // Generate 15 objects to ensure we test values >= 10 + for i := 0; i < 15; i++ { + w.Write([]byte(`{"result":{"object":"document:` + strconv.Itoa(i) + `"}}` + "\n")) + } + })) + defer server.Close() + + config, err := NewConfiguration(Configuration{ + ApiUrl: server.URL, + }) + if err != nil { + t.Fatalf("Failed to create configuration: %v", err) + } + + client := NewAPIClient(config) + ctx := context.Background() + + request := ListObjectsRequest{ + Type: "document", + Relation: "viewer", + User: "user:anne", + } + + channel, err := ExecuteStreamedListObjects(client, ctx, "test-store", request, RequestOptions{}) + + if err != nil { + t.Fatalf("ExecuteStreamedListObjects failed: %v", err) + } + + defer channel.Close() + + receivedObjects := []string{} + for obj := range channel.Objects { + receivedObjects = append(receivedObjects, obj.Object) + } + + if err := <-channel.Errors; err != nil { + t.Fatalf("Received error from channel: %v", err) + } + + expectedObjects := []string{ + "document:0", "document:1", "document:2", "document:3", "document:4", + "document:5", "document:6", "document:7", "document:8", "document:9", + "document:10", "document:11", "document:12", "document:13", "document:14", + } + + if len(receivedObjects) != len(expectedObjects) { + t.Fatalf("Expected %d objects, got %d", len(expectedObjects), len(receivedObjects)) + } + + for i, expected := range expectedObjects { + if receivedObjects[i] != expected { + t.Errorf("At index %d: expected %s, got %s", i, expected, receivedObjects[i]) + } + } +}