diff --git a/config/clients/go/config.overrides.json b/config/clients/go/config.overrides.json index 1dff2ac6d..38d299a48 100644 --- a/config/clients/go/config.overrides.json +++ b/config/clients/go/config.overrides.json @@ -2,7 +2,7 @@ "sdkId": "go", "gitRepoId": "go-sdk", "packageName": "openfga", - "packageVersion": "0.7.3", + "packageVersion": "0.8.2", "packageDescription": "Go SDK for OpenFGA", "packageDetailedDescription": "This is an autogenerated Go SDK for OpenFGA. It provides a wrapper around the [OpenFGA API definition](https://openfga.dev/api).", "fossaComplianceNoticeId": "41c01c64-f74a-414a-9e39-7aeca87bc47b", @@ -12,6 +12,8 @@ "targetGoVersion": "1.24.0", "toolchainGoVersion": "1.25.1", "supportsStreamedListObjects": "streamed_list_objects", + "supportsCallingOtherEndpoints": true, + "hideClientBatchCheckToc": true, "files": { "internal/constants.mustache": { "destinationFilename": "internal/constants/constants.go", @@ -21,13 +23,13 @@ "destinationFilename": "api_client.go", "templateType": "SupportingFiles" }, - "streaming.mustache": { - "destinationFilename": "streaming.go", - "templateType": "SupportingFiles" - }, "streaming_test.mustache": { "destinationFilename": "streaming_test.go", "templateType": "SupportingFiles" + }, + "CONTRIBUTING.md.mustache": { + "destinationFilename": "CONTRIBUTING.md", + "templateType": "SupportingFiles" } } } diff --git a/config/clients/go/template/CONTRIBUTING.md.mustache b/config/clients/go/template/CONTRIBUTING.md.mustache new file mode 100644 index 000000000..3087370fb --- /dev/null +++ b/config/clients/go/template/CONTRIBUTING.md.mustache @@ -0,0 +1,270 @@ +# Contributing to the OpenFGA Go SDK + +Thank you for considering contributing to the OpenFGA Go SDK! This guide will help you get set up and submit effective contributions. + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Prerequisites](#prerequisites) +- [Getting Started](#getting-started) +- [Project Structure](#project-structure) +- [Generated vs Hand-Written Code](#generated-vs-hand-written-code) +- [Cross-SDK Consistency](#cross-sdk-consistency) +- [Development Workflow](#development-workflow) +- [Writing Tests](#writing-tests) +- [PR Requirements](#pr-requirements) +- [Adding New API Methods](#adding-new-api-methods) +- [Working with Telemetry](#working-with-telemetry) +- [Security Considerations](#security-considerations) +- [Getting Help](#getting-help) + +## Code of Conduct + +By participating and contributing to this project, you are expected to uphold our [Code of Conduct](https://{{gitHost}}/{{gitUserId}}/.github/blob/main/CODE_OF_CONDUCT.md). + +## Prerequisites + +- **Go**: Latest two releases supported per [Go's release policy](https://go.dev/doc/devel/release#policy). We target `(latest-1).0` in `go.mod` and use the latest as `toolchain`. +- **golangci-lint**: Used for linting ([install](https://golangci-lint.run/welcome/install/)) +- **gosec** and **govulncheck**: Used for security scanning +- **make**: For running common commands (`make check`, `make test`, etc.) + +## Getting Started + +1. Fork [{{gitHost}}/{{gitUserId}}/{{gitRepoId}}](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}) +2. Clone your fork +3. Create a branch: `git checkout -b feat/your-feature` +4. Make your changes +5. Run `make check` to verify everything passes +6. Push and open a PR + +### Useful commands + +```bash +make fmt # Format code with gofmt +make lint # Format + go vet + golangci-lint +make test # Run all tests with race detection and coverage +make security # Run gosec and govulncheck +make check # All of the above +``` + +Run a single package's tests: +```bash +go test -race -v ./client/... +``` + +## Project Structure + +This SDK has a **dual-layer architecture**: + +- **Low-level layer** (root package): `APIClient`, `APIExecutor`, `OpenFgaApi` generated models, error types +- **High-level layer** (`client/` package): `OpenFgaClient` with fluent API, batch splitting, credential injection, telemetry + +| Package | Purpose | +|---------|---------| +| `client/` | High-level SDK client (Check, BatchCheck, Expand, etc.) | +| `credentials/` | Authentication (none, API token, client credentials) | +| `oauth2/` | OAuth2 token flows and Bearer token transport | +| `telemetry/` | OpenTelemetry metric recording | +| `internal/constants/` | SDK constants (batch limits, retry defaults) | +| `internal/utils/retryutils/` | Exponential backoff, Retry-After header parsing | + +## Generated vs Hand-Written Code + +Parts of this SDK are auto-generated from the [sdk-generator](https://{{gitHost}}/{{gitUserId}}/sdk-generator) repository using OpenAPI Generator. + +### Generated files (will be wiped on regeneration) + +- All `model_*.go` files (data model files) +- `api_open_fga.go` (deprecated in favor of `api_executor.go`) +- `streaming.go` +- `internal/constants/constants.go` — these constants are generated from sdk-generator and are consistent across all OpenFGA SDKs +- `docs/**` + +You can identify generated files by the header comment: `NOTE: This file was auto generated by OpenAPI Generator ... DO NOT EDIT.` + +The full list is tracked in [`.openapi-generator/FILES`](./.openapi-generator/FILES). + +### If you need to change a generated file + +You may modify generated files in this repo, but changes must also be submitted to sdk-generator to persist: + +1. Submit a PR to [{{gitUserId}}/sdk-generator](https://{{gitHost}}/{{gitUserId}}/sdk-generator) with the template/spec changes +2. Open your PR in this repo and **link the sdk-generator PR** in the description +3. Without a linked sdk-generator PR, your changes will be overwritten on the next regeneration + +This workflow ensures fixes propagate to all OpenFGA SDKs (Go, JS, Java, .NET, Python). + +### Hand-written files (safe to edit directly) + +- `api_client.go`, `api_executor.go`, `configuration.go`, `errors.go`, `utils.go`, `response.go` +- `client/**`, `credentials/**`, `oauth2/**`, `telemetry/**` +- `internal/utils/**` +- All `*_test.go` files + +## Cross-SDK Consistency + +OpenFGA maintains SDKs in multiple languages: [Go](https://{{gitHost}}/{{gitUserId}}/go-sdk), [JS/TS](https://{{gitHost}}/{{gitUserId}}/js-sdk), [Java](https://{{gitHost}}/{{gitUserId}}/java-sdk), [.NET](https://{{gitHost}}/{{gitUserId}}/dotnet-sdk), and [Python](https://{{gitHost}}/{{gitUserId}}/python-sdk). These SDKs should behave consistently: + +- **Before implementing a feature or fix**, check how the other SDKs handle it. Use them as reference to ensure your implementation matches the expected behavior and interface. When submitting your PR, note whether the change applies across SDKs. +- **Public interfaces** should match as closely as each language allows — same method names, same parameters, same defaults, same error behavior. +- **Constants** (batch limits, retry defaults, telemetry metric names, etc.) are generated from sdk-generator and must stay consistent across all SDKs. +- **Behavioral contracts** must be identical: which status codes are retried, how Retry-After headers are parsed, how token refresh works, how streaming channels behave. + +If you discover a discrepancy between SDKs, please open an issue in [{{gitUserId}}/sdk-generator](https://{{gitHost}}/{{gitUserId}}/sdk-generator/issues) so it can be tracked and resolved across all SDKs. + +## Development Workflow + +### Code style + +- Format with `gofmt` (not `goimports`) — CI checks this +- Lint with `golangci-lint run` using the repo's `.golangci.yaml` +- Use `openfga.ToPtr[T]()` for optional fields — the older `PtrString()`, `PtrBool()`, `PtrInt()` helpers are deprecated +- Propagate `context.Context` throughout — never drop or ignore contexts +- Use `github.com/sourcegraph/conc` for concurrent batch operations + +### Fluent API pattern + +All client methods follow this pattern: + +```go +response, err := fgaClient.Check(ctx). + Body(client.ClientCheckRequest{ + User: "user:alice", + Relation: "viewer", + Object: "document:budget", + }). + Options(client.ClientCheckOptions{ + AuthorizationModelId: openfga.ToPtr("01H..."), + }). + Execute() +``` + +Maintain this pattern when adding new methods. + +## Writing Tests + +Every PR must include or update tests that exercise the changed code. + +### Test conventions + +- Use `t.Parallel()` at both the top-level test function **and** inside each `t.Run()` subtest +- Use table-driven tests with `t.Run()` for multiple cases +- Use `testify` for assertions: `require` for fatal checks, `assert` for non-fatal +- Mock HTTP calls with `github.com/jarcoal/httpmock` — never make real network calls +- Use `httptest.NewServer` for streaming/NDJSON response tests +- Never use `time.Sleep` — use channels, contexts, or test helpers +- All tests must be race-safe (CI runs with `-race`) + +### Example + +```go +func TestMyFeature(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + expected string + }{ + {"valid input", "foo", "bar"}, + {"empty input", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := myFunction(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} +``` + +## PR Requirements + +### Title format + +PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +type(scope): description +``` + +Scope is optional. Examples: +- `feat: add StreamedListUsers method` +- `fix(client): handle nil response in BatchCheck` +- `chore(deps): bump go.opentelemetry.io/otel` + +**Allowed types:** + +| Type | Use for | +|------|---------| +| `feat` | New features | +| `fix` | Bug fixes | +| `refactor` | Code changes that don't fix bugs or add features | +| `perf` | Performance improvements | +| `chore` | Maintenance (use `chore(docs)`, `chore(ci)`, `chore(test)` for those areas) | +| `revert` | Reverting previous changes | +| `release` | Release PRs (e.g., `release: v0.8.0`) | + +### Checklist + +Before submitting your PR: + +- [ ] `make check` passes (fmt + lint + test + security) +- [ ] Tests added or updated for the changed code +- [ ] Documentation updated if the public interface or functionality changed (README, godoc comments, dedicated doc file if warranted) +- [ ] If generated files were modified, a linked [sdk-generator](https://{{gitHost}}/{{gitUserId}}/sdk-generator) PR is referenced in the description +- [ ] If telemetry metrics were added or changed, the changes are noted for the changelog + +## Adding New API Methods + +New public methods that call the OpenFGA API must follow this checklist: + +1. **Use the executor**: All API methods must go through `api_executor.go` — no custom HTTP logic. Client methods in `client/` call API methods, but API methods must all use the executor. +2. **Set `OperationName`** in `APIExecutorRequest` — this becomes the `fga_client_request_method` telemetry attribute. +3. **Pass `storeId`** through for metric attributes. +4. **Add to `SdkClient` interface** in `client/client.go`. +5. **Add telemetry attributes** in `telemetry/attributes.go` and `telemetry/configuration.go` if the method introduces a new dimension (like `batch_check_size` for BatchCheck). +6. **Validate inputs**: StoreId and AuthorizationModelId must be valid ULIDs (see `internal/utils/IsWellFormedUlidString`). +7. **Wrap errors** using the typed error types from `errors.go` — never return raw errors to users. + +Use existing methods like `Check`, `BatchCheck`, or `ListObjects` in `client/client.go` as reference. + +**Prefer `api_executor.go` over `api_open_fga.go`** — the latter is generated and deprecated. + +## Working with Telemetry + +The SDK emits OpenTelemetry metrics for all client operations using the `fga_client_*` prefix. + +### Key rules + +- **High-cardinality attributes** (e.g., `url_full`, unique per-request IDs) must be **disabled by default** in `DefaultTelemetryConfiguration()`. Some observability providers charge heavily for high cardinality. +- **No PII** in metric attributes — no user IDs, tokens, or request bodies. +- **Unit tests** must use `noop.NewMeterProvider()` — never live OTel exporters. +- **Document changes**: any additions or modifications to metrics must be called out in the changelog on release. + +### Adding a new metric + +1. Define the metric in `telemetry/configuration.go` as a `MetricConfiguration` +2. Add the recording method in `telemetry/metrics.go` +3. Add attribute keys in `telemetry/attributes.go` if needed +4. Test with noop provider + +## Security Considerations + +This SDK handles user credentials. When working on security-sensitive areas (`credentials/`, `oauth2/`, `errors.go`, `api_client.go`, `api_executor.go`): + +- **Never** log or include tokens, client secrets, or credentials in error messages +- **Never** expose sensitive headers in string representations or debug output +- OAuth2 token refresh must be **thread-safe** (`OpenFgaClient` is shared across goroutines) +- Always **close HTTP response bodies** in all code paths, including error paths +- **Retry only** on 429 and 5xx — never retry 4xx client errors (except 429) +- **Respect** `Retry-After` and `X-RateLimit-Reset` headers for backoff timing +- Do not remove token expiry **jitter** (300s) — it prevents thundering herd against token issuers + +## Getting Help + +- **Questions or problems**: Join the [OpenFGA discussions](https://{{gitHost}}/orgs/{{gitUserId}}/discussions) or [community]({{supportInfo}}) +- **Bug reports and feature requests**: Open an issue in [{{gitUserId}}/{{gitRepoId}}](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/issues) +- **Cross-SDK issues**: Also report in [{{gitUserId}}/sdk-generator](https://{{gitHost}}/{{gitUserId}}/sdk-generator/issues) and link the issues +- **Security vulnerabilities**: Do not use the public issue tracker. Follow the [Responsible Disclosure Program](https://github.com/openfga/.github/blob/main/SECURITY.md) diff --git a/config/clients/go/template/README_calling_other_endpoints.mustache b/config/clients/go/template/README_calling_other_endpoints.mustache new file mode 100644 index 000000000..58cb53306 --- /dev/null +++ b/config/clients/go/template/README_calling_other_endpoints.mustache @@ -0,0 +1,145 @@ +In certain cases you may want to call other APIs not yet wrapped by the SDK. You can do so by using the `APIExecutor` available from the `fgaClient`. +The `APIExecutor` allows you to make raw HTTP calls to any OpenFGA endpoint by specifying the operation name, HTTP method, path, parameters, body, and headers, while still honoring the client configuration (authentication, telemetry, retries, and error handling). + +This is useful when: + +- you want to call a new endpoint that is not yet supported by the SDK +- you are using an earlier version of the SDK that doesn't yet support a particular endpoint +- you have a custom endpoint deployed that extends the OpenFGA API + +In all cases, you initialize the SDK the same way as usual, and then get the `APIExecutor` from the `fgaClient` instance. + +```go +// Initialize the client, same as above +fgaClient, err := NewSdkClient(&ClientConfiguration{...}) + +// Get the generic API executor +executor := fgaClient.GetAPIExecutor() + +// Custom new endpoint that doesn't exist in the SDK yet +requestBody := map[string]interface{}{ + "user": "user:bob", + "action": "custom_action", + "resource": "resource:123", +} + +// Build the request +request := openfga.NewAPIExecutorRequestBuilder("CustomEndpoint", http.MethodPost, "/stores/{store_id}/custom-endpoint"). + WithPathParameter("store_id", storeID). + WithQueryParameter("page_size", "20"). + WithQueryParameter("continuation_token", "eyJwayI6..."). + WithBody(requestBody). + WithHeader("X-Experimental-Feature", "enabled"). + Build() +``` + +#### Example: Calling a new "Custom Endpoint" endpoint and handling raw response + +```go +// Get raw response without automatic decoding +rawResponse, err := executor.Execute(ctx, request) + +if err != nil { + log.Fatalf("Custom endpoint failed: %v", err) +} + +// Manually decode the response +var result map[string]interface{} +if err := json.Unmarshal(rawResponse.Body, &result); err != nil { + log.Fatalf("Failed to decode: %v", err) +} + +fmt.Printf("Response: %+v\n", result) + +// You can access fields like headers, status code, etc. from rawResponse: +fmt.Printf("Status Code: %d\n", rawResponse.StatusCode) +fmt.Printf("Headers: %+v\n", rawResponse.Headers) +``` + +#### Example: Calling a new "Custom Endpoint" endpoint and decoding response into a struct + +```go +// Define a struct to hold the response +type CustomEndpointResponse struct { + Allowed bool `json:"allowed"` + Reason string `json:"reason"` +} +var customEndpointResponse CustomEndpointResponse + +// Get raw response decoded into CustomEndpointResponse struct +rawResponse, err := executor.ExecuteWithDecode(ctx, request, &customEndpointResponse) // Pass pointer to struct for decoding + +if err != nil { + log.Fatalf("Custom endpoint failed: %v", err) +} + +fmt.Printf("Response: %+v\n", customEndpointResponse) + +// You can access fields like headers, status code, etc. from rawResponse: +fmt.Printf("Status Code: %d\n", rawResponse.StatusCode) +fmt.Printf("Headers: %+v\n", rawResponse.Headers) +``` + +#### Example: Calling streaming endpoints (e.g., StreamedListObjects) + +For streaming API endpoints, use the `ExecuteStreaming` method. This is useful for endpoints like `StreamedListObjects` that stream results as they are computed rather than waiting for all results before responding. + +```go +// Get the generic API executor +executor := fgaClient.GetAPIExecutor() + +// Build a streaming request for StreamedListObjects +request := openfga.NewAPIExecutorRequestBuilder("StreamedListObjects", http.MethodPost, "/stores/{store_id}/streamed-list-objects"). + WithPathParameter("store_id", storeID). + WithBody(openfga.ListObjectsRequest{ + AuthorizationModelId: openfga.PtrString(modelID), + Type: "document", + Relation: "viewer", + User: "user:alice", + }). + Build() + +// Execute the streaming request +// The bufferSize parameter controls how many results can be buffered in the channel +channel, err := executor.ExecuteStreaming(ctx, request, openfga.DefaultStreamBufferSize) +if err != nil { + log.Fatalf("Streaming request failed: %v", err) +} +defer channel.Close() // Always close the channel when done + +// Process results as they stream in +for { + select { + case result, ok := <-channel.Results: + if !ok { + // Results channel closed, stream completed + // Check for any final errors + select { + case err := <-channel.Errors: + if err != nil { + log.Fatalf("Stream error: %v", err) + } + default: + } + fmt.Println("Stream completed successfully") + return + } + // Decode the raw JSON bytes into a typed response + var response openfga.StreamedListObjectsResponse + if err := json.Unmarshal(result, &response); err != nil { + log.Fatalf("Failed to decode stream result: %v", err) + } + fmt.Printf("Received object: %s\n", response.Object) + case err := <-channel.Errors: + if err != nil { + log.Fatalf("Stream error: %v", err) + } + } +} +``` + +The `ExecuteStreaming` method returns an `APIExecutorStreamingChannel` with: +- `Results chan []byte`: Raw JSON bytes for each streamed result +- `Errors chan error`: Any errors that occur during streaming +- `Close()`: Method to cancel streaming and cleanup resources + diff --git a/config/clients/go/template/api.mustache b/config/clients/go/template/api.mustache index 235297a29..bf1e66c93 100644 --- a/config/clients/go/template/api.mustache +++ b/config/clients/go/template/api.mustache @@ -4,6 +4,9 @@ package {{packageName}} {{#operations}} import ( "context" +{{#supportsStreamedListObjects}} + "encoding/json" +{{/supportsStreamedListObjects}} "net/http" "net/url" "time" @@ -16,9 +19,16 @@ var ( {{#generateInterfaces}} type RequestOptions struct { - Headers map[string]string `json:"headers,omitempty"` + Headers map[string]string `json:"headers,omitempty"` } +{{#supportsStreamedListObjects}} +type StreamingRequestOptions struct { + RequestOptions + BufferSize int `json:"buffer_size,omitempty"` +} + +{{/supportsStreamedListObjects}} type {{classname}} interface { {{#operation}} @@ -34,10 +44,11 @@ type {{classname}} interface { {{{nickname}}}(ctx context.Context{{#pathParams}}, {{paramName}} {{{dataType}}}{{/pathParams}}) {{#structPrefix}}{{&classname}}{{/structPrefix}}Api{{operationId}}Request /* - * {{nickname}}Execute executes the request{{#returnType}} - * @return {{{.}}}{{/returnType}} + * {{nickname}}Execute executes the request{{^vendorExtensions.x-streaming}}{{#returnType}} + * @return {{{.}}}{{/returnType}}{{/vendorExtensions.x-streaming}}{{#vendorExtensions.x-streaming}} + * @return *StreamedListObjectsChannel{{/vendorExtensions.x-streaming}} */ - {{nickname}}Execute(r {{#structPrefix}}{{&classname}}{{/structPrefix}}Api{{operationId}}Request) ({{#returnType}}{{{.}}}, {{/returnType}}*http.Response, error) + {{nickname}}Execute(r {{#structPrefix}}{{&classname}}{{/structPrefix}}Api{{operationId}}Request) {{^vendorExtensions.x-streaming}}({{#returnType}}{{{.}}}, {{/returnType}}*http.Response, error){{/vendorExtensions.x-streaming}}{{#vendorExtensions.x-streaming}}(*StreamedListObjectsChannel, error){{/vendorExtensions.x-streaming}} {{/operation}} } {{/generateInterfaces}} @@ -56,7 +67,7 @@ type {{#structPrefix}}{{&classname}}{{/structPrefix}}Api{{operationId}}Request s {{#allParams}} {{paramName}} {{^isPathParam}}*{{/isPathParam}}{{{dataType}}} {{/allParams}} - options RequestOptions + options {{^vendorExtensions.x-streaming}}RequestOptions{{/vendorExtensions.x-streaming}}{{#vendorExtensions.x-streaming}}StreamingRequestOptions{{/vendorExtensions.x-streaming}} } {{#allParams}}{{^isPathParam}} @@ -65,6 +76,7 @@ func (r {{#structPrefix}}{{&classname}}{{/structPrefix}}Api{{operationId}}Reques return r }{{/isPathParam}}{{/allParams}} +{{^vendorExtensions.x-streaming}} func (r {{#structPrefix}}{{&classname}}{{/structPrefix}}Api{{operationId}}Request) Options(options RequestOptions) {{#structPrefix}}{{&classname}}{{/structPrefix}}Api{{operationId}}Request { r.options = options return r @@ -73,6 +85,71 @@ func (r {{#structPrefix}}{{&classname}}{{/structPrefix}}Api{{operationId}}Reques func (r {{#structPrefix}}{{&classname}}{{/structPrefix}}Api{{operationId}}Request) Execute() ({{#returnType}}{{{.}}}, {{/returnType}}*http.Response, error) { return r.ApiService.{{nickname}}Execute(r) } +{{/vendorExtensions.x-streaming}}{{#vendorExtensions.x-streaming}} +func (r {{#structPrefix}}{{&classname}}{{/structPrefix}}Api{{operationId}}Request) Options(options StreamingRequestOptions) {{#structPrefix}}{{&classname}}{{/structPrefix}}Api{{operationId}}Request { + r.options = options + return r +} + +// Execute executes the StreamedListObjects request and returns a streaming channel. +// The returned StreamedListObjectsChannel provides Objects and Errors channels for consuming +// the streamed results. The caller must call Close() on the channel when done. +// +// Example usage: +// +// channel, err := client.OpenFgaApi.StreamedListObjects(ctx, storeId). +// Body(body). +// Execute() +// if err != nil { +// return err +// } +// defer channel.Close() +// +// for { +// select { +// case obj, ok := <-channel.Objects: +// if !ok { +// // Objects channel closed; drain any terminal error before returning. +// select { +// case err := <-channel.Errors: +// return err // nil on clean EOF, non-nil on stream error +// default: +// return nil +// } +// } +// fmt.Printf("Object: %s\n", obj.Object) +// case err := <-channel.Errors: +// if err != nil { +// return err +// } +// } +// } +func (r {{#structPrefix}}{{&classname}}{{/structPrefix}}Api{{operationId}}Request) Execute() (*StreamedListObjectsChannel, error) { + return r.ApiService.{{nickname}}Execute(r) +} + +// StreamedListObjectsChannel provides channels for consuming StreamedListObjects results. +// It maintains backward compatibility with the streaming response structure. +type StreamedListObjectsChannel struct { + // Objects channel receives StreamedListObjectsResponse for each streamed object. + // The channel is closed when the stream ends or an error occurs. + Objects chan StreamedListObjectsResponse + // Errors channel receives any errors that occur during streaming. + // Only one error will be sent before the channel is closed. + Errors chan error + // cancel is the function to cancel the streaming context + cancel context.CancelFunc +} + +// Close cancels the streaming context and cleans up resources. +// It is safe to call Close multiple times. +// Always defer Close() after successfully creating a streaming channel. +func (s *StreamedListObjectsChannel) Close() { + if s.cancel != nil { + s.cancel() + } +} +{{/vendorExtensions.x-streaming}} /* * {{operationId}}{{#summary}} {{{.}}}{{/summary}}{{^summary}} Method for {{operationId}}{{/summary}} @@ -91,6 +168,7 @@ func (a *{{{classname}}}Service) {{{nickname}}}(ctx context.Context{{#pathParams } } +{{^vendorExtensions.x-streaming}} /* * Execute executes the request{{#returnType}} * @return {{{.}}}{{/returnType}} @@ -144,5 +222,103 @@ func (a *{{{classname}}}Service) {{nickname}}Execute(r {{#structPrefix}}{{&class return {{#returnType}}returnValue, {{/returnType}}nil, err } +{{/vendorExtensions.x-streaming}}{{#vendorExtensions.x-streaming}} +/* + * Execute executes the request + * @return *StreamedListObjectsChannel + */ +func (a *{{{classname}}}Service) {{nickname}}Execute(r {{#structPrefix}}{{&classname}}{{/structPrefix}}Api{{operationId}}Request) (*StreamedListObjectsChannel, error) { + {{#pathParams}} + if err := validatePathParameter("{{paramName}}", r.{{paramName}}); err != nil { + return nil, err + } + {{/pathParams}} + {{#allParams}} + {{#required}} + {{^isPathParam}} + if err := validateParameter("{{paramName}}", r.{{paramName}}); err != nil { + return nil, err + } + {{/isPathParam}} + {{/required}} + {{/allParams}} + + // Use the APIExecutor to execute the streaming request + executor := a.client.GetAPIExecutor() + + request := NewAPIExecutorRequestBuilder("{{operationId}}", http.Method{{httpMethod}}, "{{{path}}}").{{#pathParams}} + WithPathParameter("{{baseName}}", r.{{paramName}}).{{/pathParams}}{{#bodyParams}} + WithBody(r.{{paramName}}).{{/bodyParams}} + WithHeaders(r.options.Headers). + Build() + + rawChannel, err := executor.ExecuteStreaming(r.ctx, request, r.options.BufferSize) + if err != nil { + return nil, err + } + + // Convert raw JSON bytes to typed {{vendorExtensions.x-streaming-response-type}} + return convertToStreamedListObjectsChannel(r.ctx, rawChannel), nil +} + +// convertToStreamedListObjectsChannel converts an APIExecutorStreamingChannel (raw bytes) to +// a typed StreamedListObjectsChannel. +func convertToStreamedListObjectsChannel(ctx context.Context, rawChannel *APIExecutorStreamingChannel) *StreamedListObjectsChannel { + streamCtx, cancel := context.WithCancel(ctx) + + typedChannel := &StreamedListObjectsChannel{ + Objects: make(chan StreamedListObjectsResponse, cap(rawChannel.Results)), + Errors: make(chan error, 1), + cancel: cancel, + } + + go func() { + defer close(typedChannel.Objects) + defer close(typedChannel.Errors) + defer cancel() + defer rawChannel.Close() + + for { + select { + case <-streamCtx.Done(): + typedChannel.Errors <- streamCtx.Err() + return + default: + } + + // Only read from Results so buffered results are never skipped + // when both channels are ready (Go select is non-deterministic). + select { + case <-streamCtx.Done(): + typedChannel.Errors <- streamCtx.Err() + return + case rawResult, ok := <-rawChannel.Results: + if !ok { + // Results drained; forward any error from the raw producer. + if err, ok := <-rawChannel.Errors; ok && err != nil { + typedChannel.Errors <- err + } + return + } + + var response StreamedListObjectsResponse + if err := json.Unmarshal(rawResult, &response); err != nil { + typedChannel.Errors <- err + return + } + + select { + case <-streamCtx.Done(): + typedChannel.Errors <- streamCtx.Err() + return + case typedChannel.Objects <- response: + } + } + } + }() + + return typedChannel +} +{{/vendorExtensions.x-streaming}} {{/operation}} {{/operations}} diff --git a/config/clients/go/template/internal/constants.mustache b/config/clients/go/template/internal/constants.mustache index cb48c8dc8..bb158f120 100644 --- a/config/clients/go/template/internal/constants.mustache +++ b/config/clients/go/template/internal/constants.mustache @@ -7,7 +7,7 @@ const ( SdkVersion = "{{packageVersion}}" // x-release-please-version // UserAgent is the user agent used in HTTP requests. - UserAgent = "{{userAgent}}" + UserAgent = "openfga-sdk go/" + SdkVersion // SampleBaseDomain is the example API domain for documentation/tests. SampleBaseDomain = "{{sampleApiDomain}}" diff --git a/config/clients/go/template/streaming.mustache b/config/clients/go/template/streaming.mustache deleted file mode 100644 index 610e914f5..000000000 --- a/config/clients/go/template/streaming.mustache +++ /dev/null @@ -1,242 +0,0 @@ -{{>partial_header}} -package {{packageName}} - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "io" - "net/http" - "net/url" - "strings" -) - -// StreamResult represents a generic streaming result wrapper with either a result or an error -type StreamResult[T any] struct { - Result *T `json:"result,omitempty" yaml:"result,omitempty"` - Error *Status `json:"error,omitempty" yaml:"error,omitempty"` -} - -// StreamingChannel represents a generic channel for streaming responses -type StreamingChannel[T any] struct { - Results chan T - Errors chan error - cancel context.CancelFunc -} - -// Close cancels the streaming context and cleans up resources -func (s *StreamingChannel[T]) Close() { - if s.cancel != nil { - s.cancel() - } -} - -// ProcessStreamingResponse processes an HTTP response as a streaming NDJSON response -// and returns a StreamingChannel with results and errors -// -// Parameters: -// - ctx: The context for cancellation -// - httpResponse: The HTTP response to process -// - bufferSize: The buffer size for the channels (default 10 if <= 0) -// -// Returns: -// - *StreamingChannel[T]: A channel containing streaming results and errors -// - error: An error if the response is invalid -func ProcessStreamingResponse[T any](ctx context.Context, httpResponse *http.Response, bufferSize int) (*StreamingChannel[T], error) { - streamCtx, cancel := context.WithCancel(ctx) - - // Use default buffer size of 10 if not specified or invalid - if bufferSize <= 0 { - bufferSize = 10 - } - - channel := &StreamingChannel[T]{ - Results: make(chan T, 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.Results) - defer close(channel.Errors) - defer cancel() - defer func() { _ = 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 StreamResult[T] - 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.Results <- *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 -} - -// StreamedListObjectsChannel maintains backward compatibility with the old channel structure -type StreamedListObjectsChannel struct { - Objects chan StreamedListObjectsResponse - Errors chan error - cancel context.CancelFunc -} - -// Close cancels the streaming context and cleans up resources -func (s *StreamedListObjectsChannel) Close() { - if s.cancel != nil { - s.cancel() - } -} - -// ProcessStreamedListObjectsResponse processes a StreamedListObjects response -// This is a backward compatibility wrapper around ProcessStreamingResponse -func ProcessStreamedListObjectsResponse(ctx context.Context, httpResponse *http.Response, bufferSize int) (*StreamedListObjectsChannel, error) { - channel, err := ProcessStreamingResponse[StreamedListObjectsResponse](ctx, httpResponse, bufferSize) - if err != nil { - return nil, err - } - - // Create a new channel with the old field name for backward compatibility - compatChannel := &StreamedListObjectsChannel{ - Objects: channel.Results, - Errors: channel.Errors, - cancel: channel.cancel, - } - - return compatChannel, nil -} - -// ExecuteStreamedListObjects executes a StreamedListObjects request -func ExecuteStreamedListObjects(client *APIClient, ctx context.Context, storeId string, body ListObjectsRequest, options RequestOptions) (*StreamedListObjectsChannel, error) { - return ExecuteStreamedListObjectsWithBufferSize(client, ctx, storeId, body, options, 0) -} - -// ExecuteStreamedListObjectsWithBufferSize executes a StreamedListObjects request with a custom buffer size -func ExecuteStreamedListObjectsWithBufferSize(client *APIClient, ctx context.Context, storeId string, body ListObjectsRequest, options RequestOptions, bufferSize int) (*StreamedListObjectsChannel, error) { - channel, err := executeStreamingRequest[ListObjectsRequest, StreamedListObjectsResponse]( - client, - ctx, - "/stores/{store_id}/streamed-list-objects", - storeId, - body, - options, - bufferSize, - "StreamedListObjects", - ) - if err != nil { - return nil, err - } - - // Convert to backward-compatible channel structure - return &StreamedListObjectsChannel{ - Objects: channel.Results, - Errors: channel.Errors, - cancel: channel.cancel, - }, nil -} - -// executeStreamingRequest is a generic function to execute streaming requests -func executeStreamingRequest[TReq any, TRes any]( - client *APIClient, - ctx context.Context, - pathTemplate string, - storeId string, - body TReq, - options RequestOptions, - bufferSize int, - operationName string, -) (*StreamingChannel[TRes], error) { - if storeId == "" { - return nil, reportError("storeId is required and must be specified") - } - - path := pathTemplate - 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 { - return nil, err - } - if httpResponse == nil { - return nil, errors.New("nil HTTP response from API client") - } - - 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, operationName, storeId) - return nil, err - } - - return ProcessStreamingResponse[TRes](ctx, httpResponse, bufferSize) -} - - diff --git a/config/clients/go/template/streaming_test.mustache b/config/clients/go/template/streaming_test.mustache index 38938e7f9..83c3cecdc 100644 --- a/config/clients/go/template/streaming_test.mustache +++ b/config/clients/go/template/streaming_test.mustache @@ -45,7 +45,7 @@ func TestStreamedListObjectsChannel_Close(t *testing.T) { } } -func TestStreamedListObjectsWithChannel_Success(t *testing.T) { +func TestStreamedListObjectsWithAPI_Success(t *testing.T) { objects := []string{"document:1", "document:2", "document:3"} expectedResults := []string{} for _, obj := range objects { @@ -83,10 +83,12 @@ func TestStreamedListObjectsWithChannel_Success(t *testing.T) { User: "user:anne", } - channel, err := ExecuteStreamedListObjects(client, ctx, "test-store", request, RequestOptions{}) + channel, err := client.OpenFgaApi.StreamedListObjects(ctx, "test-store"). + Body(request). + Execute() if err != nil { - t.Fatalf("ExecuteStreamedListObjects failed: %v", err) + t.Fatalf("StreamedListObjects failed: %v", err) } defer channel.Close() @@ -111,7 +113,7 @@ func TestStreamedListObjectsWithChannel_Success(t *testing.T) { } } -func TestStreamedListObjectsWithChannel_EmptyLines(t *testing.T) { +func TestStreamedListObjectsWithAPI_EmptyLines(t *testing.T) { responseBody := `{"result":{"object":"document:1"}} {"result":{"object":"document:2"}} @@ -141,10 +143,12 @@ func TestStreamedListObjectsWithChannel_EmptyLines(t *testing.T) { User: "user:anne", } - channel, err := ExecuteStreamedListObjects(client, ctx, "test-store", request, RequestOptions{}) + channel, err := client.OpenFgaApi.StreamedListObjects(ctx, "test-store"). + Body(request). + Execute() if err != nil { - t.Fatalf("ExecuteStreamedListObjects failed: %v", err) + t.Fatalf("StreamedListObjects failed: %v", err) } defer channel.Close() @@ -159,7 +163,7 @@ func TestStreamedListObjectsWithChannel_EmptyLines(t *testing.T) { } } -func TestStreamedListObjectsWithChannel_ErrorInStream(t *testing.T) { +func TestStreamedListObjectsWithAPI_ErrorInStream(t *testing.T) { responseBody := `{"result":{"object":"document:1"}} {"error":{"code":500,"message":"Internal error"}}` @@ -186,10 +190,12 @@ func TestStreamedListObjectsWithChannel_ErrorInStream(t *testing.T) { User: "user:anne", } - channel, err := ExecuteStreamedListObjects(client, ctx, "test-store", request, RequestOptions{}) + channel, err := client.OpenFgaApi.StreamedListObjects(ctx, "test-store"). + Body(request). + Execute() if err != nil { - t.Fatalf("ExecuteStreamedListObjects failed: %v", err) + t.Fatalf("StreamedListObjects failed: %v", err) } defer channel.Close() @@ -213,7 +219,7 @@ func TestStreamedListObjectsWithChannel_ErrorInStream(t *testing.T) { } } -func TestStreamedListObjectsWithChannel_InvalidJSON(t *testing.T) { +func TestStreamedListObjectsWithAPI_InvalidJSON(t *testing.T) { responseBody := `{"result":{"object":"document:1"}} invalid json` @@ -240,10 +246,12 @@ invalid json` User: "user:anne", } - channel, err := ExecuteStreamedListObjects(client, ctx, "test-store", request, RequestOptions{}) + channel, err := client.OpenFgaApi.StreamedListObjects(ctx, "test-store"). + Body(request). + Execute() if err != nil { - t.Fatalf("ExecuteStreamedListObjects failed: %v", err) + t.Fatalf("StreamedListObjects failed: %v", err) } defer channel.Close() @@ -263,7 +271,7 @@ invalid json` } } -func TestStreamedListObjectsWithChannel_ContextCancellation(t *testing.T) { +func TestStreamedListObjectsWithAPI_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) @@ -293,10 +301,12 @@ func TestStreamedListObjectsWithChannel_ContextCancellation(t *testing.T) { User: "user:anne", } - channel, err := ExecuteStreamedListObjects(client, ctx, "test-store", request, RequestOptions{}) + channel, err := client.OpenFgaApi.StreamedListObjects(ctx, "test-store"). + Body(request). + Execute() if err != nil { - t.Fatalf("ExecuteStreamedListObjects failed: %v", err) + t.Fatalf("StreamedListObjects failed: %v", err) } defer channel.Close() @@ -321,7 +331,7 @@ func TestStreamedListObjectsWithChannel_ContextCancellation(t *testing.T) { } } -func TestStreamedListObjectsWithChannel_CustomBufferSize(t *testing.T) { +func TestStreamedListObjectsWithAPI_ManyObjects(t *testing.T) { numObjects := 100 expectedResults := []string{} for i := 0; i < numObjects; i++ { @@ -352,11 +362,12 @@ func TestStreamedListObjectsWithChannel_CustomBufferSize(t *testing.T) { User: "user:anne", } - // Use custom buffer size of 50 - channel, err := ExecuteStreamedListObjectsWithBufferSize(client, ctx, "test-store", request, RequestOptions{}, 50) + channel, err := client.OpenFgaApi.StreamedListObjects(ctx, "test-store"). + Body(request). + Execute() if err != nil { - t.Fatalf("ExecuteStreamedListObjectsWithBufferSize failed: %v", err) + t.Fatalf("StreamedListObjects failed: %v", err) } defer channel.Close() @@ -392,13 +403,10 @@ func TestProcessStreamingResponse_Generic(t *testing.T) { t.Fatalf("Failed to make request: %v", err) } - ctx := context.Background() - channel, err := ProcessStreamingResponse[StreamedListObjectsResponse](ctx, resp, 10) - + channel, err := ProcessStreamingResponse[StreamedListObjectsResponse](context.Background(), resp, 10) if err != nil { t.Fatalf("ProcessStreamingResponse failed: %v", err) } - defer channel.Close() receivedObjects := []string{} @@ -406,18 +414,31 @@ func TestProcessStreamingResponse_Generic(t *testing.T) { receivedObjects = append(receivedObjects, obj.Object) } - if err := <-channel.Errors; err != nil { - t.Fatalf("Received error from channel: %v", err) - } - if len(receivedObjects) != 2 { t.Fatalf("Expected 2 objects, got %d", len(receivedObjects)) } +} - if receivedObjects[0] != "document:1" { - t.Errorf("Expected document:1, got %s", receivedObjects[0]) +func TestProcessStreamingResponse_NilResponse(t *testing.T) { + channel, err := ProcessStreamingResponse[StreamedListObjectsResponse](context.Background(), nil, 10) + if err == nil { + t.Fatal("Expected error for nil response, got nil") + } + if channel != nil { + t.Fatal("Expected nil channel for nil response") + } +} + +func TestProcessStreamingResponse_NilBody(t *testing.T) { + resp := &http.Response{ + StatusCode: 200, + Body: nil, + } + channel, err := ProcessStreamingResponse[StreamedListObjectsResponse](context.Background(), resp, 10) + if err == nil { + t.Fatal("Expected error for nil body, got nil") } - if receivedObjects[1] != "document:2" { - t.Errorf("Expected document:2, got %s", receivedObjects[1]) + if channel != nil { + t.Fatal("Expected nil channel for nil body") } } diff --git a/config/clients/java/template/README_calling_other_endpoints.mustache b/config/clients/java/template/README_calling_other_endpoints.mustache new file mode 100644 index 000000000..72d7b64a0 --- /dev/null +++ b/config/clients/java/template/README_calling_other_endpoints.mustache @@ -0,0 +1,103 @@ +The API Executor provides direct HTTP access to OpenFGA endpoints not yet wrapped by the SDK. It maintains the SDK's client configuration including authentication, telemetry, retries, and error handling. + +Use cases: +- Calling endpoints not yet supported by the SDK +- Using an SDK version that lacks support for a particular endpoint +- Accessing custom endpoints that extend the OpenFGA API + +Initialize the SDK normally and access the API Executor via the `fgaClient` instance: + +```java +// Initialize the client, same as above +ClientConfiguration config = new ClientConfiguration() + .apiUrl("http://localhost:8080") + .storeId("01YCP46JKYM8FJCQ37NMBYHE5X"); +OpenFgaClient fgaClient = new OpenFgaClient(config); + +// Custom new endpoint that doesn't exist in the SDK yet +Map requestBody = Map.of( + "user", "user:bob", + "action", "custom_action", + "resource", "resource:123" +); + +// Build the request +ApiExecutorRequestBuilder request = ApiExecutorRequestBuilder.builder("POST", "/stores/{store_id}/custom-endpoint") + .pathParam("store_id", storeId) + .queryParam("page_size", "20") + .queryParam("continuation_token", "eyJwayI6...") + .body(requestBody) + .header("X-Experimental-Feature", "enabled") + .build(); +``` + +#### Example: Calling a new "Custom Endpoint" endpoint and handling raw response + +```java +// Get raw response without automatic decoding +ApiResponse rawResponse = fgaClient.apiExecutor().send(request).get(); + +String rawJson = rawResponse.getData(); +System.out.println("Response: " + rawJson); + +// You can access fields like headers, status code, etc. from rawResponse: +System.out.println("Status Code: " + rawResponse.getStatusCode()); +System.out.println("Headers: " + rawResponse.getHeaders()); +``` + +#### Example: Calling a new "Custom Endpoint" endpoint and decoding response into a struct + +```java +// Define a class to hold the response +class CustomEndpointResponse { + private boolean allowed; + private String reason; + + public boolean isAllowed() { return allowed; } + public void setAllowed(boolean allowed) { this.allowed = allowed; } + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } +} + +// Get response decoded into CustomEndpointResponse class +ApiResponse response = fgaClient.apiExecutor() + .send(request, CustomEndpointResponse.class) + .get(); + +CustomEndpointResponse customEndpointResponse = response.getData(); +System.out.println("Allowed: " + customEndpointResponse.isAllowed()); +System.out.println("Reason: " + customEndpointResponse.getReason()); + +// You can access fields like headers, status code, etc. from response: +System.out.println("Status Code: " + response.getStatusCode()); +System.out.println("Headers: " + response.getHeaders()); +``` + +#### Calling a streaming endpoint + +For streaming endpoints, use `streamingApiExecutor` instead. Pass the response class directly — the SDK handles the rest. It delivers each response object to a consumer callback as it arrives, and returns a `CompletableFuture` that completes when the stream is exhausted. + +```java +ApiExecutorRequestBuilder request = ApiExecutorRequestBuilder.builder(HttpMethod.POST, "/stores/{store_id}/streamed-list-objects") + .body(new ListObjectsRequest().user("user:anne").relation("viewer").type("document")) + .build(); + +fgaClient.streamingApiExecutor(StreamedListObjectsResponse.class) + .stream( + request, + response -> System.out.println("Object: " + response.getObject()), // called per object + error -> System.err.println("Stream error: " + error.getMessage()) // optional + ) + .thenRun(() -> System.out.println("Streaming complete")) + .exceptionally(err -> { + System.err.println("Fatal error: " + err.getMessage()); + return null; + }); +``` + +For a complete working example, see [examples/api-executor](examples/api-executor). + +#### Documentation + +See [docs/ApiExecutor.md](docs/ApiExecutor.md) for complete API reference and examples for both `ApiExecutor` and `StreamingApiExecutor`. + diff --git a/config/clients/java/template/README_retries.mustache b/config/clients/java/template/README_retries.mustache index bf9ef1647..fd4654724 100644 --- a/config/clients/java/template/README_retries.mustache +++ b/config/clients/java/template/README_retries.mustache @@ -64,107 +64,3 @@ try { } ``` -### Calling Other Endpoints - -The API Executor provides direct HTTP access to OpenFGA endpoints not yet wrapped by the SDK. It maintains the SDK's client configuration including authentication, telemetry, retries, and error handling. - -Use cases: -- Calling endpoints not yet supported by the SDK -- Using an SDK version that lacks support for a particular endpoint -- Accessing custom endpoints that extend the OpenFGA API - -Initialize the SDK normally and access the API Executor via the `fgaClient` instance: - -```java -// Initialize the client, same as above -ClientConfiguration config = new ClientConfiguration() - .apiUrl("http://localhost:8080") - .storeId("01YCP46JKYM8FJCQ37NMBYHE5X"); -OpenFgaClient fgaClient = new OpenFgaClient(config); - -// Custom new endpoint that doesn't exist in the SDK yet -Map requestBody = Map.of( - "user", "user:bob", - "action", "custom_action", - "resource", "resource:123" -); - -// Build the request -ApiExecutorRequestBuilder request = ApiExecutorRequestBuilder.builder("POST", "/stores/{store_id}/custom-endpoint") - .pathParam("store_id", storeId) - .queryParam("page_size", "20") - .queryParam("continuation_token", "eyJwayI6...") - .body(requestBody) - .header("X-Experimental-Feature", "enabled") - .build(); -``` - -#### Example: Calling a new "Custom Endpoint" endpoint and handling raw response - -```java -// Get raw response without automatic decoding -ApiResponse rawResponse = fgaClient.apiExecutor().send(request).get(); - -String rawJson = rawResponse.getData(); -System.out.println("Response: " + rawJson); - -// You can access fields like headers, status code, etc. from rawResponse: -System.out.println("Status Code: " + rawResponse.getStatusCode()); -System.out.println("Headers: " + rawResponse.getHeaders()); -``` - -#### Example: Calling a new "Custom Endpoint" endpoint and decoding response into a struct - -```java -// Define a class to hold the response -class CustomEndpointResponse { - private boolean allowed; - private String reason; - - public boolean isAllowed() { return allowed; } - public void setAllowed(boolean allowed) { this.allowed = allowed; } - public String getReason() { return reason; } - public void setReason(String reason) { this.reason = reason; } -} - -// Get response decoded into CustomEndpointResponse class -ApiResponse response = fgaClient.apiExecutor() - .send(request, CustomEndpointResponse.class) - .get(); - -CustomEndpointResponse customEndpointResponse = response.getData(); -System.out.println("Allowed: " + customEndpointResponse.isAllowed()); -System.out.println("Reason: " + customEndpointResponse.getReason()); - -// You can access fields like headers, status code, etc. from response: -System.out.println("Status Code: " + response.getStatusCode()); -System.out.println("Headers: " + response.getHeaders()); -``` - -#### Calling a streaming endpoint - -For streaming endpoints, use `streamingApiExecutor` instead. Pass the response class directly — the SDK handles the rest. It delivers each response object to a consumer callback as it arrives, and returns a `CompletableFuture` that completes when the stream is exhausted. - -```java -ApiExecutorRequestBuilder request = ApiExecutorRequestBuilder.builder(HttpMethod.POST, "/stores/{store_id}/streamed-list-objects") - .body(new ListObjectsRequest().user("user:anne").relation("viewer").type("document")) - .build(); - -fgaClient.streamingApiExecutor(StreamedListObjectsResponse.class) - .stream( - request, - response -> System.out.println("Object: " + response.getObject()), // called per object - error -> System.err.println("Stream error: " + error.getMessage()) // optional - ) - .thenRun(() -> System.out.println("Streaming complete")) - .exceptionally(err -> { - System.err.println("Fatal error: " + err.getMessage()); - return null; - }); -``` - -For a complete working example, see [examples/api-executor](examples/api-executor). - -#### Documentation - -See [docs/ApiExecutor.md](docs/ApiExecutor.md) for complete API reference and examples for both `ApiExecutor` and `StreamingApiExecutor`. diff --git a/config/clients/python/template/README_calling_api.mustache b/config/clients/python/template/README_calling_api.mustache index 29cf3b8ec..f49aac4bd 100644 --- a/config/clients/python/template/README_calling_api.mustache +++ b/config/clients/python/template/README_calling_api.mustache @@ -963,93 +963,3 @@ body = [ClientAssertion( response = await fga_client.write_assertions(body, options) ``` -### Calling Other Endpoints - -In certain cases you may want to call other APIs not yet wrapped by the SDK. You can do so by using the `execute_api_request` method available on the `OpenFgaClient`. It allows you to make raw HTTP calls to any OpenFGA endpoint by specifying the HTTP method, path, body, query parameters, and path parameters, while still honoring the client configuration (authentication, telemetry, retries, and error handling). - -For streaming endpoints (e.g. `streamed-list-objects`), use `execute_streamed_api_request` instead. It returns an `AsyncIterator` (or `Iterator` in the sync client) that yields one parsed JSON object per chunk. - -This is useful when: -- You want to call a new endpoint that is not yet supported by the SDK -- You are using an earlier version of the SDK that doesn't yet support a particular endpoint -- You have a custom endpoint deployed that extends the OpenFGA API - -#### Example: Calling a Custom Endpoint with POST - -```python -# Call a custom endpoint using path parameters -response = await fga_client.execute_api_request( - operation_name="CustomEndpoint", # For telemetry/logging - method="POST", - path="/stores/{store_id}/custom-endpoint", - path_params={"store_id": FGA_STORE_ID}, - body={ - "user": "user:bob", - "action": "custom_action", - "resource": "resource:123", - }, - query_params={ - "page_size": 20, - }, -) - -# Access the response data -if response.status == 200: - result = response.json() - print(f"Response: {result}") -``` - -#### Example: Calling an existing endpoint with GET - -```python -# Get a list of stores with query parameters -stores_response = await fga_client.execute_api_request( - operation_name="ListStores", - method="GET", - path="/stores", - query_params={ - "page_size": 10, - "continuation_token": "eyJwayI6...", - }, -) - -stores = stores_response.json() -print("Stores:", stores) -``` - -#### Example: Calling a Streaming Endpoint - -```python -# Stream objects visible to a user -async for chunk in fga_client.execute_streamed_api_request( - operation_name="StreamedListObjects", - method="POST", - path="/stores/{store_id}/streamed-list-objects", - path_params={"store_id": FGA_STORE_ID}, - body={ - "type": "document", - "relation": "viewer", - "user": "user:anne", - "authorization_model_id": FGA_MODEL_ID, - }, -): - # Each chunk has the shape {"result": {"object": "..."}} or {"error": {...}} - if "result" in chunk: - print(chunk["result"]["object"]) # e.g. "document:roadmap" -``` - -#### Example: Using Path Parameters - -Path parameters are specified in the path using `{param_name}` syntax and must all be provided explicitly via `path_params` (URL-encoded automatically): - -```python -response = await fga_client.execute_api_request( - operation_name="GetAuthorizationModel", - method="GET", - path="/stores/{store_id}/authorization-models/{model_id}", - path_params={ - "store_id": "your-store-id", - "model_id": "your-model-id", - }, -) -``` diff --git a/config/clients/python/template/README_calling_other_endpoints.mustache b/config/clients/python/template/README_calling_other_endpoints.mustache new file mode 100644 index 000000000..b4de47d7d --- /dev/null +++ b/config/clients/python/template/README_calling_other_endpoints.mustache @@ -0,0 +1,89 @@ +In certain cases you may want to call other APIs not yet wrapped by the SDK. You can do so by using the `execute_api_request` method available on the `OpenFgaClient`. It allows you to make raw HTTP calls to any OpenFGA endpoint by specifying the HTTP method, path, body, query parameters, and path parameters, while still honoring the client configuration (authentication, telemetry, retries, and error handling). + +For streaming endpoints (e.g. `streamed-list-objects`), use `execute_streamed_api_request` instead. It returns an `AsyncIterator` (or `Iterator` in the sync client) that yields one parsed JSON object per chunk. + +This is useful when: +- You want to call a new endpoint that is not yet supported by the SDK +- You are using an earlier version of the SDK that doesn't yet support a particular endpoint +- You have a custom endpoint deployed that extends the OpenFGA API + +#### Example: Calling a Custom Endpoint with POST + +```python +# Call a custom endpoint using path parameters +response = await fga_client.execute_api_request( + operation_name="CustomEndpoint", # For telemetry/logging + method="POST", + path="/stores/{store_id}/custom-endpoint", + path_params={"store_id": FGA_STORE_ID}, + body={ + "user": "user:bob", + "action": "custom_action", + "resource": "resource:123", + }, + query_params={ + "page_size": 20, + }, +) + +# Access the response data +if response.status == 200: + result = response.json() + print(f"Response: {result}") +``` + +#### Example: Calling an existing endpoint with GET + +```python +# Get a list of stores with query parameters +stores_response = await fga_client.execute_api_request( + operation_name="ListStores", + method="GET", + path="/stores", + query_params={ + "page_size": 10, + "continuation_token": "eyJwayI6...", + }, +) + +stores = stores_response.json() +print("Stores:", stores) +``` + +#### Example: Calling a Streaming Endpoint + +```python +# Stream objects visible to a user +async for chunk in fga_client.execute_streamed_api_request( + operation_name="StreamedListObjects", + method="POST", + path="/stores/{store_id}/streamed-list-objects", + path_params={"store_id": FGA_STORE_ID}, + body={ + "type": "document", + "relation": "viewer", + "user": "user:anne", + "authorization_model_id": FGA_MODEL_ID, + }, +): + # Each chunk has the shape {"result": {"object": "..."}} or {"error": {...}} + if "result" in chunk: + print(chunk["result"]["object"]) # e.g. "document:roadmap" +``` + +#### Example: Using Path Parameters + +Path parameters are specified in the path using `{param_name}` syntax and must all be provided explicitly via `path_params` (URL-encoded automatically): + +```python +response = await fga_client.execute_api_request( + operation_name="GetAuthorizationModel", + method="GET", + path="/stores/{store_id}/authorization-models/{model_id}", + path_params={ + "store_id": "your-store-id", + "model_id": "your-model-id", + }, +) +``` + diff --git a/config/common/files/README.mustache b/config/common/files/README.mustache index a4db9ccfd..efa590f2c 100644 --- a/config/common/files/README.mustache +++ b/config/common/files/README.mustache @@ -50,10 +50,10 @@ - [Assertions](#assertions) - [Read Assertions](#read-assertions) - [Write Assertions](#write-assertions) - - [Retries](#retries) {{#supportsCallingOtherEndpoints}} - [Calling Other Endpoints](#calling-other-endpoints) {{/supportsCallingOtherEndpoints}} + - [Retries](#retries) - [API Endpoints](#api-endpoints) - [Models](#models) {{#openTelemetryDocumentation}} @@ -110,6 +110,12 @@ If your server is configured with [authentication enabled]({{docsUrl}}/getting-s {{>README_calling_api}} +{{#supportsCallingOtherEndpoints}} +### Calling Other Endpoints + +{{>README_calling_other_endpoints}} + +{{/supportsCallingOtherEndpoints}} ### Retries {{>README_retries}}