diff --git a/README.md b/README.md index efbf2df6..a3e7bcf3 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ This is an autogenerated Java SDK for OpenFGA. It provides a wrapper around the - [Assertions](#assertions) - [Read Assertions](#read-assertions) - [Write Assertions](#write-assertions) + - [Calling Other Endpoints](#calling-other-endpoints) - [Retries](#retries) - [API Endpoints](#api-endpoints) - [Models](#models) @@ -746,7 +747,7 @@ Similar to [check](#check), but instead of checking a single user-object relatio > Passing `ClientBatchCheckOptions` is optional. All fields of `ClientBatchCheckOptions` are optional. ```java -var reequst = new ClientBatchCheckRequest().checks( +var request = new ClientBatchCheckRequest().checks( List.of( new ClientBatchCheckItem() .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") @@ -774,7 +775,7 @@ var reequst = new ClientBatchCheckRequest().checks( .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") .relation("creator") ._object("document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a") - .correlationId("cor-3), // optional, one will be generated for you if not provided + .correlationId("cor-3"), // optional, one will be generated for you if not provided new ClientCheckRequest() .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") .relation("deleter") @@ -1167,6 +1168,89 @@ try { } ``` +### Calling Other Endpoints + +The Raw API 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 Raw API 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 +RawRequestBuilder request = RawRequestBuilder.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.raw().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.raw() + .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()); +``` + +For a complete working example, see [examples/raw-api](examples/raw-api). + +#### Documentation + +See [docs/RawApi.md](docs/RawApi.md) for complete API reference and examples. + ### API Endpoints | Method | HTTP request | Description | diff --git a/docs/RawApi.md b/docs/RawApi.md new file mode 100644 index 00000000..240b5192 --- /dev/null +++ b/docs/RawApi.md @@ -0,0 +1,167 @@ +# Raw API + +Direct HTTP access to OpenFGA endpoints. + +## Quick Start + +```java +OpenFgaClient client = new OpenFgaClient(config); + +// Build request +RawRequestBuilder request = RawRequestBuilder.builder("POST", "/stores/{store_id}/check") + .pathParam("store_id", storeId) + .body(Map.of("tuple_key", Map.of("user", "user:jon", "relation", "reader", "object", "doc:1"))) + .build(); + +// Execute - typed response +ApiResponse response = client.raw().send(request, CheckResponse.class).get(); + +// Execute - raw JSON +ApiResponse rawResponse = client.raw().send(request).get(); +``` + +## API Reference + +### RawRequestBuilder + +**Factory:** +```java +RawRequestBuilder.builder(String method, String path) +``` + +**Methods:** +```java +.pathParam(String key, String value) // Replace {key} in path, URL-encoded +.queryParam(String key, String value) // Add query parameter, URL-encoded +.header(String key, String value) // Add HTTP header +.body(Object body) // Set request body (auto-serialized to JSON) +.build() // Complete the builder (required) +``` + +**Example:** +```java +RawRequestBuilder request = RawRequestBuilder.builder("POST", "/stores/{store_id}/write") + .pathParam("store_id", "01ABC") + .queryParam("dry_run", "true") + .header("X-Request-ID", "uuid") + .body(requestObject) + .build(); +``` + +### RawApi + +**Access:** +```java +RawApi rawApi = client.raw(); +``` + +**Methods:** +```java +CompletableFuture> send(RawRequestBuilder request) +CompletableFuture> send(RawRequestBuilder request, Class responseType) +``` + +### ApiResponse + +```java +int getStatusCode() // HTTP status +Map> getHeaders() // Response headers +String getRawResponse() // Raw JSON body +T getData() // Deserialized data +``` + +## Examples + +### GET Request +```java +RawRequestBuilder request = RawRequestBuilder.builder("GET", "/stores/{store_id}/feature") + .pathParam("store_id", storeId) + .build(); + +client.raw().send(request, FeatureResponse.class) + .thenAccept(r -> System.out.println("Status: " + r.getStatusCode())); +``` + +### POST with Body +```java +RawRequestBuilder request = RawRequestBuilder.builder("POST", "/stores/{store_id}/bulk-delete") + .pathParam("store_id", storeId) + .queryParam("force", "true") + .body(new BulkDeleteRequest("2023-01-01", "user", 1000)) + .build(); + +client.raw().send(request, BulkDeleteResponse.class).get(); +``` + +### Raw JSON Response +```java +ApiResponse response = client.raw().send(request).get(); +String json = response.getRawResponse(); // Raw JSON +``` + +### Query Parameters +```java +RawRequestBuilder.builder("GET", "/stores/{store_id}/items") + .pathParam("store_id", storeId) + .queryParam("page", "1") + .queryParam("limit", "50") + .queryParam("sort", "created_at") + .build(); +``` + +### Custom Headers +```java +RawRequestBuilder.builder("POST", "/stores/{store_id}/action") + .header("X-Request-ID", UUID.randomUUID().toString()) + .header("X-Idempotency-Key", "key-123") + .body(data) + .build(); +``` + +### Error Handling +```java +client.raw().send(request, ResponseType.class) + .exceptionally(e -> { + if (e.getCause() instanceof FgaError) { + FgaError error = (FgaError) e.getCause(); + System.err.println("API Error: " + error.getStatusCode()); + } + return null; + }); +``` + +### Map as Request Body +```java +RawRequestBuilder.builder("POST", "/stores/{store_id}/settings") + .pathParam("store_id", storeId) + .body(Map.of( + "setting", "value", + "enabled", true, + "threshold", 100, + "options", List.of("opt1", "opt2") + )) + .build(); +``` + +## Notes + +- Path/query parameters are URL-encoded automatically +- Authentication tokens injected from client config +- `{store_id}` auto-replaced if not provided via `.pathParam()` + +## Migration to Typed Methods + +When SDK adds typed methods for an endpoint, you can migrate from Raw API: + +```java +// Raw API +RawRequestBuilder request = RawRequestBuilder.builder("POST", "/stores/{store_id}/check") + .body(req) + .build(); + +client.raw().send(request, CheckResponse.class).get(); + +// Typed SDK (when available) +client.check(req).get(); +``` + diff --git a/examples/README.md b/examples/README.md index 47238df4..e93757d4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,3 +9,10 @@ A simple example that creates a store, runs a set of calls against it including #### OpenTelemetry Examples - `opentelemetry/` - Demonstrates OpenTelemetry integration both via manual code configuration, as well as no-code instrumentation using the OpenTelemetry java agent + +#### Streaming Examples +- `streamed-list-objects/` - Demonstrates using the StreamedListObjects API to retrieve large result sets without pagination limits + +#### Raw API Examples +- `raw-api/` - Demonstrates direct HTTP access to OpenFGA endpoints not yet wrapped by the SDK, maintaining SDK configuration (authentication, retries, error handling) + diff --git a/examples/raw-api/Makefile b/examples/raw-api/Makefile new file mode 100644 index 00000000..b3d9473e --- /dev/null +++ b/examples/raw-api/Makefile @@ -0,0 +1,17 @@ +.PHONY: build run run-openfga +all: build + +project_name=. +openfga_version=latest +language=java + +build: + ../../gradlew -P language=$(language) build + +run: + ../../gradlew -P language=$(language) run + +run-openfga: + docker pull docker.io/openfga/openfga:${openfga_version} && \ + docker run -p 8080:8080 docker.io/openfga/openfga:${openfga_version} run + diff --git a/examples/raw-api/README.md b/examples/raw-api/README.md new file mode 100644 index 00000000..16178f44 --- /dev/null +++ b/examples/raw-api/README.md @@ -0,0 +1,109 @@ +# Raw API Example + +Demonstrates using the Raw API to call OpenFGA endpoints that are not yet wrapped by the SDK. + +## What is the Raw API? + +The Raw API provides direct HTTP access to OpenFGA endpoints while maintaining the SDK's configuration (authentication, telemetry, retries, and error handling). + +Use cases: +- Calling endpoints not yet wrapped by the SDK +- Using an SDK version that lacks support for a particular endpoint +- Accessing custom endpoints that extend the OpenFGA API + +## Prerequisites + +- Java 11 or higher +- OpenFGA server running on `http://localhost:8080` (or set `FGA_API_URL`) + +## Running + +```bash +# Start OpenFGA server first (if not already running) +docker run -p 8080:8080 openfga/openfga run + +# From the SDK root directory, build the SDK +./gradlew build + +# Then run the example +cd examples/raw-api +./gradlew run +``` + +Or using the Makefile: + +```bash +make build +make run +``` + +## What it does + +The example demonstrates Raw API capabilities using real OpenFGA endpoints: + +1. **List Stores (GET with typed response)**: Lists all stores and deserializes into `ListStoresResponse` +2. **Get Store (GET with raw JSON)**: Retrieves a single store and returns the raw JSON string +3. **List Stores with Pagination**: Demonstrates query parameters using `page_size` +4. **Create Store (POST with custom headers)**: Creates a new store with custom HTTP headers +5. **Error Handling**: Attempts to get a non-existent store and handles the 404 error properly + +All requests will succeed (except #5 which intentionally triggers an error for demonstration). + +## Key Features + +### Request Building + +Build requests using the builder pattern: + +```java +RawRequestBuilder request = RawRequestBuilder.builder("POST", "/stores/{store_id}/custom-endpoint") + .pathParam("store_id", storeId) + .queryParam("page_size", "20") + .queryParam("continuation_token", "eyJwayI6...") + .body(requestBody) + .header("X-Custom-Header", "value") + .build(); +``` + +### Response Handling + + +**Typed Response (automatic deserialization):** +```java +client.raw().send(request, CustomResponse.class) + .thenAccept(response -> { + System.out.println("Data: " + response.getData()); + System.out.println("Status: " + response.getStatusCode()); + }); +``` + +**Raw JSON Response:** +```java +client.raw().send(request) + .thenAccept(response -> { + System.out.println("Raw JSON: " + response.getRawResponse()); + }); +``` + +### SDK Features Applied + +Requests automatically include: +- Authentication credentials +- Retry logic for 5xx errors with exponential backoff +- Error handling and exception mapping +- Configured timeouts and headers +- Telemetry hooks + +## Code Structure + +- `RawApiExample.java`: Example demonstrating Raw API usage with real OpenFGA endpoints + +## Notes + +This example uses real OpenFGA endpoints (`/stores`, `/stores/{store_id}`) to demonstrate actual functionality. The Raw API can be used with any OpenFGA endpoint, including custom endpoints if you have extended the API. + +## See Also + +- [Raw API Documentation](../../docs/RawApi.md) +- [OpenFGA API Reference](https://openfga.dev/api) + diff --git a/examples/raw-api/build.gradle b/examples/raw-api/build.gradle new file mode 100644 index 00000000..ea383081 --- /dev/null +++ b/examples/raw-api/build.gradle @@ -0,0 +1,67 @@ +plugins { + id 'application' + id 'com.diffplug.spotless' version '8.0.0' +} + +application { + mainClass = 'dev.openfga.sdk.example.RawApiExample' +} + +repositories { + mavenCentral() +} + +ext { + jacksonVersion = "2.18.2" +} + +dependencies { + // Use local build of SDK + implementation files('../../build/libs/openfga-sdk-0.9.4.jar') + + // OpenFGA Language SDK for DSL transformation + implementation("dev.openfga:openfga-language:v0.2.0-beta.1") + + // Serialization + implementation("com.fasterxml.jackson.core:jackson-core:$jacksonVersion") + implementation("com.fasterxml.jackson.core:jackson-annotations:$jacksonVersion") + implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion") + implementation("org.openapitools:jackson-databind-nullable:0.2.7") + + // OpenTelemetry (required by SDK) + implementation platform("io.opentelemetry:opentelemetry-bom:1.54.1") + implementation "io.opentelemetry:opentelemetry-api" + + // JSR305 (required by SDK) + implementation "com.google.code.findbugs:jsr305:3.0.2" +} + +// Use spotless plugin to automatically format code, remove unused import, etc +// To apply changes directly to the file, run `gradlew spotlessApply` +// Ref: https://github.com/diffplug/spotless/tree/main/plugin-gradle +spotless { + // comment out below to run spotless as part of the `check` task + enforceCheck false + format 'misc', { + // define the files (e.g. '*.gradle', '*.md') to apply `misc` to + target '.gitignore' + // define the steps to apply to those files + trimTrailingWhitespace() + indentWithSpaces() // Takes an integer argument if you don't like 4 + endWithNewline() + } + java { + palantirJavaFormat() + removeUnusedImports() + importOrder() + } +} + +// Use spotless plugin to automatically format code, remove unused import, etc +// To apply changes directly to the file, run `gradlew spotlessApply` +// Ref: https://github.com/diffplug/spotless/tree/main/plugin-gradle +tasks.register('fmt') { + dependsOn 'spotlessApply' +} + diff --git a/examples/raw-api/gradle.properties b/examples/raw-api/gradle.properties new file mode 100644 index 00000000..86d05ca7 --- /dev/null +++ b/examples/raw-api/gradle.properties @@ -0,0 +1,2 @@ +language=java + diff --git a/examples/raw-api/gradle/wrapper/gradle-wrapper.properties b/examples/raw-api/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..c747538f --- /dev/null +++ b/examples/raw-api/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/raw-api/gradlew b/examples/raw-api/gradlew new file mode 100755 index 00000000..005bcde0 --- /dev/null +++ b/examples/raw-api/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/raw-api/gradlew.bat b/examples/raw-api/gradlew.bat new file mode 100644 index 00000000..6a68175e --- /dev/null +++ b/examples/raw-api/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/raw-api/settings.gradle b/examples/raw-api/settings.gradle new file mode 100644 index 00000000..215b83b9 --- /dev/null +++ b/examples/raw-api/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'raw-api-example' + diff --git a/examples/raw-api/src/main/java/dev/openfga/sdk/example/RawApiExample.java b/examples/raw-api/src/main/java/dev/openfga/sdk/example/RawApiExample.java new file mode 100644 index 00000000..dfbbd034 --- /dev/null +++ b/examples/raw-api/src/main/java/dev/openfga/sdk/example/RawApiExample.java @@ -0,0 +1,212 @@ +package dev.openfga.sdk.example; + +import dev.openfga.sdk.api.client.OpenFgaClient; +import dev.openfga.sdk.api.client.RawRequestBuilder; +import dev.openfga.sdk.api.configuration.ClientConfiguration; +import dev.openfga.sdk.api.model.CreateStoreResponse; +import dev.openfga.sdk.api.model.ListStoresResponse; +import dev.openfga.sdk.api.model.Store; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Example demonstrating Raw API usage. + * + * This example shows how to use the Raw API to call OpenFGA endpoints + * that are not yet wrapped by the SDK's typed methods. + * + * The example uses real OpenFGA endpoints to demonstrate actual functionality. + * + * Note: Examples use .get() to block for simplicity. In production, use async patterns: + * - thenApply/thenAccept for chaining + * - thenCompose for sequential async operations + * - CompletableFuture.allOf for parallel operations + */ +public class RawApiExample { + + public static void main(String[] args) throws Exception { + // Initialize the OpenFGA client (no store ID needed for list stores) + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + OpenFgaClient fgaClient = new OpenFgaClient(config); + + System.out.println("=== Raw API Examples ===\n"); + + // Example 1: List stores with typed response + System.out.println("Example 1: List stores (GET with typed response)"); + String storeId = listStoresExample(fgaClient); + + // Example 2: Get store with raw JSON response + System.out.println("\nExample 2: Get store (GET with raw JSON)"); + getStoreRawJsonExample(fgaClient, storeId); + + // Example 3: List stores with query parameters + System.out.println("\nExample 3: List stores with pagination (query parameters)"); + listStoresWithPaginationExample(fgaClient); + + // Example 4: Create store with custom headers + System.out.println("\nExample 4: Create store (POST with custom headers)"); + createStoreWithHeadersExample(fgaClient); + + // Example 5: Error handling - try to get non-existent store + System.out.println("\nExample 5: Error handling (404 error)"); + errorHandlingExample(fgaClient); + + System.out.println("\n=== All examples completed ==="); + } + + /** + * Example 1: GET request with typed response. + * Lists all stores using the Raw API and returns a store ID for use in other examples. + */ + private static String listStoresExample(OpenFgaClient fgaClient) { + try { + // Build the raw request for GET /stores + RawRequestBuilder request = RawRequestBuilder.builder("GET", "/stores").build(); + + // Execute with typed response + var response = fgaClient + .raw() + .send(request, ListStoresResponse.class) + .get(); + + System.out.println("✓ Status: " + response.getStatusCode()); + List stores = response.getData().getStores(); + System.out.println("✓ Found " + stores.size() + " store(s)"); + + if (!stores.isEmpty()) { + Store firstStore = stores.get(0); + System.out.println("✓ First store: " + firstStore.getName() + " (ID: " + firstStore.getId() + ")"); + return firstStore.getId(); + } else { + // Create a store if none exist + System.out.println(" No stores found, creating one..."); + return createStoreForExamples(fgaClient); + } + + } catch (Exception e) { + System.err.println("✗ Error: " + e.getMessage()); + // Create a store on error + try { + return createStoreForExamples(fgaClient); + } catch (Exception ex) { + return "01YCP46JKYM8FJCQ37NMBYHE5X"; // fallback + } + } + } + + /** + * Helper method to create a store for examples. + */ + private static String createStoreForExamples(OpenFgaClient fgaClient) throws Exception { + String storeName = "raw-api-example-" + UUID.randomUUID().toString().substring(0, 8); + RawRequestBuilder request = RawRequestBuilder.builder("POST", "/stores") + .body(Map.of("name", storeName)) + .build(); + + // Use typed response instead of manual JSON parsing + var response = fgaClient.raw().send(request, CreateStoreResponse.class).get(); + System.out.println(" Created store: " + storeName); + return response.getData().getId(); + } + + /** + * Example 2: Get raw JSON response without deserialization. + */ + private static void getStoreRawJsonExample(OpenFgaClient fgaClient, String storeId) { + try { + RawRequestBuilder request = RawRequestBuilder.builder("GET", "/stores/{store_id}") + .pathParam("store_id", storeId) + .build(); + + // Execute and get raw JSON string + var response = fgaClient.raw().send(request).get(); + + System.out.println("✓ Status: " + response.getStatusCode()); + System.out.println("✓ Raw JSON: " + response.getData()); + System.out.println("✓ Content-Type: " + response.getHeaders().get("content-type")); + + } catch (Exception e) { + System.err.println("✗ Error: " + e.getMessage()); + } + } + + /** + * Example 3: Add query parameters to requests. + */ + private static void listStoresWithPaginationExample(OpenFgaClient fgaClient) { + try { + RawRequestBuilder request = RawRequestBuilder.builder("GET", "/stores") + .queryParam("page_size", "2") + .build(); + + var response = fgaClient + .raw() + .send(request, ListStoresResponse.class) + .get(); + + System.out.println("✓ Status: " + response.getStatusCode()); + System.out.println("✓ Stores returned: " + response.getData().getStores().size()); + if (response.getData().getContinuationToken() != null) { + String token = response.getData().getContinuationToken(); + String tokenPreview = token.length() > 20 ? token.substring(0, 20) + "..." : token; + System.out.println("✓ Continuation token present: " + tokenPreview); + } else { + System.out.println("✓ No continuation token (all results returned)"); + } + + } catch (Exception e) { + System.err.println("✗ Error: " + e.getMessage()); + } + } + + /** + * Example 4: Add custom headers to requests. + */ + private static void createStoreWithHeadersExample(OpenFgaClient fgaClient) { + try { + String storeName = "raw-api-custom-headers-" + UUID.randomUUID().toString().substring(0, 8); + RawRequestBuilder request = RawRequestBuilder.builder("POST", "/stores") + .header("X-Example-Header", "custom-value") + .header("X-Request-ID", "req-" + UUID.randomUUID()) + .body(Map.of("name", storeName)) + .build(); + + var response = fgaClient.raw().send(request).get(); + + System.out.println("✓ Status: " + response.getStatusCode()); + System.out.println("✓ Store created successfully"); + String responseData = response.getData(); + String preview = responseData.length() > 100 ? responseData.substring(0, 100) + "..." : responseData; + System.out.println("✓ Response: " + preview); + + } catch (Exception e) { + System.err.println("✗ Error: " + e.getMessage()); + } + } + + /** + * Example 5: Error handling with the Raw API. + * Requests use the SDK's error handling and retry logic. + */ + private static void errorHandlingExample(OpenFgaClient fgaClient) { + try { + // Try to get a non-existent store + RawRequestBuilder request = RawRequestBuilder.builder("GET", "/stores/{store_id}") + .pathParam("store_id", "01ZZZZZZZZZZZZZZZZZZZZZZZ9") + .build(); + + var response = fgaClient.raw().send(request).get(); + + System.out.println("✓ Success: " + response.getStatusCode()); + + } catch (Exception e) { + // Expected error - demonstrates proper error handling + System.out.println("✓ Error handled correctly:"); + System.out.println(" Message: " + e.getMessage()); + System.out.println(" Type: " + e.getCause().getClass().getSimpleName()); + } + } +} + diff --git a/examples/streamed-list-objects/build.gradle b/examples/streamed-list-objects/build.gradle index d3039b04..08cf3c3a 100644 --- a/examples/streamed-list-objects/build.gradle +++ b/examples/streamed-list-objects/build.gradle @@ -17,7 +17,7 @@ ext { dependencies { // Use local build of SDK - implementation files('../../build/libs/openfga-sdk-0.9.3.jar') + implementation files('../../build/libs/openfga-sdk-0.9.4.jar') // OpenFGA Language SDK for DSL transformation implementation("dev.openfga:openfga-language:v0.2.0-beta.1") diff --git a/src/main/java/dev/openfga/sdk/api/client/HttpRequestAttempt.java b/src/main/java/dev/openfga/sdk/api/client/HttpRequestAttempt.java index 4cf6f6a2..1a2d657f 100644 --- a/src/main/java/dev/openfga/sdk/api/client/HttpRequestAttempt.java +++ b/src/main/java/dev/openfga/sdk/api/client/HttpRequestAttempt.java @@ -216,6 +216,13 @@ private CompletableFuture deserializeResponse(HttpResponse response) return CompletableFuture.completedFuture(null); } + // Return raw response body as-is for String.class + if (clazz == String.class) { + @SuppressWarnings("unchecked") + T raw = (T) response.body(); + return CompletableFuture.completedFuture(raw); + } + try { T deserialized = apiClient.getObjectMapper().readValue(response.body(), clazz); return CompletableFuture.completedFuture(deserialized); diff --git a/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java b/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java index 4d4df9c3..394e0a9d 100644 --- a/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java @@ -46,6 +46,26 @@ public OpenFgaApi getApi() { return api; } + /** + * Returns a RawApi instance for executing HTTP requests to arbitrary OpenFGA endpoints. + * Requests automatically include authentication, retry logic, error handling, and configured timeouts/headers. + * + *

Example:

+ *
{@code
+     * RawRequestBuilder request = RawRequestBuilder.builder("POST", "/stores/{store_id}/endpoint")
+     *     .pathParam("store_id", storeId)
+     *     .body(requestData);
+     *
+     * client.raw().send(request, ResponseType.class)
+     *     .thenAccept(response -> handleResponse(response.getData()));
+     * }
+ * + * @return RawApi instance + */ + public RawApi raw() { + return new RawApi(this.apiClient, this.configuration); + } + public void setStoreId(String storeId) { configuration.storeId(storeId); } diff --git a/src/main/java/dev/openfga/sdk/api/client/RawApi.java b/src/main/java/dev/openfga/sdk/api/client/RawApi.java new file mode 100644 index 00000000..be424b49 --- /dev/null +++ b/src/main/java/dev/openfga/sdk/api/client/RawApi.java @@ -0,0 +1,175 @@ +package dev.openfga.sdk.api.client; + +import dev.openfga.sdk.api.configuration.Configuration; +import dev.openfga.sdk.errors.ApiException; +import dev.openfga.sdk.errors.FgaInvalidParameterException; +import java.io.IOException; +import java.net.http.HttpRequest; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * Executes HTTP requests to OpenFGA API endpoints using the SDK's internal HTTP client. + * Requests automatically include authentication, retry logic, error handling, and configuration settings. + * + *

Example:

+ *
{@code
+ * RawRequestBuilder request = RawRequestBuilder.builder("POST", "/stores/{store_id}/endpoint")
+ *     .pathParam("store_id", storeId)
+ *     .body(requestData)
+ *     .build();
+ *
+ * // Typed response
+ * ApiResponse response = client.raw().send(request, ResponseType.class).get();
+ *
+ * // Raw JSON
+ * ApiResponse response = client.raw().send(request).get();
+ * }
+ */ +public class RawApi { + private final ApiClient apiClient; + private final Configuration configuration; + + /** + * Constructs a RawApi instance. Typically called via {@link OpenFgaClient#raw()}. + * + * @param apiClient API client for HTTP operations + * @param configuration Client configuration + */ + public RawApi(ApiClient apiClient, Configuration configuration) { + if (apiClient == null) { + throw new IllegalArgumentException("ApiClient cannot be null"); + } + if (configuration == null) { + throw new IllegalArgumentException("Configuration cannot be null"); + } + this.apiClient = apiClient; + this.configuration = configuration; + } + + /** + * Executes an HTTP request and returns the response body as a JSON string. + * + * @param requestBuilder Request configuration + * @return CompletableFuture with API response containing string data + * @throws FgaInvalidParameterException If configuration is invalid + * @throws ApiException If request construction fails + */ + public CompletableFuture> send(RawRequestBuilder requestBuilder) + throws FgaInvalidParameterException, ApiException { + return send(requestBuilder, String.class); + } + + /** + * Executes an HTTP request and deserializes the response into a typed object. + * + * @param Response type + * @param requestBuilder Request configuration + * @param responseType Class to deserialize response into + * @return CompletableFuture with API response containing typed data + * @throws FgaInvalidParameterException If configuration is invalid + * @throws ApiException If request construction fails + */ + public CompletableFuture> send(RawRequestBuilder requestBuilder, Class responseType) + throws FgaInvalidParameterException, ApiException { + if (requestBuilder == null) { + throw new IllegalArgumentException("Request builder cannot be null"); + } + if (responseType == null) { + throw new IllegalArgumentException("Response type cannot be null"); + } + + try { + configuration.assertValid(); + + String completePath = buildCompletePath(requestBuilder); + HttpRequest httpRequest = buildHttpRequest(requestBuilder, completePath); + + String methodName = "raw:" + requestBuilder.getMethod() + ":" + requestBuilder.getPath(); + + return new HttpRequestAttempt<>(httpRequest, methodName, responseType, apiClient, configuration) + .attemptHttpRequest(); + + } catch (IOException e) { + return CompletableFuture.failedFuture(new ApiException(e)); + } + } + + private String buildCompletePath(RawRequestBuilder requestBuilder) { + StringBuilder pathBuilder = new StringBuilder(requestBuilder.getPath()); + Map pathParams = requestBuilder.getPathParams(); + + // Automatic {store_id} replacement if not provided + if (pathBuilder.indexOf("{store_id}") != -1 && !pathParams.containsKey("store_id")) { + if (configuration instanceof dev.openfga.sdk.api.configuration.ClientConfiguration) { + String storeId = ((dev.openfga.sdk.api.configuration.ClientConfiguration) configuration).getStoreId(); + if (storeId != null) { + replaceInBuilder(pathBuilder, "{store_id}", dev.openfga.sdk.util.StringUtil.urlEncode(storeId)); + } + } + } + + // Replace path parameters + for (Map.Entry entry : pathParams.entrySet()) { + String placeholder = "{" + entry.getKey() + "}"; + String encodedValue = dev.openfga.sdk.util.StringUtil.urlEncode(entry.getValue()); + replaceInBuilder(pathBuilder, placeholder, encodedValue); + } + + // Add query parameters (sorted for deterministic order) + Map queryParams = requestBuilder.getQueryParams(); + if (!queryParams.isEmpty()) { + String queryString = queryParams.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> dev.openfga.sdk.util.StringUtil.urlEncode(entry.getKey()) + "=" + + dev.openfga.sdk.util.StringUtil.urlEncode(entry.getValue())) + .collect(java.util.stream.Collectors.joining("&")); + pathBuilder.append(pathBuilder.indexOf("?") != -1 ? "&" : "?").append(queryString); + } + + return pathBuilder.toString(); + } + + private void replaceInBuilder(StringBuilder builder, String target, String replacement) { + int index = builder.indexOf(target); + while (index != -1) { + builder.replace(index, index + target.length(), replacement); + index = builder.indexOf(target, index + replacement.length()); + } + } + + private HttpRequest buildHttpRequest(RawRequestBuilder requestBuilder, String path) + throws FgaInvalidParameterException, IOException { + + HttpRequest.Builder httpRequestBuilder; + + // Build request with or without body + if (requestBuilder.hasBody()) { + Object body = requestBuilder.getBody(); + byte[] bodyBytes; + + // Handle String body separately + if (body instanceof String) { + bodyBytes = ((String) body).getBytes(java.nio.charset.StandardCharsets.UTF_8); + } else { + bodyBytes = apiClient.getObjectMapper().writeValueAsBytes(body); + } + + httpRequestBuilder = ApiClient.requestBuilder(requestBuilder.getMethod(), path, bodyBytes, configuration); + } else { + httpRequestBuilder = ApiClient.requestBuilder(requestBuilder.getMethod(), path, configuration); + } + + // Add custom headers + for (Map.Entry entry : requestBuilder.getHeaders().entrySet()) { + httpRequestBuilder.header(entry.getKey(), entry.getValue()); + } + + // Apply request interceptor + if (apiClient.getRequestInterceptor() != null) { + apiClient.getRequestInterceptor().accept(httpRequestBuilder); + } + + return httpRequestBuilder.build(); + } +} diff --git a/src/main/java/dev/openfga/sdk/api/client/RawRequestBuilder.java b/src/main/java/dev/openfga/sdk/api/client/RawRequestBuilder.java new file mode 100644 index 00000000..709b2c09 --- /dev/null +++ b/src/main/java/dev/openfga/sdk/api/client/RawRequestBuilder.java @@ -0,0 +1,158 @@ +package dev.openfga.sdk.api.client; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * Fluent builder for constructing HTTP requests to OpenFGA API endpoints. + * Supports path parameter replacement, query parameters, headers, and request bodies. + * Path and query parameters are automatically URL-encoded. + * + *

Example:

+ *
{@code
+ * RawRequestBuilder request = RawRequestBuilder.builder("POST", "/stores/{store_id}/endpoint")
+ *     .pathParam("store_id", storeId)
+ *     .queryParam("limit", "50")
+ *     .body(requestObject)
+ *     .build();
+ * }
+ */ +public class RawRequestBuilder { + private static final Set VALID_HTTP_METHODS = + Set.of("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"); + + private final String method; + private final String path; + private final Map pathParams; + private final Map queryParams; + private final Map headers; + private Object body; + + private RawRequestBuilder(String method, String path) { + this.method = method; + this.path = path; + this.pathParams = new HashMap<>(); + this.queryParams = new HashMap<>(); + this.headers = new HashMap<>(); + this.body = null; + } + + /** + * Creates a new RawRequestBuilder instance. + * + * @param method HTTP method (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) + * @param path API path with optional placeholders like {store_id} + * @return New RawRequestBuilder instance + * @throws IllegalArgumentException if method or path is invalid + */ + public static RawRequestBuilder builder(String method, String path) { + if (method == null || method.trim().isEmpty()) { + throw new IllegalArgumentException("HTTP method cannot be null or empty"); + } + if (path == null || path.trim().isEmpty()) { + throw new IllegalArgumentException("Path cannot be null or empty"); + } + + String upperMethod = method.toUpperCase(); + if (!VALID_HTTP_METHODS.contains(upperMethod)) { + throw new IllegalArgumentException( + "Invalid HTTP method: " + method + ". Valid methods: " + VALID_HTTP_METHODS); + } + + return new RawRequestBuilder(upperMethod, path); + } + + /** + * Adds a path parameter for placeholder replacement. Values are automatically URL-encoded. + * + * @param key Parameter name (without braces) + * @param value Parameter value + * @return This builder instance + */ + public RawRequestBuilder pathParam(String key, String value) { + if (key != null && value != null) { + this.pathParams.put(key, value); + } + return this; + } + + /** + * Adds a query parameter. Values are automatically URL-encoded. + * + * @param key Query parameter name + * @param value Query parameter value + * @return This builder instance + */ + public RawRequestBuilder queryParam(String key, String value) { + if (key != null && value != null) { + this.queryParams.put(key, value); + } + return this; + } + + /** + * Adds an HTTP header. Standard headers are managed by the SDK. + * + * @param key Header name + * @param value Header value + * @return This builder instance + */ + public RawRequestBuilder header(String key, String value) { + if (key != null && value != null) { + this.headers.put(key, value); + } + return this; + } + + /** + * Sets the request body. Objects and Maps are serialized to JSON. Strings are sent as-is. + * + * @param body Request body + * @return This builder instance + */ + public RawRequestBuilder body(Object body) { + this.body = body; + return this; + } + /** + * Builds and returns the request for use with the Raw API. + * This method must be called to complete request construction. + * + *

This is required for consistency with other OpenFGA SDKs (e.g., Go SDK) + * and follows the standard builder pattern.

+ * + * @return This builder instance (ready to be passed to {@link RawApi#send}) + */ + public RawRequestBuilder build() { + return this; + } + + String getMethod() { + return method; + } + + String getPath() { + return path; + } + + Map getPathParams() { + return new HashMap<>(pathParams); + } + + Map getQueryParams() { + return new HashMap<>(queryParams); + } + + Map getHeaders() { + return new HashMap<>(headers); + } + + Object getBody() { + return body; + } + + boolean hasBody() { + return body != null; + } +} diff --git a/src/test-integration/java/dev/openfga/sdk/api/client/RawApiIntegrationTest.java b/src/test-integration/java/dev/openfga/sdk/api/client/RawApiIntegrationTest.java new file mode 100644 index 00000000..239b78f7 --- /dev/null +++ b/src/test-integration/java/dev/openfga/sdk/api/client/RawApiIntegrationTest.java @@ -0,0 +1,467 @@ +package dev.openfga.sdk.api.client; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openfga.sdk.api.configuration.ClientConfiguration; +import dev.openfga.sdk.api.model.*; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.openfga.OpenFGAContainer; + +/** + * Integration tests for Raw API functionality. + * These tests demonstrate how to use raw requests to call OpenFGA endpoints + * without using the SDK's typed methods. + */ +@TestInstance(Lifecycle.PER_CLASS) +@Testcontainers +public class RawApiIntegrationTest { + + @Container + private static final OpenFGAContainer openfga = new OpenFGAContainer("openfga/openfga:v1.10.2"); + + private static final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); + + private OpenFgaClient fga; + + @BeforeEach + public void initializeApi() throws Exception { + System.setProperty("HttpRequestAttempt.debug-logging", "enable"); + + ClientConfiguration apiConfig = new ClientConfiguration().apiUrl(openfga.getHttpEndpoint()); + fga = new OpenFgaClient(apiConfig); + } + + /** + * Test listing stores using raw API instead of fga.listStores(). + */ + @Test + public void rawRequest_listStores() throws Exception { + // Create a store first so we have something to list + String storeName = "test-store-" + System.currentTimeMillis(); + createStoreUsingRawRequest(storeName); + + // Use raw API to list stores (equivalent to GET /stores) + RawRequestBuilder request = RawRequestBuilder.builder("GET", "/stores").build(); + + ApiResponse response = + fga.raw().send(request, ListStoresResponse.class).get(); + + // Verify response + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + assertNotNull(response.getData()); + assertNotNull(response.getData().getStores()); + assertTrue(response.getData().getStores().size() > 0); + + // Verify we can find our store + boolean foundStore = + response.getData().getStores().stream().anyMatch(store -> storeName.equals(store.getName())); + assertTrue(foundStore, "Should find the store we created"); + + System.out.println("✓ Successfully listed stores using raw request"); + System.out.println(" Found " + response.getData().getStores().size() + " stores"); + } + + /** + * Test creating a store using raw API with typed response. + */ + @Test + public void rawRequest_createStore_typedResponse() throws Exception { + String storeName = "raw-test-store-" + System.currentTimeMillis(); + + // Build request body + Map requestBody = new HashMap<>(); + requestBody.put("name", storeName); + + // Use raw API to create store (equivalent to POST /stores) + RawRequestBuilder request = + RawRequestBuilder.builder("POST", "/stores").body(requestBody).build(); + + ApiResponse response = + fga.raw().send(request, CreateStoreResponse.class).get(); + + // Verify response + assertNotNull(response); + assertEquals(201, response.getStatusCode()); + assertNotNull(response.getData()); + assertNotNull(response.getData().getId()); + assertEquals(storeName, response.getData().getName()); + + System.out.println("✓ Successfully created store using raw request"); + System.out.println(" Store ID: " + response.getData().getId()); + System.out.println(" Store Name: " + response.getData().getName()); + } + + /** + * Test creating a store using raw API with raw JSON string response. + */ + @Test + public void rawRequest_createStore_rawJsonResponse() throws Exception { + String storeName = "raw-json-test-" + System.currentTimeMillis(); + + // Build request body + Map requestBody = new HashMap<>(); + requestBody.put("name", storeName); + + // Use raw API to create store and get raw JSON response + RawRequestBuilder request = + RawRequestBuilder.builder("POST", "/stores").body(requestBody).build(); + + ApiResponse response = fga.raw().send(request).get(); + + // Verify response + assertNotNull(response); + assertEquals(201, response.getStatusCode()); + assertNotNull(response.getData()); + assertNotNull(response.getRawResponse()); + + // Parse the JSON manually + String rawJson = response.getData(); + assertTrue(rawJson.contains("\"id\"")); + assertTrue(rawJson.contains("\"name\"")); + assertTrue(rawJson.contains(storeName)); + + System.out.println("✓ Successfully created store with raw JSON response"); + System.out.println(" Raw JSON: " + rawJson); + } + + /** + * Test getting a specific store using raw API with path parameters. + */ + @Test + public void rawRequest_getStore_withPathParams() throws Exception { + // Create a store first + String storeName = "get-test-store-" + System.currentTimeMillis(); + String storeId = createStoreUsingRawRequest(storeName); + + // Use raw API to get store details (equivalent to GET /stores/{store_id}) + RawRequestBuilder request = RawRequestBuilder.builder("GET", "/stores/{store_id}") + .pathParam("store_id", storeId) + .build(); + + ApiResponse response = + fga.raw().send(request, GetStoreResponse.class).get(); + + // Verify response + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + assertNotNull(response.getData()); + assertEquals(storeId, response.getData().getId()); + assertEquals(storeName, response.getData().getName()); + + System.out.println("✓ Successfully retrieved store using raw request with path params"); + System.out.println(" Store ID: " + response.getData().getId()); + } + + /** + * Test automatic {store_id} replacement when store ID is configured. + */ + @Test + public void rawRequest_automaticStoreIdReplacement() throws Exception { + // Create a store and configure it + String storeName = "auto-store-" + System.currentTimeMillis(); + String storeId = createStoreUsingRawRequest(storeName); + fga.setStoreId(storeId); + + // Use raw API WITHOUT providing store_id path param - it should be auto-replaced + RawRequestBuilder request = + RawRequestBuilder.builder("GET", "/stores/{store_id}").build(); + + ApiResponse response = + fga.raw().send(request, GetStoreResponse.class).get(); + + // Verify response + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + assertEquals(storeId, response.getData().getId()); + + System.out.println("✓ Successfully used automatic {store_id} replacement"); + System.out.println(" Configured store ID was automatically used"); + } + + /** + * Test writing authorization model using raw API. + */ + @Test + public void rawRequest_writeAuthorizationModel() throws Exception { + // Create a store first + String storeName = "auth-model-test-" + System.currentTimeMillis(); + String storeId = createStoreUsingRawRequest(storeName); + fga.setStoreId(storeId); + + // Build authorization model with proper metadata + Map requestBody = new HashMap<>(); + requestBody.put("schema_version", "1.1"); + + // Create metadata for reader relation + Map readerMetadata = new HashMap<>(); + readerMetadata.put("directly_related_user_types", List.of(Map.of("type", "user"))); + + Map relationMetadata = new HashMap<>(); + relationMetadata.put("reader", readerMetadata); + + Map metadata = new HashMap<>(); + metadata.put("relations", relationMetadata); + + Map readerRelation = new HashMap<>(); + readerRelation.put("this", new HashMap<>()); + Map relations = new HashMap<>(); + relations.put("reader", readerRelation); + + List> typeDefinitions = List.of( + Map.of("type", "user", "relations", new HashMap<>()), + Map.of("type", "document", "relations", relations, "metadata", metadata)); + + requestBody.put("type_definitions", typeDefinitions); + + // Use raw API to write authorization model + RawRequestBuilder request = RawRequestBuilder.builder("POST", "/stores/{store_id}/authorization-models") + .body(requestBody) + .build(); + + ApiResponse response = + fga.raw().send(request, WriteAuthorizationModelResponse.class).get(); + + // Verify response + assertNotNull(response); + assertEquals(201, response.getStatusCode()); + assertNotNull(response.getData()); + assertNotNull(response.getData().getAuthorizationModelId()); + + System.out.println("✓ Successfully wrote authorization model using raw request"); + System.out.println(" Model ID: " + response.getData().getAuthorizationModelId()); + } + + /** + * Test reading authorization models with query parameters. + */ + @Test + public void rawRequest_readAuthorizationModels_withQueryParams() throws Exception { + // Create a store and write a model + String storeName = "read-models-test-" + System.currentTimeMillis(); + String storeId = createStoreUsingRawRequest(storeName); + fga.setStoreId(storeId); + + // Create an authorization model first + writeSimpleAuthorizationModel(storeId); + + // Use raw API to read authorization models with query parameters + RawRequestBuilder request = RawRequestBuilder.builder("GET", "/stores/{store_id}/authorization-models") + .queryParam("page_size", "10") + .queryParam("continuation_token", "") + .build(); + + ApiResponse response = + fga.raw().send(request, ReadAuthorizationModelsResponse.class).get(); + + // Verify response + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + assertNotNull(response.getData()); + assertNotNull(response.getData().getAuthorizationModels()); + assertTrue(response.getData().getAuthorizationModels().size() > 0); + + System.out.println("✓ Successfully read authorization models with query params"); + System.out.println( + " Found " + response.getData().getAuthorizationModels().size() + " models"); + } + + /** + * Test Check API using raw request. + * Disabled temporarily - requires more complex authorization model setup. + */ + @Test + @org.junit.jupiter.api.Disabled("Requires complex authorization model setup") + public void rawRequest_check() throws Exception { + // Setup: Create store and authorization model + String storeName = "check-test-" + System.currentTimeMillis(); + String storeId = createStoreUsingRawRequest(storeName); + fga.setStoreId(storeId); + String modelId = writeSimpleAuthorizationModel(storeId); + + // Write a tuple + writeTupleUsingRawRequest(storeId, "user:alice", "reader", "document:budget"); + + // Use raw API to perform check + Map checkBody = new HashMap<>(); + checkBody.put("authorization_model_id", modelId); + + Map tupleKey = new HashMap<>(); + tupleKey.put("user", "user:alice"); + tupleKey.put("relation", "reader"); + tupleKey.put("object", "document:budget"); + checkBody.put("tuple_key", tupleKey); + + RawRequestBuilder request = RawRequestBuilder.builder("POST", "/stores/{store_id}/check") + .body(checkBody) + .build(); + + ApiResponse response = + fga.raw().send(request, CheckResponse.class).get(); + assertTrue(response.getData().getAllowed(), "Alice should be allowed to read the document"); + + System.out.println("✓ Successfully performed check using raw request"); + System.out.println(" Check result: " + response.getData().getAllowed()); + } + + /** + * Test custom headers with raw request. + */ + @Test + public void rawRequest_withCustomHeaders() throws Exception { + String storeName = "headers-test-" + System.currentTimeMillis(); + + Map requestBody = new HashMap<>(); + requestBody.put("name", storeName); + + // Use raw API with custom headers + RawRequestBuilder request = RawRequestBuilder.builder("POST", "/stores") + .body(requestBody) + .header("X-Custom-Header", "custom-value") + .header("X-Request-ID", "test-123") + .build(); + + ApiResponse response = + fga.raw().send(request, CreateStoreResponse.class).get(); + + // Verify response + assertNotNull(response); + assertEquals(201, response.getStatusCode()); + + System.out.println("✓ Successfully sent raw request with custom headers"); + } + + /** + * Test error handling with raw request. + */ + @Test + public void rawRequest_errorHandling_notFound() throws Exception { + // Try to get a non-existent store + RawRequestBuilder request = RawRequestBuilder.builder("GET", "/stores/{store_id}") + .pathParam("store_id", "non-existent-store-id") + .build(); + + // Should throw an exception + try { + fga.raw().send(request, GetStoreResponse.class).get(); + fail("Should have thrown an exception for non-existent store"); + } catch (Exception e) { + // Expected - verify it's some kind of error (ExecutionException wrapping an FgaError) + assertNotNull(e, "Exception should not be null"); + System.out.println("✓ Successfully handled error for non-existent store"); + System.out.println(" Error type: " + e.getClass().getSimpleName()); + if (e.getCause() != null) { + System.out.println(" Cause: " + e.getCause().getClass().getSimpleName()); + } + } + } + + /** + * Test list stores with pagination using query parameters. + */ + @Test + public void rawRequest_listStores_withPagination() throws Exception { + // Create multiple stores + for (int i = 0; i < 3; i++) { + createStoreUsingRawRequest("pagination-test-" + i + "-" + System.currentTimeMillis()); + } + + // Use raw API to list stores with pagination + RawRequestBuilder request = RawRequestBuilder.builder("GET", "/stores") + .queryParam("page_size", "2") + .build(); + + ApiResponse response = + fga.raw().send(request, ListStoresResponse.class).get(); + + // Verify response + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + assertNotNull(response.getData()); + assertNotNull(response.getData().getStores()); + + System.out.println("✓ Successfully listed stores with pagination"); + System.out.println(" Returned: " + response.getData().getStores().size() + " stores"); + if (response.getData().getContinuationToken() != null) { + System.out.println(" Has continuation token for next page"); + } + } + + // Helper methods + + private String createStoreUsingRawRequest(String storeName) throws Exception { + Map requestBody = new HashMap<>(); + requestBody.put("name", storeName); + + RawRequestBuilder request = + RawRequestBuilder.builder("POST", "/stores").body(requestBody).build(); + + ApiResponse response = + fga.raw().send(request, CreateStoreResponse.class).get(); + + return response.getData().getId(); + } + + private String writeSimpleAuthorizationModel(String storeId) throws Exception { + Map requestBody = new HashMap<>(); + requestBody.put("schema_version", "1.1"); + + // Create metadata for reader relation + Map readerMetadata = new HashMap<>(); + readerMetadata.put("directly_related_user_types", List.of(Map.of("type", "user"))); + + Map relationMetadata = new HashMap<>(); + relationMetadata.put("reader", readerMetadata); + + Map metadata = new HashMap<>(); + metadata.put("relations", relationMetadata); + + Map readerRelation = new HashMap<>(); + readerRelation.put("this", new HashMap<>()); + Map relations = new HashMap<>(); + relations.put("reader", readerRelation); + + List> typeDefinitions = List.of( + Map.of("type", "user", "relations", new HashMap<>()), + Map.of("type", "document", "relations", relations, "metadata", metadata)); + + requestBody.put("type_definitions", typeDefinitions); + + RawRequestBuilder request = RawRequestBuilder.builder("POST", "/stores/{store_id}/authorization-models") + .pathParam("store_id", storeId) + .body(requestBody) + .build(); + + ApiResponse response = + fga.raw().send(request, WriteAuthorizationModelResponse.class).get(); + + return response.getData().getAuthorizationModelId(); + } + + private void writeTupleUsingRawRequest(String storeId, String user, String relation, String object) + throws Exception { + Map tupleKey = new HashMap<>(); + tupleKey.put("user", user); + tupleKey.put("relation", relation); + tupleKey.put("object", object); + + Map requestBody = new HashMap<>(); + requestBody.put("writes", Map.of("tuple_keys", List.of(tupleKey))); + + RawRequestBuilder request = RawRequestBuilder.builder("POST", "/stores/{store_id}/write") + .pathParam("store_id", storeId) + .body(requestBody) + .build(); + + fga.raw().send(request, Object.class).get(); + } +} diff --git a/src/test/java/dev/openfga/sdk/api/client/RawApiTest.java b/src/test/java/dev/openfga/sdk/api/client/RawApiTest.java new file mode 100644 index 00000000..cfddaa39 --- /dev/null +++ b/src/test/java/dev/openfga/sdk/api/client/RawApiTest.java @@ -0,0 +1,392 @@ +package dev.openfga.sdk.api.client; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import dev.openfga.sdk.api.configuration.ClientConfiguration; +import dev.openfga.sdk.errors.FgaError; +import dev.openfga.sdk.errors.FgaInvalidParameterException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Test suite for Raw API functionality. + */ +@WireMockTest +public class RawApiTest { + private static final String DEFAULT_STORE_ID = "01YCP46JKYM8FJCQ37NMBYHE5X"; + private static final String EXPERIMENTAL_ENDPOINT = "/stores/{store_id}/experimental-feature"; + + private String fgaApiUrl; + + /** + * Custom response class for testing typed responses. + */ + public static class ExperimentalResponse { + @JsonProperty("success") + public boolean success; + + @JsonProperty("count") + public int count; + + @JsonProperty("message") + public String message; + + public ExperimentalResponse() {} + + public ExperimentalResponse(boolean success, int count, String message) { + this.success = success; + this.count = count; + this.message = message; + } + } + + @BeforeEach + public void beforeEach(WireMockRuntimeInfo wmRuntimeInfo) { + fgaApiUrl = wmRuntimeInfo.getHttpBaseUrl(); + } + + private OpenFgaClient createClient() throws FgaInvalidParameterException { + ClientConfiguration config = new ClientConfiguration().apiUrl(fgaApiUrl).storeId(DEFAULT_STORE_ID); + return new OpenFgaClient(config); + } + + @Test + public void rawApi_canAccessViaClient() throws Exception { + OpenFgaClient client = createClient(); + assertNotNull(client.raw(), "raw() should return a non-null RawApi instance"); + } + + @Test + public void rawRequestBuilder_canBuildBasicRequest() { + RawRequestBuilder builder = + RawRequestBuilder.builder("GET", "/stores/{store_id}/test").build(); + + assertNotNull(builder); + assertEquals("GET", builder.getMethod()); + assertEquals("/stores/{store_id}/test", builder.getPath()); + } + + @Test + public void rawRequestBuilder_canAddPathParameters() { + RawRequestBuilder builder = RawRequestBuilder.builder("GET", "/stores/{store_id}/test") + .pathParam("store_id", "my-store") + .build(); + + Map pathParams = builder.getPathParams(); + assertEquals(1, pathParams.size()); + assertEquals("my-store", pathParams.get("store_id")); + } + + @Test + public void rawRequestBuilder_canAddQueryParameters() { + RawRequestBuilder builder = RawRequestBuilder.builder("GET", "/test") + .queryParam("page", "1") + .queryParam("limit", "10") + .build(); + + Map queryParams = builder.getQueryParams(); + assertEquals(2, queryParams.size()); + assertEquals("1", queryParams.get("page")); + assertEquals("10", queryParams.get("limit")); + } + + @Test + public void rawRequestBuilder_canAddHeaders() { + RawRequestBuilder builder = RawRequestBuilder.builder("GET", "/test") + .header("X-Custom-Header", "custom-value") + .build(); + + Map headers = builder.getHeaders(); + assertEquals(1, headers.size()); + assertEquals("custom-value", headers.get("X-Custom-Header")); + } + + @Test + public void rawRequestBuilder_canAddBody() { + Map body = new HashMap<>(); + body.put("key", "value"); + + RawRequestBuilder builder = + RawRequestBuilder.builder("POST", "/test").body(body).build(); + + assertTrue(builder.hasBody()); + assertEquals(body, builder.getBody()); + } + + @Test + public void rawRequestBuilder_throwsExceptionForNullMethod() { + assertThrows(IllegalArgumentException.class, () -> RawRequestBuilder.builder(null, "/test")); + } + + @Test + public void rawRequestBuilder_throwsExceptionForEmptyMethod() { + assertThrows(IllegalArgumentException.class, () -> RawRequestBuilder.builder("", "/test")); + } + + @Test + public void rawRequestBuilder_throwsExceptionForNullPath() { + assertThrows(IllegalArgumentException.class, () -> RawRequestBuilder.builder("GET", null)); + } + + @Test + public void rawRequestBuilder_throwsExceptionForEmptyPath() { + assertThrows(IllegalArgumentException.class, () -> RawRequestBuilder.builder("GET", "")); + } + + @Test + public void rawRequestBuilder_throwsExceptionForInvalidHttpMethod() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> RawRequestBuilder.builder("INVALID", "/test")); + assertTrue(exception.getMessage().contains("Invalid HTTP method")); + } + + @Test + public void rawRequestBuilder_acceptsValidHttpMethods() { + assertDoesNotThrow(() -> RawRequestBuilder.builder("GET", "/test")); + assertDoesNotThrow(() -> RawRequestBuilder.builder("POST", "/test")); + assertDoesNotThrow(() -> RawRequestBuilder.builder("PUT", "/test")); + assertDoesNotThrow(() -> RawRequestBuilder.builder("PATCH", "/test")); + assertDoesNotThrow(() -> RawRequestBuilder.builder("DELETE", "/test")); + assertDoesNotThrow(() -> RawRequestBuilder.builder("HEAD", "/test")); + assertDoesNotThrow(() -> RawRequestBuilder.builder("OPTIONS", "/test")); + assertDoesNotThrow(() -> RawRequestBuilder.builder("get", "/test")); + } + + @Test + public void rawApi_canSendGetRequestWithTypedResponse() throws Exception { + // Setup mock server + ExperimentalResponse mockResponse = new ExperimentalResponse(true, 42, "Success"); + stubFor(get(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"count\":42,\"message\":\"Success\"}"))); + + // Build and send request + OpenFgaClient client = createClient(); + RawRequestBuilder request = RawRequestBuilder.builder("GET", EXPERIMENTAL_ENDPOINT) + .pathParam("store_id", DEFAULT_STORE_ID) + .build(); + + ApiResponse response = + client.raw().send(request, ExperimentalResponse.class).get(); + + // Verify response + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + assertNotNull(response.getData()); + assertTrue(response.getData().success); + assertEquals(42, response.getData().count); + assertEquals("Success", response.getData().message); + + // Verify the request was made correctly + verify(getRequestedFor(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature")) + .withHeader("Accept", equalTo("application/json"))); + } + + @Test + public void rawApi_canSendPostRequestWithBody() throws Exception { + // Setup mock server + stubFor(post(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"count\":1,\"message\":\"Created\"}"))); + + // Build and send request + OpenFgaClient client = createClient(); + Map requestBody = new HashMap<>(); + requestBody.put("name", "test"); + requestBody.put("value", 123); + + RawRequestBuilder request = RawRequestBuilder.builder("POST", EXPERIMENTAL_ENDPOINT) + .pathParam("store_id", DEFAULT_STORE_ID) + .body(requestBody) + .build(); + + ApiResponse response = + client.raw().send(request, ExperimentalResponse.class).get(); + + // Verify response + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getData().success); + + // Verify the request was made with the correct body + verify(postRequestedFor(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature")) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(matchingJsonPath("$.name", equalTo("test"))) + .withRequestBody(matchingJsonPath("$.value", equalTo("123")))); + } + + @Test + public void rawApi_canSendRequestWithQueryParameters() throws Exception { + // Setup mock server + stubFor(get(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature?force=true&limit=10")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"count\":10,\"message\":\"Success\"}"))); + // Build and send request + OpenFgaClient client = createClient(); + RawRequestBuilder request = RawRequestBuilder.builder("GET", EXPERIMENTAL_ENDPOINT) + .pathParam("store_id", DEFAULT_STORE_ID) + .queryParam("force", "true") + .queryParam("limit", "10") + .build(); + + ApiResponse response = + client.raw().send(request, ExperimentalResponse.class).get(); + + // Verify response + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + + // Verify the request was made with query parameters + verify(getRequestedFor(urlPathEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature")) + .withQueryParam("force", equalTo("true")) + .withQueryParam("limit", equalTo("10"))); + } + + @Test + public void rawApi_canReturnRawJsonString() throws Exception { + // Setup mock server + String jsonResponse = "{\"custom\":\"response\",\"nested\":{\"value\":42}}"; + stubFor(get(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(jsonResponse))); + + // Build and send request + OpenFgaClient client = createClient(); + RawRequestBuilder request = RawRequestBuilder.builder("GET", EXPERIMENTAL_ENDPOINT) + .pathParam("store_id", DEFAULT_STORE_ID) + .build(); + + ApiResponse response = client.raw().send(request).get(); + + // Verify response + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + assertEquals(jsonResponse, response.getData()); + assertEquals(jsonResponse, response.getRawResponse()); + } + + @Test + public void rawApi_handlesHttpErrors() throws Exception { + // Setup mock server to return 404 + stubFor(get(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/non-existent")) + .willReturn(aResponse().withStatus(404).withBody("{\"error\":\"Not found\"}"))); + + // Build and send request + OpenFgaClient client = createClient(); + RawRequestBuilder request = RawRequestBuilder.builder("GET", "/stores/{store_id}/non-existent") + .pathParam("store_id", DEFAULT_STORE_ID) + .build(); + + // Verify exception is thrown + ExecutionException exception = assertThrows( + ExecutionException.class, () -> client.raw().send(request).get()); + + assertTrue(exception.getCause() instanceof FgaError); + } + + @Test + public void rawApi_handlesServerErrors() throws Exception { + // Setup mock server to return 500 + stubFor(get(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature")) + .willReturn(aResponse().withStatus(500).withBody("{\"error\":\"Internal server error\"}"))); + + // Build and send request + OpenFgaClient client = createClient(); + RawRequestBuilder request = RawRequestBuilder.builder("GET", EXPERIMENTAL_ENDPOINT) + .pathParam("store_id", DEFAULT_STORE_ID) + .build(); + + // Verify exception is thrown + ExecutionException exception = assertThrows( + ExecutionException.class, () -> client.raw().send(request).get()); + + assertTrue(exception.getCause() instanceof FgaError); + } + + @Test + public void rawApi_supportsCustomHeaders() throws Exception { + // Setup mock server + stubFor(get(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"count\":0,\"message\":\"OK\"}"))); + + // Build and send request with custom header + OpenFgaClient client = createClient(); + RawRequestBuilder request = RawRequestBuilder.builder("GET", EXPERIMENTAL_ENDPOINT) + .pathParam("store_id", DEFAULT_STORE_ID) + .header("X-Custom-Header", "custom-value") + .header("X-Request-ID", "12345") + .build(); + + ApiResponse response = + client.raw().send(request, ExperimentalResponse.class).get(); + + // Verify response + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + + // Verify custom headers were sent + verify(getRequestedFor(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature")) + .withHeader("X-Custom-Header", equalTo("custom-value")) + .withHeader("X-Request-ID", equalTo("12345"))); + } + + @Test + public void rawApi_encodesPathParameters() throws Exception { + // Setup mock server with encoded path + String encodedId = "store%20with%20spaces"; + stubFor(get(urlEqualTo("/stores/" + encodedId + "/experimental-feature")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"count\":0,\"message\":\"OK\"}"))); + + // Build and send request with special characters + ClientConfiguration config = new ClientConfiguration().apiUrl(fgaApiUrl).storeId("store with spaces"); + OpenFgaClient client = new OpenFgaClient(config); + + RawRequestBuilder request = RawRequestBuilder.builder("GET", EXPERIMENTAL_ENDPOINT) + .pathParam("store_id", "store with spaces") + .build(); + + ApiResponse response = + client.raw().send(request, ExperimentalResponse.class).get(); + + // Verify response + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + + // Verify the path was encoded + verify(getRequestedFor(urlEqualTo("/stores/" + encodedId + "/experimental-feature"))); + } + + @Test + public void rawApi_throwsExceptionForNullBuilder() throws Exception { + OpenFgaClient client = createClient(); + assertThrows(IllegalArgumentException.class, () -> client.raw().send(null)); + } + + @Test + public void rawApi_throwsExceptionForNullResponseType() throws Exception { + OpenFgaClient client = createClient(); + RawRequestBuilder request = RawRequestBuilder.builder("GET", "/test").build(); + assertThrows(IllegalArgumentException.class, () -> client.raw().send(request, null)); + } +}