diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5afe9b1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,104 @@ +version: 2 +updates: + # GitHub Actions + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] + + # Go (root + every dir that has its own go.mod for mage) + - package-ecosystem: gomod + directories: + - / + - /spicedb-gen + - /spicedb-gen/testdata/go + - /spicedb-go + - /spicedb-python + - /spicedb-typescript + - /spicedb-csharp + - /spicedb-java + - /spicedb-ruby + - /spicedb-rust + - /proto-clients/spicedb-go-proto + - /proto-clients/spicedb-python-proto + - /proto-clients/spicedb-typescript-proto + - /proto-clients/spicedb-csharp-proto + - /proto-clients/spicedb-java-proto + - /proto-clients/spicedb-ruby-proto + - /proto-clients/spicedb-rust-proto + schedule: + interval: weekly + groups: + gomod: + patterns: ["*"] + + # Python (uv) + - package-ecosystem: pip + directories: + - /proto-clients/spicedb-python-proto + - /spicedb-gen/testdata/python + - /spicedb-python + schedule: + interval: weekly + groups: + pip: + patterns: ["*"] + + # TypeScript (pnpm) + - package-ecosystem: npm + directories: + - / + - /proto-clients/spicedb-typescript-proto + - /spicedb-typescript + schedule: + interval: weekly + groups: + npm: + patterns: ["*"] + + # C# (NuGet) + - package-ecosystem: nuget + directories: + - /proto-clients/spicedb-csharp-proto + - /spicedb-csharp + schedule: + interval: weekly + groups: + nuget: + patterns: ["*"] + + # Java (Gradle) + - package-ecosystem: gradle + directories: + - /proto-clients/spicedb-java-proto + - /spicedb-java + schedule: + interval: weekly + groups: + gradle: + patterns: ["*"] + + # Ruby (Bundler) + - package-ecosystem: bundler + directories: + - /proto-clients/spicedb-ruby-proto + - /spicedb-ruby + schedule: + interval: weekly + groups: + bundler: + patterns: ["*"] + + # Rust (Cargo) + - package-ecosystem: cargo + directories: + - /proto-clients/spicedb-rust-proto + - /spicedb-rust + schedule: + interval: weekly + groups: + cargo: + patterns: ["*"] diff --git a/.github/workflows/csharp.yaml b/.github/workflows/csharp.yaml new file mode 100644 index 0000000..b3af88c --- /dev/null +++ b/.github/workflows/csharp.yaml @@ -0,0 +1,107 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +--- +name: CSharp +on: + push: + branches: [main] + pull_request: + branches: ["*"] + merge_group: + types: [checks_requested] +permissions: + contents: read +concurrency: + group: "csharp-${{ github.event.pull_request.number || github.sha }}" + cancel-in-progress: true +jobs: + paths-filter: + runs-on: depot-ubuntu-24.04-arm-small + outputs: + changed: ${{ steps.filter.outputs.changed }} + steps: + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 + id: filter + with: + filters: | + changed: + - '.github/workflows/csharp.yaml' + - 'Magefile.go' + - 'go.mod' + - 'go.sum' + - 'proto-clients/spicedb-csharp-proto/**' + - 'spicedb-csharp/**' + + lint: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Lint + run: mage -d spicedb-csharp lint + + unit: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Proto client tests + run: mage -d proto-clients/spicedb-csharp-proto test + - name: Idiomatic client tests + run: mage -d spicedb-csharp test + + integration: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Integration test + run: mage -d spicedb-csharp integrationTest + + apicompat: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' && github.event_name == 'pull_request' + runs-on: depot-ubuntu-24.04-arm-small + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Install Microsoft.DotNet.ApiCompat.Tool + run: dotnet tool install --global Microsoft.DotNet.ApiCompat.Tool + - name: API compatibility check + run: mage -d spicedb-csharp apiCompat origin/${{ github.base_ref }} diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml new file mode 100644 index 0000000..1a5f894 --- /dev/null +++ b/.github/workflows/go.yaml @@ -0,0 +1,98 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +--- +name: Go +on: + push: + branches: [main] + pull_request: + branches: ["*"] + merge_group: + types: [checks_requested] +permissions: + contents: read +concurrency: + group: "go-${{ github.event.pull_request.number || github.sha }}" + cancel-in-progress: true +jobs: + paths-filter: + runs-on: depot-ubuntu-24.04-arm-small + outputs: + changed: ${{ steps.filter.outputs.changed }} + steps: + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 + id: filter + with: + filters: | + changed: + - '.github/workflows/go.yaml' + - 'Magefile.go' + - 'go.mod' + - 'go.sum' + - 'proto-clients/spicedb-go-proto/**' + - 'spicedb-go/**' + + lint: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6 + with: + working-directory: spicedb-go + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Lint + run: mage -d spicedb-go lint + + unit: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Proto client tests + run: mage -d proto-clients/spicedb-go-proto test + - name: Idiomatic client tests + run: mage -d spicedb-go test + + integration: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Integration test + run: mage -d spicedb-go integrationTest + + apicompat: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' && github.event_name == 'pull_request' + runs-on: depot-ubuntu-24.04-arm-small + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Install go-apidiff + run: go install github.com/joelanford/go-apidiff@latest + - name: API compatibility check + run: mage -d spicedb-go apiCompat origin/${{ github.base_ref }} diff --git a/.github/workflows/java.yaml b/.github/workflows/java.yaml new file mode 100644 index 0000000..244fee2 --- /dev/null +++ b/.github/workflows/java.yaml @@ -0,0 +1,118 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +--- +name: Java +on: + push: + branches: [main] + pull_request: + branches: ["*"] + merge_group: + types: [checks_requested] +permissions: + contents: read +concurrency: + group: "java-${{ github.event.pull_request.number || github.sha }}" + cancel-in-progress: true +jobs: + paths-filter: + runs-on: depot-ubuntu-24.04-arm-small + outputs: + changed: ${{ steps.filter.outputs.changed }} + steps: + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 + id: filter + with: + filters: | + changed: + - '.github/workflows/java.yaml' + - 'Magefile.go' + - 'go.mod' + - 'go.sum' + - 'proto-clients/spicedb-java-proto/**' + - 'spicedb-java/**' + + lint: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + - uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4 + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Lint + run: mage -d spicedb-java lint + + unit: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + - uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4 + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Proto client tests + run: mage -d proto-clients/spicedb-java-proto test + - name: Idiomatic client tests + run: mage -d spicedb-java test + + integration: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + - uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4 + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Integration test + run: mage -d spicedb-java integrationTest + + apicompat: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' && github.event_name == 'pull_request' + runs-on: depot-ubuntu-24.04-arm-small + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + - uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4 + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Install japicmp + run: | + mkdir -p tools + curl -fsSL -o tools/japicmp.jar \ + "https://repo1.maven.org/maven2/com/github/siom79/japicmp/japicmp/0.23.1/japicmp-0.23.1-jar-with-dependencies.jar" + - name: API compatibility check + run: mage -d spicedb-java apiCompat origin/${{ github.base_ref }} diff --git a/.github/workflows/meta.yaml b/.github/workflows/meta.yaml new file mode 100644 index 0000000..5e51a13 --- /dev/null +++ b/.github/workflows/meta.yaml @@ -0,0 +1,89 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +--- +name: Meta +on: + push: + branches: [main] + pull_request: + branches: ["*"] + merge_group: + types: [checks_requested] +permissions: + contents: read +concurrency: + group: "meta-${{ github.event.pull_request.number || github.sha }}" + cancel-in-progress: true +jobs: + yaml-lint: + runs-on: depot-ubuntu-24.04-arm-small + steps: + - uses: actions/checkout@v6 + - name: Install yamllint + run: pip3 install yamllint + - name: Lint workflow YAML + run: "yamllint -d '{extends: relaxed, rules: {line-length: disable}}' .github/workflows/" + + markdown-lint: + runs-on: depot-ubuntu-24.04-arm-small + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install markdownlint-cli2 + run: npm install -g markdownlint-cli2 + - name: Lint Markdown + run: markdownlint-cli2 + + gen-nodiff: + runs-on: depot-ubuntu-24.04-arm-8 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 + with: + enable-cache: true + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + - uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4 + - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 + with: + ruby-version: '3.2' + bundler-cache: false + working-directory: spicedb-ruby + - name: Install Ruby deps + working-directory: spicedb-ruby + run: bundle install + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: spicedb-rust + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - name: Install buf + uses: bufbuild/buf-setup-action@a47c93e0b1648d5651a065437926377d060baa99 # v1 + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Install pnpm deps + run: pnpm install --frozen-lockfile + - name: Build proto package + run: pnpm --filter @spicedb/proto build + - name: Generate all clients + run: mage gen:all + - name: Fail if generated files drifted + uses: chainguard-dev/actions/nodiff@3e76343fe029f8e2a10bfbec0da8f437e5dab7a5 # main + with: + path: "" + fixup-command: mage gen:all diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml new file mode 100644 index 0000000..8c38be9 --- /dev/null +++ b/.github/workflows/python.yaml @@ -0,0 +1,121 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +--- +name: Python +on: + push: + branches: [main] + pull_request: + branches: ["*"] + merge_group: + types: [checks_requested] +permissions: + contents: read +concurrency: + group: "python-${{ github.event.pull_request.number || github.sha }}" + cancel-in-progress: true +jobs: + paths-filter: + runs-on: depot-ubuntu-24.04-arm-small + outputs: + changed: ${{ steps.filter.outputs.changed }} + steps: + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 + id: filter + with: + filters: | + changed: + - '.github/workflows/python.yaml' + - 'Magefile.go' + - 'go.mod' + - 'go.sum' + - 'proto-clients/spicedb-python-proto/**' + - 'spicedb-python/**' + + lint: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 + with: + enable-cache: true + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Install ruff + run: uv tool install ruff + - name: Sync deps + working-directory: spicedb-python + run: uv sync --extra dev + - name: Lint + run: mage -d spicedb-python lint + + unit: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 + with: + enable-cache: true + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Sync proto client dev deps + working-directory: proto-clients/spicedb-python-proto + run: uv sync --extra dev + - name: Sync idiomatic client dev deps + working-directory: spicedb-python + run: uv sync --extra dev + - name: Proto client tests + run: mage -d proto-clients/spicedb-python-proto test + - name: Idiomatic client tests + run: mage -d spicedb-python test + + integration: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 + with: + enable-cache: true + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Sync deps + working-directory: spicedb-python + run: uv sync --extra dev + - name: Integration test + run: mage -d spicedb-python integrationTest + + apicompat: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' && github.event_name == 'pull_request' + runs-on: depot-ubuntu-24.04-arm-small + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 + with: + enable-cache: true + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Install griffe + run: uv tool install griffe + - name: API compatibility check + run: mage -d spicedb-python apiCompat origin/${{ github.base_ref }} diff --git a/.github/workflows/ruby.yaml b/.github/workflows/ruby.yaml new file mode 100644 index 0000000..06364b2 --- /dev/null +++ b/.github/workflows/ruby.yaml @@ -0,0 +1,117 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +--- +name: Ruby +on: + push: + branches: [main] + pull_request: + branches: ["*"] + merge_group: + types: [checks_requested] +permissions: + contents: read +concurrency: + group: "ruby-${{ github.event.pull_request.number || github.sha }}" + cancel-in-progress: true +jobs: + paths-filter: + runs-on: depot-ubuntu-24.04-arm-small + outputs: + changed: ${{ steps.filter.outputs.changed }} + steps: + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 + id: filter + with: + filters: | + changed: + - '.github/workflows/ruby.yaml' + - 'Magefile.go' + - 'go.mod' + - 'go.sum' + - 'proto-clients/spicedb-ruby-proto/**' + - 'spicedb-ruby/**' + + lint: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 + with: + ruby-version: '3.2' + bundler-cache: false + working-directory: spicedb-ruby + - name: Install Ruby deps + working-directory: spicedb-ruby + run: bundle install + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Lint + run: mage -d spicedb-ruby lint + + unit: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 + with: + ruby-version: '3.2' + bundler-cache: false + working-directory: spicedb-ruby + - name: Install Ruby deps (idiomatic client) + working-directory: spicedb-ruby + run: bundle install + - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 + with: + ruby-version: '3.2' + bundler-cache: false + working-directory: proto-clients/spicedb-ruby-proto + - name: Install Ruby deps (proto client) + working-directory: proto-clients/spicedb-ruby-proto + run: bundle install + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Proto client tests + run: mage -d proto-clients/spicedb-ruby-proto test + - name: Idiomatic client tests + run: mage -d spicedb-ruby test + + integration: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 + with: + ruby-version: '3.2' + bundler-cache: false + working-directory: spicedb-ruby + - name: Install Ruby deps (idiomatic client) + working-directory: spicedb-ruby + run: bundle install + - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 + with: + ruby-version: '3.2' + bundler-cache: false + working-directory: proto-clients/spicedb-ruby-proto + - name: Install Ruby deps (proto client) + working-directory: proto-clients/spicedb-ruby-proto + run: bundle install + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Integration test + run: mage -d spicedb-ruby integrationTest diff --git a/.github/workflows/rust.yaml b/.github/workflows/rust.yaml new file mode 100644 index 0000000..334b0b5 --- /dev/null +++ b/.github/workflows/rust.yaml @@ -0,0 +1,121 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +--- +name: Rust +on: + push: + branches: [main] + pull_request: + branches: ["*"] + merge_group: + types: [checks_requested] +permissions: + contents: read +concurrency: + group: "rust-${{ github.event.pull_request.number || github.sha }}" + cancel-in-progress: true +jobs: + paths-filter: + runs-on: depot-ubuntu-24.04-arm-small + outputs: + changed: ${{ steps.filter.outputs.changed }} + steps: + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 + id: filter + with: + filters: | + changed: + - '.github/workflows/rust.yaml' + - 'Magefile.go' + - 'go.mod' + - 'go.sum' + - 'proto-clients/spicedb-rust-proto/**' + - 'spicedb-rust/**' + + lint: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: spicedb-rust + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Lint + run: mage -d spicedb-rust lint + + unit: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: spicedb-rust + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Proto client tests + run: mage -d proto-clients/spicedb-rust-proto test + - name: Idiomatic client tests + run: mage -d spicedb-rust test + + integration: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: spicedb-rust + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Integration test + run: mage -d spicedb-rust integrationTest + + apicompat: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' && github.event_name == 'pull_request' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: spicedb-rust + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Install cargo-semver-checks + run: cargo install cargo-semver-checks --locked + - name: API compatibility check + run: mage -d spicedb-rust apiCompat origin/${{ github.base_ref }} diff --git a/.github/workflows/spicedb-gen.yaml b/.github/workflows/spicedb-gen.yaml new file mode 100644 index 0000000..068909e --- /dev/null +++ b/.github/workflows/spicedb-gen.yaml @@ -0,0 +1,123 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +--- +name: spicedb-gen +on: + push: + branches: [main] + pull_request: + branches: ["*"] + merge_group: + types: [checks_requested] +permissions: + contents: read +concurrency: + group: "spicedb-gen-${{ github.event.pull_request.number || github.sha }}" + cancel-in-progress: true +jobs: + paths-filter: + runs-on: depot-ubuntu-24.04-arm-small + outputs: + changed: ${{ steps.filter.outputs.changed }} + steps: + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 + id: filter + with: + filters: | + changed: + - '.github/workflows/spicedb-gen.yaml' + - 'Magefile.go' + - 'go.mod' + - 'go.sum' + - 'spicedb-gen/**' + + test: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Unit tests + run: mage -d spicedb-gen test + + go-int: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Go integration test + run: mage -d spicedb-gen goIntegrationTest + + ts-int: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Install pnpm deps + run: pnpm install --frozen-lockfile + - name: Build proto package + run: pnpm --filter @spicedb/proto build + - name: Build client package + run: pnpm --filter @spicedb/client build + - name: TypeScript integration test + run: mage -d spicedb-gen typeScriptIntegrationTest + + java-int: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + - uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4 + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Java integration test + run: mage -d spicedb-gen javaIntegrationTest + + python-int: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 + with: + enable-cache: true + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Install pyright (positive + expected-failure checks) + run: uv tool install pyright + - name: Python integration test + run: mage -d spicedb-gen pythonIntegrationTest diff --git a/.github/workflows/typescript.yaml b/.github/workflows/typescript.yaml new file mode 100644 index 0000000..fc75897 --- /dev/null +++ b/.github/workflows/typescript.yaml @@ -0,0 +1,132 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +--- +name: TypeScript +on: + push: + branches: [main] + pull_request: + branches: ["*"] + merge_group: + types: [checks_requested] +permissions: + contents: read +concurrency: + group: "typescript-${{ github.event.pull_request.number || github.sha }}" + cancel-in-progress: true +jobs: + paths-filter: + runs-on: depot-ubuntu-24.04-arm-small + outputs: + changed: ${{ steps.filter.outputs.changed }} + steps: + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 + id: filter + with: + filters: | + changed: + - '.github/workflows/typescript.yaml' + - 'Magefile.go' + - 'go.mod' + - 'go.sum' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'proto-clients/spicedb-typescript-proto/**' + - 'spicedb-typescript/**' + + lint: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Install deps + run: pnpm install --frozen-lockfile + - name: Build proto package + run: pnpm --filter @spicedb/proto build + - name: Lint + run: mage -d spicedb-typescript lint + + unit: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Install deps + run: pnpm install --frozen-lockfile + - name: Build proto package + run: pnpm --filter @spicedb/proto build + - name: Proto client tests + run: mage -d proto-clients/spicedb-typescript-proto test + - name: Idiomatic client tests + run: mage -d spicedb-typescript test + + integration: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' + runs-on: depot-ubuntu-24.04-arm-4 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Install deps + run: pnpm install --frozen-lockfile + - name: Build proto package + run: pnpm --filter @spicedb/proto build + - name: Integration test + run: mage -d spicedb-typescript integrationTest + + apicompat: + needs: paths-filter + if: needs.paths-filter.outputs.changed == 'true' && github.event_name == 'pull_request' + runs-on: depot-ubuntu-24.04-arm-small + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - name: Install mage + run: go install github.com/magefile/mage@latest + - name: Install deps + run: pnpm install --frozen-lockfile + - name: Build proto package + run: pnpm --filter @spicedb/proto build + - name: API compatibility check + run: mage -d spicedb-typescript apiCompat origin/${{ github.base_ref }} diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml new file mode 100644 index 0000000..56628c2 --- /dev/null +++ b/.markdownlint-cli2.yaml @@ -0,0 +1,19 @@ +config: + MD012: false + MD013: false + MD031: false + MD032: false + MD033: false + MD040: false + MD041: false + MD060: false +globs: + - "**/*.md" +ignores: + - "node_modules/**" + - "**/target/**" + - "**/.venv/**" + - "**/build/**" + - "docs/superpowers/**" + - "proto-clients/**" + - "spicedb-rust/target/**" diff --git a/DESIGN.md b/DESIGN.md index f1dcb22..14794b3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -78,3 +78,27 @@ All idiomatic clients should provide: - No silent defaults for consistency — make users choose explicitly - No exposing gRPC internals (channels, stubs, metadata) in the primary API (escape hatches for advanced use are acceptable as clearly marked secondary API) + +## CI Workflow Conventions + +The repo uses one GitHub Actions workflow file per language plus `spicedb-gen.yaml` and `meta.yaml`. Rules for maintaining and extending CI: + +1. **Per-language file.** Every language has its own `.github/workflows/.yaml`. Do not add language-specific jobs to a shared workflow. Cross-cutting checks (e.g., `mage gen:all` nodiff, YAML lint) live in `meta.yaml`. + +2. **Standard job set per language workflow.** Each language workflow contains: `paths-filter`, `lint`, `unit`, `integration`, and `apicompat` (if the language appears in `apiCompatLanguages` in the root `Magefile.go`). All four work-jobs gate on `paths-filter.outputs.changed == 'true'`. `apicompat` additionally gates on `github.event_name == 'pull_request'` so it has a base ref to diff against. + +3. **paths-filter scope.** Each filter must watch the workflow file itself, the language's `proto-clients/spicedb--proto/**`, the language's `spicedb-/**`, root `Magefile.go`, and root `go.mod` / `go.sum`. Workflow edits without filter coverage produce a silently-skipping CI. + +4. **Runner sizing.** Default to `depot-ubuntu-24.04-arm-4`. Use `arm-small` for trivial steps (paths-filter, apicompat, yaml lint). Use `arm-8` only for cross-cutting work that touches every language (e.g. `meta.gen-nodiff`). Justify any size deviation in a comment. + +5. **Action pinning.** Third-party actions (anything outside `actions/*` and `authzed/*`) pin to commit SHA with `# vX.Y.Z` comment. Dependabot's `github-actions` ecosystem keeps these current. + +6. **Integration tests.** Each `mage integrationTest` target is self-contained — it starts its own docker-compose, runs tests, tears down. CI runs at most one `integrationTest` per job (they all bind `localhost:50051`). Cross-language parallelism is fine because each runner is isolated. + +7. **Adding a new language.** Checklist: + - Create `.github/workflows/.yaml` from an existing one (Go is a good template if compiled, Python if interpreted). + - Add the language's directories to `.github/dependabot.yml`. + - Add a per-language Quick Start in `README.md` (typed if `spicedb-gen` supports it, idiomatic otherwise). + - Add the language to `languages` (and `apiCompatLanguages` if applicable) in the root `Magefile.go`. + +8. **Adding a new job type.** If you find yourself wanting a new kind of check (e.g., security scanning, license check), add it to **every** language workflow at once, document it here, and add a paths-filter clause if it should be skippable. diff --git a/package.json b/package.json index 352055c..da2dbc8 100644 --- a/package.json +++ b/package.json @@ -1,3 +1,4 @@ { - "private": true + "private": true, + "packageManager": "pnpm@9.15.0" } diff --git a/proto-clients/spicedb-csharp-proto/Magefile.go b/proto-clients/spicedb-csharp-proto/Magefile.go index 62ccf6c..fa5b6d2 100644 --- a/proto-clients/spicedb-csharp-proto/Magefile.go +++ b/proto-clients/spicedb-csharp-proto/Magefile.go @@ -13,14 +13,40 @@ import ( const maxRetries = 3 +// claudeAvailable returns true if the claude CLI is installed and usable. +// Returns false when running in CI (CI env var set) because the claude binary +// may be present but not authenticated. +func claudeAvailable() bool { + if os.Getenv("CI") != "" { + return false + } + _, err := exec.LookPath("claude") + return err == nil +} + // Gen regenerates the proto client: runs buf generate, then invokes Claude -// to add boilerplate per DESIGN.md. +// to add boilerplate per DESIGN.md. If claude is not available (e.g. in +// gen-nodiff CI), any buf generate changes are rolled back and Gen returns +// nil so the working tree stays clean for the nodiff check. func Gen() error { fmt.Println("==> Running buf generate...") if err := sh.Run("buf", "generate"); err != nil { return fmt.Errorf("buf generate failed: %w", err) } + diff, _ := sh.Output("git", "diff", "--name-only", ".") + if strings.TrimSpace(diff) == "" { + fmt.Println("==> No proto changes detected after buf generate, skipping Claude step.") + return nil + } + + if !claudeAvailable() { + fmt.Println("==> claude not available; rolling back buf generate changes (gen-nodiff mode).") + _ = sh.Run("git", "checkout", "--", ".") + _ = sh.Run("git", "clean", "-fd", ".") + return nil + } + fmt.Println("==> Invoking Claude to add boilerplate...") if err := runClaude( "Read DESIGN.md. Review the generated code under gen/. " + @@ -41,6 +67,7 @@ func Gen() error { if attempt == maxRetries { fmt.Printf("==> Tests failed after %d attempts. Rolling back.\n", maxRetries) _ = sh.Run("git", "checkout", "--", ".") + _ = sh.Run("git", "clean", "-fd", ".") return fmt.Errorf("tests failed after %d retries", maxRetries) } diff --git a/proto-clients/spicedb-go-proto/Magefile.go b/proto-clients/spicedb-go-proto/Magefile.go index 2867190..1d3de92 100644 --- a/proto-clients/spicedb-go-proto/Magefile.go +++ b/proto-clients/spicedb-go-proto/Magefile.go @@ -13,14 +13,40 @@ import ( const maxRetries = 3 +// claudeAvailable returns true if the claude CLI is installed and usable. +// Returns false when running in CI (CI env var set) because the claude binary +// may be present but not authenticated. +func claudeAvailable() bool { + if os.Getenv("CI") != "" { + return false + } + _, err := exec.LookPath("claude") + return err == nil +} + // Gen regenerates the proto client: runs buf generate, then invokes Claude -// to add boilerplate per DESIGN.md. +// to add boilerplate per DESIGN.md. If claude is not available (e.g. in +// gen-nodiff CI), any buf generate changes are rolled back and Gen returns +// nil so the working tree stays clean for the nodiff check. func Gen() error { fmt.Println("==> Running buf generate...") if err := sh.Run("buf", "generate"); err != nil { return fmt.Errorf("buf generate failed: %w", err) } + diff, _ := sh.Output("git", "diff", "--name-only", ".") + if strings.TrimSpace(diff) == "" { + fmt.Println("==> No proto changes detected after buf generate, skipping Claude step.") + return nil + } + + if !claudeAvailable() { + fmt.Println("==> claude not available; rolling back buf generate changes (gen-nodiff mode).") + _ = sh.Run("git", "checkout", "--", ".") + _ = sh.Run("git", "clean", "-fd", ".") + return nil + } + fmt.Println("==> Invoking Claude to add boilerplate...") if err := runClaude( "Read DESIGN.md. Review the generated code under gen/. " + @@ -41,6 +67,7 @@ func Gen() error { if attempt == maxRetries { fmt.Printf("==> Tests failed after %d attempts. Rolling back.\n", maxRetries) _ = sh.Run("git", "checkout", "--", ".") + _ = sh.Run("git", "clean", "-fd", ".") return fmt.Errorf("tests failed after %d retries", maxRetries) } diff --git a/proto-clients/spicedb-java-proto/Magefile.go b/proto-clients/spicedb-java-proto/Magefile.go index edde645..d2217d4 100644 --- a/proto-clients/spicedb-java-proto/Magefile.go +++ b/proto-clients/spicedb-java-proto/Magefile.go @@ -13,14 +13,43 @@ import ( const maxRetries = 3 +// claudeAvailable returns true if the claude CLI is installed and usable. +// Returns false when running in CI (CI env var set) because the claude binary +// may be present but not authenticated. +func claudeAvailable() bool { + if os.Getenv("CI") != "" { + return false + } + _, err := exec.LookPath("claude") + return err == nil +} + // Gen regenerates the proto client: runs buf generate, then invokes Claude -// to add boilerplate per DESIGN.md. +// to add boilerplate per DESIGN.md. If claude is not available (e.g. in +// gen-nodiff CI), any buf generate changes are rolled back and Gen returns +// nil so the working tree stays clean for the nodiff check. func Gen() error { fmt.Println("==> Running buf generate...") if err := sh.Run("buf", "generate"); err != nil { return fmt.Errorf("buf generate failed: %w", err) } + // Use git status --porcelain to detect both tracked modifications AND new + // untracked files (buf generate for Java creates new files in gen/ that are + // not checked in — git diff would miss them). + status, _ := sh.Output("git", "status", "--porcelain", ".") + if strings.TrimSpace(status) == "" { + fmt.Println("==> No proto changes detected after buf generate, skipping Claude step.") + return nil + } + + if !claudeAvailable() { + fmt.Println("==> claude not available; rolling back buf generate changes (gen-nodiff mode).") + _ = sh.Run("git", "checkout", "--", ".") + _ = sh.Run("git", "clean", "-fd", ".") + return nil + } + fmt.Println("==> Invoking Claude to add boilerplate...") if err := runClaude( "Read DESIGN.md. Review the generated code under gen/. " + @@ -41,6 +70,7 @@ func Gen() error { if attempt == maxRetries { fmt.Printf("==> Tests failed after %d attempts. Rolling back.\n", maxRetries) _ = sh.Run("git", "checkout", "--", ".") + _ = sh.Run("git", "clean", "-fd", ".") return fmt.Errorf("tests failed after %d retries", maxRetries) } diff --git a/proto-clients/spicedb-python-proto/Magefile.go b/proto-clients/spicedb-python-proto/Magefile.go index a3c05c0..df9c38a 100644 --- a/proto-clients/spicedb-python-proto/Magefile.go +++ b/proto-clients/spicedb-python-proto/Magefile.go @@ -13,13 +13,39 @@ import ( const maxRetries = 3 -// Gen regenerates the Python proto client. +// claudeAvailable returns true if the claude CLI is installed and usable. +// Returns false when running in CI (CI env var set) because the claude binary +// may be present but not authenticated. +func claudeAvailable() bool { + if os.Getenv("CI") != "" { + return false + } + _, err := exec.LookPath("claude") + return err == nil +} + +// Gen regenerates the Python proto client. If claude is not available (e.g. in +// gen-nodiff CI), any buf generate changes are rolled back and Gen returns nil +// so the working tree stays clean for the nodiff check. func Gen() error { fmt.Println("==> Running buf generate...") if err := sh.Run("buf", "generate"); err != nil { return fmt.Errorf("buf generate failed: %w", err) } + diff, _ := sh.Output("git", "diff", "--name-only", ".") + if strings.TrimSpace(diff) == "" { + fmt.Println("==> No proto changes detected after buf generate, skipping Claude step.") + return nil + } + + if !claudeAvailable() { + fmt.Println("==> claude not available; rolling back buf generate changes (gen-nodiff mode).") + _ = sh.Run("git", "checkout", "--", ".") + _ = sh.Run("git", "clean", "-fd", ".") + return nil + } + fmt.Println("==> Syncing Python deps...") if err := sh.RunV("uv", "sync"); err != nil { return fmt.Errorf("uv sync failed: %w", err) @@ -44,6 +70,7 @@ func Gen() error { if attempt == maxRetries { fmt.Printf("==> Tests failed after %d attempts. Rolling back.\n", maxRetries) _ = sh.Run("git", "checkout", "--", ".") + _ = sh.Run("git", "clean", "-fd", ".") return fmt.Errorf("tests failed after %d retries", maxRetries) } diff --git a/proto-clients/spicedb-ruby-proto/Magefile.go b/proto-clients/spicedb-ruby-proto/Magefile.go index f4ae805..ab67757 100644 --- a/proto-clients/spicedb-ruby-proto/Magefile.go +++ b/proto-clients/spicedb-ruby-proto/Magefile.go @@ -13,14 +13,39 @@ import ( const maxRetries = 3 +// claudeAvailable returns true if the claude CLI is installed and usable. +// Returns false when running in CI (CI env var set) because the claude binary +// may be present but not authenticated. +func claudeAvailable() bool { + if os.Getenv("CI") != "" { + return false + } + _, err := exec.LookPath("claude") + return err == nil +} + // Gen regenerates the proto client: runs buf generate, then invokes Claude -// to add boilerplate per DESIGN.md. +// to add boilerplate per DESIGN.md. If claude is not available (e.g. in +// gen-nodiff CI), any buf generate changes are rolled back and Gen returns +// nil so the working tree stays clean for the nodiff check. func Gen() error { fmt.Println("==> Running buf generate...") if err := sh.Run("buf", "generate"); err != nil { return fmt.Errorf("buf generate failed: %w", err) } + diff, _ := sh.Output("git", "diff", "--name-only", ".") + if strings.TrimSpace(diff) == "" { + fmt.Println("==> No proto changes detected after buf generate, skipping Claude step.") + return nil + } + + if !claudeAvailable() { + fmt.Println("==> claude not available; rolling back buf generate changes (gen-nodiff mode).") + _ = sh.Run("git", "checkout", "--", ".") + return nil + } + fmt.Println("==> Installing Ruby dependencies...") if err := sh.Run("bundle", "install"); err != nil { return fmt.Errorf("bundle install failed: %w", err) diff --git a/proto-clients/spicedb-rust-proto/Cargo.toml b/proto-clients/spicedb-rust-proto/Cargo.toml index d951b81..24d8f95 100644 --- a/proto-clients/spicedb-rust-proto/Cargo.toml +++ b/proto-clients/spicedb-rust-proto/Cargo.toml @@ -4,6 +4,12 @@ version = "0.1.0" edition = "2021" description = "SpiceDB proto client for Rust — thin wrapper over tonic-generated gRPC stubs" +# Disable doctests: tonic-build generates doc comments containing JSON +# examples (e.g. `"reason": "ERROR_REASON_SCHEMA_PARSE_ERROR"`) that +# Rust's doctest runner tries to parse as Rust code and fails. +[lib] +doctest = false + [dependencies] tonic = { version = "0.12", features = ["channel", "tls"] } prost = "0.13" diff --git a/proto-clients/spicedb-rust-proto/Magefile.go b/proto-clients/spicedb-rust-proto/Magefile.go index ba1f232..fb88220 100644 --- a/proto-clients/spicedb-rust-proto/Magefile.go +++ b/proto-clients/spicedb-rust-proto/Magefile.go @@ -13,13 +13,39 @@ import ( const maxRetries = 3 +// claudeAvailable returns true if the claude CLI is installed and usable. +// Returns false when running in CI (CI env var set) because the claude binary +// may be present but not authenticated. +func claudeAvailable() bool { + if os.Getenv("CI") != "" { + return false + } + _, err := exec.LookPath("claude") + return err == nil +} + // Gen exports protos via buf, invokes Claude to wire up the client, then tests. +// If claude is not available (e.g. in gen-nodiff CI), any buf export changes +// are rolled back and Gen returns nil so the working tree stays clean for the +// nodiff check. func Gen() error { fmt.Println("==> Exporting protos via buf...") if err := sh.Run("buf", "export", "buf.build/authzed/api", "-o", "proto"); err != nil { return fmt.Errorf("buf export failed: %w", err) } + diff, _ := sh.Output("git", "diff", "--name-only", "proto") + if strings.TrimSpace(diff) == "" { + fmt.Println("==> No proto changes detected after buf export, skipping Claude step.") + return nil + } + + if !claudeAvailable() { + fmt.Println("==> claude not available; rolling back buf export changes (gen-nodiff mode).") + _ = sh.Run("git", "checkout", "--", "proto") + return nil + } + fmt.Println("==> Invoking Claude to wire up generated code...") if err := runClaude( "Read DESIGN.md. The proto/ directory has been populated by buf export. " + diff --git a/proto-clients/spicedb-rust-proto/go.sum b/proto-clients/spicedb-rust-proto/go.sum new file mode 100644 index 0000000..4ee1b87 --- /dev/null +++ b/proto-clients/spicedb-rust-proto/go.sum @@ -0,0 +1,2 @@ +github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= +github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= diff --git a/proto-clients/spicedb-rust-proto/src/client.rs b/proto-clients/spicedb-rust-proto/src/client.rs index 5449fa2..06a6dc7 100644 --- a/proto-clients/spicedb-rust-proto/src/client.rs +++ b/proto-clients/spicedb-rust-proto/src/client.rs @@ -1,6 +1,6 @@ -use tonic::transport::{Channel, ClientTlsConfig, Endpoint}; use tonic::metadata::MetadataValue; use tonic::service::Interceptor; +use tonic::transport::{Channel, ClientTlsConfig, Endpoint}; use crate::authzed::api::v1; @@ -50,7 +50,8 @@ pub struct SpiceDBProtoClient { pub permissions: v1::permissions_service_client::PermissionsServiceClient, pub schema: v1::schema_service_client::SchemaServiceClient, pub watch: v1::watch_service_client::WatchServiceClient, - pub experimental: v1::experimental_service_client::ExperimentalServiceClient, + pub experimental: + v1::experimental_service_client::ExperimentalServiceClient, } impl SpiceDBProtoClient { @@ -79,7 +80,15 @@ impl SpiceDBProtoClient { ep = ep.tls_config(ClientTlsConfig::new())?; } - let channel = ep.connect().await?; + // Use connect_lazy for insecure (plaintext) connections so that client + // construction succeeds even when no server is running. For TLS + // connections, a real handshake is needed to validate certs, so we + // keep the eager connect() path there. + let channel = if insecure { + ep.connect_lazy() + } else { + ep.connect().await? + }; let bearer = format!("Bearer {}", token) .parse::>() @@ -95,8 +104,7 @@ impl SpiceDBProtoClient { v1::permissions_service_client::PermissionsServiceClient::new(svc.clone()); let schema = v1::schema_service_client::SchemaServiceClient::new(svc.clone()); let watch = v1::watch_service_client::WatchServiceClient::new(svc.clone()); - let experimental = - v1::experimental_service_client::ExperimentalServiceClient::new(svc); + let experimental = v1::experimental_service_client::ExperimentalServiceClient::new(svc); Ok(Self { permissions, diff --git a/proto-clients/spicedb-typescript-proto/Magefile.go b/proto-clients/spicedb-typescript-proto/Magefile.go index 0802080..3eb60f4 100644 --- a/proto-clients/spicedb-typescript-proto/Magefile.go +++ b/proto-clients/spicedb-typescript-proto/Magefile.go @@ -13,13 +13,39 @@ import ( const maxRetries = 3 -// Gen regenerates the TypeScript proto client. +// claudeAvailable returns true if the claude CLI is installed and usable. +// Returns false when running in CI (CI env var set) because the claude binary +// may be present but not authenticated. +func claudeAvailable() bool { + if os.Getenv("CI") != "" { + return false + } + _, err := exec.LookPath("claude") + return err == nil +} + +// Gen regenerates the TypeScript proto client. If claude is not available (e.g. in +// gen-nodiff CI), any buf generate changes are rolled back and Gen returns nil +// so the working tree stays clean for the nodiff check. func Gen() error { fmt.Println("==> Running buf generate...") if err := sh.Run("buf", "generate"); err != nil { return fmt.Errorf("buf generate failed: %w", err) } + diff, _ := sh.Output("git", "diff", "--name-only", ".") + if strings.TrimSpace(diff) == "" { + fmt.Println("==> No proto changes detected after buf generate, skipping Claude step.") + return nil + } + + if !claudeAvailable() { + fmt.Println("==> claude not available; rolling back buf generate changes (gen-nodiff mode).") + _ = sh.Run("git", "checkout", "--", ".") + _ = sh.Run("git", "clean", "-fd", ".") + return nil + } + fmt.Println("==> Installing deps...") if err := sh.RunV("pnpm", "install"); err != nil { return fmt.Errorf("pnpm install failed: %w", err) @@ -41,6 +67,7 @@ func Gen() error { if err := sh.RunV("pnpm", "build"); err != nil { if attempt == maxRetries { _ = sh.Run("git", "checkout", "--", ".") + _ = sh.Run("git", "clean", "-fd", ".") return fmt.Errorf("build failed after %d retries", maxRetries) } fmt.Println("==> Build failed, asking Claude to fix...") @@ -59,6 +86,7 @@ func Gen() error { if attempt == maxRetries { fmt.Printf("==> Tests failed after %d attempts. Rolling back.\n", maxRetries) _ = sh.Run("git", "checkout", "--", ".") + _ = sh.Run("git", "clean", "-fd", ".") return fmt.Errorf("tests failed after %d retries", maxRetries) } diff --git a/spicedb-csharp/Magefile.go b/spicedb-csharp/Magefile.go index 2e0c8bf..484dc66 100644 --- a/spicedb-csharp/Magefile.go +++ b/spicedb-csharp/Magefile.go @@ -180,9 +180,10 @@ func IntegrationTest() error { return err } - // Run integration tests + // Run integration tests (no --no-build: VSTest cannot load .NET 10 assemblies + // without a fresh build step, and the build is fast enough to not matter here) fmt.Println("==> Running integration tests...") - return sh.RunV("dotnet", "test", "SpiceDB.Client.sln", "--no-build", "--verbosity", "normal") + return sh.RunV("dotnet", "test", "SpiceDB.Client.sln", "--verbosity", "normal") } func waitForReady(addr string, timeout time.Duration) error { diff --git a/spicedb-java/Magefile.go b/spicedb-java/Magefile.go index 2e503e4..d12e458 100644 --- a/spicedb-java/Magefile.go +++ b/spicedb-java/Magefile.go @@ -93,9 +93,11 @@ func Gen() error { return nil } -// Test runs all tests via Gradle. +// Test runs the lib unit tests via Gradle. Examples are integration tests +// that need a live SpiceDB; they run via IntegrationTest, which starts +// docker-compose first. func Test() error { - return sh.RunV("gradle", "test") + return sh.RunV("gradle", ":lib:test") } // Build compiles all source via Gradle. diff --git a/spicedb-java/build.gradle.kts b/spicedb-java/build.gradle.kts index 3431f97..16ddbcd 100644 --- a/spicedb-java/build.gradle.kts +++ b/spicedb-java/build.gradle.kts @@ -1,5 +1,6 @@ plugins { java + id("com.diffplug.spotless") version "6.25.0" } allprojects { @@ -20,6 +21,7 @@ allprojects { subprojects { apply(plugin = "java") + apply(plugin = "com.diffplug.spotless") java { sourceCompatibility = JavaVersion.VERSION_17 @@ -30,4 +32,13 @@ subprojects { options.encoding = "UTF-8" options.compilerArgs.addAll(listOf("-Xlint:all", "-Xlint:-processing")) } + + configure { + java { + googleJavaFormat("1.22.0") + removeUnusedImports() + trimTrailingWhitespace() + endWithNewline() + } + } } diff --git a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/BulkOperationsTest.java b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/BulkOperationsTest.java index 65fe7cd..0d91742 100644 --- a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/BulkOperationsTest.java +++ b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/BulkOperationsTest.java @@ -1,80 +1,88 @@ package com.authzed.spicedb.examples; +import static com.authzed.spicedb.Consistency.*; +import static org.assertj.core.api.Assertions.*; + import com.authzed.spicedb.Filter; import com.authzed.spicedb.Relationship; import com.authzed.spicedb.Transaction; - +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.List; - -import static com.authzed.spicedb.Consistency.*; -import static org.assertj.core.api.Assertions.*; - /** - * Demonstrates bulk permission checks using {@link com.authzed.spicedb.SpiceDBClient#checkPermissions}, - * {@link com.authzed.spicedb.SpiceDBClient#checkAll}, and {@link com.authzed.spicedb.SpiceDBClient#checkAny}. + * Demonstrates bulk permission checks using {@link + * com.authzed.spicedb.SpiceDBClient#checkPermissions}, {@link + * com.authzed.spicedb.SpiceDBClient#checkAll}, and {@link + * com.authzed.spicedb.SpiceDBClient#checkAny}. */ class BulkOperationsTest extends SpiceDBIntegrationTest { - private String writeRevision; + private String writeRevision; - @BeforeEach - void setUp() { - client.deleteRelationships(Filter.of("document")); + @BeforeEach + void setUp() { + client.deleteRelationships(Filter.of("document")); - // Bulk write relationships - var txn = new Transaction(); - for (String user : List.of("alice", "bob", "charlie")) { - txn.touch(Relationship.of("document", "report", "viewer", "user", user)); - } - txn.touch(Relationship.of("document", "report", "editor", "user", "alice")); - writeRevision = client.write(txn); + // Bulk write relationships + var txn = new Transaction(); + for (String user : List.of("alice", "bob", "charlie")) { + txn.touch(Relationship.of("document", "report", "viewer", "user", user)); } - - @Test - void bulk_check_returns_result_per_relationship() { - List results = client.checkPermissions( - atLeast(writeRevision), "view", + txn.touch(Relationship.of("document", "report", "editor", "user", "alice")); + writeRevision = client.write(txn); + } + + @Test + void bulk_check_returns_result_per_relationship() { + List results = + client.checkPermissions( + atLeast(writeRevision), + "view", Relationship.of("document", "report", "view", "user", "alice"), Relationship.of("document", "report", "view", "user", "bob"), Relationship.of("document", "report", "view", "user", "charlie")); - assertThat(results).hasSize(3); - assertThat(results).containsExactly(true, true, true); - } + assertThat(results).hasSize(3); + assertThat(results).containsExactly(true, true, true); + } - @Test - void checkAll_returns_true_when_all_have_permission() { - boolean allCanView = client.checkAll( - atLeast(writeRevision), "view", + @Test + void checkAll_returns_true_when_all_have_permission() { + boolean allCanView = + client.checkAll( + atLeast(writeRevision), + "view", Relationship.of("document", "report", "view", "user", "alice"), Relationship.of("document", "report", "view", "user", "bob"), Relationship.of("document", "report", "view", "user", "charlie")); - assertThat(allCanView).isTrue(); - } + assertThat(allCanView).isTrue(); + } - @Test - void checkAll_returns_false_when_not_all_have_permission() { - boolean allCanEdit = client.checkAll( - atLeast(writeRevision), "edit", + @Test + void checkAll_returns_false_when_not_all_have_permission() { + boolean allCanEdit = + client.checkAll( + atLeast(writeRevision), + "edit", Relationship.of("document", "report", "edit", "user", "alice"), Relationship.of("document", "report", "edit", "user", "bob")); - // bob is only a viewer, not an editor - assertThat(allCanEdit).isFalse(); - } + // bob is only a viewer, not an editor + assertThat(allCanEdit).isFalse(); + } - @Test - void checkAny_returns_true_when_at_least_one_has_permission() { - boolean anyCanEdit = client.checkAny( - atLeast(writeRevision), "edit", + @Test + void checkAny_returns_true_when_at_least_one_has_permission() { + boolean anyCanEdit = + client.checkAny( + atLeast(writeRevision), + "edit", Relationship.of("document", "report", "edit", "user", "alice"), Relationship.of("document", "report", "edit", "user", "bob")); - // alice is an editor - assertThat(anyCanEdit).isTrue(); - } + // alice is an editor + assertThat(anyCanEdit).isTrue(); + } } diff --git a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/CheckPermissionTest.java b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/CheckPermissionTest.java index 0ebe703..948e576 100644 --- a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/CheckPermissionTest.java +++ b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/CheckPermissionTest.java @@ -1,58 +1,58 @@ package com.authzed.spicedb.examples; +import static com.authzed.spicedb.Consistency.*; +import static org.assertj.core.api.Assertions.*; + import com.authzed.spicedb.Filter; import com.authzed.spicedb.Relationship; import com.authzed.spicedb.Transaction; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static com.authzed.spicedb.Consistency.*; -import static org.assertj.core.api.Assertions.*; - /** - * Demonstrates checking a single permission using {@link com.authzed.spicedb.SpiceDBClient#checkPermission}. + * Demonstrates checking a single permission using {@link + * com.authzed.spicedb.SpiceDBClient#checkPermission}. */ class CheckPermissionTest extends SpiceDBIntegrationTest { - @BeforeEach - void setUp() { - client.deleteRelationships(Filter.of("document")); - - var txn = new Transaction(); - txn.touch(Relationship.of("document", "firstdoc", "viewer", "user", "alice")); - txn.touch(Relationship.of("document", "firstdoc", "editor", "user", "bob")); - client.write(txn); - } - - @Test - void alice_can_view_document() { - boolean allowed = client.checkPermission( - full(), "view", - Relationship.of("document", "firstdoc", "view", "user", "alice")); - - assertThat(allowed).isTrue(); - } - - @Test - void alice_cannot_edit_document() { - boolean allowed = client.checkPermission( - full(), "edit", - Relationship.of("document", "firstdoc", "edit", "user", "alice")); - - assertThat(allowed).isFalse(); - } - - @Test - void bob_can_edit_and_view_document() { - boolean canEdit = client.checkPermission( - full(), "edit", - Relationship.of("document", "firstdoc", "edit", "user", "bob")); - boolean canView = client.checkPermission( - full(), "view", - Relationship.of("document", "firstdoc", "view", "user", "bob")); - - assertThat(canEdit).isTrue(); - assertThat(canView).isTrue(); - } + @BeforeEach + void setUp() { + client.deleteRelationships(Filter.of("document")); + + var txn = new Transaction(); + txn.touch(Relationship.of("document", "firstdoc", "viewer", "user", "alice")); + txn.touch(Relationship.of("document", "firstdoc", "editor", "user", "bob")); + client.write(txn); + } + + @Test + void alice_can_view_document() { + boolean allowed = + client.checkPermission( + full(), "view", Relationship.of("document", "firstdoc", "view", "user", "alice")); + + assertThat(allowed).isTrue(); + } + + @Test + void alice_cannot_edit_document() { + boolean allowed = + client.checkPermission( + full(), "edit", Relationship.of("document", "firstdoc", "edit", "user", "alice")); + + assertThat(allowed).isFalse(); + } + + @Test + void bob_can_edit_and_view_document() { + boolean canEdit = + client.checkPermission( + full(), "edit", Relationship.of("document", "firstdoc", "edit", "user", "bob")); + boolean canView = + client.checkPermission( + full(), "view", Relationship.of("document", "firstdoc", "view", "user", "bob")); + + assertThat(canEdit).isTrue(); + assertThat(canView).isTrue(); + } } diff --git a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/LookupResourcesTest.java b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/LookupResourcesTest.java index 5ae978d..77aeae4 100644 --- a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/LookupResourcesTest.java +++ b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/LookupResourcesTest.java @@ -1,61 +1,59 @@ package com.authzed.spicedb.examples; +import static com.authzed.spicedb.Consistency.*; +import static org.assertj.core.api.Assertions.*; + import com.authzed.spicedb.Filter; import com.authzed.spicedb.Relationship; import com.authzed.spicedb.Transaction; - +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.List; - -import static com.authzed.spicedb.Consistency.*; -import static org.assertj.core.api.Assertions.*; - /** - * Demonstrates finding resources a subject can access using - * {@link com.authzed.spicedb.SpiceDBClient#lookupResources}. + * Demonstrates finding resources a subject can access using {@link + * com.authzed.spicedb.SpiceDBClient#lookupResources}. */ class LookupResourcesTest extends SpiceDBIntegrationTest { - @BeforeEach - void setUp() { - client.deleteRelationships(Filter.of("document")); - - var txn = new Transaction(); - txn.touch(Relationship.of("document", "firstdoc", "viewer", "user", "alice")); - txn.touch(Relationship.of("document", "seconddoc", "editor", "user", "alice")); - txn.touch(Relationship.of("document", "thirddoc", "owner", "user", "bob")); - client.write(txn); + @BeforeEach + void setUp() { + client.deleteRelationships(Filter.of("document")); + + var txn = new Transaction(); + txn.touch(Relationship.of("document", "firstdoc", "viewer", "user", "alice")); + txn.touch(Relationship.of("document", "seconddoc", "editor", "user", "alice")); + txn.touch(Relationship.of("document", "thirddoc", "owner", "user", "bob")); + client.write(txn); + } + + @Test + void alice_can_view_two_documents() { + List resourceIDs; + try (var stream = client.lookupResources(full(), "document", "view", "user", "alice")) { + resourceIDs = stream.toList(); } - @Test - void alice_can_view_two_documents() { - List resourceIDs; - try (var stream = client.lookupResources(full(), "document", "view", "user", "alice")) { - resourceIDs = stream.toList(); - } + assertThat(resourceIDs).containsExactlyInAnyOrder("firstdoc", "seconddoc"); + } - assertThat(resourceIDs).containsExactlyInAnyOrder("firstdoc", "seconddoc"); + @Test + void alice_can_edit_only_seconddoc() { + List resourceIDs; + try (var stream = client.lookupResources(full(), "document", "edit", "user", "alice")) { + resourceIDs = stream.toList(); } - @Test - void alice_can_edit_only_seconddoc() { - List resourceIDs; - try (var stream = client.lookupResources(full(), "document", "edit", "user", "alice")) { - resourceIDs = stream.toList(); - } + assertThat(resourceIDs).containsExactly("seconddoc"); + } - assertThat(resourceIDs).containsExactly("seconddoc"); + @Test + void bob_can_delete_thirddoc() { + List resourceIDs; + try (var stream = client.lookupResources(full(), "document", "delete", "user", "bob")) { + resourceIDs = stream.toList(); } - @Test - void bob_can_delete_thirddoc() { - List resourceIDs; - try (var stream = client.lookupResources(full(), "document", "delete", "user", "bob")) { - resourceIDs = stream.toList(); - } - - assertThat(resourceIDs).containsExactly("thirddoc"); - } + assertThat(resourceIDs).containsExactly("thirddoc"); + } } diff --git a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/LookupSubjectsTest.java b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/LookupSubjectsTest.java index d4dde26..06f9651 100644 --- a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/LookupSubjectsTest.java +++ b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/LookupSubjectsTest.java @@ -1,61 +1,59 @@ package com.authzed.spicedb.examples; +import static com.authzed.spicedb.Consistency.*; +import static org.assertj.core.api.Assertions.*; + import com.authzed.spicedb.Filter; import com.authzed.spicedb.Relationship; import com.authzed.spicedb.Transaction; - +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.List; - -import static com.authzed.spicedb.Consistency.*; -import static org.assertj.core.api.Assertions.*; - /** - * Demonstrates finding subjects with access to a resource using - * {@link com.authzed.spicedb.SpiceDBClient#lookupSubjects}. + * Demonstrates finding subjects with access to a resource using {@link + * com.authzed.spicedb.SpiceDBClient#lookupSubjects}. */ class LookupSubjectsTest extends SpiceDBIntegrationTest { - @BeforeEach - void setUp() { - client.deleteRelationships(Filter.of("document")); - - var txn = new Transaction(); - txn.touch(Relationship.of("document", "firstdoc", "viewer", "user", "alice")); - txn.touch(Relationship.of("document", "firstdoc", "editor", "user", "bob")); - txn.touch(Relationship.of("document", "firstdoc", "owner", "user", "charlie")); - client.write(txn); + @BeforeEach + void setUp() { + client.deleteRelationships(Filter.of("document")); + + var txn = new Transaction(); + txn.touch(Relationship.of("document", "firstdoc", "viewer", "user", "alice")); + txn.touch(Relationship.of("document", "firstdoc", "editor", "user", "bob")); + txn.touch(Relationship.of("document", "firstdoc", "owner", "user", "charlie")); + client.write(txn); + } + + @Test + void all_three_users_can_view() { + List subjectIDs; + try (var stream = client.lookupSubjects(full(), "document", "firstdoc", "view", "user")) { + subjectIDs = stream.toList(); } - @Test - void all_three_users_can_view() { - List subjectIDs; - try (var stream = client.lookupSubjects(full(), "document", "firstdoc", "view", "user")) { - subjectIDs = stream.toList(); - } + assertThat(subjectIDs).containsExactlyInAnyOrder("alice", "bob", "charlie"); + } - assertThat(subjectIDs).containsExactlyInAnyOrder("alice", "bob", "charlie"); + @Test + void only_bob_and_charlie_can_edit() { + List subjectIDs; + try (var stream = client.lookupSubjects(full(), "document", "firstdoc", "edit", "user")) { + subjectIDs = stream.toList(); } - @Test - void only_bob_and_charlie_can_edit() { - List subjectIDs; - try (var stream = client.lookupSubjects(full(), "document", "firstdoc", "edit", "user")) { - subjectIDs = stream.toList(); - } + assertThat(subjectIDs).containsExactlyInAnyOrder("bob", "charlie"); + } - assertThat(subjectIDs).containsExactlyInAnyOrder("bob", "charlie"); + @Test + void only_charlie_can_delete() { + List subjectIDs; + try (var stream = client.lookupSubjects(full(), "document", "firstdoc", "delete", "user")) { + subjectIDs = stream.toList(); } - @Test - void only_charlie_can_delete() { - List subjectIDs; - try (var stream = client.lookupSubjects(full(), "document", "firstdoc", "delete", "user")) { - subjectIDs = stream.toList(); - } - - assertThat(subjectIDs).containsExactly("charlie"); - } + assertThat(subjectIDs).containsExactly("charlie"); + } } diff --git a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/ReadRelationshipsTest.java b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/ReadRelationshipsTest.java index 3689be8..4a4b1e7 100644 --- a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/ReadRelationshipsTest.java +++ b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/ReadRelationshipsTest.java @@ -1,74 +1,68 @@ package com.authzed.spicedb.examples; +import static com.authzed.spicedb.Consistency.*; +import static org.assertj.core.api.Assertions.*; + import com.authzed.spicedb.Filter; import com.authzed.spicedb.Relationship; import com.authzed.spicedb.Transaction; - +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.List; - -import static com.authzed.spicedb.Consistency.*; -import static org.assertj.core.api.Assertions.*; - /** - * Demonstrates reading relationships using {@link com.authzed.spicedb.SpiceDBClient#readRelationships} - * with cursor-based auto-pagination. + * Demonstrates reading relationships using {@link + * com.authzed.spicedb.SpiceDBClient#readRelationships} with cursor-based auto-pagination. */ class ReadRelationshipsTest extends SpiceDBIntegrationTest { - @BeforeEach - void setUp() { - client.deleteRelationships(Filter.of("document")); + @BeforeEach + void setUp() { + client.deleteRelationships(Filter.of("document")); - var txn = new Transaction(); - txn.touch(Relationship.of("document", "firstdoc", "viewer", "user", "alice")); - txn.touch(Relationship.of("document", "firstdoc", "viewer", "user", "bob")); - txn.touch(Relationship.of("document", "firstdoc", "editor", "user", "charlie")); - client.write(txn); - } - - @Test - void reads_viewers_of_document() { - Filter filter = Filter.of("document") - .withResourceID("firstdoc") - .withRelation("viewer"); + var txn = new Transaction(); + txn.touch(Relationship.of("document", "firstdoc", "viewer", "user", "alice")); + txn.touch(Relationship.of("document", "firstdoc", "viewer", "user", "bob")); + txn.touch(Relationship.of("document", "firstdoc", "editor", "user", "charlie")); + client.write(txn); + } - List relationships; - try (var stream = client.readRelationships(full(), filter)) { - relationships = stream.toList(); - } + @Test + void reads_viewers_of_document() { + Filter filter = Filter.of("document").withResourceID("firstdoc").withRelation("viewer"); - assertThat(relationships).hasSize(2); - assertThat(relationships) - .extracting(Relationship::subjectID) - .containsExactlyInAnyOrder("alice", "bob"); + List relationships; + try (var stream = client.readRelationships(full(), filter)) { + relationships = stream.toList(); } - @Test - void reads_all_relations_on_document() { - Filter filter = Filter.of("document") - .withResourceID("firstdoc"); + assertThat(relationships).hasSize(2); + assertThat(relationships) + .extracting(Relationship::subjectID) + .containsExactlyInAnyOrder("alice", "bob"); + } - List relationships; - try (var stream = client.readRelationships(full(), filter)) { - relationships = stream.toList(); - } + @Test + void reads_all_relations_on_document() { + Filter filter = Filter.of("document").withResourceID("firstdoc"); - assertThat(relationships).hasSize(3); - assertThat(relationships) - .extracting(Relationship::resourceRelation) - .contains("viewer", "editor"); + List relationships; + try (var stream = client.readRelationships(full(), filter)) { + relationships = stream.toList(); } - @Test - void empty_result_for_nonexistent_resource() { - Filter filter = Filter.of("document") - .withResourceID("nonexistent"); + assertThat(relationships).hasSize(3); + assertThat(relationships) + .extracting(Relationship::resourceRelation) + .contains("viewer", "editor"); + } + + @Test + void empty_result_for_nonexistent_resource() { + Filter filter = Filter.of("document").withResourceID("nonexistent"); - try (var stream = client.readRelationships(full(), filter)) { - assertThat(stream.count()).isZero(); - } + try (var stream = client.readRelationships(full(), filter)) { + assertThat(stream.count()).isZero(); } + } } diff --git a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/RelationshipCountersTest.java b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/RelationshipCountersTest.java index 9378237..7cdc5a6 100644 --- a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/RelationshipCountersTest.java +++ b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/RelationshipCountersTest.java @@ -1,80 +1,78 @@ package com.authzed.spicedb.examples; +import static org.assertj.core.api.Assertions.*; + import com.authzed.spicedb.Filter; import com.authzed.spicedb.Relationship; import com.authzed.spicedb.SpiceDBClient; import com.authzed.spicedb.Transaction; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.*; - /** - * Demonstrates experimental relationship counters: registering, reading, - * and unregistering counters. + * Demonstrates experimental relationship counters: registering, reading, and unregistering + * counters. * *

Note: these APIs are experimental and may change without notice. */ class RelationshipCountersTest extends SpiceDBIntegrationTest { - private static final String COUNTER_NAME = "java_example_document_viewers"; + private static final String COUNTER_NAME = "java_example_document_viewers"; - @BeforeEach - void setUp() { - client.deleteRelationships(Filter.of("document")); + @BeforeEach + void setUp() { + client.deleteRelationships(Filter.of("document")); - var txn = new Transaction(); - txn.touch(Relationship.of("document", "countdoc1", "viewer", "user", "alice")); - txn.touch(Relationship.of("document", "countdoc2", "viewer", "user", "bob")); - txn.touch(Relationship.of("document", "countdoc3", "viewer", "user", "charlie")); - client.write(txn); + var txn = new Transaction(); + txn.touch(Relationship.of("document", "countdoc1", "viewer", "user", "alice")); + txn.touch(Relationship.of("document", "countdoc2", "viewer", "user", "bob")); + txn.touch(Relationship.of("document", "countdoc3", "viewer", "user", "charlie")); + client.write(txn); - // Clean up any existing counter from a prior run - try { - client.experimentalUnregisterRelationshipCounter(COUNTER_NAME); - } catch (Exception ignored) { - // Counter may not exist - } + // Clean up any existing counter from a prior run + try { + client.experimentalUnregisterRelationshipCounter(COUNTER_NAME); + } catch (Exception ignored) { + // Counter may not exist } - - @AfterEach - void tearDown() { - try { - client.experimentalUnregisterRelationshipCounter(COUNTER_NAME); - } catch (Exception ignored) { - // Counter may not exist - } + } + + @AfterEach + void tearDown() { + try { + client.experimentalUnregisterRelationshipCounter(COUNTER_NAME); + } catch (Exception ignored) { + // Counter may not exist } + } - @Test - void register_and_read_counter() throws Exception { - Filter filter = Filter.of("document").withRelation("viewer"); + @Test + void register_and_read_counter() throws Exception { + Filter filter = Filter.of("document").withRelation("viewer"); - client.experimentalRegisterRelationshipCounter(COUNTER_NAME, filter); + client.experimentalRegisterRelationshipCounter(COUNTER_NAME, filter); - // Wait for the counter to be computed - Thread.sleep(3000); + // Wait for the counter to be computed + Thread.sleep(3000); - SpiceDBClient.CountResult result = client.experimentalCountRelationships(COUNTER_NAME); + SpiceDBClient.CountResult result = client.experimentalCountRelationships(COUNTER_NAME); - if (!result.stillCalculating()) { - assertThat(result.relationshipCount()).isGreaterThanOrEqualTo(3); - assertThat(result.revision()).isNotEmpty(); - } - // If still calculating, that's acceptable for this example + if (!result.stillCalculating()) { + assertThat(result.relationshipCount()).isGreaterThanOrEqualTo(3); + assertThat(result.revision()).isNotEmpty(); } + // If still calculating, that's acceptable for this example + } - @Test - void unregister_counter() { - Filter filter = Filter.of("document").withRelation("viewer"); + @Test + void unregister_counter() { + Filter filter = Filter.of("document").withRelation("viewer"); - client.experimentalRegisterRelationshipCounter(COUNTER_NAME, filter); + client.experimentalRegisterRelationshipCounter(COUNTER_NAME, filter); - // Unregistering should not throw - assertThatCode(() -> - client.experimentalUnregisterRelationshipCounter(COUNTER_NAME) - ).doesNotThrowAnyException(); - } + // Unregistering should not throw + assertThatCode(() -> client.experimentalUnregisterRelationshipCounter(COUNTER_NAME)) + .doesNotThrowAnyException(); + } } diff --git a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/SchemaManagementTest.java b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/SchemaManagementTest.java index b50343f..1317110 100644 --- a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/SchemaManagementTest.java +++ b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/SchemaManagementTest.java @@ -1,38 +1,38 @@ package com.authzed.spicedb.examples; -import com.authzed.spicedb.SpiceDBClient; +import static org.assertj.core.api.Assertions.*; +import com.authzed.spicedb.SpiceDBClient; import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.*; - /** - * Demonstrates reading and writing schema using - * {@link SpiceDBClient#writeSchema} and {@link SpiceDBClient#readSchema}. + * Demonstrates reading and writing schema using {@link SpiceDBClient#writeSchema} and {@link + * SpiceDBClient#readSchema}. */ class SchemaManagementTest extends SpiceDBIntegrationTest { - @Test - void write_schema_returns_revision() { - String revision = client.writeSchema(SCHEMA); + @Test + void write_schema_returns_revision() { + String revision = client.writeSchema(SCHEMA); - assertThat(revision).isNotEmpty(); - } + assertThat(revision).isNotEmpty(); + } - @Test - void read_schema_returns_written_definitions() { - SpiceDBClient.SchemaResult result = client.readSchema(); + @Test + void read_schema_returns_written_definitions() { + SpiceDBClient.SchemaResult result = client.readSchema(); - assertThat(result.revision()).isNotEmpty(); - assertThat(result.schema()).contains("definition user"); - assertThat(result.schema()).contains("definition document"); - assertThat(result.schema()).contains("permission view"); - } + assertThat(result.revision()).isNotEmpty(); + assertThat(result.schema()).contains("definition user"); + assertThat(result.schema()).contains("definition document"); + assertThat(result.schema()).contains("permission view"); + } - @Test - void write_updated_schema_with_new_relation() { - // Write a schema that adds an admin relation to document - String updatedSchema = """ + @Test + void write_updated_schema_with_new_relation() { + // Write a schema that adds an admin relation to document + String updatedSchema = + """ definition user {} definition document { @@ -46,15 +46,15 @@ void write_updated_schema_with_new_relation() { permission manage = admin }"""; - String revision = client.writeSchema(updatedSchema); + String revision = client.writeSchema(updatedSchema); - assertThat(revision).isNotEmpty(); + assertThat(revision).isNotEmpty(); - SpiceDBClient.SchemaResult result = client.readSchema(); - assertThat(result.schema()).contains("relation admin: user"); - assertThat(result.schema()).contains("permission manage"); + SpiceDBClient.SchemaResult result = client.readSchema(); + assertThat(result.schema()).contains("relation admin: user"); + assertThat(result.schema()).contains("permission manage"); - // Restore the standard schema for subsequent tests - client.writeSchema(SCHEMA); - } + // Restore the standard schema for subsequent tests + client.writeSchema(SCHEMA); + } } diff --git a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/SchemaReflectionTest.java b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/SchemaReflectionTest.java index ef5e87b..cafb033 100644 --- a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/SchemaReflectionTest.java +++ b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/SchemaReflectionTest.java @@ -1,67 +1,64 @@ package com.authzed.spicedb.examples; +import static com.authzed.spicedb.Consistency.*; +import static org.assertj.core.api.Assertions.*; + import com.authzed.spicedb.SpiceDBClient.ComputablePermissionsResult; import com.authzed.spicedb.SpiceDBClient.DependentRelationsResult; import com.authzed.spicedb.SpiceDBClient.DiffSchemaResult; import com.authzed.spicedb.SpiceDBClient.ReflectSchemaResult; import com.authzed.spicedb.SpiceDBClient.SchemaDefinition; - import org.junit.jupiter.api.Test; -import static com.authzed.spicedb.Consistency.*; -import static org.assertj.core.api.Assertions.*; - /** - * Demonstrates schema reflection APIs: inspecting definitions, computing - * permissions, finding dependent relations, and diffing schemas. + * Demonstrates schema reflection APIs: inspecting definitions, computing permissions, finding + * dependent relations, and diffing schemas. */ class SchemaReflectionTest extends SpiceDBIntegrationTest { - @Test - void reflect_schema_returns_definitions() { - ReflectSchemaResult result = client.reflectSchema(full()); + @Test + void reflect_schema_returns_definitions() { + ReflectSchemaResult result = client.reflectSchema(full()); - assertThat(result.revision()).isNotEmpty(); - assertThat(result.definitions()).hasSizeGreaterThanOrEqualTo(2); + assertThat(result.revision()).isNotEmpty(); + assertThat(result.definitions()).hasSizeGreaterThanOrEqualTo(2); - SchemaDefinition docDef = result.definitions().stream() + SchemaDefinition docDef = + result.definitions().stream() .filter(d -> d.name().equals("document")) .findFirst() .orElseThrow(); - assertThat(docDef.relations()).extracting("name") - .containsExactlyInAnyOrder("viewer", "editor", "owner"); - assertThat(docDef.permissions()).extracting("name") - .containsExactlyInAnyOrder("view", "edit", "delete"); - } - - @Test - void computable_permissions_for_viewer_relation() { - ComputablePermissionsResult result = - client.computablePermissions(full(), "document", "viewer"); - - assertThat(result.revision()).isNotEmpty(); - assertThat(result.permissions()).isNotEmpty(); - assertThat(result.permissions()) - .extracting("relationName") - .contains("view"); - } - - @Test - void dependent_relations_for_view_permission() { - DependentRelationsResult result = - client.dependentRelations(full(), "document", "view"); - - assertThat(result.revision()).isNotEmpty(); - assertThat(result.relations()).isNotEmpty(); - assertThat(result.relations()) - .extracting("relationName") - .contains("viewer", "editor", "owner"); - } - - @Test - void diff_schema_detects_added_relation_and_permission() { - String newSchema = """ + assertThat(docDef.relations()) + .extracting("name") + .containsExactlyInAnyOrder("viewer", "editor", "owner"); + assertThat(docDef.permissions()) + .extracting("name") + .containsExactlyInAnyOrder("view", "edit", "delete"); + } + + @Test + void computable_permissions_for_viewer_relation() { + ComputablePermissionsResult result = client.computablePermissions(full(), "document", "viewer"); + + assertThat(result.revision()).isNotEmpty(); + assertThat(result.permissions()).isNotEmpty(); + assertThat(result.permissions()).extracting("relationName").contains("view"); + } + + @Test + void dependent_relations_for_view_permission() { + DependentRelationsResult result = client.dependentRelations(full(), "document", "view"); + + assertThat(result.revision()).isNotEmpty(); + assertThat(result.relations()).isNotEmpty(); + assertThat(result.relations()).extracting("relationName").contains("viewer", "editor", "owner"); + } + + @Test + void diff_schema_detects_added_relation_and_permission() { + String newSchema = + """ definition user {} definition document { @@ -75,9 +72,9 @@ void diff_schema_detects_added_relation_and_permission() { permission manage = admin }"""; - DiffSchemaResult result = client.diffSchema(full(), newSchema); + DiffSchemaResult result = client.diffSchema(full(), newSchema); - assertThat(result.revision()).isNotEmpty(); - assertThat(result.diffs()).isNotEmpty(); - } + assertThat(result.revision()).isNotEmpty(); + assertThat(result.diffs()).isNotEmpty(); + } } diff --git a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/SpiceDBIntegrationTest.java b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/SpiceDBIntegrationTest.java index 27b4321..07bdb5c 100644 --- a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/SpiceDBIntegrationTest.java +++ b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/SpiceDBIntegrationTest.java @@ -1,23 +1,21 @@ package com.authzed.spicedb.examples; import com.authzed.spicedb.SpiceDBClient; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; /** * Base class for integration tests that share a single SpiceDB schema. * - *

All tests use the same standard {@code user} and {@code document} types, - * matching the Go examples. Each test writes the same schema in {@code @BeforeEach} - * (idempotent), so tests can run sequentially without conflicts. + *

All tests use the same standard {@code user} and {@code document} types, matching the Go + * examples. Each test writes the same schema in {@code @BeforeEach} (idempotent), so tests can run + * sequentially without conflicts. */ abstract class SpiceDBIntegrationTest { - /** - * The standard schema used by all integration tests, matching the Go examples. - */ - protected static final String SCHEMA = """ + /** The standard schema used by all integration tests, matching the Go examples. */ + protected static final String SCHEMA = + """ definition user {} definition document { @@ -29,18 +27,18 @@ abstract class SpiceDBIntegrationTest { permission delete = owner }"""; - protected SpiceDBClient client; + protected SpiceDBClient client; - @BeforeEach - void baseSetUp() { - client = SpiceDBClient.createPlaintext("localhost:50051", "somerandomkeyhere"); - client.writeSchema(SCHEMA); - } + @BeforeEach + void baseSetUp() { + client = SpiceDBClient.createPlaintext("localhost:50051", "somerandomkeyhere"); + client.writeSchema(SCHEMA); + } - @AfterEach - void baseTearDown() { - if (client != null) { - client.close(); - } + @AfterEach + void baseTearDown() { + if (client != null) { + client.close(); } + } } diff --git a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/WatchChangesTest.java b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/WatchChangesTest.java index 28e1d4e..830af1c 100644 --- a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/WatchChangesTest.java +++ b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/WatchChangesTest.java @@ -1,53 +1,50 @@ package com.authzed.spicedb.examples; +import static org.assertj.core.api.Assertions.*; + import com.authzed.spicedb.Relationship; import com.authzed.spicedb.SpiceDBClient; import com.authzed.spicedb.Transaction; - -import org.junit.jupiter.api.Test; - import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; - -import static org.assertj.core.api.Assertions.*; +import org.junit.jupiter.api.Test; /** - * Demonstrates watching for relationship changes using - * {@link SpiceDBClient#updates}. + * Demonstrates watching for relationship changes using {@link SpiceDBClient#updates}. * - *

The watch API streams updates starting from a given revision. - * This test writes a relationship and then watches for the change - * starting from the revision just before the write. + *

The watch API streams updates starting from a given revision. This test writes a relationship + * and then watches for the change starting from the revision just before the write. */ class WatchChangesTest extends SpiceDBIntegrationTest { - @Test - void watches_for_relationship_changes() throws Exception { - // Get a starting revision by writing initial data - var setup = new Transaction(); - setup.touch(Relationship.of("document", "watchdoc", "viewer", "user", "setup")); - String startRevision = client.write(setup); - - // Write a new relationship after the start revision - var txn = new Transaction(); - txn.touch(Relationship.of("document", "watchdoc", "viewer", "user", "alice")); - client.write(txn); - - // Watch from the start revision and collect the first update - CompletableFuture> future = CompletableFuture.supplyAsync(() -> { - try (var stream = client.updates(List.of("document"), startRevision)) { + @Test + void watches_for_relationship_changes() throws Exception { + // Get a starting revision by writing initial data + var setup = new Transaction(); + setup.touch(Relationship.of("document", "watchdoc", "viewer", "user", "setup")); + String startRevision = client.write(setup); + + // Write a new relationship after the start revision + var txn = new Transaction(); + txn.touch(Relationship.of("document", "watchdoc", "viewer", "user", "alice")); + client.write(txn); + + // Watch from the start revision and collect the first update + CompletableFuture> future = + CompletableFuture.supplyAsync( + () -> { + try (var stream = client.updates(List.of("document"), startRevision)) { return stream.limit(1).toList(); - } - }); - - List updates = future.get(10, TimeUnit.SECONDS); - - assertThat(updates).isNotEmpty(); - SpiceDBClient.Update update = updates.get(0); - assertThat(update.operation()).isIn( - SpiceDBClient.UpdateOperation.CREATE, - SpiceDBClient.UpdateOperation.TOUCH); - assertThat(update.relationship().resourceType()).isEqualTo("document"); - } + } + }); + + List updates = future.get(10, TimeUnit.SECONDS); + + assertThat(updates).isNotEmpty(); + SpiceDBClient.Update update = updates.get(0); + assertThat(update.operation()) + .isIn(SpiceDBClient.UpdateOperation.CREATE, SpiceDBClient.UpdateOperation.TOUCH); + assertThat(update.relationship().resourceType()).isEqualTo("document"); + } } diff --git a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/WriteRelationshipsTest.java b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/WriteRelationshipsTest.java index 7225068..6b2081f 100644 --- a/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/WriteRelationshipsTest.java +++ b/spicedb-java/examples/src/test/java/com/authzed/spicedb/examples/WriteRelationshipsTest.java @@ -1,69 +1,72 @@ package com.authzed.spicedb.examples; +import static com.authzed.spicedb.Consistency.*; +import static org.assertj.core.api.Assertions.*; + import com.authzed.spicedb.Filter; import com.authzed.spicedb.Relationship; import com.authzed.spicedb.Transaction; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static com.authzed.spicedb.Consistency.*; -import static org.assertj.core.api.Assertions.*; - /** - * Demonstrates writing relationships with the {@link Transaction} builder, - * including touch, create, delete, and preconditions. + * Demonstrates writing relationships with the {@link Transaction} builder, including touch, create, + * delete, and preconditions. */ class WriteRelationshipsTest extends SpiceDBIntegrationTest { - @BeforeEach - void setUp() { - client.deleteRelationships(Filter.of("document")); - } + @BeforeEach + void setUp() { + client.deleteRelationships(Filter.of("document")); + } - @Test - void touch_creates_relationships_and_returns_revision() { - var txn = new Transaction(); - txn.touch(Relationship.of("document", "firstdoc", "viewer", "user", "alice")); - txn.touch(Relationship.of("document", "firstdoc", "editor", "user", "bob")); + @Test + void touch_creates_relationships_and_returns_revision() { + var txn = new Transaction(); + txn.touch(Relationship.of("document", "firstdoc", "viewer", "user", "alice")); + txn.touch(Relationship.of("document", "firstdoc", "editor", "user", "bob")); - String revision = client.write(txn); + String revision = client.write(txn); - assertThat(revision).isNotEmpty(); - } + assertThat(revision).isNotEmpty(); + } - @Test - void precondition_mustNotMatch_succeeds_when_no_match() { - var txn = new Transaction(); - txn.touch(Relationship.of("document", "firstdoc", "viewer", "user", "alice")); - txn.mustNotMatch(Filter.of("document") + @Test + void precondition_mustNotMatch_succeeds_when_no_match() { + var txn = new Transaction(); + txn.touch(Relationship.of("document", "firstdoc", "viewer", "user", "alice")); + txn.mustNotMatch( + Filter.of("document") .withResourceID("firstdoc") .withRelation("owner") .withSubjectType("user") .withSubjectID("mallory")); - String revision = client.write(txn); + String revision = client.write(txn); - assertThat(revision).isNotEmpty(); - } + assertThat(revision).isNotEmpty(); + } - @Test - void delete_removes_relationship() { - // First create - var create = new Transaction(); - create.touch(Relationship.of("document", "deldoc", "viewer", "user", "charlie")); - client.write(create); + @Test + void delete_removes_relationship() { + // First create + var create = new Transaction(); + create.touch(Relationship.of("document", "deldoc", "viewer", "user", "charlie")); + client.write(create); - // Then delete - var del = new Transaction(); - del.delete(Relationship.of("document", "deldoc", "viewer", "user", "charlie")); - String revision = client.write(del); + // Then delete + var del = new Transaction(); + del.delete(Relationship.of("document", "deldoc", "viewer", "user", "charlie")); + String revision = client.write(del); - assertThat(revision).isNotEmpty(); + assertThat(revision).isNotEmpty(); - // Verify it's gone - long count = client.readRelationships(full(), - Filter.of("document").withResourceID("deldoc").withRelation("viewer")).count(); - assertThat(count).isZero(); - } + // Verify it's gone + long count = + client + .readRelationships( + full(), Filter.of("document").withResourceID("deldoc").withRelation("viewer")) + .count(); + assertThat(count).isZero(); + } } diff --git a/spicedb-java/lib/src/main/java/com/authzed/spicedb/Consistency.java b/spicedb-java/lib/src/main/java/com/authzed/spicedb/Consistency.java index 5b980dd..62ef1ed 100644 --- a/spicedb-java/lib/src/main/java/com/authzed/spicedb/Consistency.java +++ b/spicedb-java/lib/src/main/java/com/authzed/spicedb/Consistency.java @@ -5,115 +5,107 @@ /** * Consistency strategies for SpiceDB read operations. * - *

Every read operation requires an explicit consistency strategy. This prevents - * silent defaults and makes consistency choices visible in the calling code. + *

Every read operation requires an explicit consistency strategy. This prevents silent defaults + * and makes consistency choices visible in the calling code. * *

ZedTokens are represented as opaque {@code String} values, never proto types. */ public final class Consistency { - private final build.buf.gen.authzed.api.v1.Consistency proto; + private final build.buf.gen.authzed.api.v1.Consistency proto; - private Consistency(build.buf.gen.authzed.api.v1.Consistency proto) { - this.proto = proto; - } + private Consistency(build.buf.gen.authzed.api.v1.Consistency proto) { + this.proto = proto; + } - /** - * Returns the underlying proto Consistency for use by the client. - * This is exposed for advanced use cases only. - */ - public build.buf.gen.authzed.api.v1.Consistency toProto() { - return proto; - } + /** + * Returns the underlying proto Consistency for use by the client. This is exposed for advanced + * use cases only. + */ + public build.buf.gen.authzed.api.v1.Consistency toProto() { + return proto; + } - /** - * Returns a strategy that requires full consistency. This is the least - * performant option but guarantees the most up-to-date results. - */ - public static Consistency full() { - return new Consistency( - build.buf.gen.authzed.api.v1.Consistency.newBuilder() - .setFullyConsistent(true) - .build() - ); - } + /** + * Returns a strategy that requires full consistency. This is the least performant option but + * guarantees the most up-to-date results. + */ + public static Consistency full() { + return new Consistency( + build.buf.gen.authzed.api.v1.Consistency.newBuilder().setFullyConsistent(true).build()); + } - /** - * Returns a strategy that uses SpiceDB's preferred revision for optimal - * performance. This is the recommended default for most read operations. - */ - public static Consistency minLatency() { - return new Consistency( - build.buf.gen.authzed.api.v1.Consistency.newBuilder() - .setMinimizeLatency(true) - .build() - ); - } + /** + * Returns a strategy that uses SpiceDB's preferred revision for optimal performance. This is the + * recommended default for most read operations. + */ + public static Consistency minLatency() { + return new Consistency( + build.buf.gen.authzed.api.v1.Consistency.newBuilder().setMinimizeLatency(true).build()); + } - /** - * Returns a strategy that ensures results are at least as fresh as the - * given revision. Use this for read-after-write consistency. - * - * @param revision a ZedToken string from a previous write - * @throws IllegalArgumentException if revision is null or empty - */ - public static Consistency atLeast(String revision) { - if (revision == null || revision.isEmpty()) { - throw new IllegalArgumentException("revision must not be null or empty"); - } - return new Consistency( - build.buf.gen.authzed.api.v1.Consistency.newBuilder() - .setAtLeastAsFresh(ZedToken.newBuilder().setToken(revision).build()) - .build() - ); + /** + * Returns a strategy that ensures results are at least as fresh as the given revision. Use this + * for read-after-write consistency. + * + * @param revision a ZedToken string from a previous write + * @throws IllegalArgumentException if revision is null or empty + */ + public static Consistency atLeast(String revision) { + if (revision == null || revision.isEmpty()) { + throw new IllegalArgumentException("revision must not be null or empty"); } + return new Consistency( + build.buf.gen.authzed.api.v1.Consistency.newBuilder() + .setAtLeastAsFresh(ZedToken.newBuilder().setToken(revision).build()) + .build()); + } - /** - * Returns a strategy that reads at the exact given revision. - * - * @param revision a ZedToken string - * @throws IllegalArgumentException if revision is null or empty - */ - public static Consistency snapshot(String revision) { - if (revision == null || revision.isEmpty()) { - throw new IllegalArgumentException("revision must not be null or empty"); - } - return new Consistency( - build.buf.gen.authzed.api.v1.Consistency.newBuilder() - .setAtExactSnapshot(ZedToken.newBuilder().setToken(revision).build()) - .build() - ); + /** + * Returns a strategy that reads at the exact given revision. + * + * @param revision a ZedToken string + * @throws IllegalArgumentException if revision is null or empty + */ + public static Consistency snapshot(String revision) { + if (revision == null || revision.isEmpty()) { + throw new IllegalArgumentException("revision must not be null or empty"); } + return new Consistency( + build.buf.gen.authzed.api.v1.Consistency.newBuilder() + .setAtExactSnapshot(ZedToken.newBuilder().setToken(revision).build()) + .build()); + } - /** - * Returns {@link #atLeast(String)} if revision is non-null and non-empty, - * otherwise {@link #full()}. Use this when you have an optional revision - * from a previous write and want the safest fallback. - * - * @param revision a ZedToken string, or null/empty - */ - public static Consistency atLeastOrFull(String revision) { - if (revision == null || revision.isEmpty()) { - return full(); - } - return atLeast(revision); + /** + * Returns {@link #atLeast(String)} if revision is non-null and non-empty, otherwise {@link + * #full()}. Use this when you have an optional revision from a previous write and want the safest + * fallback. + * + * @param revision a ZedToken string, or null/empty + */ + public static Consistency atLeastOrFull(String revision) { + if (revision == null || revision.isEmpty()) { + return full(); } + return atLeast(revision); + } - /** - * Returns {@link #atLeast(String)} if revision is non-null and non-empty, - * otherwise {@link #minLatency()}. Use this when you have an optional - * revision but prefer performance over full consistency as the fallback. - * - * @param revision a ZedToken string, or null/empty - */ - public static Consistency atLeastOrMinLatency(String revision) { - if (revision == null || revision.isEmpty()) { - return minLatency(); - } - return atLeast(revision); + /** + * Returns {@link #atLeast(String)} if revision is non-null and non-empty, otherwise {@link + * #minLatency()}. Use this when you have an optional revision but prefer performance over full + * consistency as the fallback. + * + * @param revision a ZedToken string, or null/empty + */ + public static Consistency atLeastOrMinLatency(String revision) { + if (revision == null || revision.isEmpty()) { + return minLatency(); } + return atLeast(revision); + } - private Consistency() { - throw new UnsupportedOperationException(); - } + private Consistency() { + throw new UnsupportedOperationException(); + } } diff --git a/spicedb-java/lib/src/main/java/com/authzed/spicedb/Filter.java b/spicedb-java/lib/src/main/java/com/authzed/spicedb/Filter.java index 6f5657e..db0f7e9 100644 --- a/spicedb-java/lib/src/main/java/com/authzed/spicedb/Filter.java +++ b/spicedb-java/lib/src/main/java/com/authzed/spicedb/Filter.java @@ -5,8 +5,8 @@ /** * An immutable filter for matching SpiceDB relationships. * - *

Use the builder-style methods to narrow the filter. Each method returns a - * new {@code Filter} instance (immutable). + *

Use the builder-style methods to narrow the filter. Each method returns a new {@code Filter} + * instance (immutable). * *

{@code
  * Filter filter = Filter.of("document")
@@ -22,49 +22,54 @@ public record Filter(
     String relation,
     String subjectType,
     String subjectID,
-    String subjectRelation
-) {
+    String subjectRelation) {
 
-    /**
-     * Creates a filter that matches relationships of the given resource type.
-     *
-     * @throws IllegalArgumentException if resourceType is null or empty
-     */
-    public static Filter of(String resourceType) {
-        Objects.requireNonNull(resourceType, "resourceType must not be null");
-        if (resourceType.isEmpty()) {
-            throw new IllegalArgumentException("resourceType must not be empty");
-        }
-        return new Filter(resourceType, "", "", "", "", "", "");
+  /**
+   * Creates a filter that matches relationships of the given resource type.
+   *
+   * @throws IllegalArgumentException if resourceType is null or empty
+   */
+  public static Filter of(String resourceType) {
+    Objects.requireNonNull(resourceType, "resourceType must not be null");
+    if (resourceType.isEmpty()) {
+      throw new IllegalArgumentException("resourceType must not be empty");
     }
+    return new Filter(resourceType, "", "", "", "", "", "");
+  }
 
-    /** Narrows the filter to a specific resource ID. */
-    public Filter withResourceID(String id) {
-        return new Filter(resourceType, id, resourceIDPrefix, relation, subjectType, subjectID, subjectRelation);
-    }
+  /** Narrows the filter to a specific resource ID. */
+  public Filter withResourceID(String id) {
+    return new Filter(
+        resourceType, id, resourceIDPrefix, relation, subjectType, subjectID, subjectRelation);
+  }
 
-    /** Narrows the filter to resource IDs with the given prefix. */
-    public Filter withResourceIDPrefix(String prefix) {
-        return new Filter(resourceType, resourceID, prefix, relation, subjectType, subjectID, subjectRelation);
-    }
+  /** Narrows the filter to resource IDs with the given prefix. */
+  public Filter withResourceIDPrefix(String prefix) {
+    return new Filter(
+        resourceType, resourceID, prefix, relation, subjectType, subjectID, subjectRelation);
+  }
 
-    /** Narrows the filter to a specific relation. */
-    public Filter withRelation(String rel) {
-        return new Filter(resourceType, resourceID, resourceIDPrefix, rel, subjectType, subjectID, subjectRelation);
-    }
+  /** Narrows the filter to a specific relation. */
+  public Filter withRelation(String rel) {
+    return new Filter(
+        resourceType, resourceID, resourceIDPrefix, rel, subjectType, subjectID, subjectRelation);
+  }
 
-    /** Narrows the filter to a specific subject type. */
-    public Filter withSubjectType(String type) {
-        return new Filter(resourceType, resourceID, resourceIDPrefix, relation, type, subjectID, subjectRelation);
-    }
+  /** Narrows the filter to a specific subject type. */
+  public Filter withSubjectType(String type) {
+    return new Filter(
+        resourceType, resourceID, resourceIDPrefix, relation, type, subjectID, subjectRelation);
+  }
 
-    /** Narrows the filter to a specific subject ID. */
-    public Filter withSubjectID(String id) {
-        return new Filter(resourceType, resourceID, resourceIDPrefix, relation, subjectType, id, subjectRelation);
-    }
+  /** Narrows the filter to a specific subject ID. */
+  public Filter withSubjectID(String id) {
+    return new Filter(
+        resourceType, resourceID, resourceIDPrefix, relation, subjectType, id, subjectRelation);
+  }
 
-    /** Narrows the filter to a specific subject relation. */
-    public Filter withSubjectRelation(String rel) {
-        return new Filter(resourceType, resourceID, resourceIDPrefix, relation, subjectType, subjectID, rel);
-    }
+  /** Narrows the filter to a specific subject relation. */
+  public Filter withSubjectRelation(String rel) {
+    return new Filter(
+        resourceType, resourceID, resourceIDPrefix, relation, subjectType, subjectID, rel);
+  }
 }
diff --git a/spicedb-java/lib/src/main/java/com/authzed/spicedb/Relationship.java b/spicedb-java/lib/src/main/java/com/authzed/spicedb/Relationship.java
index bbe5dfe..c526e63 100644
--- a/spicedb-java/lib/src/main/java/com/authzed/spicedb/Relationship.java
+++ b/spicedb-java/lib/src/main/java/com/authzed/spicedb/Relationship.java
@@ -7,18 +7,18 @@
 /**
  * A flat, immutable representation of a SpiceDB relationship.
  *
- * 

Avoids nested proto types in favor of plain Java fields. Use - * {@link #of} or {@link #fromTuple} to construct instances. + *

Avoids nested proto types in favor of plain Java fields. Use {@link #of} or {@link #fromTuple} + * to construct instances. * - * @param resourceType the type of the resource (e.g. "document") - * @param resourceID the ID of the resource + * @param resourceType the type of the resource (e.g. "document") + * @param resourceID the ID of the resource * @param resourceRelation the relation on the resource (e.g. "viewer") - * @param subjectType the type of the subject (e.g. "user") - * @param subjectID the ID of the subject - * @param subjectRelation optional relation on the subject (e.g. "member"), may be empty - * @param caveatName optional caveat name, may be null - * @param caveatContext optional caveat context, may be null - * @param expiration optional expiration time, may be null + * @param subjectType the type of the subject (e.g. "user") + * @param subjectID the ID of the subject + * @param subjectRelation optional relation on the subject (e.g. "member"), may be empty + * @param caveatName optional caveat name, may be null + * @param caveatContext optional caveat context, may be null + * @param expiration optional expiration time, may be null */ public record Relationship( String resourceType, @@ -29,131 +29,154 @@ public record Relationship( String subjectRelation, String caveatName, Map caveatContext, - Instant expiration -) { + Instant expiration) { - /** - * Creates a relationship with all required fields. - * - * @throws IllegalArgumentException if any required field is null or empty - */ - public static Relationship of( - String resourceType, String resourceID, String resourceRelation, - String subjectType, String subjectID, String subjectRelation) { - if (resourceType == null || resourceType.isEmpty() - || resourceID == null || resourceID.isEmpty() - || resourceRelation == null || resourceRelation.isEmpty()) { - throw new IllegalArgumentException("resource type, id, and relation are required"); - } - if (subjectType == null || subjectType.isEmpty() - || subjectID == null || subjectID.isEmpty()) { - throw new IllegalArgumentException("subject type and id are required"); - } - return new Relationship( - resourceType, resourceID, resourceRelation, - subjectType, subjectID, - subjectRelation != null ? subjectRelation : "", - null, null, null - ); + /** + * Creates a relationship with all required fields. + * + * @throws IllegalArgumentException if any required field is null or empty + */ + public static Relationship of( + String resourceType, + String resourceID, + String resourceRelation, + String subjectType, + String subjectID, + String subjectRelation) { + if (resourceType == null + || resourceType.isEmpty() + || resourceID == null + || resourceID.isEmpty() + || resourceRelation == null + || resourceRelation.isEmpty()) { + throw new IllegalArgumentException("resource type, id, and relation are required"); } - - /** - * Creates a relationship without a subject relation. - */ - public static Relationship of( - String resourceType, String resourceID, String resourceRelation, - String subjectType, String subjectID) { - return of(resourceType, resourceID, resourceRelation, subjectType, subjectID, ""); + if (subjectType == null || subjectType.isEmpty() || subjectID == null || subjectID.isEmpty()) { + throw new IllegalArgumentException("subject type and id are required"); } + return new Relationship( + resourceType, + resourceID, + resourceRelation, + subjectType, + subjectID, + subjectRelation != null ? subjectRelation : "", + null, + null, + null); + } - /** - * Parses a relationship from a tuple string in the format: - * {@code resourceType:resourceID#relation@subjectType:subjectID[#subjectRelation]} - * - * @throws IllegalArgumentException if the format is invalid - */ - public static Relationship fromTuple(String tuple) { - Objects.requireNonNull(tuple, "tuple must not be null"); - - String[] atParts = tuple.split("@", 2); - if (atParts.length != 2) { - throw new IllegalArgumentException("invalid tuple format: missing '@' separator"); - } + /** Creates a relationship without a subject relation. */ + public static Relationship of( + String resourceType, + String resourceID, + String resourceRelation, + String subjectType, + String subjectID) { + return of(resourceType, resourceID, resourceRelation, subjectType, subjectID, ""); + } - String resourcePart = atParts[0]; - String subjectPart = atParts[1]; + /** + * Parses a relationship from a tuple string in the format: {@code + * resourceType:resourceID#relation@subjectType:subjectID[#subjectRelation]} + * + * @throws IllegalArgumentException if the format is invalid + */ + public static Relationship fromTuple(String tuple) { + Objects.requireNonNull(tuple, "tuple must not be null"); - // Parse resource: type:id#relation - String[] resourceHash = resourcePart.split("#", 2); - if (resourceHash.length != 2) { - throw new IllegalArgumentException("invalid tuple format: missing '#' in resource"); - } - String[] resourceTypeId = resourceHash[0].split(":", 2); - if (resourceTypeId.length != 2) { - throw new IllegalArgumentException("invalid tuple format: missing ':' in resource type:id"); - } + String[] atParts = tuple.split("@", 2); + if (atParts.length != 2) { + throw new IllegalArgumentException("invalid tuple format: missing '@' separator"); + } - // Parse subject: type:id[#relation] - String[] subjectHash = subjectPart.split("#", 2); - String[] subjectTypeId = subjectHash[0].split(":", 2); - if (subjectTypeId.length != 2) { - throw new IllegalArgumentException("invalid tuple format: missing ':' in subject type:id"); - } - String subjectRelation = subjectHash.length == 2 ? subjectHash[1] : ""; + String resourcePart = atParts[0]; + String subjectPart = atParts[1]; - return of( - resourceTypeId[0], resourceTypeId[1], resourceHash[1], - subjectTypeId[0], subjectTypeId[1], subjectRelation - ); + // Parse resource: type:id#relation + String[] resourceHash = resourcePart.split("#", 2); + if (resourceHash.length != 2) { + throw new IllegalArgumentException("invalid tuple format: missing '#' in resource"); } - - /** - * Returns a copy of this relationship with the given caveat. - */ - public Relationship withCaveat(String name, Map context) { - return new Relationship( - resourceType, resourceID, resourceRelation, - subjectType, subjectID, subjectRelation, - name, context, expiration - ); + String[] resourceTypeId = resourceHash[0].split(":", 2); + if (resourceTypeId.length != 2) { + throw new IllegalArgumentException("invalid tuple format: missing ':' in resource type:id"); } - /** - * Returns a copy of this relationship with the given expiration. - */ - public Relationship withExpiration(Instant exp) { - return new Relationship( - resourceType, resourceID, resourceRelation, - subjectType, subjectID, subjectRelation, - caveatName, caveatContext, exp - ); + // Parse subject: type:id[#relation] + String[] subjectHash = subjectPart.split("#", 2); + String[] subjectTypeId = subjectHash[0].split(":", 2); + if (subjectTypeId.length != 2) { + throw new IllegalArgumentException("invalid tuple format: missing ':' in subject type:id"); } + String subjectRelation = subjectHash.length == 2 ? subjectHash[1] : ""; - /** - * Returns a {@link Filter} that matches the exact resource of this relationship. - */ - public Filter toFilter() { - return Filter.of(resourceType) - .withResourceID(resourceID) - .withRelation(resourceRelation) - .withSubjectType(subjectType) - .withSubjectID(subjectID); - } + return of( + resourceTypeId[0], + resourceTypeId[1], + resourceHash[1], + subjectTypeId[0], + subjectTypeId[1], + subjectRelation); + } + + /** Returns a copy of this relationship with the given caveat. */ + public Relationship withCaveat(String name, Map context) { + return new Relationship( + resourceType, + resourceID, + resourceRelation, + subjectType, + subjectID, + subjectRelation, + name, + context, + expiration); + } + + /** Returns a copy of this relationship with the given expiration. */ + public Relationship withExpiration(Instant exp) { + return new Relationship( + resourceType, + resourceID, + resourceRelation, + subjectType, + subjectID, + subjectRelation, + caveatName, + caveatContext, + exp); + } + + /** Returns a {@link Filter} that matches the exact resource of this relationship. */ + public Filter toFilter() { + return Filter.of(resourceType) + .withResourceID(resourceID) + .withRelation(resourceRelation) + .withSubjectType(subjectType) + .withSubjectID(subjectID); + } - /** - * Returns the tuple string representation: - * {@code resourceType:resourceID#relation@subjectType:subjectID[#subjectRelation]} - */ - @Override - public String toString() { - var sb = new StringBuilder() - .append(resourceType).append(':').append(resourceID) - .append('#').append(resourceRelation) + /** + * Returns the tuple string representation: {@code + * resourceType:resourceID#relation@subjectType:subjectID[#subjectRelation]} + */ + @Override + public String toString() { + var sb = + new StringBuilder() + .append(resourceType) + .append(':') + .append(resourceID) + .append('#') + .append(resourceRelation) .append('@') - .append(subjectType).append(':').append(subjectID); - if (subjectRelation != null && !subjectRelation.isEmpty()) { - sb.append('#').append(subjectRelation); - } - return sb.toString(); + .append(subjectType) + .append(':') + .append(subjectID); + if (subjectRelation != null && !subjectRelation.isEmpty()) { + sb.append('#').append(subjectRelation); } + return sb.toString(); + } } diff --git a/spicedb-java/lib/src/main/java/com/authzed/spicedb/SpiceDBClient.java b/spicedb-java/lib/src/main/java/com/authzed/spicedb/SpiceDBClient.java index 6bc15fd..36d7468 100644 --- a/spicedb-java/lib/src/main/java/com/authzed/spicedb/SpiceDBClient.java +++ b/spicedb-java/lib/src/main/java/com/authzed/spicedb/SpiceDBClient.java @@ -3,14 +3,12 @@ import build.buf.gen.authzed.api.v1.*; import com.authzed.spicedb.errors.ErrorMapper; import com.authzed.spicedb.errors.SpiceDBException; - import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; import io.grpc.Metadata; import io.grpc.StatusRuntimeException; import io.grpc.stub.MetadataUtils; import io.grpc.stub.StreamObserver; - import java.time.Instant; import java.util.*; import java.util.concurrent.TimeUnit; @@ -20,11 +18,11 @@ /** * Idiomatic Java client for SpiceDB. * - *

Implements {@link AutoCloseable} for use with try-with-resources. All - * streaming methods return {@link Stream} instances that should also be closed - * when done. + *

Implements {@link AutoCloseable} for use with try-with-resources. All streaming methods return + * {@link Stream} instances that should also be closed when done. * *

Use the static factory methods to create instances: + * *

{@code
  * try (var client = SpiceDBClient.createPlaintext("localhost:50051", "testtoken")) {
  *     boolean allowed = client.checkPermission(
@@ -35,1135 +33,1197 @@
  */
 public final class SpiceDBClient implements AutoCloseable {
 
-    private static final int DEFAULT_READ_PAGE_SIZE = 512;
-    private static final int DEFAULT_LOOKUP_PAGE_SIZE = 512;
-    private static final int DEFAULT_DELETE_PAGE_SIZE = 1_000;
-    private static final int DEFAULT_IMPORT_BATCH_SIZE = 1_000;
-    private static final int DEFAULT_EXPORT_PAGE_SIZE = 512;
-    private static final int DEFAULT_CHECK_BATCH_SIZE = 1_000;
-
-    private static final int MAX_RETRIES = 3;
-    private static final long INITIAL_BACKOFF_MS = 100;
-
-    private final ManagedChannel channel;
-    private final PermissionsServiceGrpc.PermissionsServiceBlockingStub permissionsStub;
-    private final SchemaServiceGrpc.SchemaServiceBlockingStub schemaStub;
-    private final WatchServiceGrpc.WatchServiceBlockingStub watchStub;
-    private final ExperimentalServiceGrpc.ExperimentalServiceBlockingStub experimentalStub;
-    private final PermissionsServiceGrpc.PermissionsServiceStub permissionsAsyncStub;
-
-    private SpiceDBClient(ManagedChannel channel, Metadata metadata) {
-        this.channel = channel;
-        this.permissionsStub = PermissionsServiceGrpc.newBlockingStub(channel)
+  private static final int DEFAULT_READ_PAGE_SIZE = 512;
+  private static final int DEFAULT_LOOKUP_PAGE_SIZE = 512;
+  private static final int DEFAULT_DELETE_PAGE_SIZE = 1_000;
+  private static final int DEFAULT_IMPORT_BATCH_SIZE = 1_000;
+  private static final int DEFAULT_EXPORT_PAGE_SIZE = 512;
+  private static final int DEFAULT_CHECK_BATCH_SIZE = 1_000;
+
+  private static final int MAX_RETRIES = 3;
+  private static final long INITIAL_BACKOFF_MS = 100;
+
+  private final ManagedChannel channel;
+  private final PermissionsServiceGrpc.PermissionsServiceBlockingStub permissionsStub;
+  private final SchemaServiceGrpc.SchemaServiceBlockingStub schemaStub;
+  private final WatchServiceGrpc.WatchServiceBlockingStub watchStub;
+  private final ExperimentalServiceGrpc.ExperimentalServiceBlockingStub experimentalStub;
+  private final PermissionsServiceGrpc.PermissionsServiceStub permissionsAsyncStub;
+
+  private SpiceDBClient(ManagedChannel channel, Metadata metadata) {
+    this.channel = channel;
+    this.permissionsStub =
+        PermissionsServiceGrpc.newBlockingStub(channel)
             .withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata));
-        this.schemaStub = SchemaServiceGrpc.newBlockingStub(channel)
+    this.schemaStub =
+        SchemaServiceGrpc.newBlockingStub(channel)
             .withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata));
-        this.watchStub = WatchServiceGrpc.newBlockingStub(channel)
+    this.watchStub =
+        WatchServiceGrpc.newBlockingStub(channel)
             .withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata));
-        this.experimentalStub = ExperimentalServiceGrpc.newBlockingStub(channel)
+    this.experimentalStub =
+        ExperimentalServiceGrpc.newBlockingStub(channel)
             .withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata));
-        this.permissionsAsyncStub = PermissionsServiceGrpc.newStub(channel)
+    this.permissionsAsyncStub =
+        PermissionsServiceGrpc.newStub(channel)
             .withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata));
+  }
+
+  /**
+   * Creates a client with an insecure (plaintext) connection. Use this for testing only — the lack
+   * of TLS is made obvious by the name.
+   */
+  public static SpiceDBClient createPlaintext(String endpoint, String presharedKey) {
+    ManagedChannel channel = ManagedChannelBuilder.forTarget(endpoint).usePlaintext().build();
+    return new SpiceDBClient(channel, bearerMetadata(presharedKey));
+  }
+
+  /**
+   * Creates a client using the system's TLS certificate pool. Use this for production connections.
+   */
+  public static SpiceDBClient createSystemTls(String endpoint, String presharedKey) {
+    ManagedChannel channel =
+        ManagedChannelBuilder.forTarget(endpoint).useTransportSecurity().build();
+    return new SpiceDBClient(channel, bearerMetadata(presharedKey));
+  }
+
+  /**
+   * Creates a client with custom options.
+   *
+   * @param endpoint the SpiceDB endpoint
+   * @param presharedKey the bearer token
+   * @param options additional configuration options
+   */
+  public static SpiceDBClient create(
+      String endpoint, String presharedKey, ClientOption... options) {
+    var builder = ManagedChannelBuilder.forTarget(endpoint);
+    for (ClientOption option : options) {
+      option.apply(builder);
     }
-
-    /**
-     * Creates a client with an insecure (plaintext) connection.
-     * Use this for testing only — the lack of TLS is made obvious by the name.
-     */
-    public static SpiceDBClient createPlaintext(String endpoint, String presharedKey) {
-        ManagedChannel channel = ManagedChannelBuilder.forTarget(endpoint)
-            .usePlaintext()
-            .build();
-        return new SpiceDBClient(channel, bearerMetadata(presharedKey));
-    }
-
-    /**
-     * Creates a client using the system's TLS certificate pool.
-     * Use this for production connections.
-     */
-    public static SpiceDBClient createSystemTls(String endpoint, String presharedKey) {
-        ManagedChannel channel = ManagedChannelBuilder.forTarget(endpoint)
-            .useTransportSecurity()
-            .build();
-        return new SpiceDBClient(channel, bearerMetadata(presharedKey));
-    }
-
-    /**
-     * Creates a client with custom options.
-     *
-     * @param endpoint     the SpiceDB endpoint
-     * @param presharedKey the bearer token
-     * @param options      additional configuration options
-     */
-    public static SpiceDBClient create(String endpoint, String presharedKey, ClientOption... options) {
-        var builder = ManagedChannelBuilder.forTarget(endpoint);
-        for (ClientOption option : options) {
-            option.apply(builder);
-        }
-        return new SpiceDBClient(builder.build(), bearerMetadata(presharedKey));
-    }
-
-    /** Functional option for customizing the client. */
-    @FunctionalInterface
-    public interface ClientOption {
-        void apply(ManagedChannelBuilder builder);
-    }
-
-    /** Option to disable TLS (plaintext). Use only for testing. */
-    public static ClientOption withInsecure() {
-        return ManagedChannelBuilder::usePlaintext;
+    return new SpiceDBClient(builder.build(), bearerMetadata(presharedKey));
+  }
+
+  /** Functional option for customizing the client. */
+  @FunctionalInterface
+  public interface ClientOption {
+    void apply(ManagedChannelBuilder builder);
+  }
+
+  /** Option to disable TLS (plaintext). Use only for testing. */
+  public static ClientOption withInsecure() {
+    return ManagedChannelBuilder::usePlaintext;
+  }
+
+  // -----------------------------------------------------------------------
+  // Checks — all use BulkCheckPermissions under the hood
+  // -----------------------------------------------------------------------
+
+  /**
+   * Checks a single permission and returns true if granted. Uses BulkCheckPermissions under the
+   * hood.
+   */
+  public boolean checkPermission(Consistency consistency, String permission, Relationship r) {
+    List results = checkPermissions(consistency, permission, r);
+    return results.get(0);
+  }
+
+  /**
+   * Checks permissions for multiple relationships, returning a boolean for each. All checks use
+   * BulkCheckPermissions under the hood.
+   */
+  public List checkPermissions(
+      Consistency consistency, String permission, Relationship... relationships) {
+    if (relationships.length == 0) {
+      return List.of();
     }
 
-    // -----------------------------------------------------------------------
-    // Checks — all use BulkCheckPermissions under the hood
-    // -----------------------------------------------------------------------
-
-    /**
-     * Checks a single permission and returns true if granted.
-     * Uses BulkCheckPermissions under the hood.
-     */
-    public boolean checkPermission(Consistency consistency, String permission, Relationship r) {
-        List results = checkPermissions(consistency, permission, r);
-        return results.get(0);
+    var items = new ArrayList(relationships.length);
+    for (Relationship r : relationships) {
+      items.add(checkItemFromRel(r, permission));
     }
 
-    /**
-     * Checks permissions for multiple relationships, returning a boolean for each.
-     * All checks use BulkCheckPermissions under the hood.
-     */
-    public List checkPermissions(Consistency consistency, String permission, Relationship... relationships) {
-        if (relationships.length == 0) {
-            return List.of();
-        }
-
-        var items = new ArrayList(relationships.length);
-        for (Relationship r : relationships) {
-            items.add(checkItemFromRel(r, permission));
-        }
-
-        CheckBulkPermissionsResponse resp = withRetry(() ->
-            permissionsStub.checkBulkPermissions(
-                CheckBulkPermissionsRequest.newBuilder()
-                    .setConsistency(consistency.toProto())
-                    .addAllItems(items)
-                    .build()
-            )
-        );
-
-        var results = new ArrayList(resp.getPairsCount());
-        for (int i = 0; i < resp.getPairsCount(); i++) {
-            CheckBulkPermissionsPair pair = resp.getPairs(i);
-            if (pair.hasError()) {
-                throw new SpiceDBException("check item " + i + ": " + pair.getError().getMessage());
-            }
-            results.add(pair.getItem().getPermissionship() ==
-                CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION);
-        }
-        return results;
+    CheckBulkPermissionsResponse resp =
+        withRetry(
+            () ->
+                permissionsStub.checkBulkPermissions(
+                    CheckBulkPermissionsRequest.newBuilder()
+                        .setConsistency(consistency.toProto())
+                        .addAllItems(items)
+                        .build()));
+
+    var results = new ArrayList(resp.getPairsCount());
+    for (int i = 0; i < resp.getPairsCount(); i++) {
+      CheckBulkPermissionsPair pair = resp.getPairs(i);
+      if (pair.hasError()) {
+        throw new SpiceDBException("check item " + i + ": " + pair.getError().getMessage());
+      }
+      results.add(
+          pair.getItem().getPermissionship()
+              == CheckPermissionResponse.Permissionship.PERMISSIONSHIP_HAS_PERMISSION);
     }
-
-    /**
-     * Returns true if any of the given relationships have the permission.
-     */
-    public boolean checkAny(Consistency consistency, String permission, Relationship... relationships) {
-        List results = checkPermissions(consistency, permission, relationships);
-        for (boolean r : results) {
-            if (r) return true;
-        }
-        return false;
+    return results;
+  }
+
+  /** Returns true if any of the given relationships have the permission. */
+  public boolean checkAny(
+      Consistency consistency, String permission, Relationship... relationships) {
+    List results = checkPermissions(consistency, permission, relationships);
+    for (boolean r : results) {
+      if (r) return true;
     }
-
-    /**
-     * Returns true if all of the given relationships have the permission.
-     */
-    public boolean checkAll(Consistency consistency, String permission, Relationship... relationships) {
-        List results = checkPermissions(consistency, permission, relationships);
-        for (boolean r : results) {
-            if (!r) return false;
-        }
-        return true;
+    return false;
+  }
+
+  /** Returns true if all of the given relationships have the permission. */
+  public boolean checkAll(
+      Consistency consistency, String permission, Relationship... relationships) {
+    List results = checkPermissions(consistency, permission, relationships);
+    for (boolean r : results) {
+      if (!r) return false;
     }
-
-    // -----------------------------------------------------------------------
-    // Writes
-    // -----------------------------------------------------------------------
-
-    /**
-     * Commits a transaction of relationship mutations to SpiceDB, returning
-     * the revision at which the write occurred.
-     */
-    public String write(Transaction txn) {
-        var reqBuilder = WriteRelationshipsRequest.newBuilder();
-
-        for (Transaction.Mutation m : txn.mutations()) {
-            reqBuilder.addUpdates(toRelationshipUpdate(m));
-        }
-
-        for (Transaction.Precondition p : txn.preconditions()) {
-            reqBuilder.addOptionalPreconditions(toPrecondition(p));
-        }
-
-        WriteRelationshipsResponse resp = withRetry(() ->
-            permissionsStub.writeRelationships(reqBuilder.build())
-        );
-        return resp.getWrittenAt().getToken();
+    return true;
+  }
+
+  // -----------------------------------------------------------------------
+  // Writes
+  // -----------------------------------------------------------------------
+
+  /**
+   * Commits a transaction of relationship mutations to SpiceDB, returning the revision at which the
+   * write occurred.
+   */
+  public String write(Transaction txn) {
+    var reqBuilder = WriteRelationshipsRequest.newBuilder();
+
+    for (Transaction.Mutation m : txn.mutations()) {
+      reqBuilder.addUpdates(toRelationshipUpdate(m));
     }
 
-    // -----------------------------------------------------------------------
-    // Read Relationships — cursor-based auto-pagination (512-item pages)
-    // -----------------------------------------------------------------------
-
-    /**
-     * Returns a stream over relationships matching the given filter.
-     * Cursors are handled transparently — the client automatically re-fetches
-     * pages of 512 relationships.
-     *
-     * 

The returned stream should be closed when done (it is AutoCloseable). - */ - public Stream readRelationships(Consistency consistency, Filter filter) { - return paginatedRelationshipStream(consistency, filter, DEFAULT_READ_PAGE_SIZE); + for (Transaction.Precondition p : txn.preconditions()) { + reqBuilder.addOptionalPreconditions(toPrecondition(p)); } - // ----------------------------------------------------------------------- - // Delete Relationships — auto-paging 10,000-item batches - // ----------------------------------------------------------------------- - - /** - * Deletes all relationships matching the given filter. Large result sets - * are automatically paged in batches of 10,000. Returns the revision of - * the final deletion. - */ - public String deleteRelationships(Filter filter) { - String revision = ""; - while (true) { - DeleteRelationshipsResponse resp = withRetry(() -> - permissionsStub.deleteRelationships( - DeleteRelationshipsRequest.newBuilder() - .setRelationshipFilter(toRelationshipFilter(filter)) - .setOptionalLimit(DEFAULT_DELETE_PAGE_SIZE) - .setOptionalAllowPartialDeletions(true) - .build() - ) - ); - revision = resp.getDeletedAt().getToken(); - if (resp.getDeletionProgress() == - DeleteRelationshipsResponse.DeletionProgress.DELETION_PROGRESS_COMPLETE) { - return revision; - } - } + WriteRelationshipsResponse resp = + withRetry(() -> permissionsStub.writeRelationships(reqBuilder.build())); + return resp.getWrittenAt().getToken(); + } + + // ----------------------------------------------------------------------- + // Read Relationships — cursor-based auto-pagination (512-item pages) + // ----------------------------------------------------------------------- + + /** + * Returns a stream over relationships matching the given filter. Cursors are handled + * transparently — the client automatically re-fetches pages of 512 relationships. + * + *

The returned stream should be closed when done (it is AutoCloseable). + */ + public Stream readRelationships(Consistency consistency, Filter filter) { + return paginatedRelationshipStream(consistency, filter, DEFAULT_READ_PAGE_SIZE); + } + + // ----------------------------------------------------------------------- + // Delete Relationships — auto-paging 10,000-item batches + // ----------------------------------------------------------------------- + + /** + * Deletes all relationships matching the given filter. Large result sets are automatically paged + * in batches of 10,000. Returns the revision of the final deletion. + */ + public String deleteRelationships(Filter filter) { + String revision = ""; + while (true) { + DeleteRelationshipsResponse resp = + withRetry( + () -> + permissionsStub.deleteRelationships( + DeleteRelationshipsRequest.newBuilder() + .setRelationshipFilter(toRelationshipFilter(filter)) + .setOptionalLimit(DEFAULT_DELETE_PAGE_SIZE) + .setOptionalAllowPartialDeletions(true) + .build())); + revision = resp.getDeletedAt().getToken(); + if (resp.getDeletionProgress() + == DeleteRelationshipsResponse.DeletionProgress.DELETION_PROGRESS_COMPLETE) { + return revision; + } } - - // ----------------------------------------------------------------------- - // Lookups — cursor-based auto-pagination (512-item pages) - // ----------------------------------------------------------------------- - - /** - * Returns a stream over resource IDs of the given type that the subject - * has the specified permission on. Cursors are handled transparently. - * - *

The returned stream should be closed when done. - */ - public Stream lookupResources(Consistency consistency, - String resourceType, String permission, String subjectType, String subjectID) { - Iterator iterator = new Iterator<>() { - private Cursor cursor = null; - private Iterator currentPage = Collections.emptyIterator(); - private boolean done = false; - private int pageCount = 0; - - @Override - public boolean hasNext() { - if (currentPage.hasNext()) return true; - if (done) return false; - fetchNextPage(); - return currentPage.hasNext(); - } - - @Override - public String next() { - if (!hasNext()) throw new NoSuchElementException(); - LookupResourcesResponse resp = currentPage.next(); - pageCount++; - cursor = resp.getAfterResultCursor(); - return resp.getResourceObjectId(); - } - - private void fetchNextPage() { - var reqBuilder = LookupResourcesRequest.newBuilder() + } + + // ----------------------------------------------------------------------- + // Lookups — cursor-based auto-pagination (512-item pages) + // ----------------------------------------------------------------------- + + /** + * Returns a stream over resource IDs of the given type that the subject has the specified + * permission on. Cursors are handled transparently. + * + *

The returned stream should be closed when done. + */ + public Stream lookupResources( + Consistency consistency, + String resourceType, + String permission, + String subjectType, + String subjectID) { + Iterator iterator = + new Iterator<>() { + private Cursor cursor = null; + private Iterator currentPage = Collections.emptyIterator(); + private boolean done = false; + private int pageCount = 0; + + @Override + public boolean hasNext() { + if (currentPage.hasNext()) return true; + if (done) return false; + fetchNextPage(); + return currentPage.hasNext(); + } + + @Override + public String next() { + if (!hasNext()) throw new NoSuchElementException(); + LookupResourcesResponse resp = currentPage.next(); + pageCount++; + cursor = resp.getAfterResultCursor(); + return resp.getResourceObjectId(); + } + + private void fetchNextPage() { + var reqBuilder = + LookupResourcesRequest.newBuilder() .setConsistency(consistency.toProto()) .setResourceObjectType(resourceType) .setPermission(permission) - .setSubject(SubjectReference.newBuilder() - .setObject(ObjectReference.newBuilder() - .setObjectType(subjectType) - .setObjectId(subjectID) + .setSubject( + SubjectReference.newBuilder() + .setObject( + ObjectReference.newBuilder() + .setObjectType(subjectType) + .setObjectId(subjectID) + .build()) .build()) - .build()) .setOptionalLimit(DEFAULT_LOOKUP_PAGE_SIZE); - if (cursor != null) { - reqBuilder.setOptionalCursor(cursor); - } - - var responses = new ArrayList(); - var serverStream = withRetry(() -> - permissionsStub.lookupResources(reqBuilder.build()) - ); - serverStream.forEachRemaining(responses::add); - - currentPage = responses.iterator(); - if (responses.size() < DEFAULT_LOOKUP_PAGE_SIZE) { - done = true; - } - if (pageCount > 0 && responses.isEmpty()) { - done = true; - } - pageCount = 0; + if (cursor != null) { + reqBuilder.setOptionalCursor(cursor); } - }; - return StreamSupport.stream( - Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), false); - } + var responses = new ArrayList(); + var serverStream = withRetry(() -> permissionsStub.lookupResources(reqBuilder.build())); + serverStream.forEachRemaining(responses::add); - /** - * Returns a stream over subject IDs of the given type that have the - * specified permission on the resource. Unlike lookupResources, this does - * not use cursor-based pagination (not supported in SpiceDB yet) and - * streams all results in a single call. - * - *

The returned stream should be closed when done. - */ - public Stream lookupSubjects(Consistency consistency, - String resourceType, String resourceID, String permission, String subjectType) { - var responses = new ArrayList(); - var serverStream = withRetry(() -> - permissionsStub.lookupSubjects( - LookupSubjectsRequest.newBuilder() - .setConsistency(consistency.toProto()) - .setResource(ObjectReference.newBuilder() - .setObjectType(resourceType) - .setObjectId(resourceID) - .build()) - .setPermission(permission) - .setSubjectObjectType(subjectType) - .build() - ) - ); - serverStream.forEachRemaining(responses::add); - - return responses.stream().map(resp -> { - String id = resp.getSubject().getSubjectObjectId(); - if (id == null || id.isEmpty()) { + currentPage = responses.iterator(); + if (responses.size() < DEFAULT_LOOKUP_PAGE_SIZE) { + done = true; + } + if (pageCount > 0 && responses.isEmpty()) { + done = true; + } + pageCount = 0; + } + }; + + return StreamSupport.stream( + Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), false); + } + + /** + * Returns a stream over subject IDs of the given type that have the specified permission on the + * resource. Unlike lookupResources, this does not use cursor-based pagination (not supported in + * SpiceDB yet) and streams all results in a single call. + * + *

The returned stream should be closed when done. + */ + public Stream lookupSubjects( + Consistency consistency, + String resourceType, + String resourceID, + String permission, + String subjectType) { + var responses = new ArrayList(); + var serverStream = + withRetry( + () -> + permissionsStub.lookupSubjects( + LookupSubjectsRequest.newBuilder() + .setConsistency(consistency.toProto()) + .setResource( + ObjectReference.newBuilder() + .setObjectType(resourceType) + .setObjectId(resourceID) + .build()) + .setPermission(permission) + .setSubjectObjectType(subjectType) + .build())); + serverStream.forEachRemaining(responses::add); + + return responses.stream() + .map( + resp -> { + String id = resp.getSubject().getSubjectObjectId(); + if (id == null || id.isEmpty()) { // Fall back to deprecated field id = resp.getSubjectObjectId(); - } - return id; - }); + } + return id; + }); + } + + // ----------------------------------------------------------------------- + // Schema + // ----------------------------------------------------------------------- + + /** Result of a {@link #readSchema()} call. */ + public record SchemaResult(String schema, String revision) {} + + /** Returns the current SpiceDB schema. */ + public SchemaResult readSchema() { + ReadSchemaResponse resp = + withRetry(() -> schemaStub.readSchema(ReadSchemaRequest.getDefaultInstance())); + return new SchemaResult(resp.getSchemaText(), resp.getReadAt().getToken()); + } + + /** Writes a new schema to SpiceDB, returning the revision. */ + public String writeSchema(String schema) { + WriteSchemaResponse resp = + withRetry( + () -> + schemaStub.writeSchema(WriteSchemaRequest.newBuilder().setSchema(schema).build())); + return resp.getWrittenAt().getToken(); + } + + /** A definition in a SpiceDB schema. */ + public record SchemaDefinition( + String name, + String comment, + List relations, + List permissions) {} + + /** A relation within a schema definition. */ + public record SchemaRelation(String name, String comment, String parentDefinitionName) {} + + /** A permission within a schema definition. */ + public record SchemaPermission(String name, String comment, String parentDefinitionName) {} + + /** A caveat defined in a SpiceDB schema. */ + public record SchemaCaveat( + String name, String comment, String expression, List parameters) {} + + /** A parameter of a caveat. */ + public record SchemaCaveatParameter(String name, String type, String parentCaveatName) {} + + /** Result of a {@link #reflectSchema(Consistency)} call. */ + public record ReflectSchemaResult( + List definitions, List caveats, String revision) {} + + /** Returns the definitions and caveats in the current schema. */ + public ReflectSchemaResult reflectSchema(Consistency consistency) { + ReflectSchemaResponse resp = + withRetry( + () -> + schemaStub.reflectSchema( + ReflectSchemaRequest.newBuilder() + .setConsistency(consistency.toProto()) + .build())); + + var definitions = new ArrayList(); + for (var def : resp.getDefinitionsList()) { + var relations = new ArrayList(); + for (var rel : def.getRelationsList()) { + relations.add( + new SchemaRelation(rel.getName(), rel.getComment(), rel.getParentDefinitionName())); + } + var permissions = new ArrayList(); + for (var perm : def.getPermissionsList()) { + permissions.add( + new SchemaPermission( + perm.getName(), perm.getComment(), perm.getParentDefinitionName())); + } + definitions.add( + new SchemaDefinition( + def.getName(), def.getComment(), List.copyOf(relations), List.copyOf(permissions))); } - // ----------------------------------------------------------------------- - // Schema - // ----------------------------------------------------------------------- - - /** Result of a {@link #readSchema()} call. */ - public record SchemaResult(String schema, String revision) { } - - /** Returns the current SpiceDB schema. */ - public SchemaResult readSchema() { - ReadSchemaResponse resp = withRetry(() -> - schemaStub.readSchema(ReadSchemaRequest.getDefaultInstance()) - ); - return new SchemaResult(resp.getSchemaText(), resp.getReadAt().getToken()); + var caveats = new ArrayList(); + for (var cav : resp.getCaveatsList()) { + var params = new ArrayList(); + for (var param : cav.getParametersList()) { + params.add( + new SchemaCaveatParameter( + param.getName(), param.getType(), param.getParentCaveatName())); + } + caveats.add( + new SchemaCaveat( + cav.getName(), cav.getComment(), cav.getExpression(), List.copyOf(params))); } - /** Writes a new schema to SpiceDB, returning the revision. */ - public String writeSchema(String schema) { - WriteSchemaResponse resp = withRetry(() -> - schemaStub.writeSchema( - WriteSchemaRequest.newBuilder().setSchema(schema).build() - ) - ); - return resp.getWrittenAt().getToken(); + return new ReflectSchemaResult( + List.copyOf(definitions), List.copyOf(caveats), resp.getReadAt().getToken()); + } + + /** Identifies a relation or permission on a definition. */ + public record RelationReference( + String definitionName, String relationName, boolean isPermission) {} + + /** Result of a {@link #computablePermissions} call. */ + public record ComputablePermissionsResult(List permissions, String revision) {} + + /** Returns the permissions that are computable for the given relation. */ + public ComputablePermissionsResult computablePermissions( + Consistency consistency, String definitionName, String relationName) { + ComputablePermissionsResponse resp = + withRetry( + () -> + schemaStub.computablePermissions( + ComputablePermissionsRequest.newBuilder() + .setConsistency(consistency.toProto()) + .setDefinitionName(definitionName) + .setRelationName(relationName) + .build())); + + var refs = new ArrayList(); + for (var perm : resp.getPermissionsList()) { + refs.add( + new RelationReference( + perm.getDefinitionName(), perm.getRelationName(), perm.getIsPermission())); } - - /** A definition in a SpiceDB schema. */ - public record SchemaDefinition( - String name, String comment, - List relations, - List permissions - ) { } - - /** A relation within a schema definition. */ - public record SchemaRelation(String name, String comment, String parentDefinitionName) { } - - /** A permission within a schema definition. */ - public record SchemaPermission(String name, String comment, String parentDefinitionName) { } - - /** A caveat defined in a SpiceDB schema. */ - public record SchemaCaveat( - String name, String comment, String expression, - List parameters - ) { } - - /** A parameter of a caveat. */ - public record SchemaCaveatParameter(String name, String type, String parentCaveatName) { } - - /** Result of a {@link #reflectSchema(Consistency)} call. */ - public record ReflectSchemaResult( - List definitions, - List caveats, - String revision - ) { } - - /** Returns the definitions and caveats in the current schema. */ - public ReflectSchemaResult reflectSchema(Consistency consistency) { - ReflectSchemaResponse resp = withRetry(() -> - schemaStub.reflectSchema( - ReflectSchemaRequest.newBuilder() - .setConsistency(consistency.toProto()) - .build() - ) - ); - - var definitions = new ArrayList(); - for (var def : resp.getDefinitionsList()) { - var relations = new ArrayList(); - for (var rel : def.getRelationsList()) { - relations.add(new SchemaRelation( - rel.getName(), rel.getComment(), rel.getParentDefinitionName())); - } - var permissions = new ArrayList(); - for (var perm : def.getPermissionsList()) { - permissions.add(new SchemaPermission( - perm.getName(), perm.getComment(), perm.getParentDefinitionName())); - } - definitions.add(new SchemaDefinition( - def.getName(), def.getComment(), List.copyOf(relations), List.copyOf(permissions))); - } - - var caveats = new ArrayList(); - for (var cav : resp.getCaveatsList()) { - var params = new ArrayList(); - for (var param : cav.getParametersList()) { - params.add(new SchemaCaveatParameter( - param.getName(), param.getType(), param.getParentCaveatName())); - } - caveats.add(new SchemaCaveat( - cav.getName(), cav.getComment(), cav.getExpression(), List.copyOf(params))); - } - - return new ReflectSchemaResult( - List.copyOf(definitions), List.copyOf(caveats), - resp.getReadAt().getToken()); + return new ComputablePermissionsResult(List.copyOf(refs), resp.getReadAt().getToken()); + } + + /** Result of a {@link #dependentRelations} call. */ + public record DependentRelationsResult(List relations, String revision) {} + + /** Returns the relations that the given permission depends on. */ + public DependentRelationsResult dependentRelations( + Consistency consistency, String definitionName, String permissionName) { + DependentRelationsResponse resp = + withRetry( + () -> + schemaStub.dependentRelations( + DependentRelationsRequest.newBuilder() + .setConsistency(consistency.toProto()) + .setDefinitionName(definitionName) + .setPermissionName(permissionName) + .build())); + + var refs = new ArrayList(); + for (var rel : resp.getRelationsList()) { + refs.add( + new RelationReference( + rel.getDefinitionName(), rel.getRelationName(), rel.getIsPermission())); } + return new DependentRelationsResult(List.copyOf(refs), resp.getReadAt().getToken()); + } + + /** A single difference between two schemas. */ + public record SchemaDiff( + String kind, + String definitionName, + String relationName, + String permissionName, + String caveatName) {} + + /** Result of a {@link #diffSchema} call. */ + public record DiffSchemaResult(List diffs, String revision) {} + + /** Compares the current schema against the given comparison schema. */ + public DiffSchemaResult diffSchema(Consistency consistency, String comparisonSchema) { + DiffSchemaResponse resp = + withRetry( + () -> + schemaStub.diffSchema( + DiffSchemaRequest.newBuilder() + .setConsistency(consistency.toProto()) + .setComparisonSchema(comparisonSchema) + .build())); + + var diffs = new ArrayList(); + for (var d : resp.getDiffsList()) { + diffs.add(schemaDiffFromProto(d)); + } + return new DiffSchemaResult(List.copyOf(diffs), resp.getReadAt().getToken()); + } + + // ----------------------------------------------------------------------- + // Expand + // ----------------------------------------------------------------------- + + /** Result of an {@link #expandPermissionTree} call. */ + public record ExpandResult(PermissionRelationshipTree treeRoot, String revision) {} + + /** + * Expands the permission tree for the given resource and permission, returning the full tree of + * subjects with access. + */ + public ExpandResult expandPermissionTree( + Consistency consistency, String resourceType, String resourceID, String permission) { + ExpandPermissionTreeResponse resp = + withRetry( + () -> + permissionsStub.expandPermissionTree( + ExpandPermissionTreeRequest.newBuilder() + .setConsistency(consistency.toProto()) + .setResource( + ObjectReference.newBuilder() + .setObjectType(resourceType) + .setObjectId(resourceID) + .build()) + .setPermission(permission) + .build())); + return new ExpandResult(resp.getTreeRoot(), resp.getExpandedAt().getToken()); + } + + // ----------------------------------------------------------------------- + // Bulk Import / Export + // ----------------------------------------------------------------------- + + /** + * Streams relationships to SpiceDB for bulk import, returning the number of relationships loaded. + * Relationships are automatically batched into chunks of 1,000. + */ + public long importRelationships(Iterable relationships) { + var resultHolder = new long[1]; + var errorHolder = new Throwable[1]; + var latch = new java.util.concurrent.CountDownLatch(1); + + StreamObserver responseObserver = + new StreamObserver<>() { + @Override + public void onNext(ImportBulkRelationshipsResponse resp) { + resultHolder[0] = resp.getNumLoaded(); + } + + @Override + public void onError(Throwable t) { + errorHolder[0] = t; + latch.countDown(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + }; - /** Identifies a relation or permission on a definition. */ - public record RelationReference(String definitionName, String relationName, boolean isPermission) { } - - /** Result of a {@link #computablePermissions} call. */ - public record ComputablePermissionsResult(List permissions, String revision) { } + StreamObserver requestObserver = + permissionsAsyncStub.importBulkRelationships(responseObserver); + + var batch = new ArrayList(DEFAULT_IMPORT_BATCH_SIZE); + for (Relationship r : relationships) { + batch.add(toProtoRelationship(r)); + if (batch.size() >= DEFAULT_IMPORT_BATCH_SIZE) { + requestObserver.onNext( + ImportBulkRelationshipsRequest.newBuilder().addAllRelationships(batch).build()); + batch.clear(); + } + } - /** Returns the permissions that are computable for the given relation. */ - public ComputablePermissionsResult computablePermissions( - Consistency consistency, String definitionName, String relationName) { - ComputablePermissionsResponse resp = withRetry(() -> - schemaStub.computablePermissions( - ComputablePermissionsRequest.newBuilder() - .setConsistency(consistency.toProto()) - .setDefinitionName(definitionName) - .setRelationName(relationName) - .build() - ) - ); - - var refs = new ArrayList(); - for (var perm : resp.getPermissionsList()) { - refs.add(new RelationReference( - perm.getDefinitionName(), perm.getRelationName(), perm.getIsPermission())); - } - return new ComputablePermissionsResult(List.copyOf(refs), resp.getReadAt().getToken()); + if (!batch.isEmpty()) { + requestObserver.onNext( + ImportBulkRelationshipsRequest.newBuilder().addAllRelationships(batch).build()); } - /** Result of a {@link #dependentRelations} call. */ - public record DependentRelationsResult(List relations, String revision) { } + requestObserver.onCompleted(); - /** Returns the relations that the given permission depends on. */ - public DependentRelationsResult dependentRelations( - Consistency consistency, String definitionName, String permissionName) { - DependentRelationsResponse resp = withRetry(() -> - schemaStub.dependentRelations( - DependentRelationsRequest.newBuilder() - .setConsistency(consistency.toProto()) - .setDefinitionName(definitionName) - .setPermissionName(permissionName) - .build() - ) - ); - - var refs = new ArrayList(); - for (var rel : resp.getRelationsList()) { - refs.add(new RelationReference( - rel.getDefinitionName(), rel.getRelationName(), rel.getIsPermission())); - } - return new DependentRelationsResult(List.copyOf(refs), resp.getReadAt().getToken()); + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new SpiceDBException("import interrupted", e); } - /** A single difference between two schemas. */ - public record SchemaDiff( - String kind, - String definitionName, - String relationName, - String permissionName, - String caveatName - ) { } - - /** Result of a {@link #diffSchema} call. */ - public record DiffSchemaResult(List diffs, String revision) { } - - /** Compares the current schema against the given comparison schema. */ - public DiffSchemaResult diffSchema(Consistency consistency, String comparisonSchema) { - DiffSchemaResponse resp = withRetry(() -> - schemaStub.diffSchema( - DiffSchemaRequest.newBuilder() - .setConsistency(consistency.toProto()) - .setComparisonSchema(comparisonSchema) - .build() - ) - ); - - var diffs = new ArrayList(); - for (var d : resp.getDiffsList()) { - diffs.add(schemaDiffFromProto(d)); - } - return new DiffSchemaResult(List.copyOf(diffs), resp.getReadAt().getToken()); + if (errorHolder[0] != null) { + if (errorHolder[0] instanceof StatusRuntimeException sre) { + throw ErrorMapper.toSpiceDBException(sre); + } + throw new SpiceDBException("import failed", errorHolder[0]); } - // ----------------------------------------------------------------------- - // Expand - // ----------------------------------------------------------------------- - - /** Result of an {@link #expandPermissionTree} call. */ - public record ExpandResult(PermissionRelationshipTree treeRoot, String revision) { } - - /** - * Expands the permission tree for the given resource and permission, - * returning the full tree of subjects with access. - */ - public ExpandResult expandPermissionTree( - Consistency consistency, String resourceType, String resourceID, String permission) { - ExpandPermissionTreeResponse resp = withRetry(() -> - permissionsStub.expandPermissionTree( - ExpandPermissionTreeRequest.newBuilder() + return resultHolder[0]; + } + + /** + * Returns a stream over all relationships matching the optional filter, streamed from SpiceDB in + * bulk. Cursors are handled transparently with 512-item pages. + * + *

The returned stream should be closed when done. + */ + public Stream exportRelationships(Consistency consistency, Filter filter) { + Iterator iterator = + new Iterator<>() { + private Cursor cursor = null; + private final List buffer = new ArrayList<>(); + private int bufferIndex = 0; + private boolean done = false; + + @Override + public boolean hasNext() { + if (bufferIndex < buffer.size()) return true; + if (done) return false; + fetchNextPage(); + return bufferIndex < buffer.size(); + } + + @Override + public Relationship next() { + if (!hasNext()) throw new NoSuchElementException(); + return buffer.get(bufferIndex++); + } + + private void fetchNextPage() { + buffer.clear(); + bufferIndex = 0; + + var reqBuilder = + ExportBulkRelationshipsRequest.newBuilder() .setConsistency(consistency.toProto()) - .setResource(ObjectReference.newBuilder() - .setObjectType(resourceType) - .setObjectId(resourceID) - .build()) - .setPermission(permission) - .build() - ) - ); - return new ExpandResult(resp.getTreeRoot(), resp.getExpandedAt().getToken()); - } - - // ----------------------------------------------------------------------- - // Bulk Import / Export - // ----------------------------------------------------------------------- - - /** - * Streams relationships to SpiceDB for bulk import, returning the number - * of relationships loaded. Relationships are automatically batched into - * chunks of 1,000. - */ - public long importRelationships(Iterable relationships) { - var resultHolder = new long[1]; - var errorHolder = new Throwable[1]; - var latch = new java.util.concurrent.CountDownLatch(1); - - StreamObserver responseObserver = new StreamObserver<>() { - @Override - public void onNext(ImportBulkRelationshipsResponse resp) { - resultHolder[0] = resp.getNumLoaded(); - } - - @Override - public void onError(Throwable t) { - errorHolder[0] = t; - latch.countDown(); - } - - @Override - public void onCompleted() { - latch.countDown(); - } - }; + .setOptionalLimit(DEFAULT_EXPORT_PAGE_SIZE); - StreamObserver requestObserver = - permissionsAsyncStub.importBulkRelationships(responseObserver); - - var batch = new ArrayList(DEFAULT_IMPORT_BATCH_SIZE); - for (Relationship r : relationships) { - batch.add(toProtoRelationship(r)); - if (batch.size() >= DEFAULT_IMPORT_BATCH_SIZE) { - requestObserver.onNext( - ImportBulkRelationshipsRequest.newBuilder() - .addAllRelationships(batch) - .build() - ); - batch.clear(); + if (filter != null) { + reqBuilder.setOptionalRelationshipFilter(toRelationshipFilter(filter)); } - } - - if (!batch.isEmpty()) { - requestObserver.onNext( - ImportBulkRelationshipsRequest.newBuilder() - .addAllRelationships(batch) - .build() - ); - } - - requestObserver.onCompleted(); - - try { - latch.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new SpiceDBException("import interrupted", e); - } - - if (errorHolder[0] != null) { - if (errorHolder[0] instanceof StatusRuntimeException sre) { - throw ErrorMapper.toSpiceDBException(sre); + if (cursor != null) { + reqBuilder.setOptionalCursor(cursor); } - throw new SpiceDBException("import failed", errorHolder[0]); - } - return resultHolder[0]; - } + var serverStream = + withRetry(() -> permissionsStub.exportBulkRelationships(reqBuilder.build())); - /** - * Returns a stream over all relationships matching the optional filter, - * streamed from SpiceDB in bulk. Cursors are handled transparently with - * 512-item pages. - * - *

The returned stream should be closed when done. - */ - public Stream exportRelationships(Consistency consistency, Filter filter) { - Iterator iterator = new Iterator<>() { - private Cursor cursor = null; - private final List buffer = new ArrayList<>(); - private int bufferIndex = 0; - private boolean done = false; - - @Override - public boolean hasNext() { - if (bufferIndex < buffer.size()) return true; - if (done) return false; - fetchNextPage(); - return bufferIndex < buffer.size(); - } - - @Override - public Relationship next() { - if (!hasNext()) throw new NoSuchElementException(); - return buffer.get(bufferIndex++); + int pageCount = 0; + while (serverStream.hasNext()) { + ExportBulkRelationshipsResponse resp = serverStream.next(); + cursor = resp.getAfterResultCursor(); + for (var r : resp.getRelationshipsList()) { + buffer.add(fromProtoRelationship(r)); + pageCount++; + } } - private void fetchNextPage() { - buffer.clear(); - bufferIndex = 0; - - var reqBuilder = ExportBulkRelationshipsRequest.newBuilder() - .setConsistency(consistency.toProto()) - .setOptionalLimit(DEFAULT_EXPORT_PAGE_SIZE); - - if (filter != null) { - reqBuilder.setOptionalRelationshipFilter(toRelationshipFilter(filter)); - } - if (cursor != null) { - reqBuilder.setOptionalCursor(cursor); - } - - var serverStream = withRetry(() -> - permissionsStub.exportBulkRelationships(reqBuilder.build()) - ); - - int pageCount = 0; - while (serverStream.hasNext()) { - ExportBulkRelationshipsResponse resp = serverStream.next(); - cursor = resp.getAfterResultCursor(); - for (var r : resp.getRelationshipsList()) { - buffer.add(fromProtoRelationship(r)); - pageCount++; - } - } - - if (pageCount < DEFAULT_EXPORT_PAGE_SIZE) { - done = true; - } + if (pageCount < DEFAULT_EXPORT_PAGE_SIZE) { + done = true; } + } }; - return StreamSupport.stream( - Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), false); + return StreamSupport.stream( + Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), false); + } + + // ----------------------------------------------------------------------- + // Watch + // ----------------------------------------------------------------------- + + /** A relationship update from the watch API. */ + public record Update(UpdateOperation operation, Relationship relationship) {} + + /** The type of mutation in an Update. */ + public enum UpdateOperation { + CREATE, + TOUCH, + DELETE + } + + /** + * Returns a stream over relationship changes from SpiceDB's watch API, starting from the given + * revision. + * + *

The returned stream should be closed when done. + */ + public Stream updates(List objectTypes, String startRevision) { + var reqBuilder = WatchRequest.newBuilder(); + if (objectTypes != null) { + reqBuilder.addAllOptionalObjectTypes(objectTypes); } - - // ----------------------------------------------------------------------- - // Watch - // ----------------------------------------------------------------------- - - /** A relationship update from the watch API. */ - public record Update(UpdateOperation operation, Relationship relationship) { } - - /** The type of mutation in an Update. */ - public enum UpdateOperation { - CREATE, TOUCH, DELETE + if (startRevision != null && !startRevision.isEmpty()) { + reqBuilder.setOptionalStartCursor(ZedToken.newBuilder().setToken(startRevision).build()); } - /** - * Returns a stream over relationship changes from SpiceDB's watch API, - * starting from the given revision. - * - *

The returned stream should be closed when done. - */ - public Stream updates(List objectTypes, String startRevision) { - var reqBuilder = WatchRequest.newBuilder(); - if (objectTypes != null) { - reqBuilder.addAllOptionalObjectTypes(objectTypes); - } - if (startRevision != null && !startRevision.isEmpty()) { - reqBuilder.setOptionalStartCursor( - ZedToken.newBuilder().setToken(startRevision).build()); - } + var serverStream = withRetry(() -> watchStub.watch(reqBuilder.build())); - var serverStream = withRetry(() -> watchStub.watch(reqBuilder.build())); + Iterator iterator = + new Iterator<>() { + private final Queue buffer = new ArrayDeque<>(); - Iterator iterator = new Iterator<>() { - private final Queue buffer = new ArrayDeque<>(); - - @Override - public boolean hasNext() { - if (!buffer.isEmpty()) return true; - if (!serverStream.hasNext()) return false; - WatchResponse resp = serverStream.next(); - for (var u : resp.getUpdatesList()) { - buffer.add(updateFromProto(u)); - } - return !buffer.isEmpty(); - } - - @Override - public Update next() { - if (!hasNext()) throw new NoSuchElementException(); - return buffer.poll(); + @Override + public boolean hasNext() { + if (!buffer.isEmpty()) return true; + if (!serverStream.hasNext()) return false; + WatchResponse resp = serverStream.next(); + for (var u : resp.getUpdatesList()) { + buffer.add(updateFromProto(u)); } + return !buffer.isEmpty(); + } + + @Override + public Update next() { + if (!hasNext()) throw new NoSuchElementException(); + return buffer.poll(); + } }; - return StreamSupport.stream( - Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), false); - } - - // ----------------------------------------------------------------------- - // Experimental — these APIs may change without following the backwards - // compatibility mandate - // ----------------------------------------------------------------------- - - /** - * Registers a named counter that tracks relationships matching the given filter. - * The counter is computed asynchronously by SpiceDB. - * - *

Experimental: this API may change without notice. - */ - public void experimentalRegisterRelationshipCounter(String name, Filter filter) { - withRetry(() -> + return StreamSupport.stream( + Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), false); + } + + // ----------------------------------------------------------------------- + // Experimental — these APIs may change without following the backwards + // compatibility mandate + // ----------------------------------------------------------------------- + + /** + * Registers a named counter that tracks relationships matching the given filter. The counter is + * computed asynchronously by SpiceDB. + * + *

Experimental: this API may change without notice. + */ + public void experimentalRegisterRelationshipCounter(String name, Filter filter) { + withRetry( + () -> experimentalStub.experimentalRegisterRelationshipCounter( ExperimentalRegisterRelationshipCounterRequest.newBuilder() .setName(name) .setRelationshipFilter(toRelationshipFilter(filter)) - .build() - ) - ); + .build())); + } + + /** Result of an {@link #experimentalCountRelationships} call. */ + public record CountResult(long relationshipCount, String revision, boolean stillCalculating) {} + + /** + * Reads the value of a previously registered relationship counter. + * + *

Experimental: this API may change without notice. + */ + public CountResult experimentalCountRelationships(String name) { + ExperimentalCountRelationshipsResponse resp = + withRetry( + () -> + experimentalStub.experimentalCountRelationships( + ExperimentalCountRelationshipsRequest.newBuilder().setName(name).build())); + + if (resp.getCounterStillCalculating()) { + return new CountResult(0, "", true); } - /** Result of an {@link #experimentalCountRelationships} call. */ - public record CountResult(long relationshipCount, String revision, boolean stillCalculating) { } - - /** - * Reads the value of a previously registered relationship counter. - * - *

Experimental: this API may change without notice. - */ - public CountResult experimentalCountRelationships(String name) { - ExperimentalCountRelationshipsResponse resp = withRetry(() -> - experimentalStub.experimentalCountRelationships( - ExperimentalCountRelationshipsRequest.newBuilder() - .setName(name) - .build() - ) - ); - - if (resp.getCounterStillCalculating()) { - return new CountResult(0, "", true); - } - - var cv = resp.getReadCounterValue(); - return new CountResult( - cv.getRelationshipCount(), - cv.getReadAt().getToken(), - false - ); - } - - /** - * Removes a previously registered relationship counter. - * - *

Experimental: this API may change without notice. - */ - public void experimentalUnregisterRelationshipCounter(String name) { - withRetry(() -> + var cv = resp.getReadCounterValue(); + return new CountResult(cv.getRelationshipCount(), cv.getReadAt().getToken(), false); + } + + /** + * Removes a previously registered relationship counter. + * + *

Experimental: this API may change without notice. + */ + public void experimentalUnregisterRelationshipCounter(String name) { + withRetry( + () -> experimentalStub.experimentalUnregisterRelationshipCounter( ExperimentalUnregisterRelationshipCounterRequest.newBuilder() .setName(name) - .build() - ) - ); + .build())); + } + + // ----------------------------------------------------------------------- + // AutoCloseable + // ----------------------------------------------------------------------- + + @Override + public void close() { + channel.shutdown(); + try { + if (!channel.awaitTermination(5, TimeUnit.SECONDS)) { + channel.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + channel.shutdownNow(); } - - // ----------------------------------------------------------------------- - // AutoCloseable - // ----------------------------------------------------------------------- - - @Override - public void close() { - channel.shutdown(); - try { - if (!channel.awaitTermination(5, TimeUnit.SECONDS)) { - channel.shutdownNow(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - channel.shutdownNow(); + } + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + private static Metadata bearerMetadata(String presharedKey) { + Metadata metadata = new Metadata(); + metadata.put( + Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER), + "Bearer " + presharedKey); + return metadata; + } + + /** Retry with exponential backoff for transient gRPC errors. */ + @FunctionalInterface + private interface RetryableCall { + T call(); + } + + private T withRetry(RetryableCall call) { + long backoff = INITIAL_BACKOFF_MS; + for (int attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + return call.call(); + } catch (StatusRuntimeException e) { + if (!ErrorMapper.isTransient(e) || attempt == MAX_RETRIES - 1) { + throw ErrorMapper.toSpiceDBException(e); } - } - - // ----------------------------------------------------------------------- - // Internal helpers - // ----------------------------------------------------------------------- - - private static Metadata bearerMetadata(String presharedKey) { - Metadata metadata = new Metadata(); - metadata.put( - Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER), - "Bearer " + presharedKey - ); - return metadata; - } - - /** Retry with exponential backoff for transient gRPC errors. */ - @FunctionalInterface - private interface RetryableCall { - T call(); - } - - private T withRetry(RetryableCall call) { - long backoff = INITIAL_BACKOFF_MS; - for (int attempt = 0; attempt < MAX_RETRIES; attempt++) { - try { - return call.call(); - } catch (StatusRuntimeException e) { - if (!ErrorMapper.isTransient(e) || attempt == MAX_RETRIES - 1) { - throw ErrorMapper.toSpiceDBException(e); - } - try { - Thread.sleep(backoff); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - throw ErrorMapper.toSpiceDBException(e); - } - backoff *= 2; - } + try { + Thread.sleep(backoff); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw ErrorMapper.toSpiceDBException(e); } - throw new SpiceDBException("unreachable"); + backoff *= 2; + } } - - private Stream paginatedRelationshipStream( - Consistency consistency, Filter filter, int pageSize) { - Iterator iterator = new Iterator<>() { - private Cursor cursor = null; - private final List buffer = new ArrayList<>(); - private int bufferIndex = 0; - private boolean done = false; - - @Override - public boolean hasNext() { - if (bufferIndex < buffer.size()) return true; - if (done) return false; - fetchNextPage(); - return bufferIndex < buffer.size(); - } - - @Override - public Relationship next() { - if (!hasNext()) throw new NoSuchElementException(); - return buffer.get(bufferIndex++); - } - - private void fetchNextPage() { - buffer.clear(); - bufferIndex = 0; - - var reqBuilder = ReadRelationshipsRequest.newBuilder() + throw new SpiceDBException("unreachable"); + } + + private Stream paginatedRelationshipStream( + Consistency consistency, Filter filter, int pageSize) { + Iterator iterator = + new Iterator<>() { + private Cursor cursor = null; + private final List buffer = new ArrayList<>(); + private int bufferIndex = 0; + private boolean done = false; + + @Override + public boolean hasNext() { + if (bufferIndex < buffer.size()) return true; + if (done) return false; + fetchNextPage(); + return bufferIndex < buffer.size(); + } + + @Override + public Relationship next() { + if (!hasNext()) throw new NoSuchElementException(); + return buffer.get(bufferIndex++); + } + + private void fetchNextPage() { + buffer.clear(); + bufferIndex = 0; + + var reqBuilder = + ReadRelationshipsRequest.newBuilder() .setConsistency(consistency.toProto()) .setRelationshipFilter(toRelationshipFilter(filter)) .setOptionalLimit(pageSize); - if (cursor != null) { - reqBuilder.setOptionalCursor(cursor); - } + if (cursor != null) { + reqBuilder.setOptionalCursor(cursor); + } - var serverStream = withRetry(() -> - permissionsStub.readRelationships(reqBuilder.build()) - ); + var serverStream = + withRetry(() -> permissionsStub.readRelationships(reqBuilder.build())); - while (serverStream.hasNext()) { - ReadRelationshipsResponse resp = serverStream.next(); - cursor = resp.getAfterResultCursor(); - buffer.add(fromProtoRelationship(resp.getRelationship())); - } + while (serverStream.hasNext()) { + ReadRelationshipsResponse resp = serverStream.next(); + cursor = resp.getAfterResultCursor(); + buffer.add(fromProtoRelationship(resp.getRelationship())); + } - if (buffer.size() < pageSize) { - done = true; - } + if (buffer.size() < pageSize) { + done = true; } + } }; - return StreamSupport.stream( - Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), false); - } + return StreamSupport.stream( + Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), false); + } - private static CheckBulkPermissionsRequestItem checkItemFromRel( - Relationship r, String permission) { - return CheckBulkPermissionsRequestItem.newBuilder() - .setResource(ObjectReference.newBuilder() + private static CheckBulkPermissionsRequestItem checkItemFromRel( + Relationship r, String permission) { + return CheckBulkPermissionsRequestItem.newBuilder() + .setResource( + ObjectReference.newBuilder() .setObjectType(r.resourceType()) .setObjectId(r.resourceID()) .build()) - .setPermission(permission) - .setSubject(SubjectReference.newBuilder() - .setObject(ObjectReference.newBuilder() - .setObjectType(r.subjectType()) - .setObjectId(r.subjectID()) - .build()) + .setPermission(permission) + .setSubject( + SubjectReference.newBuilder() + .setObject( + ObjectReference.newBuilder() + .setObjectType(r.subjectType()) + .setObjectId(r.subjectID()) + .build()) .setOptionalRelation(r.subjectRelation() != null ? r.subjectRelation() : "") .build()) - .build(); - } - - private static RelationshipUpdate toRelationshipUpdate(Transaction.Mutation m) { - RelationshipUpdate.Operation op = switch (m.operation()) { - case CREATE -> RelationshipUpdate.Operation.OPERATION_CREATE; - case TOUCH -> RelationshipUpdate.Operation.OPERATION_TOUCH; - case DELETE -> RelationshipUpdate.Operation.OPERATION_DELETE; + .build(); + } + + private static RelationshipUpdate toRelationshipUpdate(Transaction.Mutation m) { + RelationshipUpdate.Operation op = + switch (m.operation()) { + case CREATE -> RelationshipUpdate.Operation.OPERATION_CREATE; + case TOUCH -> RelationshipUpdate.Operation.OPERATION_TOUCH; + case DELETE -> RelationshipUpdate.Operation.OPERATION_DELETE; }; - return RelationshipUpdate.newBuilder() - .setOperation(op) - .setRelationship(toProtoRelationship(m.relationship())) - .build(); - } - - private static Precondition toPrecondition(Transaction.Precondition p) { - Precondition.Operation op = switch (p.operation()) { - case MUST_NOT_MATCH -> Precondition.Operation.OPERATION_MUST_NOT_MATCH; - case MUST_MATCH -> Precondition.Operation.OPERATION_MUST_MATCH; + return RelationshipUpdate.newBuilder() + .setOperation(op) + .setRelationship(toProtoRelationship(m.relationship())) + .build(); + } + + private static Precondition toPrecondition(Transaction.Precondition p) { + Precondition.Operation op = + switch (p.operation()) { + case MUST_NOT_MATCH -> Precondition.Operation.OPERATION_MUST_NOT_MATCH; + case MUST_MATCH -> Precondition.Operation.OPERATION_MUST_MATCH; }; - return Precondition.newBuilder() - .setOperation(op) - .setFilter(toRelationshipFilter(p.filter())) - .build(); - } - - static build.buf.gen.authzed.api.v1.Relationship toProtoRelationship(Relationship r) { - var builder = build.buf.gen.authzed.api.v1.Relationship.newBuilder() - .setResource(ObjectReference.newBuilder() - .setObjectType(r.resourceType()) - .setObjectId(r.resourceID()) - .build()) - .setRelation(r.resourceRelation()) - .setSubject(SubjectReference.newBuilder() - .setObject(ObjectReference.newBuilder() - .setObjectType(r.subjectType()) - .setObjectId(r.subjectID()) + return Precondition.newBuilder() + .setOperation(op) + .setFilter(toRelationshipFilter(p.filter())) + .build(); + } + + static build.buf.gen.authzed.api.v1.Relationship toProtoRelationship(Relationship r) { + var builder = + build.buf.gen.authzed.api.v1.Relationship.newBuilder() + .setResource( + ObjectReference.newBuilder() + .setObjectType(r.resourceType()) + .setObjectId(r.resourceID()) .build()) - .setOptionalRelation(r.subjectRelation() != null ? r.subjectRelation() : "") - .build()); - - if (r.caveatName() != null && !r.caveatName().isEmpty()) { - var caveatBuilder = ContextualizedCaveat.newBuilder() - .setCaveatName(r.caveatName()); - if (r.caveatContext() != null) { - var structBuilder = com.google.protobuf.Struct.newBuilder(); - for (var entry : r.caveatContext().entrySet()) { - structBuilder.putFields(entry.getKey(), toProtoValue(entry.getValue())); - } - caveatBuilder.setContext(structBuilder.build()); - } - builder.setOptionalCaveat(caveatBuilder.build()); - } - - if (r.expiration() != null) { - builder.setOptionalExpiresAt( - com.google.protobuf.Timestamp.newBuilder() - .setSeconds(r.expiration().getEpochSecond()) - .setNanos(r.expiration().getNano()) - .build() - ); + .setRelation(r.resourceRelation()) + .setSubject( + SubjectReference.newBuilder() + .setObject( + ObjectReference.newBuilder() + .setObjectType(r.subjectType()) + .setObjectId(r.subjectID()) + .build()) + .setOptionalRelation(r.subjectRelation() != null ? r.subjectRelation() : "") + .build()); + + if (r.caveatName() != null && !r.caveatName().isEmpty()) { + var caveatBuilder = ContextualizedCaveat.newBuilder().setCaveatName(r.caveatName()); + if (r.caveatContext() != null) { + var structBuilder = com.google.protobuf.Struct.newBuilder(); + for (var entry : r.caveatContext().entrySet()) { + structBuilder.putFields(entry.getKey(), toProtoValue(entry.getValue())); } - - return builder.build(); + caveatBuilder.setContext(structBuilder.build()); + } + builder.setOptionalCaveat(caveatBuilder.build()); } - static Relationship fromProtoRelationship(build.buf.gen.authzed.api.v1.Relationship pr) { - String caveatName = null; - Map caveatContext = null; - if (pr.hasOptionalCaveat()) { - caveatName = pr.getOptionalCaveat().getCaveatName(); - if (pr.getOptionalCaveat().hasContext()) { - caveatContext = new HashMap<>(); - for (var entry : pr.getOptionalCaveat().getContext().getFieldsMap().entrySet()) { - caveatContext.put(entry.getKey(), fromProtoValue(entry.getValue())); - } - } - } - - Instant expiration = null; - if (pr.hasOptionalExpiresAt()) { - expiration = Instant.ofEpochSecond( - pr.getOptionalExpiresAt().getSeconds(), - pr.getOptionalExpiresAt().getNanos() - ); - } - - return new Relationship( - pr.getResource().getObjectType(), - pr.getResource().getObjectId(), - pr.getRelation(), - pr.getSubject().getObject().getObjectType(), - pr.getSubject().getObject().getObjectId(), - pr.getSubject().getOptionalRelation(), - caveatName, - caveatContext, - expiration - ); + if (r.expiration() != null) { + builder.setOptionalExpiresAt( + com.google.protobuf.Timestamp.newBuilder() + .setSeconds(r.expiration().getEpochSecond()) + .setNanos(r.expiration().getNano()) + .build()); } - private static RelationshipFilter toRelationshipFilter(Filter f) { - var builder = RelationshipFilter.newBuilder() - .setResourceType(f.resourceType()); - - if (f.resourceID() != null && !f.resourceID().isEmpty()) { - builder.setOptionalResourceId(f.resourceID()); - } - if (f.resourceIDPrefix() != null && !f.resourceIDPrefix().isEmpty()) { - builder.setOptionalResourceIdPrefix(f.resourceIDPrefix()); + return builder.build(); + } + + static Relationship fromProtoRelationship(build.buf.gen.authzed.api.v1.Relationship pr) { + String caveatName = null; + Map caveatContext = null; + if (pr.hasOptionalCaveat()) { + caveatName = pr.getOptionalCaveat().getCaveatName(); + if (pr.getOptionalCaveat().hasContext()) { + caveatContext = new HashMap<>(); + for (var entry : pr.getOptionalCaveat().getContext().getFieldsMap().entrySet()) { + caveatContext.put(entry.getKey(), fromProtoValue(entry.getValue())); } - if (f.relation() != null && !f.relation().isEmpty()) { - builder.setOptionalRelation(f.relation()); - } - if (f.subjectType() != null && !f.subjectType().isEmpty()) { - var subjectBuilder = SubjectFilter.newBuilder() - .setSubjectType(f.subjectType()); - if (f.subjectID() != null && !f.subjectID().isEmpty()) { - subjectBuilder.setOptionalSubjectId(f.subjectID()); - } - if (f.subjectRelation() != null && !f.subjectRelation().isEmpty()) { - subjectBuilder.setOptionalRelation( - SubjectFilter.RelationFilter.newBuilder() - .setRelation(f.subjectRelation()) - .build()); - } - builder.setOptionalSubjectFilter(subjectBuilder.build()); - } - return builder.build(); + } } - private Update updateFromProto(RelationshipUpdate pu) { - UpdateOperation op = switch (pu.getOperation()) { - case OPERATION_CREATE -> UpdateOperation.CREATE; - case OPERATION_TOUCH -> UpdateOperation.TOUCH; - case OPERATION_DELETE -> UpdateOperation.DELETE; - default -> UpdateOperation.TOUCH; - }; - return new Update(op, fromProtoRelationship(pu.getRelationship())); + Instant expiration = null; + if (pr.hasOptionalExpiresAt()) { + expiration = + Instant.ofEpochSecond( + pr.getOptionalExpiresAt().getSeconds(), pr.getOptionalExpiresAt().getNanos()); } - private SchemaDiff schemaDiffFromProto(ReflectionSchemaDiff d) { - // Map each diff case to a descriptive kind string - if (d.hasDefinitionAdded()) { - return new SchemaDiff("definition_added", d.getDefinitionAdded().getName(), "", "", ""); - } else if (d.hasDefinitionRemoved()) { - return new SchemaDiff("definition_removed", d.getDefinitionRemoved().getName(), "", "", ""); - } else if (d.hasDefinitionDocCommentChanged()) { - return new SchemaDiff("definition_doc_comment_changed", d.getDefinitionDocCommentChanged().getName(), "", "", ""); - } else if (d.hasRelationAdded()) { - return new SchemaDiff("relation_added", d.getRelationAdded().getParentDefinitionName(), d.getRelationAdded().getName(), "", ""); - } else if (d.hasRelationRemoved()) { - return new SchemaDiff("relation_removed", d.getRelationRemoved().getParentDefinitionName(), d.getRelationRemoved().getName(), "", ""); - } else if (d.hasRelationDocCommentChanged()) { - return new SchemaDiff("relation_doc_comment_changed", d.getRelationDocCommentChanged().getParentDefinitionName(), d.getRelationDocCommentChanged().getName(), "", ""); - } else if (d.hasRelationSubjectTypeAdded()) { - return new SchemaDiff("relation_subject_type_added", d.getRelationSubjectTypeAdded().getRelation().getParentDefinitionName(), d.getRelationSubjectTypeAdded().getRelation().getName(), "", ""); - } else if (d.hasRelationSubjectTypeRemoved()) { - return new SchemaDiff("relation_subject_type_removed", d.getRelationSubjectTypeRemoved().getRelation().getParentDefinitionName(), d.getRelationSubjectTypeRemoved().getRelation().getName(), "", ""); - } else if (d.hasPermissionAdded()) { - return new SchemaDiff("permission_added", d.getPermissionAdded().getParentDefinitionName(), "", d.getPermissionAdded().getName(), ""); - } else if (d.hasPermissionRemoved()) { - return new SchemaDiff("permission_removed", d.getPermissionRemoved().getParentDefinitionName(), "", d.getPermissionRemoved().getName(), ""); - } else if (d.hasPermissionDocCommentChanged()) { - return new SchemaDiff("permission_doc_comment_changed", d.getPermissionDocCommentChanged().getParentDefinitionName(), "", d.getPermissionDocCommentChanged().getName(), ""); - } else if (d.hasPermissionExprChanged()) { - return new SchemaDiff("permission_expr_changed", d.getPermissionExprChanged().getParentDefinitionName(), "", d.getPermissionExprChanged().getName(), ""); - } else if (d.hasCaveatAdded()) { - return new SchemaDiff("caveat_added", "", "", "", d.getCaveatAdded().getName()); - } else if (d.hasCaveatRemoved()) { - return new SchemaDiff("caveat_removed", "", "", "", d.getCaveatRemoved().getName()); - } else if (d.hasCaveatDocCommentChanged()) { - return new SchemaDiff("caveat_doc_comment_changed", "", "", "", d.getCaveatDocCommentChanged().getName()); - } else if (d.hasCaveatExprChanged()) { - return new SchemaDiff("caveat_expr_changed", "", "", "", d.getCaveatExprChanged().getName()); - } else if (d.hasCaveatParameterAdded()) { - return new SchemaDiff("caveat_parameter_added", "", "", "", d.getCaveatParameterAdded().getParentCaveatName()); - } else if (d.hasCaveatParameterRemoved()) { - return new SchemaDiff("caveat_parameter_removed", "", "", "", d.getCaveatParameterRemoved().getParentCaveatName()); - } else if (d.hasCaveatParameterTypeChanged()) { - return new SchemaDiff("caveat_parameter_type_changed", "", "", "", d.getCaveatParameterTypeChanged().getParameter().getParentCaveatName()); - } - return new SchemaDiff("unknown", "", "", "", ""); + return new Relationship( + pr.getResource().getObjectType(), + pr.getResource().getObjectId(), + pr.getRelation(), + pr.getSubject().getObject().getObjectType(), + pr.getSubject().getObject().getObjectId(), + pr.getSubject().getOptionalRelation(), + caveatName, + caveatContext, + expiration); + } + + private static RelationshipFilter toRelationshipFilter(Filter f) { + var builder = RelationshipFilter.newBuilder().setResourceType(f.resourceType()); + + if (f.resourceID() != null && !f.resourceID().isEmpty()) { + builder.setOptionalResourceId(f.resourceID()); } - - private static com.google.protobuf.Value toProtoValue(Object value) { - if (value == null) { - return com.google.protobuf.Value.newBuilder() - .setNullValue(com.google.protobuf.NullValue.NULL_VALUE).build(); - } else if (value instanceof Boolean b) { - return com.google.protobuf.Value.newBuilder().setBoolValue(b).build(); - } else if (value instanceof Number n) { - return com.google.protobuf.Value.newBuilder().setNumberValue(n.doubleValue()).build(); - } else if (value instanceof String s) { - return com.google.protobuf.Value.newBuilder().setStringValue(s).build(); - } else { - return com.google.protobuf.Value.newBuilder() - .setStringValue(value.toString()).build(); - } + if (f.resourceIDPrefix() != null && !f.resourceIDPrefix().isEmpty()) { + builder.setOptionalResourceIdPrefix(f.resourceIDPrefix()); } - - private static Object fromProtoValue(com.google.protobuf.Value value) { - return switch (value.getKindCase()) { - case NULL_VALUE -> null; - case BOOL_VALUE -> value.getBoolValue(); - case NUMBER_VALUE -> value.getNumberValue(); - case STRING_VALUE -> value.getStringValue(); - default -> value.toString(); + if (f.relation() != null && !f.relation().isEmpty()) { + builder.setOptionalRelation(f.relation()); + } + if (f.subjectType() != null && !f.subjectType().isEmpty()) { + var subjectBuilder = SubjectFilter.newBuilder().setSubjectType(f.subjectType()); + if (f.subjectID() != null && !f.subjectID().isEmpty()) { + subjectBuilder.setOptionalSubjectId(f.subjectID()); + } + if (f.subjectRelation() != null && !f.subjectRelation().isEmpty()) { + subjectBuilder.setOptionalRelation( + SubjectFilter.RelationFilter.newBuilder().setRelation(f.subjectRelation()).build()); + } + builder.setOptionalSubjectFilter(subjectBuilder.build()); + } + return builder.build(); + } + + private Update updateFromProto(RelationshipUpdate pu) { + UpdateOperation op = + switch (pu.getOperation()) { + case OPERATION_CREATE -> UpdateOperation.CREATE; + case OPERATION_TOUCH -> UpdateOperation.TOUCH; + case OPERATION_DELETE -> UpdateOperation.DELETE; + default -> UpdateOperation.TOUCH; }; + return new Update(op, fromProtoRelationship(pu.getRelationship())); + } + + private SchemaDiff schemaDiffFromProto(ReflectionSchemaDiff d) { + // Map each diff case to a descriptive kind string + if (d.hasDefinitionAdded()) { + return new SchemaDiff("definition_added", d.getDefinitionAdded().getName(), "", "", ""); + } else if (d.hasDefinitionRemoved()) { + return new SchemaDiff("definition_removed", d.getDefinitionRemoved().getName(), "", "", ""); + } else if (d.hasDefinitionDocCommentChanged()) { + return new SchemaDiff( + "definition_doc_comment_changed", + d.getDefinitionDocCommentChanged().getName(), + "", + "", + ""); + } else if (d.hasRelationAdded()) { + return new SchemaDiff( + "relation_added", + d.getRelationAdded().getParentDefinitionName(), + d.getRelationAdded().getName(), + "", + ""); + } else if (d.hasRelationRemoved()) { + return new SchemaDiff( + "relation_removed", + d.getRelationRemoved().getParentDefinitionName(), + d.getRelationRemoved().getName(), + "", + ""); + } else if (d.hasRelationDocCommentChanged()) { + return new SchemaDiff( + "relation_doc_comment_changed", + d.getRelationDocCommentChanged().getParentDefinitionName(), + d.getRelationDocCommentChanged().getName(), + "", + ""); + } else if (d.hasRelationSubjectTypeAdded()) { + return new SchemaDiff( + "relation_subject_type_added", + d.getRelationSubjectTypeAdded().getRelation().getParentDefinitionName(), + d.getRelationSubjectTypeAdded().getRelation().getName(), + "", + ""); + } else if (d.hasRelationSubjectTypeRemoved()) { + return new SchemaDiff( + "relation_subject_type_removed", + d.getRelationSubjectTypeRemoved().getRelation().getParentDefinitionName(), + d.getRelationSubjectTypeRemoved().getRelation().getName(), + "", + ""); + } else if (d.hasPermissionAdded()) { + return new SchemaDiff( + "permission_added", + d.getPermissionAdded().getParentDefinitionName(), + "", + d.getPermissionAdded().getName(), + ""); + } else if (d.hasPermissionRemoved()) { + return new SchemaDiff( + "permission_removed", + d.getPermissionRemoved().getParentDefinitionName(), + "", + d.getPermissionRemoved().getName(), + ""); + } else if (d.hasPermissionDocCommentChanged()) { + return new SchemaDiff( + "permission_doc_comment_changed", + d.getPermissionDocCommentChanged().getParentDefinitionName(), + "", + d.getPermissionDocCommentChanged().getName(), + ""); + } else if (d.hasPermissionExprChanged()) { + return new SchemaDiff( + "permission_expr_changed", + d.getPermissionExprChanged().getParentDefinitionName(), + "", + d.getPermissionExprChanged().getName(), + ""); + } else if (d.hasCaveatAdded()) { + return new SchemaDiff("caveat_added", "", "", "", d.getCaveatAdded().getName()); + } else if (d.hasCaveatRemoved()) { + return new SchemaDiff("caveat_removed", "", "", "", d.getCaveatRemoved().getName()); + } else if (d.hasCaveatDocCommentChanged()) { + return new SchemaDiff( + "caveat_doc_comment_changed", "", "", "", d.getCaveatDocCommentChanged().getName()); + } else if (d.hasCaveatExprChanged()) { + return new SchemaDiff("caveat_expr_changed", "", "", "", d.getCaveatExprChanged().getName()); + } else if (d.hasCaveatParameterAdded()) { + return new SchemaDiff( + "caveat_parameter_added", "", "", "", d.getCaveatParameterAdded().getParentCaveatName()); + } else if (d.hasCaveatParameterRemoved()) { + return new SchemaDiff( + "caveat_parameter_removed", + "", + "", + "", + d.getCaveatParameterRemoved().getParentCaveatName()); + } else if (d.hasCaveatParameterTypeChanged()) { + return new SchemaDiff( + "caveat_parameter_type_changed", + "", + "", + "", + d.getCaveatParameterTypeChanged().getParameter().getParentCaveatName()); + } + return new SchemaDiff("unknown", "", "", "", ""); + } + + private static com.google.protobuf.Value toProtoValue(Object value) { + if (value == null) { + return com.google.protobuf.Value.newBuilder() + .setNullValue(com.google.protobuf.NullValue.NULL_VALUE) + .build(); + } else if (value instanceof Boolean b) { + return com.google.protobuf.Value.newBuilder().setBoolValue(b).build(); + } else if (value instanceof Number n) { + return com.google.protobuf.Value.newBuilder().setNumberValue(n.doubleValue()).build(); + } else if (value instanceof String s) { + return com.google.protobuf.Value.newBuilder().setStringValue(s).build(); + } else { + return com.google.protobuf.Value.newBuilder().setStringValue(value.toString()).build(); } + } + + private static Object fromProtoValue(com.google.protobuf.Value value) { + return switch (value.getKindCase()) { + case NULL_VALUE -> null; + case BOOL_VALUE -> value.getBoolValue(); + case NUMBER_VALUE -> value.getNumberValue(); + case STRING_VALUE -> value.getStringValue(); + default -> value.toString(); + }; + } } diff --git a/spicedb-java/lib/src/main/java/com/authzed/spicedb/Transaction.java b/spicedb-java/lib/src/main/java/com/authzed/spicedb/Transaction.java index 3f6978d..674f039 100644 --- a/spicedb-java/lib/src/main/java/com/authzed/spicedb/Transaction.java +++ b/spicedb-java/lib/src/main/java/com/authzed/spicedb/Transaction.java @@ -7,8 +7,8 @@ /** * A transaction builder for batching relationship writes to SpiceDB. * - *

Collects relationship mutations (create, touch, delete) and preconditions, - * then passes them to {@link SpiceDBClient#write(Transaction)}. + *

Collects relationship mutations (create, touch, delete) and preconditions, then passes them to + * {@link SpiceDBClient#write(Transaction)}. * *

{@code
  * var txn = new Transaction();
@@ -20,77 +20,71 @@
  */
 public final class Transaction {
 
-    /** The operation type for a relationship mutation. */
-    public enum Operation {
-        CREATE,
-        TOUCH,
-        DELETE
-    }
+  /** The operation type for a relationship mutation. */
+  public enum Operation {
+    CREATE,
+    TOUCH,
+    DELETE
+  }
 
-    /** A single relationship mutation within a transaction. */
-    public record Mutation(Operation operation, Relationship relationship) { }
+  /** A single relationship mutation within a transaction. */
+  public record Mutation(Operation operation, Relationship relationship) {}
 
-    /** The type of precondition check. */
-    public enum PreconditionOperation {
-        MUST_NOT_MATCH,
-        MUST_MATCH
-    }
+  /** The type of precondition check. */
+  public enum PreconditionOperation {
+    MUST_NOT_MATCH,
+    MUST_MATCH
+  }
 
-    /** A precondition that must hold for the transaction to succeed. */
-    public record Precondition(PreconditionOperation operation, Filter filter) { }
+  /** A precondition that must hold for the transaction to succeed. */
+  public record Precondition(PreconditionOperation operation, Filter filter) {}
 
-    private final List mutations = new ArrayList<>();
-    private final List preconditions = new ArrayList<>();
+  private final List mutations = new ArrayList<>();
+  private final List preconditions = new ArrayList<>();
 
-    /**
-     * Adds a relationship create to the transaction. Fails if the relationship
-     * already exists.
-     */
-    public void create(Relationship r) {
-        mutations.add(new Mutation(Operation.CREATE, r));
-    }
+  /** Adds a relationship create to the transaction. Fails if the relationship already exists. */
+  public void create(Relationship r) {
+    mutations.add(new Mutation(Operation.CREATE, r));
+  }
 
-    /**
-     * Adds a relationship touch to the transaction. Creates or updates
-     * the relationship.
-     */
-    public void touch(Relationship r) {
-        mutations.add(new Mutation(Operation.TOUCH, r));
-    }
+  /** Adds a relationship touch to the transaction. Creates or updates the relationship. */
+  public void touch(Relationship r) {
+    mutations.add(new Mutation(Operation.TOUCH, r));
+  }
 
-    /** Adds a relationship delete to the transaction. */
-    public void delete(Relationship r) {
-        mutations.add(new Mutation(Operation.DELETE, r));
-    }
+  /** Adds a relationship delete to the transaction. */
+  public void delete(Relationship r) {
+    mutations.add(new Mutation(Operation.DELETE, r));
+  }
 
-    /**
-     * Adds a precondition that no relationships match the given filter.
-     * The transaction will fail if any matching relationship exists.
-     */
-    public void mustNotMatch(Filter f) {
-        preconditions.add(new Precondition(PreconditionOperation.MUST_NOT_MATCH, f));
-    }
+  /**
+   * Adds a precondition that no relationships match the given filter. The transaction will fail if
+   * any matching relationship exists.
+   */
+  public void mustNotMatch(Filter f) {
+    preconditions.add(new Precondition(PreconditionOperation.MUST_NOT_MATCH, f));
+  }
 
-    /**
-     * Adds a precondition that at least one relationship matches the given filter.
-     * The transaction will fail if no matching relationship exists.
-     */
-    public void mustMatch(Filter f) {
-        preconditions.add(new Precondition(PreconditionOperation.MUST_MATCH, f));
-    }
+  /**
+   * Adds a precondition that at least one relationship matches the given filter. The transaction
+   * will fail if no matching relationship exists.
+   */
+  public void mustMatch(Filter f) {
+    preconditions.add(new Precondition(PreconditionOperation.MUST_MATCH, f));
+  }
 
-    /** Returns an unmodifiable view of the mutations in this transaction. */
-    public List mutations() {
-        return Collections.unmodifiableList(mutations);
-    }
+  /** Returns an unmodifiable view of the mutations in this transaction. */
+  public List mutations() {
+    return Collections.unmodifiableList(mutations);
+  }
 
-    /** Returns an unmodifiable view of the preconditions in this transaction. */
-    public List preconditions() {
-        return Collections.unmodifiableList(preconditions);
-    }
+  /** Returns an unmodifiable view of the preconditions in this transaction. */
+  public List preconditions() {
+    return Collections.unmodifiableList(preconditions);
+  }
 
-    /** Returns true if this transaction has no mutations. */
-    public boolean isEmpty() {
-        return mutations.isEmpty();
-    }
+  /** Returns true if this transaction has no mutations. */
+  public boolean isEmpty() {
+    return mutations.isEmpty();
+  }
 }
diff --git a/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/AlreadyExistsException.java b/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/AlreadyExistsException.java
index c0c310a..0490f27 100644
--- a/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/AlreadyExistsException.java
+++ b/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/AlreadyExistsException.java
@@ -3,11 +3,11 @@
 /** The resource already exists. */
 public class AlreadyExistsException extends SpiceDBException {
 
-    public AlreadyExistsException(String message) {
-        super(message);
-    }
+  public AlreadyExistsException(String message) {
+    super(message);
+  }
 
-    public AlreadyExistsException(String message, Throwable cause) {
-        super(message, cause);
-    }
+  public AlreadyExistsException(String message, Throwable cause) {
+    super(message, cause);
+  }
 }
diff --git a/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/ErrorMapper.java b/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/ErrorMapper.java
index b5d55b8..8673aca 100644
--- a/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/ErrorMapper.java
+++ b/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/ErrorMapper.java
@@ -2,50 +2,44 @@
 
 import io.grpc.Status;
 import io.grpc.StatusRuntimeException;
-
 import java.util.Set;
 
 /**
  * Maps gRPC {@link StatusRuntimeException} to typed SpiceDB exceptions.
  *
- * 

Transient error codes (UNAVAILABLE, RESOURCE_EXHAUSTED, ABORTED) are - * identified by {@link #isTransient(StatusRuntimeException)} for retry logic. + *

Transient error codes (UNAVAILABLE, RESOURCE_EXHAUSTED, ABORTED) are identified by {@link + * #isTransient(StatusRuntimeException)} for retry logic. */ public final class ErrorMapper { - private static final Set TRANSIENT_CODES = Set.of( - Status.Code.UNAVAILABLE, - Status.Code.RESOURCE_EXHAUSTED, - Status.Code.ABORTED - ); - - private ErrorMapper() { } - - /** - * Converts a gRPC {@link StatusRuntimeException} to a typed SpiceDB exception. - * - * @param e the gRPC exception - * @return a typed {@link SpiceDBException} subclass - */ - public static SpiceDBException toSpiceDBException(StatusRuntimeException e) { - String message = e.getStatus().getDescription(); - if (message == null) { - message = e.getMessage(); - } - - return switch (e.getStatus().getCode()) { - case PERMISSION_DENIED -> new PermissionDeniedException(message, e); - case NOT_FOUND -> new NotFoundException(message, e); - case ALREADY_EXISTS -> new AlreadyExistsException(message, e); - case INVALID_ARGUMENT -> new InvalidArgumentException(message, e); - default -> new SpiceDBException(message, e); - }; + private static final Set TRANSIENT_CODES = + Set.of(Status.Code.UNAVAILABLE, Status.Code.RESOURCE_EXHAUSTED, Status.Code.ABORTED); + + private ErrorMapper() {} + + /** + * Converts a gRPC {@link StatusRuntimeException} to a typed SpiceDB exception. + * + * @param e the gRPC exception + * @return a typed {@link SpiceDBException} subclass + */ + public static SpiceDBException toSpiceDBException(StatusRuntimeException e) { + String message = e.getStatus().getDescription(); + if (message == null) { + message = e.getMessage(); } - /** - * Returns {@code true} if the error is transient and worth retrying. - */ - public static boolean isTransient(StatusRuntimeException e) { - return TRANSIENT_CODES.contains(e.getStatus().getCode()); - } + return switch (e.getStatus().getCode()) { + case PERMISSION_DENIED -> new PermissionDeniedException(message, e); + case NOT_FOUND -> new NotFoundException(message, e); + case ALREADY_EXISTS -> new AlreadyExistsException(message, e); + case INVALID_ARGUMENT -> new InvalidArgumentException(message, e); + default -> new SpiceDBException(message, e); + }; + } + + /** Returns {@code true} if the error is transient and worth retrying. */ + public static boolean isTransient(StatusRuntimeException e) { + return TRANSIENT_CODES.contains(e.getStatus().getCode()); + } } diff --git a/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/InvalidArgumentException.java b/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/InvalidArgumentException.java index 90515f5..14a0512 100644 --- a/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/InvalidArgumentException.java +++ b/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/InvalidArgumentException.java @@ -3,11 +3,11 @@ /** The request contained an invalid argument. */ public class InvalidArgumentException extends SpiceDBException { - public InvalidArgumentException(String message) { - super(message); - } + public InvalidArgumentException(String message) { + super(message); + } - public InvalidArgumentException(String message, Throwable cause) { - super(message, cause); - } + public InvalidArgumentException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/NotFoundException.java b/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/NotFoundException.java index 2ebe219..cae64c1 100644 --- a/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/NotFoundException.java +++ b/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/NotFoundException.java @@ -3,11 +3,11 @@ /** The requested resource was not found. */ public class NotFoundException extends SpiceDBException { - public NotFoundException(String message) { - super(message); - } + public NotFoundException(String message) { + super(message); + } - public NotFoundException(String message, Throwable cause) { - super(message, cause); - } + public NotFoundException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/PermissionDeniedException.java b/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/PermissionDeniedException.java index 680498a..c8820a9 100644 --- a/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/PermissionDeniedException.java +++ b/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/PermissionDeniedException.java @@ -3,11 +3,11 @@ /** The caller does not have permission to execute the operation. */ public class PermissionDeniedException extends SpiceDBException { - public PermissionDeniedException(String message) { - super(message); - } + public PermissionDeniedException(String message) { + super(message); + } - public PermissionDeniedException(String message, Throwable cause) { - super(message, cause); - } + public PermissionDeniedException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/SpiceDBException.java b/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/SpiceDBException.java index 99fda82..a092999 100644 --- a/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/SpiceDBException.java +++ b/spicedb-java/lib/src/main/java/com/authzed/spicedb/errors/SpiceDBException.java @@ -3,16 +3,16 @@ /** * Base unchecked exception for all SpiceDB errors. * - *

All SpiceDB-specific exceptions extend this class, allowing callers to - * catch all SpiceDB errors with a single {@code catch (SpiceDBException e)}. + *

All SpiceDB-specific exceptions extend this class, allowing callers to catch all SpiceDB + * errors with a single {@code catch (SpiceDBException e)}. */ public class SpiceDBException extends RuntimeException { - public SpiceDBException(String message) { - super(message); - } + public SpiceDBException(String message) { + super(message); + } - public SpiceDBException(String message, Throwable cause) { - super(message, cause); - } + public SpiceDBException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/spicedb-java/lib/src/test/java/com/authzed/spicedb/ConsistencyTest.java b/spicedb-java/lib/src/test/java/com/authzed/spicedb/ConsistencyTest.java index e58acf0..27ca400 100644 --- a/spicedb-java/lib/src/test/java/com/authzed/spicedb/ConsistencyTest.java +++ b/spicedb-java/lib/src/test/java/com/authzed/spicedb/ConsistencyTest.java @@ -1,92 +1,92 @@ package com.authzed.spicedb; -import org.junit.jupiter.api.Test; - import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; + class ConsistencyTest { - @Test - void fullReturnsFullConsistency() { - Consistency c = Consistency.full(); - assertNotNull(c.toProto()); - assertTrue(c.toProto().getFullyConsistent()); - } - - @Test - void minLatencyReturnsMinimizeLatency() { - Consistency c = Consistency.minLatency(); - assertNotNull(c.toProto()); - assertTrue(c.toProto().getMinimizeLatency()); - } - - @Test - void atLeastReturnsAtLeastAsFresh() { - Consistency c = Consistency.atLeast("token123"); - assertNotNull(c.toProto()); - assertEquals("token123", c.toProto().getAtLeastAsFresh().getToken()); - } - - @Test - void atLeastRejectsNullRevision() { - assertThrows(IllegalArgumentException.class, () -> Consistency.atLeast(null)); - } - - @Test - void atLeastRejectsEmptyRevision() { - assertThrows(IllegalArgumentException.class, () -> Consistency.atLeast("")); - } - - @Test - void snapshotReturnsExactSnapshot() { - Consistency c = Consistency.snapshot("snap456"); - assertNotNull(c.toProto()); - assertEquals("snap456", c.toProto().getAtExactSnapshot().getToken()); - } - - @Test - void snapshotRejectsNullRevision() { - assertThrows(IllegalArgumentException.class, () -> Consistency.snapshot(null)); - } - - @Test - void snapshotRejectsEmptyRevision() { - assertThrows(IllegalArgumentException.class, () -> Consistency.snapshot("")); - } - - @Test - void atLeastOrFullReturnsFullWhenEmpty() { - Consistency c = Consistency.atLeastOrFull(""); - assertTrue(c.toProto().getFullyConsistent()); - } - - @Test - void atLeastOrFullReturnsFullWhenNull() { - Consistency c = Consistency.atLeastOrFull(null); - assertTrue(c.toProto().getFullyConsistent()); - } - - @Test - void atLeastOrFullReturnsAtLeastWhenPresent() { - Consistency c = Consistency.atLeastOrFull("rev1"); - assertEquals("rev1", c.toProto().getAtLeastAsFresh().getToken()); - } - - @Test - void atLeastOrMinLatencyReturnsMinLatencyWhenEmpty() { - Consistency c = Consistency.atLeastOrMinLatency(""); - assertTrue(c.toProto().getMinimizeLatency()); - } - - @Test - void atLeastOrMinLatencyReturnsMinLatencyWhenNull() { - Consistency c = Consistency.atLeastOrMinLatency(null); - assertTrue(c.toProto().getMinimizeLatency()); - } - - @Test - void atLeastOrMinLatencyReturnsAtLeastWhenPresent() { - Consistency c = Consistency.atLeastOrMinLatency("rev2"); - assertEquals("rev2", c.toProto().getAtLeastAsFresh().getToken()); - } + @Test + void fullReturnsFullConsistency() { + Consistency c = Consistency.full(); + assertNotNull(c.toProto()); + assertTrue(c.toProto().getFullyConsistent()); + } + + @Test + void minLatencyReturnsMinimizeLatency() { + Consistency c = Consistency.minLatency(); + assertNotNull(c.toProto()); + assertTrue(c.toProto().getMinimizeLatency()); + } + + @Test + void atLeastReturnsAtLeastAsFresh() { + Consistency c = Consistency.atLeast("token123"); + assertNotNull(c.toProto()); + assertEquals("token123", c.toProto().getAtLeastAsFresh().getToken()); + } + + @Test + void atLeastRejectsNullRevision() { + assertThrows(IllegalArgumentException.class, () -> Consistency.atLeast(null)); + } + + @Test + void atLeastRejectsEmptyRevision() { + assertThrows(IllegalArgumentException.class, () -> Consistency.atLeast("")); + } + + @Test + void snapshotReturnsExactSnapshot() { + Consistency c = Consistency.snapshot("snap456"); + assertNotNull(c.toProto()); + assertEquals("snap456", c.toProto().getAtExactSnapshot().getToken()); + } + + @Test + void snapshotRejectsNullRevision() { + assertThrows(IllegalArgumentException.class, () -> Consistency.snapshot(null)); + } + + @Test + void snapshotRejectsEmptyRevision() { + assertThrows(IllegalArgumentException.class, () -> Consistency.snapshot("")); + } + + @Test + void atLeastOrFullReturnsFullWhenEmpty() { + Consistency c = Consistency.atLeastOrFull(""); + assertTrue(c.toProto().getFullyConsistent()); + } + + @Test + void atLeastOrFullReturnsFullWhenNull() { + Consistency c = Consistency.atLeastOrFull(null); + assertTrue(c.toProto().getFullyConsistent()); + } + + @Test + void atLeastOrFullReturnsAtLeastWhenPresent() { + Consistency c = Consistency.atLeastOrFull("rev1"); + assertEquals("rev1", c.toProto().getAtLeastAsFresh().getToken()); + } + + @Test + void atLeastOrMinLatencyReturnsMinLatencyWhenEmpty() { + Consistency c = Consistency.atLeastOrMinLatency(""); + assertTrue(c.toProto().getMinimizeLatency()); + } + + @Test + void atLeastOrMinLatencyReturnsMinLatencyWhenNull() { + Consistency c = Consistency.atLeastOrMinLatency(null); + assertTrue(c.toProto().getMinimizeLatency()); + } + + @Test + void atLeastOrMinLatencyReturnsAtLeastWhenPresent() { + Consistency c = Consistency.atLeastOrMinLatency("rev2"); + assertEquals("rev2", c.toProto().getAtLeastAsFresh().getToken()); + } } diff --git a/spicedb-java/lib/src/test/java/com/authzed/spicedb/ErrorMapperTest.java b/spicedb-java/lib/src/test/java/com/authzed/spicedb/ErrorMapperTest.java index 29dd32a..36a5d78 100644 --- a/spicedb-java/lib/src/test/java/com/authzed/spicedb/ErrorMapperTest.java +++ b/spicedb-java/lib/src/test/java/com/authzed/spicedb/ErrorMapperTest.java @@ -1,102 +1,100 @@ package com.authzed.spicedb; -import com.authzed.spicedb.errors.*; +import static org.junit.jupiter.api.Assertions.*; +import com.authzed.spicedb.errors.*; import io.grpc.Status; import io.grpc.StatusRuntimeException; - import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - class ErrorMapperTest { - @Test - void permissionDeniedMapsCorrectly() { - StatusRuntimeException e = new StatusRuntimeException( - Status.PERMISSION_DENIED.withDescription("forbidden")); - SpiceDBException mapped = ErrorMapper.toSpiceDBException(e); - assertInstanceOf(PermissionDeniedException.class, mapped); - assertEquals("forbidden", mapped.getMessage()); - } - - @Test - void notFoundMapsCorrectly() { - StatusRuntimeException e = new StatusRuntimeException( - Status.NOT_FOUND.withDescription("not found")); - SpiceDBException mapped = ErrorMapper.toSpiceDBException(e); - assertInstanceOf(NotFoundException.class, mapped); - } - - @Test - void alreadyExistsMapsCorrectly() { - StatusRuntimeException e = new StatusRuntimeException( - Status.ALREADY_EXISTS.withDescription("already exists")); - SpiceDBException mapped = ErrorMapper.toSpiceDBException(e); - assertInstanceOf(AlreadyExistsException.class, mapped); - } - - @Test - void invalidArgumentMapsCorrectly() { - StatusRuntimeException e = new StatusRuntimeException( - Status.INVALID_ARGUMENT.withDescription("bad arg")); - SpiceDBException mapped = ErrorMapper.toSpiceDBException(e); - assertInstanceOf(InvalidArgumentException.class, mapped); - } - - @Test - void unknownCodeMapsToBaseException() { - StatusRuntimeException e = new StatusRuntimeException( - Status.INTERNAL.withDescription("internal error")); - SpiceDBException mapped = ErrorMapper.toSpiceDBException(e); - assertInstanceOf(SpiceDBException.class, mapped); - assertFalse(mapped instanceof PermissionDeniedException); - assertFalse(mapped instanceof NotFoundException); - } - - @Test - void nullDescriptionFallsBackToMessage() { - StatusRuntimeException e = new StatusRuntimeException(Status.NOT_FOUND); - SpiceDBException mapped = ErrorMapper.toSpiceDBException(e); - assertInstanceOf(NotFoundException.class, mapped); - assertNotNull(mapped.getMessage()); - } - - @Test - void isTransientForUnavailable() { - StatusRuntimeException e = new StatusRuntimeException(Status.UNAVAILABLE); - assertTrue(ErrorMapper.isTransient(e)); - } - - @Test - void isTransientForResourceExhausted() { - StatusRuntimeException e = new StatusRuntimeException(Status.RESOURCE_EXHAUSTED); - assertTrue(ErrorMapper.isTransient(e)); - } - - @Test - void isTransientForAborted() { - StatusRuntimeException e = new StatusRuntimeException(Status.ABORTED); - assertTrue(ErrorMapper.isTransient(e)); - } - - @Test - void isNotTransientForPermissionDenied() { - StatusRuntimeException e = new StatusRuntimeException(Status.PERMISSION_DENIED); - assertFalse(ErrorMapper.isTransient(e)); - } - - @Test - void isNotTransientForNotFound() { - StatusRuntimeException e = new StatusRuntimeException(Status.NOT_FOUND); - assertFalse(ErrorMapper.isTransient(e)); - } - - @Test - void causeIsPreserved() { - StatusRuntimeException e = new StatusRuntimeException( - Status.PERMISSION_DENIED.withDescription("forbidden")); - SpiceDBException mapped = ErrorMapper.toSpiceDBException(e); - assertSame(e, mapped.getCause()); - } + @Test + void permissionDeniedMapsCorrectly() { + StatusRuntimeException e = + new StatusRuntimeException(Status.PERMISSION_DENIED.withDescription("forbidden")); + SpiceDBException mapped = ErrorMapper.toSpiceDBException(e); + assertInstanceOf(PermissionDeniedException.class, mapped); + assertEquals("forbidden", mapped.getMessage()); + } + + @Test + void notFoundMapsCorrectly() { + StatusRuntimeException e = + new StatusRuntimeException(Status.NOT_FOUND.withDescription("not found")); + SpiceDBException mapped = ErrorMapper.toSpiceDBException(e); + assertInstanceOf(NotFoundException.class, mapped); + } + + @Test + void alreadyExistsMapsCorrectly() { + StatusRuntimeException e = + new StatusRuntimeException(Status.ALREADY_EXISTS.withDescription("already exists")); + SpiceDBException mapped = ErrorMapper.toSpiceDBException(e); + assertInstanceOf(AlreadyExistsException.class, mapped); + } + + @Test + void invalidArgumentMapsCorrectly() { + StatusRuntimeException e = + new StatusRuntimeException(Status.INVALID_ARGUMENT.withDescription("bad arg")); + SpiceDBException mapped = ErrorMapper.toSpiceDBException(e); + assertInstanceOf(InvalidArgumentException.class, mapped); + } + + @Test + void unknownCodeMapsToBaseException() { + StatusRuntimeException e = + new StatusRuntimeException(Status.INTERNAL.withDescription("internal error")); + SpiceDBException mapped = ErrorMapper.toSpiceDBException(e); + assertInstanceOf(SpiceDBException.class, mapped); + assertFalse(mapped instanceof PermissionDeniedException); + assertFalse(mapped instanceof NotFoundException); + } + + @Test + void nullDescriptionFallsBackToMessage() { + StatusRuntimeException e = new StatusRuntimeException(Status.NOT_FOUND); + SpiceDBException mapped = ErrorMapper.toSpiceDBException(e); + assertInstanceOf(NotFoundException.class, mapped); + assertNotNull(mapped.getMessage()); + } + + @Test + void isTransientForUnavailable() { + StatusRuntimeException e = new StatusRuntimeException(Status.UNAVAILABLE); + assertTrue(ErrorMapper.isTransient(e)); + } + + @Test + void isTransientForResourceExhausted() { + StatusRuntimeException e = new StatusRuntimeException(Status.RESOURCE_EXHAUSTED); + assertTrue(ErrorMapper.isTransient(e)); + } + + @Test + void isTransientForAborted() { + StatusRuntimeException e = new StatusRuntimeException(Status.ABORTED); + assertTrue(ErrorMapper.isTransient(e)); + } + + @Test + void isNotTransientForPermissionDenied() { + StatusRuntimeException e = new StatusRuntimeException(Status.PERMISSION_DENIED); + assertFalse(ErrorMapper.isTransient(e)); + } + + @Test + void isNotTransientForNotFound() { + StatusRuntimeException e = new StatusRuntimeException(Status.NOT_FOUND); + assertFalse(ErrorMapper.isTransient(e)); + } + + @Test + void causeIsPreserved() { + StatusRuntimeException e = + new StatusRuntimeException(Status.PERMISSION_DENIED.withDescription("forbidden")); + SpiceDBException mapped = ErrorMapper.toSpiceDBException(e); + assertSame(e, mapped.getCause()); + } } diff --git a/spicedb-java/lib/src/test/java/com/authzed/spicedb/FilterTest.java b/spicedb-java/lib/src/test/java/com/authzed/spicedb/FilterTest.java index 2b704db..7f0c288 100644 --- a/spicedb-java/lib/src/test/java/com/authzed/spicedb/FilterTest.java +++ b/spicedb-java/lib/src/test/java/com/authzed/spicedb/FilterTest.java @@ -1,96 +1,97 @@ package com.authzed.spicedb; -import org.junit.jupiter.api.Test; - import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; + class FilterTest { - @Test - void ofCreatesFilter() { - Filter f = Filter.of("document"); - assertEquals("document", f.resourceType()); - assertEquals("", f.resourceID()); - assertEquals("", f.relation()); - assertEquals("", f.subjectType()); - assertEquals("", f.subjectID()); - assertEquals("", f.subjectRelation()); - assertEquals("", f.resourceIDPrefix()); - } - - @Test - void ofRejectsNull() { - assertThrows(NullPointerException.class, () -> Filter.of(null)); - } - - @Test - void ofRejectsEmpty() { - assertThrows(IllegalArgumentException.class, () -> Filter.of("")); - } - - @Test - void withResourceIDReturnsNewFilter() { - Filter original = Filter.of("document"); - Filter withID = original.withResourceID("doc1"); - assertEquals("", original.resourceID()); - assertEquals("doc1", withID.resourceID()); - assertEquals("document", withID.resourceType()); - } - - @Test - void withResourceIDPrefixReturnsNewFilter() { - Filter f = Filter.of("document").withResourceIDPrefix("doc"); - assertEquals("doc", f.resourceIDPrefix()); - } - - @Test - void withRelationReturnsNewFilter() { - Filter f = Filter.of("document").withRelation("viewer"); - assertEquals("viewer", f.relation()); - } - - @Test - void withSubjectTypeReturnsNewFilter() { - Filter f = Filter.of("document").withSubjectType("user"); - assertEquals("user", f.subjectType()); - } - - @Test - void withSubjectIDReturnsNewFilter() { - Filter f = Filter.of("document").withSubjectType("user").withSubjectID("alice"); - assertEquals("alice", f.subjectID()); - } - - @Test - void withSubjectRelationReturnsNewFilter() { - Filter f = Filter.of("document").withSubjectType("group").withSubjectRelation("member"); - assertEquals("member", f.subjectRelation()); - } - - @Test - void chainingPreservesAllFields() { - Filter f = Filter.of("document") + @Test + void ofCreatesFilter() { + Filter f = Filter.of("document"); + assertEquals("document", f.resourceType()); + assertEquals("", f.resourceID()); + assertEquals("", f.relation()); + assertEquals("", f.subjectType()); + assertEquals("", f.subjectID()); + assertEquals("", f.subjectRelation()); + assertEquals("", f.resourceIDPrefix()); + } + + @Test + void ofRejectsNull() { + assertThrows(NullPointerException.class, () -> Filter.of(null)); + } + + @Test + void ofRejectsEmpty() { + assertThrows(IllegalArgumentException.class, () -> Filter.of("")); + } + + @Test + void withResourceIDReturnsNewFilter() { + Filter original = Filter.of("document"); + Filter withID = original.withResourceID("doc1"); + assertEquals("", original.resourceID()); + assertEquals("doc1", withID.resourceID()); + assertEquals("document", withID.resourceType()); + } + + @Test + void withResourceIDPrefixReturnsNewFilter() { + Filter f = Filter.of("document").withResourceIDPrefix("doc"); + assertEquals("doc", f.resourceIDPrefix()); + } + + @Test + void withRelationReturnsNewFilter() { + Filter f = Filter.of("document").withRelation("viewer"); + assertEquals("viewer", f.relation()); + } + + @Test + void withSubjectTypeReturnsNewFilter() { + Filter f = Filter.of("document").withSubjectType("user"); + assertEquals("user", f.subjectType()); + } + + @Test + void withSubjectIDReturnsNewFilter() { + Filter f = Filter.of("document").withSubjectType("user").withSubjectID("alice"); + assertEquals("alice", f.subjectID()); + } + + @Test + void withSubjectRelationReturnsNewFilter() { + Filter f = Filter.of("document").withSubjectType("group").withSubjectRelation("member"); + assertEquals("member", f.subjectRelation()); + } + + @Test + void chainingPreservesAllFields() { + Filter f = + Filter.of("document") .withResourceID("doc1") .withRelation("viewer") .withSubjectType("user") .withSubjectID("alice") .withSubjectRelation("member"); - assertEquals("document", f.resourceType()); - assertEquals("doc1", f.resourceID()); - assertEquals("viewer", f.relation()); - assertEquals("user", f.subjectType()); - assertEquals("alice", f.subjectID()); - assertEquals("member", f.subjectRelation()); - } - - @Test - void immutabilityPreserved() { - Filter base = Filter.of("document").withRelation("viewer"); - Filter narrowed = base.withSubjectType("user"); - - assertEquals("", base.subjectType()); - assertEquals("user", narrowed.subjectType()); - assertEquals("viewer", narrowed.relation()); - } + assertEquals("document", f.resourceType()); + assertEquals("doc1", f.resourceID()); + assertEquals("viewer", f.relation()); + assertEquals("user", f.subjectType()); + assertEquals("alice", f.subjectID()); + assertEquals("member", f.subjectRelation()); + } + + @Test + void immutabilityPreserved() { + Filter base = Filter.of("document").withRelation("viewer"); + Filter narrowed = base.withSubjectType("user"); + + assertEquals("", base.subjectType()); + assertEquals("user", narrowed.subjectType()); + assertEquals("viewer", narrowed.relation()); + } } diff --git a/spicedb-java/lib/src/test/java/com/authzed/spicedb/RelationshipTest.java b/spicedb-java/lib/src/test/java/com/authzed/spicedb/RelationshipTest.java index b4f5095..9dcbc10 100644 --- a/spicedb-java/lib/src/test/java/com/authzed/spicedb/RelationshipTest.java +++ b/spicedb-java/lib/src/test/java/com/authzed/spicedb/RelationshipTest.java @@ -1,146 +1,149 @@ package com.authzed.spicedb; -import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; import java.time.Instant; import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; class RelationshipTest { - @Test - void ofCreatesRelationship() { - Relationship r = Relationship.of("document", "doc1", "viewer", "user", "alice", ""); - assertEquals("document", r.resourceType()); - assertEquals("doc1", r.resourceID()); - assertEquals("viewer", r.resourceRelation()); - assertEquals("user", r.subjectType()); - assertEquals("alice", r.subjectID()); - assertEquals("", r.subjectRelation()); - assertNull(r.caveatName()); - assertNull(r.caveatContext()); - assertNull(r.expiration()); - } - - @Test - void ofWithoutSubjectRelation() { - Relationship r = Relationship.of("document", "doc1", "viewer", "user", "alice"); - assertEquals("", r.subjectRelation()); - } - - @Test - void ofRejectsEmptyResourceType() { - assertThrows(IllegalArgumentException.class, - () -> Relationship.of("", "doc1", "viewer", "user", "alice")); - } - - @Test - void ofRejectsNullResourceID() { - assertThrows(IllegalArgumentException.class, - () -> Relationship.of("document", null, "viewer", "user", "alice")); - } - - @Test - void ofRejectsEmptySubjectType() { - assertThrows(IllegalArgumentException.class, - () -> Relationship.of("document", "doc1", "viewer", "", "alice")); - } - - @Test - void ofRejectsNullSubjectID() { - assertThrows(IllegalArgumentException.class, - () -> Relationship.of("document", "doc1", "viewer", "user", null)); - } - - @Test - void fromTupleBasic() { - Relationship r = Relationship.fromTuple("document:doc1#viewer@user:alice"); - assertEquals("document", r.resourceType()); - assertEquals("doc1", r.resourceID()); - assertEquals("viewer", r.resourceRelation()); - assertEquals("user", r.subjectType()); - assertEquals("alice", r.subjectID()); - assertEquals("", r.subjectRelation()); - } - - @Test - void fromTupleWithSubjectRelation() { - Relationship r = Relationship.fromTuple("document:doc1#viewer@group:eng#member"); - assertEquals("group", r.subjectType()); - assertEquals("eng", r.subjectID()); - assertEquals("member", r.subjectRelation()); - } - - @Test - void fromTupleRejectsMissingAt() { - assertThrows(IllegalArgumentException.class, - () -> Relationship.fromTuple("document:doc1#viewer")); - } - - @Test - void fromTupleRejectsMissingHash() { - assertThrows(IllegalArgumentException.class, - () -> Relationship.fromTuple("document:doc1@user:alice")); - } - - @Test - void toStringBasic() { - Relationship r = Relationship.of("document", "doc1", "viewer", "user", "alice"); - assertEquals("document:doc1#viewer@user:alice", r.toString()); - } - - @Test - void toStringWithSubjectRelation() { - Relationship r = Relationship.of("document", "doc1", "viewer", "group", "eng", "member"); - assertEquals("document:doc1#viewer@group:eng#member", r.toString()); - } - - @Test - void withCaveatReturnsNewInstance() { - Relationship original = Relationship.of("document", "doc1", "viewer", "user", "alice"); - Map ctx = Map.of("allowed", true); - Relationship withCaveat = original.withCaveat("is_allowed", ctx); - - assertNull(original.caveatName()); - assertEquals("is_allowed", withCaveat.caveatName()); - assertEquals(ctx, withCaveat.caveatContext()); - // Original fields preserved - assertEquals("document", withCaveat.resourceType()); - } - - @Test - void withExpirationReturnsNewInstance() { - Relationship original = Relationship.of("document", "doc1", "viewer", "user", "alice"); - Instant exp = Instant.parse("2026-12-31T23:59:59Z"); - Relationship withExp = original.withExpiration(exp); - - assertNull(original.expiration()); - assertEquals(exp, withExp.expiration()); - } - - @Test - void toFilterCreatesMatchingFilter() { - Relationship r = Relationship.of("document", "doc1", "viewer", "user", "alice"); - Filter f = r.toFilter(); - assertEquals("document", f.resourceType()); - assertEquals("doc1", f.resourceID()); - assertEquals("viewer", f.relation()); - assertEquals("user", f.subjectType()); - assertEquals("alice", f.subjectID()); - } - - @Test - void roundTripTupleString() { - String tuple = "document:doc1#viewer@user:alice"; - Relationship r = Relationship.fromTuple(tuple); - assertEquals(tuple, r.toString()); - } - - @Test - void roundTripTupleStringWithSubjectRelation() { - String tuple = "document:doc1#viewer@group:eng#member"; - Relationship r = Relationship.fromTuple(tuple); - assertEquals(tuple, r.toString()); - } + @Test + void ofCreatesRelationship() { + Relationship r = Relationship.of("document", "doc1", "viewer", "user", "alice", ""); + assertEquals("document", r.resourceType()); + assertEquals("doc1", r.resourceID()); + assertEquals("viewer", r.resourceRelation()); + assertEquals("user", r.subjectType()); + assertEquals("alice", r.subjectID()); + assertEquals("", r.subjectRelation()); + assertNull(r.caveatName()); + assertNull(r.caveatContext()); + assertNull(r.expiration()); + } + + @Test + void ofWithoutSubjectRelation() { + Relationship r = Relationship.of("document", "doc1", "viewer", "user", "alice"); + assertEquals("", r.subjectRelation()); + } + + @Test + void ofRejectsEmptyResourceType() { + assertThrows( + IllegalArgumentException.class, + () -> Relationship.of("", "doc1", "viewer", "user", "alice")); + } + + @Test + void ofRejectsNullResourceID() { + assertThrows( + IllegalArgumentException.class, + () -> Relationship.of("document", null, "viewer", "user", "alice")); + } + + @Test + void ofRejectsEmptySubjectType() { + assertThrows( + IllegalArgumentException.class, + () -> Relationship.of("document", "doc1", "viewer", "", "alice")); + } + + @Test + void ofRejectsNullSubjectID() { + assertThrows( + IllegalArgumentException.class, + () -> Relationship.of("document", "doc1", "viewer", "user", null)); + } + + @Test + void fromTupleBasic() { + Relationship r = Relationship.fromTuple("document:doc1#viewer@user:alice"); + assertEquals("document", r.resourceType()); + assertEquals("doc1", r.resourceID()); + assertEquals("viewer", r.resourceRelation()); + assertEquals("user", r.subjectType()); + assertEquals("alice", r.subjectID()); + assertEquals("", r.subjectRelation()); + } + + @Test + void fromTupleWithSubjectRelation() { + Relationship r = Relationship.fromTuple("document:doc1#viewer@group:eng#member"); + assertEquals("group", r.subjectType()); + assertEquals("eng", r.subjectID()); + assertEquals("member", r.subjectRelation()); + } + + @Test + void fromTupleRejectsMissingAt() { + assertThrows( + IllegalArgumentException.class, () -> Relationship.fromTuple("document:doc1#viewer")); + } + + @Test + void fromTupleRejectsMissingHash() { + assertThrows( + IllegalArgumentException.class, () -> Relationship.fromTuple("document:doc1@user:alice")); + } + + @Test + void toStringBasic() { + Relationship r = Relationship.of("document", "doc1", "viewer", "user", "alice"); + assertEquals("document:doc1#viewer@user:alice", r.toString()); + } + + @Test + void toStringWithSubjectRelation() { + Relationship r = Relationship.of("document", "doc1", "viewer", "group", "eng", "member"); + assertEquals("document:doc1#viewer@group:eng#member", r.toString()); + } + + @Test + void withCaveatReturnsNewInstance() { + Relationship original = Relationship.of("document", "doc1", "viewer", "user", "alice"); + Map ctx = Map.of("allowed", true); + Relationship withCaveat = original.withCaveat("is_allowed", ctx); + + assertNull(original.caveatName()); + assertEquals("is_allowed", withCaveat.caveatName()); + assertEquals(ctx, withCaveat.caveatContext()); + // Original fields preserved + assertEquals("document", withCaveat.resourceType()); + } + + @Test + void withExpirationReturnsNewInstance() { + Relationship original = Relationship.of("document", "doc1", "viewer", "user", "alice"); + Instant exp = Instant.parse("2026-12-31T23:59:59Z"); + Relationship withExp = original.withExpiration(exp); + + assertNull(original.expiration()); + assertEquals(exp, withExp.expiration()); + } + + @Test + void toFilterCreatesMatchingFilter() { + Relationship r = Relationship.of("document", "doc1", "viewer", "user", "alice"); + Filter f = r.toFilter(); + assertEquals("document", f.resourceType()); + assertEquals("doc1", f.resourceID()); + assertEquals("viewer", f.relation()); + assertEquals("user", f.subjectType()); + assertEquals("alice", f.subjectID()); + } + + @Test + void roundTripTupleString() { + String tuple = "document:doc1#viewer@user:alice"; + Relationship r = Relationship.fromTuple(tuple); + assertEquals(tuple, r.toString()); + } + + @Test + void roundTripTupleStringWithSubjectRelation() { + String tuple = "document:doc1#viewer@group:eng#member"; + Relationship r = Relationship.fromTuple(tuple); + assertEquals(tuple, r.toString()); + } } diff --git a/spicedb-java/lib/src/test/java/com/authzed/spicedb/SpiceDBClientTest.java b/spicedb-java/lib/src/test/java/com/authzed/spicedb/SpiceDBClientTest.java index a7ca819..890ceb7 100644 --- a/spicedb-java/lib/src/test/java/com/authzed/spicedb/SpiceDBClientTest.java +++ b/spicedb-java/lib/src/test/java/com/authzed/spicedb/SpiceDBClientTest.java @@ -1,87 +1,87 @@ package com.authzed.spicedb; -import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; import java.time.Instant; import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; /** - * Unit tests for SpiceDBClient helper methods (proto conversion, etc.). - * Integration tests require a running SpiceDB instance and belong in - * the examples directory. + * Unit tests for SpiceDBClient helper methods (proto conversion, etc.). Integration tests require a + * running SpiceDB instance and belong in the examples directory. */ class SpiceDBClientTest { - @Test - void toProtoRelationshipBasic() { - Relationship r = Relationship.of("document", "doc1", "viewer", "user", "alice"); - var proto = SpiceDBClient.toProtoRelationship(r); - assertEquals("document", proto.getResource().getObjectType()); - assertEquals("doc1", proto.getResource().getObjectId()); - assertEquals("viewer", proto.getRelation()); - assertEquals("user", proto.getSubject().getObject().getObjectType()); - assertEquals("alice", proto.getSubject().getObject().getObjectId()); - } + @Test + void toProtoRelationshipBasic() { + Relationship r = Relationship.of("document", "doc1", "viewer", "user", "alice"); + var proto = SpiceDBClient.toProtoRelationship(r); + assertEquals("document", proto.getResource().getObjectType()); + assertEquals("doc1", proto.getResource().getObjectId()); + assertEquals("viewer", proto.getRelation()); + assertEquals("user", proto.getSubject().getObject().getObjectType()); + assertEquals("alice", proto.getSubject().getObject().getObjectId()); + } - @Test - void toProtoRelationshipWithSubjectRelation() { - Relationship r = Relationship.of("document", "doc1", "viewer", "group", "eng", "member"); - var proto = SpiceDBClient.toProtoRelationship(r); - assertEquals("member", proto.getSubject().getOptionalRelation()); - } + @Test + void toProtoRelationshipWithSubjectRelation() { + Relationship r = Relationship.of("document", "doc1", "viewer", "group", "eng", "member"); + var proto = SpiceDBClient.toProtoRelationship(r); + assertEquals("member", proto.getSubject().getOptionalRelation()); + } - @Test - void toProtoRelationshipWithCaveat() { - Relationship r = Relationship.of("document", "doc1", "viewer", "user", "alice") + @Test + void toProtoRelationshipWithCaveat() { + Relationship r = + Relationship.of("document", "doc1", "viewer", "user", "alice") .withCaveat("is_allowed", Map.of("allowed", true)); - var proto = SpiceDBClient.toProtoRelationship(r); - assertTrue(proto.hasOptionalCaveat()); - assertEquals("is_allowed", proto.getOptionalCaveat().getCaveatName()); - } + var proto = SpiceDBClient.toProtoRelationship(r); + assertTrue(proto.hasOptionalCaveat()); + assertEquals("is_allowed", proto.getOptionalCaveat().getCaveatName()); + } - @Test - void toProtoRelationshipWithExpiration() { - Instant exp = Instant.parse("2026-12-31T23:59:59Z"); - Relationship r = Relationship.of("document", "doc1", "viewer", "user", "alice") - .withExpiration(exp); - var proto = SpiceDBClient.toProtoRelationship(r); - assertTrue(proto.hasOptionalExpiresAt()); - assertEquals(exp.getEpochSecond(), proto.getOptionalExpiresAt().getSeconds()); - } + @Test + void toProtoRelationshipWithExpiration() { + Instant exp = Instant.parse("2026-12-31T23:59:59Z"); + Relationship r = + Relationship.of("document", "doc1", "viewer", "user", "alice").withExpiration(exp); + var proto = SpiceDBClient.toProtoRelationship(r); + assertTrue(proto.hasOptionalExpiresAt()); + assertEquals(exp.getEpochSecond(), proto.getOptionalExpiresAt().getSeconds()); + } - @Test - void fromProtoRelationshipRoundTrip() { - Relationship original = Relationship.of( - "document", "doc1", "viewer", "user", "alice", "member"); - var proto = SpiceDBClient.toProtoRelationship(original); - Relationship restored = SpiceDBClient.fromProtoRelationship(proto); - assertEquals(original.resourceType(), restored.resourceType()); - assertEquals(original.resourceID(), restored.resourceID()); - assertEquals(original.resourceRelation(), restored.resourceRelation()); - assertEquals(original.subjectType(), restored.subjectType()); - assertEquals(original.subjectID(), restored.subjectID()); - assertEquals(original.subjectRelation(), restored.subjectRelation()); - } + @Test + void fromProtoRelationshipRoundTrip() { + Relationship original = + Relationship.of("document", "doc1", "viewer", "user", "alice", "member"); + var proto = SpiceDBClient.toProtoRelationship(original); + Relationship restored = SpiceDBClient.fromProtoRelationship(proto); + assertEquals(original.resourceType(), restored.resourceType()); + assertEquals(original.resourceID(), restored.resourceID()); + assertEquals(original.resourceRelation(), restored.resourceRelation()); + assertEquals(original.subjectType(), restored.subjectType()); + assertEquals(original.subjectID(), restored.subjectID()); + assertEquals(original.subjectRelation(), restored.subjectRelation()); + } - @Test - void fromProtoRelationshipWithCaveatRoundTrip() { - Relationship original = Relationship.of("document", "doc1", "viewer", "user", "alice") + @Test + void fromProtoRelationshipWithCaveatRoundTrip() { + Relationship original = + Relationship.of("document", "doc1", "viewer", "user", "alice") .withCaveat("test_caveat", Map.of("key", "value")); - var proto = SpiceDBClient.toProtoRelationship(original); - Relationship restored = SpiceDBClient.fromProtoRelationship(proto); - assertEquals("test_caveat", restored.caveatName()); - assertEquals("value", restored.caveatContext().get("key")); - } + var proto = SpiceDBClient.toProtoRelationship(original); + Relationship restored = SpiceDBClient.fromProtoRelationship(proto); + assertEquals("test_caveat", restored.caveatName()); + assertEquals("value", restored.caveatContext().get("key")); + } - @Test - void fromProtoRelationshipWithExpirationRoundTrip() { - Instant exp = Instant.parse("2026-06-15T12:00:00Z"); - Relationship original = Relationship.of("document", "doc1", "viewer", "user", "alice") - .withExpiration(exp); - var proto = SpiceDBClient.toProtoRelationship(original); - Relationship restored = SpiceDBClient.fromProtoRelationship(proto); - assertEquals(exp, restored.expiration()); - } + @Test + void fromProtoRelationshipWithExpirationRoundTrip() { + Instant exp = Instant.parse("2026-06-15T12:00:00Z"); + Relationship original = + Relationship.of("document", "doc1", "viewer", "user", "alice").withExpiration(exp); + var proto = SpiceDBClient.toProtoRelationship(original); + Relationship restored = SpiceDBClient.fromProtoRelationship(proto); + assertEquals(exp, restored.expiration()); + } } diff --git a/spicedb-java/lib/src/test/java/com/authzed/spicedb/TransactionTest.java b/spicedb-java/lib/src/test/java/com/authzed/spicedb/TransactionTest.java index 3a60fb2..ad2f169 100644 --- a/spicedb-java/lib/src/test/java/com/authzed/spicedb/TransactionTest.java +++ b/spicedb-java/lib/src/test/java/com/authzed/spicedb/TransactionTest.java @@ -1,98 +1,102 @@ package com.authzed.spicedb; -import org.junit.jupiter.api.Test; - import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; + class TransactionTest { - private static final Relationship REL = Relationship.of( - "document", "doc1", "viewer", "user", "alice"); - - @Test - void emptyTransactionIsEmpty() { - Transaction txn = new Transaction(); - assertTrue(txn.isEmpty()); - assertTrue(txn.mutations().isEmpty()); - assertTrue(txn.preconditions().isEmpty()); - } - - @Test - void createAddsCreateMutation() { - Transaction txn = new Transaction(); - txn.create(REL); - assertFalse(txn.isEmpty()); - assertEquals(1, txn.mutations().size()); - assertEquals(Transaction.Operation.CREATE, txn.mutations().get(0).operation()); - assertEquals(REL, txn.mutations().get(0).relationship()); - } - - @Test - void touchAddsTouchMutation() { - Transaction txn = new Transaction(); - txn.touch(REL); - assertEquals(Transaction.Operation.TOUCH, txn.mutations().get(0).operation()); - } - - @Test - void deleteAddsDeleteMutation() { - Transaction txn = new Transaction(); - txn.delete(REL); - assertEquals(Transaction.Operation.DELETE, txn.mutations().get(0).operation()); - } - - @Test - void multipleMutationsPreserveOrder() { - Relationship r2 = Relationship.of("document", "doc2", "editor", "user", "bob"); - Transaction txn = new Transaction(); - txn.create(REL); - txn.touch(r2); - txn.delete(REL); - - assertEquals(3, txn.mutations().size()); - assertEquals(Transaction.Operation.CREATE, txn.mutations().get(0).operation()); - assertEquals(Transaction.Operation.TOUCH, txn.mutations().get(1).operation()); - assertEquals(Transaction.Operation.DELETE, txn.mutations().get(2).operation()); - } - - @Test - void mustNotMatchAddsPrecondition() { - Transaction txn = new Transaction(); - Filter f = Filter.of("document").withResourceID("doc1"); - txn.mustNotMatch(f); - - assertEquals(1, txn.preconditions().size()); - assertEquals(Transaction.PreconditionOperation.MUST_NOT_MATCH, - txn.preconditions().get(0).operation()); - assertEquals(f, txn.preconditions().get(0).filter()); - } - - @Test - void mustMatchAddsPrecondition() { - Transaction txn = new Transaction(); - Filter f = Filter.of("document").withResourceID("doc1"); - txn.mustMatch(f); - - assertEquals(1, txn.preconditions().size()); - assertEquals(Transaction.PreconditionOperation.MUST_MATCH, - txn.preconditions().get(0).operation()); - } - - @Test - void mutationsListIsUnmodifiable() { - Transaction txn = new Transaction(); - txn.create(REL); - assertThrows(UnsupportedOperationException.class, - () -> txn.mutations().add(new Transaction.Mutation(Transaction.Operation.TOUCH, REL))); - } - - @Test - void preconditionsListIsUnmodifiable() { - Transaction txn = new Transaction(); - Filter f = Filter.of("document"); - txn.mustNotMatch(f); - assertThrows(UnsupportedOperationException.class, - () -> txn.preconditions().add( - new Transaction.Precondition(Transaction.PreconditionOperation.MUST_MATCH, f))); - } + private static final Relationship REL = + Relationship.of("document", "doc1", "viewer", "user", "alice"); + + @Test + void emptyTransactionIsEmpty() { + Transaction txn = new Transaction(); + assertTrue(txn.isEmpty()); + assertTrue(txn.mutations().isEmpty()); + assertTrue(txn.preconditions().isEmpty()); + } + + @Test + void createAddsCreateMutation() { + Transaction txn = new Transaction(); + txn.create(REL); + assertFalse(txn.isEmpty()); + assertEquals(1, txn.mutations().size()); + assertEquals(Transaction.Operation.CREATE, txn.mutations().get(0).operation()); + assertEquals(REL, txn.mutations().get(0).relationship()); + } + + @Test + void touchAddsTouchMutation() { + Transaction txn = new Transaction(); + txn.touch(REL); + assertEquals(Transaction.Operation.TOUCH, txn.mutations().get(0).operation()); + } + + @Test + void deleteAddsDeleteMutation() { + Transaction txn = new Transaction(); + txn.delete(REL); + assertEquals(Transaction.Operation.DELETE, txn.mutations().get(0).operation()); + } + + @Test + void multipleMutationsPreserveOrder() { + Relationship r2 = Relationship.of("document", "doc2", "editor", "user", "bob"); + Transaction txn = new Transaction(); + txn.create(REL); + txn.touch(r2); + txn.delete(REL); + + assertEquals(3, txn.mutations().size()); + assertEquals(Transaction.Operation.CREATE, txn.mutations().get(0).operation()); + assertEquals(Transaction.Operation.TOUCH, txn.mutations().get(1).operation()); + assertEquals(Transaction.Operation.DELETE, txn.mutations().get(2).operation()); + } + + @Test + void mustNotMatchAddsPrecondition() { + Transaction txn = new Transaction(); + Filter f = Filter.of("document").withResourceID("doc1"); + txn.mustNotMatch(f); + + assertEquals(1, txn.preconditions().size()); + assertEquals( + Transaction.PreconditionOperation.MUST_NOT_MATCH, txn.preconditions().get(0).operation()); + assertEquals(f, txn.preconditions().get(0).filter()); + } + + @Test + void mustMatchAddsPrecondition() { + Transaction txn = new Transaction(); + Filter f = Filter.of("document").withResourceID("doc1"); + txn.mustMatch(f); + + assertEquals(1, txn.preconditions().size()); + assertEquals( + Transaction.PreconditionOperation.MUST_MATCH, txn.preconditions().get(0).operation()); + } + + @Test + void mutationsListIsUnmodifiable() { + Transaction txn = new Transaction(); + txn.create(REL); + assertThrows( + UnsupportedOperationException.class, + () -> txn.mutations().add(new Transaction.Mutation(Transaction.Operation.TOUCH, REL))); + } + + @Test + void preconditionsListIsUnmodifiable() { + Transaction txn = new Transaction(); + Filter f = Filter.of("document"); + txn.mustNotMatch(f); + assertThrows( + UnsupportedOperationException.class, + () -> + txn.preconditions() + .add( + new Transaction.Precondition(Transaction.PreconditionOperation.MUST_MATCH, f))); + } } diff --git a/spicedb-python/Magefile.go b/spicedb-python/Magefile.go index 316608c..ec216cf 100644 --- a/spicedb-python/Magefile.go +++ b/spicedb-python/Magefile.go @@ -72,9 +72,10 @@ func Gen() error { return nil } -// Test runs the Python idiomatic client tests. +// Test runs the Python idiomatic client unit tests (no SpiceDB required). +// Integration tests (examples/) are run separately by IntegrationTest. func Test() error { - return sh.RunV("uv", "run", "pytest", "-v") + return sh.RunV("uv", "run", "pytest", "tests/", "-v") } // Lint runs ruff check on all Python code. diff --git a/spicedb-python/pyproject.toml b/spicedb-python/pyproject.toml index 1a06ea0..afebd61 100644 --- a/spicedb-python/pyproject.toml +++ b/spicedb-python/pyproject.toml @@ -26,7 +26,7 @@ packages = ["spicedb"] [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["tests", "examples"] +testpaths = ["tests"] markers = [ "integration: requires a running SpiceDB instance", ] diff --git a/spicedb-ruby/.rubocop.yml b/spicedb-ruby/.rubocop.yml new file mode 100644 index 0000000..43d2d54 --- /dev/null +++ b/spicedb-ruby/.rubocop.yml @@ -0,0 +1,68 @@ +AllCops: + NewCops: enable + SuggestExtensions: false + TargetRubyVersion: 3.2 + +# Relax metric thresholds for idiomatic client methods that are necessarily long +# (e.g. schema_diff_from_proto dispatches on many proto types). +Metrics/MethodLength: + Max: 50 + +Metrics/BlockLength: + Max: 200 + +Metrics/ModuleLength: + Max: 200 + +Metrics/ClassLength: + Max: 700 + +Metrics/AbcSize: + Max: 80 + +Metrics/CyclomaticComplexity: + Max: 25 + +Metrics/PerceivedComplexity: + Max: 25 + +Metrics/ParameterLists: + Max: 10 + +# The SpiceDB module in errors.rb is intentionally minimal; top-level doc +# belongs in the main README / DESIGN.md rather than in code. +Style/Documentation: + Enabled: false + +# Method parameter 'd' in schema_diff_from_proto is clear from context. +Naming/MethodParameterName: + MinNameLength: 1 + +# check_any/check_all are named for Boolean plurality semantics, not +# predicate convention — adding ? would be misleading. +Naming/PredicateMethod: + Enabled: false + +# Line length limit — 120 is too tight for gRPC one-liners with optional chain. +Layout/LineLength: + Max: 160 + +# Private visibility modifiers on constants are a no-op but harmless style +# choice; disable this warning. +Lint/UselessConstantScoping: + Enabled: false + +# Gemspec development dependencies in gemspec vs Gemfile is a project +# convention — disable the cop that enforces moving them to Gemfile. +Gemspec/DevelopmentDependencies: + Enabled: false + +# rubygems MFA is for published gems; this is a path-only dev dependency. +Gemspec/RequireMFA: + Enabled: false + +# The top-level `def client; @client; end` in examples/spec_helper.rb cannot +# use attr_reader because attr_reader is not available on main:Object in +# Ruby 3.2+ outside a class/module context (raises NoMethodError at load time). +Style/TrivialAccessors: + Enabled: false diff --git a/spicedb-ruby/Gemfile b/spicedb-ruby/Gemfile index c35f97c..6f2f922 100644 --- a/spicedb-ruby/Gemfile +++ b/spicedb-ruby/Gemfile @@ -1,10 +1,12 @@ # frozen_string_literal: true -source "https://rubygems.org" +source 'https://rubygems.org' + +gem 'spicedb-proto', path: '../proto-clients/spicedb-ruby-proto' gemspec group :development, :test do - gem "rspec", "~> 3.13" - gem "rubocop", "~> 1.60" + gem 'rspec', '~> 3.13' + gem 'rubocop', '~> 1.60' end diff --git a/spicedb-ruby/Gemfile.lock b/spicedb-ruby/Gemfile.lock index 662a189..944ccf6 100644 --- a/spicedb-ruby/Gemfile.lock +++ b/spicedb-ruby/Gemfile.lock @@ -1,3 +1,10 @@ +PATH + remote: ../proto-clients/spicedb-ruby-proto + specs: + spicedb-proto (0.1.0) + google-protobuf (~> 4.29) + grpc (~> 1.67) + PATH remote: . specs: @@ -116,9 +123,6 @@ GEM parser (>= 3.3.7.2) prism (~> 1.7) ruby-progressbar (1.13.0) - spicedb-proto (0.1.0) - google-protobuf (~> 4.29) - grpc (~> 1.67) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) @@ -138,6 +142,7 @@ DEPENDENCIES rspec (~> 3.13) rubocop (~> 1.60) spicedb! + spicedb-proto! CHECKSUMS addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485 diff --git a/spicedb-ruby/examples/bulk_operations/bulk_operations_spec.rb b/spicedb-ruby/examples/bulk_operations/bulk_operations_spec.rb index c240a20..13ac958 100644 --- a/spicedb-ruby/examples/bulk_operations/bulk_operations_spec.rb +++ b/spicedb-ruby/examples/bulk_operations/bulk_operations_spec.rb @@ -1,26 +1,26 @@ # frozen_string_literal: true -require_relative "../spec_helper" +require_relative '../spec_helper' -RSpec.describe "BulkOperations" do +RSpec.describe 'BulkOperations' do before do client.write_schema(TEST_SCHEMA) txn = SpiceDB::Transaction.new %w[alice bob charlie].each do |user| - txn.touch(SpiceDB::Relationship.from_triple("document", "report", "viewer", "user", user)) + txn.touch(SpiceDB::Relationship.from_triple('document', 'report', 'viewer', 'user', user)) end @revision = client.write(txn) end - it "bulk checks permissions for multiple relationships" do + it 'bulk checks permissions for multiple relationships' do checks = %w[alice bob charlie].map do |user| - SpiceDB::Relationship.from_triple("document", "report", "viewer", "user", user) + SpiceDB::Relationship.from_triple('document', 'report', 'viewer', 'user', user) end results = client.check_permissions( SpiceDB::Consistency.at_least(@revision), - "view", + 'view', *checks ) @@ -28,38 +28,38 @@ expect(results).to all(be true) end - it "check_all returns true when all subjects have permission" do + it 'check_all returns true when all subjects have permission' do checks = %w[alice bob charlie].map do |user| - SpiceDB::Relationship.from_triple("document", "report", "viewer", "user", user) + SpiceDB::Relationship.from_triple('document', 'report', 'viewer', 'user', user) end result = client.check_all( SpiceDB::Consistency.at_least(@revision), - "view", + 'view', *checks ) expect(result).to be true end - it "check_any returns true when at least one subject has permission" do + it 'check_any returns true when at least one subject has permission' do checks = [ - SpiceDB::Relationship.from_triple("document", "report", "viewer", "user", "alice"), - SpiceDB::Relationship.from_triple("document", "report", "viewer", "user", "nobody") + SpiceDB::Relationship.from_triple('document', 'report', 'viewer', 'user', 'alice'), + SpiceDB::Relationship.from_triple('document', 'report', 'viewer', 'user', 'nobody') ] result = client.check_any( SpiceDB::Consistency.at_least(@revision), - "view", + 'view', *checks ) expect(result).to be true end - it "imports relationships in bulk" do + it 'imports relationships in bulk' do relationships = (1..50).map do |i| - SpiceDB::Relationship.from_triple("document", "bulk-#{i}", "viewer", "user", "alice") + SpiceDB::Relationship.from_triple('document', "bulk-#{i}", 'viewer', 'user', 'alice') end count = client.import_relationships(relationships) @@ -67,10 +67,10 @@ expect(count).to eq(50) end - it "exports relationships in bulk" do + it 'exports relationships in bulk' do relationships = client.export_relationships( SpiceDB::Consistency.at_least(@revision), - SpiceDB::Filter.new(resource_type: "document").with_relation("viewer") + SpiceDB::Filter.new(resource_type: 'document').with_relation('viewer') ).to_a expect(relationships.length).to be >= 3 diff --git a/spicedb-ruby/examples/check_permission/check_permission_spec.rb b/spicedb-ruby/examples/check_permission/check_permission_spec.rb index f484f29..70b1144 100644 --- a/spicedb-ruby/examples/check_permission/check_permission_spec.rb +++ b/spicedb-ruby/examples/check_permission/check_permission_spec.rb @@ -1,46 +1,46 @@ # frozen_string_literal: true -require_relative "../spec_helper" +require_relative '../spec_helper' -RSpec.describe "CheckPermission" do - it "checks a single permission and returns true when granted" do +RSpec.describe 'CheckPermission' do + it 'checks a single permission and returns true when granted' do # Setup: write schema and test data client.write_schema(TEST_SCHEMA) txn = SpiceDB::Transaction.new - txn.touch(SpiceDB::Relationship.from_triple("document", "firstdoc", "viewer", "user", "alice")) + txn.touch(SpiceDB::Relationship.from_triple('document', 'firstdoc', 'viewer', 'user', 'alice')) client.write(txn) # Check permission — alice is a viewer, so she can view - rel = SpiceDB::Relationship.from_triple("document", "firstdoc", "viewer", "user", "alice") - allowed = client.check_permission(SpiceDB::Consistency.full, "view", rel) + rel = SpiceDB::Relationship.from_triple('document', 'firstdoc', 'viewer', 'user', 'alice') + allowed = client.check_permission(SpiceDB::Consistency.full, 'view', rel) expect(allowed).to be true end - it "returns false when permission is not granted" do + it 'returns false when permission is not granted' do client.write_schema(TEST_SCHEMA) txn = SpiceDB::Transaction.new - txn.touch(SpiceDB::Relationship.from_triple("document", "firstdoc", "viewer", "user", "alice")) + txn.touch(SpiceDB::Relationship.from_triple('document', 'firstdoc', 'viewer', 'user', 'alice')) client.write(txn) # alice is only a viewer, she cannot delete - rel = SpiceDB::Relationship.from_triple("document", "firstdoc", "viewer", "user", "alice") - allowed = client.check_permission(SpiceDB::Consistency.full, "delete", rel) + rel = SpiceDB::Relationship.from_triple('document', 'firstdoc', 'viewer', 'user', 'alice') + allowed = client.check_permission(SpiceDB::Consistency.full, 'delete', rel) expect(allowed).to be false end - it "uses at_least consistency with a revision from a write" do + it 'uses at_least consistency with a revision from a write' do client.write_schema(TEST_SCHEMA) txn = SpiceDB::Transaction.new - txn.touch(SpiceDB::Relationship.from_triple("document", "firstdoc", "owner", "user", "alice")) + txn.touch(SpiceDB::Relationship.from_triple('document', 'firstdoc', 'owner', 'user', 'alice')) revision = client.write(txn) - rel = SpiceDB::Relationship.from_triple("document", "firstdoc", "owner", "user", "alice") - allowed = client.check_permission(SpiceDB::Consistency.at_least(revision), "delete", rel) + rel = SpiceDB::Relationship.from_triple('document', 'firstdoc', 'owner', 'user', 'alice') + allowed = client.check_permission(SpiceDB::Consistency.at_least(revision), 'delete', rel) expect(allowed).to be true end diff --git a/spicedb-ruby/examples/lookup_resources/lookup_resources_spec.rb b/spicedb-ruby/examples/lookup_resources/lookup_resources_spec.rb index 24d0434..775b72f 100644 --- a/spicedb-ruby/examples/lookup_resources/lookup_resources_spec.rb +++ b/spicedb-ruby/examples/lookup_resources/lookup_resources_spec.rb @@ -1,50 +1,50 @@ # frozen_string_literal: true -require_relative "../spec_helper" +require_relative '../spec_helper' -RSpec.describe "LookupResources" do - it "finds all resources a subject can access" do +RSpec.describe 'LookupResources' do + it 'finds all resources a subject can access' do client.write_schema(TEST_SCHEMA) txn = SpiceDB::Transaction.new - txn.touch(SpiceDB::Relationship.from_triple("document", "firstdoc", "viewer", "user", "alice")) - txn.touch(SpiceDB::Relationship.from_triple("document", "seconddoc", "editor", "user", "alice")) + txn.touch(SpiceDB::Relationship.from_triple('document', 'firstdoc', 'viewer', 'user', 'alice')) + txn.touch(SpiceDB::Relationship.from_triple('document', 'seconddoc', 'editor', 'user', 'alice')) client.write(txn) # alice can view both documents (viewer implies view, editor implies view) resource_ids = client.lookup_resources( SpiceDB::Consistency.full, - "document", "view", "user", "alice" + 'document', 'view', 'user', 'alice' ).to_a - expect(resource_ids).to include("firstdoc") - expect(resource_ids).to include("seconddoc") + expect(resource_ids).to include('firstdoc') + expect(resource_ids).to include('seconddoc') end - it "returns only resources matching the requested permission" do + it 'returns only resources matching the requested permission' do client.write_schema(TEST_SCHEMA) txn = SpiceDB::Transaction.new - txn.touch(SpiceDB::Relationship.from_triple("document", "firstdoc", "viewer", "user", "alice")) - txn.touch(SpiceDB::Relationship.from_triple("document", "seconddoc", "owner", "user", "alice")) + txn.touch(SpiceDB::Relationship.from_triple('document', 'firstdoc', 'viewer', 'user', 'alice')) + txn.touch(SpiceDB::Relationship.from_triple('document', 'seconddoc', 'owner', 'user', 'alice')) client.write(txn) # Only seconddoc should appear for "delete" (owner implies delete) resource_ids = client.lookup_resources( SpiceDB::Consistency.full, - "document", "delete", "user", "alice" + 'document', 'delete', 'user', 'alice' ).to_a - expect(resource_ids).to include("seconddoc") - expect(resource_ids).not_to include("firstdoc") + expect(resource_ids).to include('seconddoc') + expect(resource_ids).not_to include('firstdoc') end - it "returns empty when subject has no access" do + it 'returns empty when subject has no access' do client.write_schema(TEST_SCHEMA) resource_ids = client.lookup_resources( SpiceDB::Consistency.full, - "document", "view", "user", "nobody" + 'document', 'view', 'user', 'nobody' ).to_a expect(resource_ids).to be_empty diff --git a/spicedb-ruby/examples/lookup_subjects/lookup_subjects_spec.rb b/spicedb-ruby/examples/lookup_subjects/lookup_subjects_spec.rb index 9ba9669..421a8d7 100644 --- a/spicedb-ruby/examples/lookup_subjects/lookup_subjects_spec.rb +++ b/spicedb-ruby/examples/lookup_subjects/lookup_subjects_spec.rb @@ -1,50 +1,50 @@ # frozen_string_literal: true -require_relative "../spec_helper" +require_relative '../spec_helper' -RSpec.describe "LookupSubjects" do - it "finds all subjects with access to a resource" do +RSpec.describe 'LookupSubjects' do + it 'finds all subjects with access to a resource' do client.write_schema(TEST_SCHEMA) txn = SpiceDB::Transaction.new - txn.touch(SpiceDB::Relationship.from_triple("document", "firstdoc", "viewer", "user", "alice")) - txn.touch(SpiceDB::Relationship.from_triple("document", "firstdoc", "editor", "user", "bob")) + txn.touch(SpiceDB::Relationship.from_triple('document', 'firstdoc', 'viewer', 'user', 'alice')) + txn.touch(SpiceDB::Relationship.from_triple('document', 'firstdoc', 'editor', 'user', 'bob')) client.write(txn) # Both alice (viewer) and bob (editor) can view subject_ids = client.lookup_subjects( SpiceDB::Consistency.full, - "document", "firstdoc", "view", "user" + 'document', 'firstdoc', 'view', 'user' ).to_a - expect(subject_ids).to include("alice") - expect(subject_ids).to include("bob") + expect(subject_ids).to include('alice') + expect(subject_ids).to include('bob') end - it "returns only subjects with the specific permission" do + it 'returns only subjects with the specific permission' do client.write_schema(TEST_SCHEMA) txn = SpiceDB::Transaction.new - txn.touch(SpiceDB::Relationship.from_triple("document", "firstdoc", "viewer", "user", "alice")) - txn.touch(SpiceDB::Relationship.from_triple("document", "firstdoc", "owner", "user", "bob")) + txn.touch(SpiceDB::Relationship.from_triple('document', 'firstdoc', 'viewer', 'user', 'alice')) + txn.touch(SpiceDB::Relationship.from_triple('document', 'firstdoc', 'owner', 'user', 'bob')) client.write(txn) # Only bob (owner) can delete subject_ids = client.lookup_subjects( SpiceDB::Consistency.full, - "document", "firstdoc", "delete", "user" + 'document', 'firstdoc', 'delete', 'user' ).to_a - expect(subject_ids).to include("bob") - expect(subject_ids).not_to include("alice") + expect(subject_ids).to include('bob') + expect(subject_ids).not_to include('alice') end - it "returns empty when no subjects have access" do + it 'returns empty when no subjects have access' do client.write_schema(TEST_SCHEMA) subject_ids = client.lookup_subjects( SpiceDB::Consistency.full, - "document", "nonexistent", "view", "user" + 'document', 'nonexistent', 'view', 'user' ).to_a expect(subject_ids).to be_empty diff --git a/spicedb-ruby/examples/read_relationships/read_relationships_spec.rb b/spicedb-ruby/examples/read_relationships/read_relationships_spec.rb index 9b8bd86..52c3f2c 100644 --- a/spicedb-ruby/examples/read_relationships/read_relationships_spec.rb +++ b/spicedb-ruby/examples/read_relationships/read_relationships_spec.rb @@ -1,20 +1,20 @@ # frozen_string_literal: true -require_relative "../spec_helper" +require_relative '../spec_helper' -RSpec.describe "ReadRelationships" do - it "reads relationships matching a filter" do +RSpec.describe 'ReadRelationships' do + it 'reads relationships matching a filter' do client.write_schema(TEST_SCHEMA) txn = SpiceDB::Transaction.new - txn.touch(SpiceDB::Relationship.from_triple("document", "firstdoc", "viewer", "user", "alice")) - txn.touch(SpiceDB::Relationship.from_triple("document", "firstdoc", "viewer", "user", "bob")) + txn.touch(SpiceDB::Relationship.from_triple('document', 'firstdoc', 'viewer', 'user', 'alice')) + txn.touch(SpiceDB::Relationship.from_triple('document', 'firstdoc', 'viewer', 'user', 'bob')) client.write(txn) # Read all viewer relationships on firstdoc - filter = SpiceDB::Filter.new(resource_type: "document") - .with_resource_id("firstdoc") - .with_relation("viewer") + filter = SpiceDB::Filter.new(resource_type: 'document') + .with_resource_id('firstdoc') + .with_relation('viewer') relationships = client.read_relationships(SpiceDB::Consistency.full, filter).to_a @@ -25,33 +25,33 @@ expect(subject_ids).to eq(%w[alice bob]) end - it "returns an empty enumerator when no relationships match" do + it 'returns an empty enumerator when no relationships match' do client.write_schema(TEST_SCHEMA) - filter = SpiceDB::Filter.new(resource_type: "document") - .with_resource_id("nonexistent") - .with_relation("viewer") + filter = SpiceDB::Filter.new(resource_type: 'document') + .with_resource_id('nonexistent') + .with_relation('viewer') relationships = client.read_relationships(SpiceDB::Consistency.full, filter).to_a expect(relationships).to be_empty end - it "supports filtering by subject type" do + it 'supports filtering by subject type' do client.write_schema(TEST_SCHEMA) txn = SpiceDB::Transaction.new - txn.touch(SpiceDB::Relationship.from_triple("document", "firstdoc", "viewer", "user", "alice")) + txn.touch(SpiceDB::Relationship.from_triple('document', 'firstdoc', 'viewer', 'user', 'alice')) client.write(txn) - filter = SpiceDB::Filter.new(resource_type: "document") - .with_resource_id("firstdoc") - .with_relation("viewer") - .with_subject_type("user") + filter = SpiceDB::Filter.new(resource_type: 'document') + .with_resource_id('firstdoc') + .with_relation('viewer') + .with_subject_type('user') relationships = client.read_relationships(SpiceDB::Consistency.full, filter).to_a expect(relationships.length).to eq(1) - expect(relationships.first.subject_id).to eq("alice") + expect(relationships.first.subject_id).to eq('alice') end end diff --git a/spicedb-ruby/examples/relationship_counters/relationship_counters_spec.rb b/spicedb-ruby/examples/relationship_counters/relationship_counters_spec.rb index 35c6454..fee4b23 100644 --- a/spicedb-ruby/examples/relationship_counters/relationship_counters_spec.rb +++ b/spicedb-ruby/examples/relationship_counters/relationship_counters_spec.rb @@ -1,20 +1,20 @@ # frozen_string_literal: true -require_relative "../spec_helper" +require_relative '../spec_helper' -RSpec.describe "RelationshipCounters" do +RSpec.describe 'RelationshipCounters' do before do client.write_schema(TEST_SCHEMA) txn = SpiceDB::Transaction.new - txn.touch(SpiceDB::Relationship.from_triple("document", "firstdoc", "viewer", "user", "alice")) - txn.touch(SpiceDB::Relationship.from_triple("document", "seconddoc", "viewer", "user", "bob")) + txn.touch(SpiceDB::Relationship.from_triple('document', 'firstdoc', 'viewer', 'user', 'alice')) + txn.touch(SpiceDB::Relationship.from_triple('document', 'seconddoc', 'viewer', 'user', 'bob')) client.write(txn) end - it "registers, reads, and unregisters a relationship counter" do + it 'registers, reads, and unregisters a relationship counter' do counter_name = "document_viewers_#{rand(100_000)}" - filter = SpiceDB::Filter.new(resource_type: "document").with_relation("viewer") + filter = SpiceDB::Filter.new(resource_type: 'document').with_relation('viewer') # Register the counter client.experimental_register_relationship_counter(counter_name, filter) @@ -28,17 +28,15 @@ expect(result).to be_a(SpiceDB::CountResult) expect(result.revision).not_to be_nil - unless result.still_calculating - expect(result.relationship_count).to be >= 2 - end + expect(result.relationship_count).to be >= 2 unless result.still_calculating # Unregister the counter client.experimental_unregister_relationship_counter(counter_name) end - it "returns still_calculating for a freshly registered counter" do + it 'returns still_calculating for a freshly registered counter' do counter_name = "fresh_counter_#{rand(100_000)}" - filter = SpiceDB::Filter.new(resource_type: "document").with_relation("viewer") + filter = SpiceDB::Filter.new(resource_type: 'document').with_relation('viewer') client.experimental_register_relationship_counter(counter_name, filter) diff --git a/spicedb-ruby/examples/schema_management/schema_management_spec.rb b/spicedb-ruby/examples/schema_management/schema_management_spec.rb index f44d35c..793f95c 100644 --- a/spicedb-ruby/examples/schema_management/schema_management_spec.rb +++ b/spicedb-ruby/examples/schema_management/schema_management_spec.rb @@ -1,28 +1,28 @@ # frozen_string_literal: true -require_relative "../spec_helper" +require_relative '../spec_helper' -RSpec.describe "SchemaManagement" do - it "writes a schema and returns a revision" do +RSpec.describe 'SchemaManagement' do + it 'writes a schema and returns a revision' do revision = client.write_schema(TEST_SCHEMA) expect(revision).not_to be_nil expect(revision).not_to be_empty end - it "reads back a previously written schema" do + it 'reads back a previously written schema' do client.write_schema(TEST_SCHEMA) schema_text, revision = client.read_schema expect(revision).not_to be_nil expect(revision).not_to be_empty - expect(schema_text).to include("definition user") - expect(schema_text).to include("definition document") - expect(schema_text).to include("permission view") + expect(schema_text).to include('definition user') + expect(schema_text).to include('definition document') + expect(schema_text).to include('permission view') end - it "overwrites schema with a new version" do + it 'overwrites schema with a new version' do client.write_schema(TEST_SCHEMA) updated_schema = <<~SCHEMA @@ -46,7 +46,7 @@ expect(revision).not_to be_empty schema_text, _rev = client.read_schema - expect(schema_text).to include("relation admin") - expect(schema_text).to include("permission manage") + expect(schema_text).to include('relation admin') + expect(schema_text).to include('permission manage') end end diff --git a/spicedb-ruby/examples/schema_reflection/schema_reflection_spec.rb b/spicedb-ruby/examples/schema_reflection/schema_reflection_spec.rb index 77f7f91..d914429 100644 --- a/spicedb-ruby/examples/schema_reflection/schema_reflection_spec.rb +++ b/spicedb-ruby/examples/schema_reflection/schema_reflection_spec.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true -require_relative "../spec_helper" +require_relative '../spec_helper' -RSpec.describe "SchemaReflection" do +RSpec.describe 'SchemaReflection' do before do client.write_schema(TEST_SCHEMA) end - it "reflects the current schema definitions" do + it 'reflects the current schema definitions' do result = client.reflect_schema(SpiceDB::Consistency.full) expect(result).to be_a(SpiceDB::ReflectSchemaResult) @@ -15,19 +15,19 @@ expect(result.revision).not_to be_nil definition_names = result.definitions.map(&:name) - expect(definition_names).to include("document") - expect(definition_names).to include("user") + expect(definition_names).to include('document') + expect(definition_names).to include('user') - doc_def = result.definitions.find { |d| d.name == "document" } + doc_def = result.definitions.find { |d| d.name == 'document' } expect(doc_def.relations).not_to be_empty expect(doc_def.permissions).not_to be_empty end - it "finds computable permissions for a relation" do + it 'finds computable permissions for a relation' do permissions, revision = client.computable_permissions( SpiceDB::Consistency.full, - "document", - "viewer" + 'document', + 'viewer' ) expect(revision).not_to be_nil @@ -35,14 +35,14 @@ # The "viewer" relation should contribute to the "view" permission permission_names = permissions.map(&:relation_name) - expect(permission_names).to include("view") + expect(permission_names).to include('view') end - it "finds dependent relations for a permission" do + it 'finds dependent relations for a permission' do relations, revision = client.dependent_relations( SpiceDB::Consistency.full, - "document", - "view" + 'document', + 'view' ) expect(revision).not_to be_nil @@ -50,12 +50,12 @@ # The "view" permission depends on viewer, editor, and owner relation_names = relations.map(&:relation_name) - expect(relation_names).to include("viewer") - expect(relation_names).to include("editor") - expect(relation_names).to include("owner") + expect(relation_names).to include('viewer') + expect(relation_names).to include('editor') + expect(relation_names).to include('owner') end - it "diffs the current schema against a modified schema" do + it 'diffs the current schema against a modified schema' do new_schema = <<~SCHEMA definition user {} diff --git a/spicedb-ruby/examples/spec_helper.rb b/spicedb-ruby/examples/spec_helper.rb index 98194aa..4680ea8 100644 --- a/spicedb-ruby/examples/spec_helper.rb +++ b/spicedb-ruby/examples/spec_helper.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -require "spicedb" +require 'spicedb' -SPICEDB_ENDPOINT = ENV.fetch("SPICEDB_ENDPOINT", "localhost:50051") -SPICEDB_TOKEN = ENV.fetch("SPICEDB_TOKEN", "somerandomkeyhere") +SPICEDB_ENDPOINT = ENV.fetch('SPICEDB_ENDPOINT', 'localhost:50051') +SPICEDB_TOKEN = ENV.fetch('SPICEDB_TOKEN', 'somerandomkeyhere') TEST_SCHEMA = <<~SCHEMA definition user {} @@ -28,13 +28,15 @@ # Write the standard schema (idempotent) client.write_schema(TEST_SCHEMA) # Delete all existing relationships for test isolation - client.delete_relationships(SpiceDB::Filter.new(resource_type: "document")) + client.delete_relationships(SpiceDB::Filter.new(resource_type: 'document')) example.run end end end # Helper to access the client inside examples. +# attr_reader is not available at the top level in Ruby 3.2+ strict mode, +# so define an explicit method instead. def client @client end diff --git a/spicedb-ruby/examples/write_relationships/write_relationships_spec.rb b/spicedb-ruby/examples/write_relationships/write_relationships_spec.rb index eec9ae0..9176f45 100644 --- a/spicedb-ruby/examples/write_relationships/write_relationships_spec.rb +++ b/spicedb-ruby/examples/write_relationships/write_relationships_spec.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true -require_relative "../spec_helper" +require_relative '../spec_helper' -RSpec.describe "WriteRelationships" do - it "writes relationships with touch and returns a revision" do +RSpec.describe 'WriteRelationships' do + it 'writes relationships with touch and returns a revision' do client.write_schema(TEST_SCHEMA) txn = SpiceDB::Transaction.new - txn.touch(SpiceDB::Relationship.from_triple("document", "firstdoc", "viewer", "user", "alice")) - txn.touch(SpiceDB::Relationship.from_triple("document", "firstdoc", "editor", "user", "bob")) + txn.touch(SpiceDB::Relationship.from_triple('document', 'firstdoc', 'viewer', 'user', 'alice')) + txn.touch(SpiceDB::Relationship.from_triple('document', 'firstdoc', 'editor', 'user', 'bob')) revision = client.write(txn) @@ -16,11 +16,11 @@ expect(revision).not_to be_empty end - it "writes relationships with create operation" do + it 'writes relationships with create operation' do client.write_schema(TEST_SCHEMA) txn = SpiceDB::Transaction.new - txn.create(SpiceDB::Relationship.from_triple("document", "newdoc", "owner", "user", "charlie")) + txn.create(SpiceDB::Relationship.from_triple('document', 'newdoc', 'owner', 'user', 'charlie')) revision = client.write(txn) @@ -28,18 +28,18 @@ expect(revision).not_to be_empty end - it "supports preconditions with must_not_match" do + it 'supports preconditions with must_not_match' do client.write_schema(TEST_SCHEMA) # Ensure mallory is not already an owner before writing - precondition_filter = SpiceDB::Filter.new(resource_type: "document") - .with_resource_id("firstdoc") - .with_relation("owner") - .with_subject_type("user") - .with_subject_id("mallory") + precondition_filter = SpiceDB::Filter.new(resource_type: 'document') + .with_resource_id('firstdoc') + .with_relation('owner') + .with_subject_type('user') + .with_subject_id('mallory') txn = SpiceDB::Transaction.new - txn.touch(SpiceDB::Relationship.from_triple("document", "firstdoc", "viewer", "user", "alice")) + txn.touch(SpiceDB::Relationship.from_triple('document', 'firstdoc', 'viewer', 'user', 'alice')) txn.must_not_match(precondition_filter) revision = client.write(txn) @@ -48,17 +48,17 @@ expect(revision).not_to be_empty end - it "supports delete operations" do + it 'supports delete operations' do client.write_schema(TEST_SCHEMA) # First, create a relationship txn = SpiceDB::Transaction.new - txn.touch(SpiceDB::Relationship.from_triple("document", "firstdoc", "viewer", "user", "alice")) + txn.touch(SpiceDB::Relationship.from_triple('document', 'firstdoc', 'viewer', 'user', 'alice')) client.write(txn) # Then, delete it txn = SpiceDB::Transaction.new - txn.delete(SpiceDB::Relationship.from_triple("document", "firstdoc", "viewer", "user", "alice")) + txn.delete(SpiceDB::Relationship.from_triple('document', 'firstdoc', 'viewer', 'user', 'alice')) revision = client.write(txn) diff --git a/spicedb-ruby/lib/spicedb.rb b/spicedb-ruby/lib/spicedb.rb index 581936e..6055974 100644 --- a/spicedb-ruby/lib/spicedb.rb +++ b/spicedb-ruby/lib/spicedb.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true -require_relative "spicedb/consistency" -require_relative "spicedb/relationship" -require_relative "spicedb/filter" -require_relative "spicedb/transaction" -require_relative "spicedb/errors" -require_relative "spicedb/client" +require_relative 'spicedb/consistency' +require_relative 'spicedb/relationship' +require_relative 'spicedb/filter' +require_relative 'spicedb/transaction' +require_relative 'spicedb/errors' +require_relative 'spicedb/client' # SpiceDB is the idiomatic Ruby client for SpiceDB, a database for # fine-grained authorization. module SpiceDB - VERSION = "0.1.0" + VERSION = '0.1.0' end diff --git a/spicedb-ruby/lib/spicedb/client.rb b/spicedb-ruby/lib/spicedb/client.rb index c2ca678..95bb48b 100644 --- a/spicedb-ruby/lib/spicedb/client.rb +++ b/spicedb-ruby/lib/spicedb/client.rb @@ -90,7 +90,7 @@ def initialize(endpoint:, token:, insecure: false) @insecure = insecure begin - require "spicedb_proto" + require 'spicedb_proto' @proto_client = SpiceDBProto::Client.new(endpoint, token, insecure: insecure) rescue LoadError # Proto gem not yet available (e.g. buf hasn't generated stubs). @@ -227,7 +227,8 @@ def lookup_resources(consistency, resource_type, permission, subject_type, subje cursor = nil loop do ids, new_cursor, count = with_retry do - call_lookup_resources(consistency, resource_type, permission, subject_type, subject_id, cursor, DEFAULT_LOOKUP_PAGE_SIZE) + call_lookup_resources(consistency, resource_type, permission, subject_type, subject_id, cursor, + DEFAULT_LOOKUP_PAGE_SIZE) end ids.each { |id| yielder << id } @@ -408,7 +409,7 @@ def experimental_unregister_relationship_counter(name) private # Retries the block with exponential backoff for transient gRPC errors. - def with_retry(max_retries: MAX_RETRIES, &block) + def with_retry(max_retries: MAX_RETRIES) require_proto_client! attempts = 0 begin @@ -443,7 +444,8 @@ def check_item_from_rel(relationship, permission) def require_proto_client! return if @proto_client - raise SpiceDB::Error, "proto client not available — ensure the spicedb_proto gem is installed and buf-generated stubs exist" + raise SpiceDB::Error, + 'proto client not available — ensure the spicedb_proto gem is installed and buf-generated stubs exist' end # Builds an Authzed::Api::V1::Consistency proto from a Strategy. @@ -481,7 +483,7 @@ def relationship_to_proto(rel) object_type: rel.subject_type, object_id: rel.subject_id ), - optional_relation: rel.subject_relation || "" + optional_relation: rel.subject_relation || '' ) args = { @@ -517,8 +519,7 @@ def relationship_to_proto(rel) def relationship_from_proto(proto_rel) caveat_name = nil caveat_context = nil - if proto_rel.respond_to?(:optional_caveat) && proto_rel.optional_caveat && - proto_rel.optional_caveat.caveat_name && !proto_rel.optional_caveat.caveat_name.empty? + if proto_rel.respond_to?(:optional_caveat) && proto_rel.optional_caveat&.caveat_name && !proto_rel.optional_caveat.caveat_name.empty? caveat_name = proto_rel.optional_caveat.caveat_name if proto_rel.optional_caveat.respond_to?(:context) && proto_rel.optional_caveat.context caveat_context = proto_rel.optional_caveat.context.fields.transform_values(&:string_value) @@ -526,17 +527,16 @@ def relationship_from_proto(proto_rel) end expiration = nil - if proto_rel.respond_to?(:optional_expiration) && proto_rel.optional_expiration && - proto_rel.optional_expiration.seconds > 0 + if proto_rel.respond_to?(:optional_expiration) && proto_rel.optional_expiration&.seconds&.positive? expiration = Time.at(proto_rel.optional_expiration.seconds, proto_rel.optional_expiration.nanos, :nsec) end SpiceDB::Relationship.new( resource_type: proto_rel.resource.object_type, - resource_id: proto_rel.resource["object_id"], + resource_id: proto_rel.resource['object_id'], resource_relation: proto_rel.relation, subject_type: proto_rel.subject.object.object_type, - subject_id: proto_rel.subject.object["object_id"], + subject_id: proto_rel.subject.object['object_id'], subject_relation: proto_rel.subject.optional_relation, caveat_name: caveat_name, caveat_context: caveat_context, @@ -572,7 +572,7 @@ def call_bulk_check(consistency, items) object_type: item[:subject_type], object_id: item[:subject_id] ), - optional_relation: item[:subject_relation] || "" + optional_relation: item[:subject_relation] || '' ) ) end @@ -585,9 +585,8 @@ def call_bulk_check(consistency, items) ) resp.pairs.map do |pair| - if pair.respond_to?(:error) && pair.error && pair.error.respond_to?(:message) && !pair.error.message.empty? - raise SpiceDB::Error, pair.error.message - end + raise SpiceDB::Error, pair.error.message if pair.respond_to?(:error) && pair.error && pair.error.respond_to?(:message) && !pair.error.message.empty? + # Ruby protobuf returns enum values as symbols, not integers { has_permission: pair.item.permissionship == :PERMISSIONSHIP_HAS_PERMISSION } end @@ -687,7 +686,7 @@ def call_lookup_resources(consistency, resource_type, permission, subject_type, [ids, new_cursor, count] end - def call_lookup_subjects(consistency, resource_type, resource_id, permission, subject_type, &block) + def call_lookup_subjects(consistency, resource_type, resource_id, permission, subject_type) req = Authzed::Api::V1::LookupSubjectsRequest.new( consistency: build_consistency(consistency), resource: Authzed::Api::V1::ObjectReference.new( @@ -850,12 +849,12 @@ def call_import_relationships(relationships) requests = Enumerator.new do |yielder| relationships.each do |rel| batch << relationship_to_proto(rel) - if batch.size >= DEFAULT_IMPORT_BATCH_SIZE - yielder << Authzed::Api::V1::ImportBulkRelationshipsRequest.new( - relationships: batch - ) - batch = [] - end + next unless batch.size >= DEFAULT_IMPORT_BATCH_SIZE + + yielder << Authzed::Api::V1::ImportBulkRelationshipsRequest.new( + relationships: batch + ) + batch = [] end # Send remaining batch unless batch.empty? @@ -894,12 +893,10 @@ def call_export_relationships(consistency, filter, cursor, page_size) [relationships, new_cursor, count] end - def call_watch(object_types, start_revision, &block) + def call_watch(object_types, start_revision) require_proto_client! req_args = { optional_object_types: object_types } - if start_revision && !start_revision.empty? - req_args[:optional_start_cursor] = Authzed::Api::V1::ZedToken.new(token: start_revision) - end + req_args[:optional_start_cursor] = Authzed::Api::V1::ZedToken.new(token: start_revision) if start_revision && !start_revision.empty? @proto_client.watch.watch( Authzed::Api::V1::WatchRequest.new(**req_args) @@ -938,7 +935,7 @@ def call_count_relationships(name) if resp.counter_still_calculating return CountResult.new( relationship_count: 0, - revision: "", + revision: '', still_calculating: true ) end @@ -965,45 +962,50 @@ def call_unregister_relationship_counter(name) def schema_diff_from_proto(d) # Try each possible diff type in order if (v = d.definition_added) - SchemaDiff.new(kind: "definition_added", definition_name: v.name) + SchemaDiff.new(kind: 'definition_added', definition_name: v.name) elsif (v = d.definition_removed) - SchemaDiff.new(kind: "definition_removed", definition_name: v.name) + SchemaDiff.new(kind: 'definition_removed', definition_name: v.name) elsif (v = d.definition_doc_comment_changed) - SchemaDiff.new(kind: "definition_doc_comment_changed", definition_name: v.name) + SchemaDiff.new(kind: 'definition_doc_comment_changed', definition_name: v.name) elsif (v = d.relation_added) - SchemaDiff.new(kind: "relation_added", definition_name: v.parent_definition_name, relation_name: v.name) + SchemaDiff.new(kind: 'relation_added', definition_name: v.parent_definition_name, relation_name: v.name) elsif (v = d.relation_removed) - SchemaDiff.new(kind: "relation_removed", definition_name: v.parent_definition_name, relation_name: v.name) + SchemaDiff.new(kind: 'relation_removed', definition_name: v.parent_definition_name, relation_name: v.name) elsif (v = d.relation_doc_comment_changed) - SchemaDiff.new(kind: "relation_doc_comment_changed", definition_name: v.parent_definition_name, relation_name: v.name) + SchemaDiff.new(kind: 'relation_doc_comment_changed', definition_name: v.parent_definition_name, + relation_name: v.name) elsif (v = d.relation_subject_type_added) - SchemaDiff.new(kind: "relation_subject_type_added", definition_name: v.relation.parent_definition_name, relation_name: v.relation.name) + SchemaDiff.new(kind: 'relation_subject_type_added', definition_name: v.relation.parent_definition_name, + relation_name: v.relation.name) elsif (v = d.relation_subject_type_removed) - SchemaDiff.new(kind: "relation_subject_type_removed", definition_name: v.relation.parent_definition_name, relation_name: v.relation.name) + SchemaDiff.new(kind: 'relation_subject_type_removed', definition_name: v.relation.parent_definition_name, + relation_name: v.relation.name) elsif (v = d.permission_added) - SchemaDiff.new(kind: "permission_added", definition_name: v.parent_definition_name, permission_name: v.name) + SchemaDiff.new(kind: 'permission_added', definition_name: v.parent_definition_name, permission_name: v.name) elsif (v = d.permission_removed) - SchemaDiff.new(kind: "permission_removed", definition_name: v.parent_definition_name, permission_name: v.name) + SchemaDiff.new(kind: 'permission_removed', definition_name: v.parent_definition_name, permission_name: v.name) elsif (v = d.permission_doc_comment_changed) - SchemaDiff.new(kind: "permission_doc_comment_changed", definition_name: v.parent_definition_name, permission_name: v.name) + SchemaDiff.new(kind: 'permission_doc_comment_changed', definition_name: v.parent_definition_name, + permission_name: v.name) elsif (v = d.permission_expr_changed) - SchemaDiff.new(kind: "permission_expr_changed", definition_name: v.parent_definition_name, permission_name: v.name) + SchemaDiff.new(kind: 'permission_expr_changed', definition_name: v.parent_definition_name, + permission_name: v.name) elsif (v = d.caveat_added) - SchemaDiff.new(kind: "caveat_added", caveat_name: v.name) + SchemaDiff.new(kind: 'caveat_added', caveat_name: v.name) elsif (v = d.caveat_removed) - SchemaDiff.new(kind: "caveat_removed", caveat_name: v.name) + SchemaDiff.new(kind: 'caveat_removed', caveat_name: v.name) elsif (v = d.caveat_doc_comment_changed) - SchemaDiff.new(kind: "caveat_doc_comment_changed", caveat_name: v.name) + SchemaDiff.new(kind: 'caveat_doc_comment_changed', caveat_name: v.name) elsif (v = d.caveat_expr_changed) - SchemaDiff.new(kind: "caveat_expr_changed", caveat_name: v.name) + SchemaDiff.new(kind: 'caveat_expr_changed', caveat_name: v.name) elsif (v = d.caveat_parameter_added) - SchemaDiff.new(kind: "caveat_parameter_added", caveat_name: v.parent_caveat_name) + SchemaDiff.new(kind: 'caveat_parameter_added', caveat_name: v.parent_caveat_name) elsif (v = d.caveat_parameter_removed) - SchemaDiff.new(kind: "caveat_parameter_removed", caveat_name: v.parent_caveat_name) + SchemaDiff.new(kind: 'caveat_parameter_removed', caveat_name: v.parent_caveat_name) elsif (v = d.caveat_parameter_type_changed) - SchemaDiff.new(kind: "caveat_parameter_type_changed", caveat_name: v.parameter.parent_caveat_name) + SchemaDiff.new(kind: 'caveat_parameter_type_changed', caveat_name: v.parameter.parent_caveat_name) else - SchemaDiff.new(kind: "unknown") + SchemaDiff.new(kind: 'unknown') end end end diff --git a/spicedb-ruby/lib/spicedb/errors.rb b/spicedb-ruby/lib/spicedb/errors.rb index 841f9fe..195513c 100644 --- a/spicedb-ruby/lib/spicedb/errors.rb +++ b/spicedb-ruby/lib/spicedb/errors.rb @@ -36,22 +36,22 @@ class ResourceExhaustedError < Error; end # Uses GRPC::Core::StatusCodes constants when the grpc gem is available, # falling back to integer codes. GRPC_CODE_TO_ERROR = { - 1 => CancelledError, # CANCELLED - 3 => InvalidArgumentError, # INVALID_ARGUMENT - 4 => DeadlineExceededError, # DEADLINE_EXCEEDED - 5 => NotFoundError, # NOT_FOUND - 6 => AlreadyExistsError, # ALREADY_EXISTS - 7 => PermissionDeniedError, # PERMISSION_DENIED - 8 => ResourceExhaustedError, # RESOURCE_EXHAUSTED - 9 => FailedPreconditionError, # FAILED_PRECONDITION - 14 => UnavailableError, # UNAVAILABLE + 1 => CancelledError, # CANCELLED + 3 => InvalidArgumentError, # INVALID_ARGUMENT + 4 => DeadlineExceededError, # DEADLINE_EXCEEDED + 5 => NotFoundError, # NOT_FOUND + 6 => AlreadyExistsError, # ALREADY_EXISTS + 7 => PermissionDeniedError, # PERMISSION_DENIED + 8 => ResourceExhaustedError, # RESOURCE_EXHAUSTED + 9 => FailedPreconditionError, # FAILED_PRECONDITION + 14 => UnavailableError # UNAVAILABLE }.freeze # gRPC status codes that are transient and worth retrying. TRANSIENT_CODES = [ 8, # RESOURCE_EXHAUSTED 10, # ABORTED - 14, # UNAVAILABLE + 14 # UNAVAILABLE ].freeze module_function diff --git a/spicedb-ruby/lib/spicedb/filter.rb b/spicedb-ruby/lib/spicedb/filter.rb index 09bb6a4..5f25696 100644 --- a/spicedb-ruby/lib/spicedb/filter.rb +++ b/spicedb-ruby/lib/spicedb/filter.rb @@ -29,15 +29,7 @@ def initialize( subject_id: nil, subject_relation: nil ) - super( - resource_type: resource_type, - resource_id: resource_id, - resource_id_prefix: resource_id_prefix, - relation: relation, - subject_type: subject_type, - subject_id: subject_id, - subject_relation: subject_relation - ) + super end # Narrows the filter to a specific resource ID. diff --git a/spicedb-ruby/lib/spicedb/relationship.rb b/spicedb-ruby/lib/spicedb/relationship.rb index 7db2077..7ef3cc2 100644 --- a/spicedb-ruby/lib/spicedb/relationship.rb +++ b/spicedb-ruby/lib/spicedb/relationship.rb @@ -31,22 +31,12 @@ def initialize( resource_relation:, subject_type:, subject_id:, - subject_relation: "", + subject_relation: '', caveat_name: nil, caveat_context: nil, expiration: nil ) - super( - resource_type: resource_type, - resource_id: resource_id, - resource_relation: resource_relation, - subject_type: subject_type, - subject_id: subject_id, - subject_relation: subject_relation, - caveat_name: caveat_name, - caveat_context: caveat_context, - expiration: expiration - ) + super end # Creates a Relationship from resource and subject triples. @@ -60,16 +50,16 @@ def initialize( # @return [Relationship] # @raise [SpiceDB::InvalidArgumentError] if required fields are empty def self.from_triple(resource_type, resource_id, resource_relation, - subject_type, subject_id, subject_relation = "") + subject_type, subject_id, subject_relation = '') if resource_type.nil? || resource_type.empty? || resource_id.nil? || resource_id.empty? || resource_relation.nil? || resource_relation.empty? - raise SpiceDB::InvalidArgumentError, "resource type, id, and relation are required" + raise SpiceDB::InvalidArgumentError, 'resource type, id, and relation are required' end if subject_type.nil? || subject_type.empty? || subject_id.nil? || subject_id.empty? - raise SpiceDB::InvalidArgumentError, "subject type and id are required" + raise SpiceDB::InvalidArgumentError, 'subject type and id are required' end new( @@ -88,28 +78,22 @@ def self.from_triple(resource_type, resource_id, resource_relation, # @return [Relationship] # @raise [SpiceDB::InvalidArgumentError] if the format is invalid def self.from_tuple(tuple) - parts = tuple.split("@", 2) + parts = tuple.split('@', 2) raise SpiceDB::InvalidArgumentError, "invalid tuple format: missing '@' separator" unless parts.length == 2 resource_part, subject_part = parts - resource_parts = resource_part.split("#", 2) - unless resource_parts.length == 2 - raise SpiceDB::InvalidArgumentError, "invalid tuple format: missing '#' in resource" - end + resource_parts = resource_part.split('#', 2) + raise SpiceDB::InvalidArgumentError, "invalid tuple format: missing '#' in resource" unless resource_parts.length == 2 - resource_type_id = resource_parts[0].split(":", 2) - unless resource_type_id.length == 2 - raise SpiceDB::InvalidArgumentError, "invalid tuple format: missing ':' in resource type:id" - end + resource_type_id = resource_parts[0].split(':', 2) + raise SpiceDB::InvalidArgumentError, "invalid tuple format: missing ':' in resource type:id" unless resource_type_id.length == 2 - subject_hash = subject_part.split("#", 2) - subject_type_id = subject_hash[0].split(":", 2) - unless subject_type_id.length == 2 - raise SpiceDB::InvalidArgumentError, "invalid tuple format: missing ':' in subject type:id" - end + subject_hash = subject_part.split('#', 2) + subject_type_id = subject_hash[0].split(':', 2) + raise SpiceDB::InvalidArgumentError, "invalid tuple format: missing ':' in subject type:id" unless subject_type_id.length == 2 - subject_relation = subject_hash.length == 2 ? subject_hash[1] : "" + subject_relation = subject_hash.length == 2 ? subject_hash[1] : '' from_triple( resource_type_id[0], resource_type_id[1], resource_parts[1], diff --git a/spicedb-ruby/spec/client_spec.rb b/spicedb-ruby/spec/client_spec.rb index 79ab2a9..8c90c04 100644 --- a/spicedb-ruby/spec/client_spec.rb +++ b/spicedb-ruby/spec/client_spec.rb @@ -1,17 +1,17 @@ # frozen_string_literal: true -require_relative "../lib/spicedb" +require_relative '../lib/spicedb' RSpec.describe SpiceDB::Client do - describe ".new_plaintext" do - it "creates a client" do - client = described_class.new_plaintext("localhost:50051", "testtoken") + describe '.new_plaintext' do + it 'creates a client' do + client = described_class.new_plaintext('localhost:50051', 'testtoken') expect(client).to be_a(described_class) end - it "supports block form" do + it 'supports block form' do client_ref = nil - described_class.new_plaintext("localhost:50051", "testtoken") do |client| + described_class.new_plaintext('localhost:50051', 'testtoken') do |client| client_ref = client expect(client).to be_a(described_class) end @@ -19,15 +19,15 @@ end end - describe ".new_system_tls" do - it "creates a client" do - client = described_class.new_system_tls("grpc.example.com:443", "my-token") + describe '.new_system_tls' do + it 'creates a client' do + client = described_class.new_system_tls('grpc.example.com:443', 'my-token') expect(client).to be_a(described_class) end - it "supports block form" do + it 'supports block form' do client_ref = nil - described_class.new_system_tls("grpc.example.com:443", "my-token") do |client| + described_class.new_system_tls('grpc.example.com:443', 'my-token') do |client| client_ref = client expect(client).to be_a(described_class) end @@ -35,8 +35,8 @@ end end - describe "constants" do - it "has sensible page size defaults" do + describe 'constants' do + it 'has sensible page size defaults' do expect(described_class::DEFAULT_READ_PAGE_SIZE).to eq(512) expect(described_class::DEFAULT_LOOKUP_PAGE_SIZE).to eq(512) expect(described_class::DEFAULT_EXPORT_PAGE_SIZE).to eq(512) @@ -46,71 +46,71 @@ end end - describe "result types" do - describe "SpiceDB::Update" do - it "is a Data.define value type" do + describe 'result types' do + describe 'SpiceDB::Update' do + it 'is a Data.define value type' do update = SpiceDB::Update.new( operation: :create, - relationship: SpiceDB::Relationship.from_triple("document", "doc1", "viewer", "user", "alice") + relationship: SpiceDB::Relationship.from_triple('document', 'doc1', 'viewer', 'user', 'alice') ) expect(update.operation).to eq(:create) - expect(update.relationship.subject_id).to eq("alice") + expect(update.relationship.subject_id).to eq('alice') expect(update).to be_frozen end end - describe "SpiceDB::ExpandResult" do - it "is a Data.define value type" do - result = SpiceDB::ExpandResult.new(tree_root: { "type" => "union" }, revision: "rev1") - expect(result.revision).to eq("rev1") + describe 'SpiceDB::ExpandResult' do + it 'is a Data.define value type' do + result = SpiceDB::ExpandResult.new(tree_root: { 'type' => 'union' }, revision: 'rev1') + expect(result.revision).to eq('rev1') expect(result).to be_frozen end end - describe "SpiceDB::CountResult" do - it "is a Data.define value type" do - result = SpiceDB::CountResult.new(relationship_count: 42, revision: "rev1", still_calculating: false) + describe 'SpiceDB::CountResult' do + it 'is a Data.define value type' do + result = SpiceDB::CountResult.new(relationship_count: 42, revision: 'rev1', still_calculating: false) expect(result.relationship_count).to eq(42) expect(result.still_calculating).to be false expect(result).to be_frozen end end - describe "SpiceDB::SchemaDiff" do - it "supports optional fields" do - diff = SpiceDB::SchemaDiff.new(kind: "definition_added", definition_name: "user") - expect(diff.kind).to eq("definition_added") - expect(diff.definition_name).to eq("user") + describe 'SpiceDB::SchemaDiff' do + it 'supports optional fields' do + diff = SpiceDB::SchemaDiff.new(kind: 'definition_added', definition_name: 'user') + expect(diff.kind).to eq('definition_added') + expect(diff.definition_name).to eq('user') expect(diff.relation_name).to be_nil expect(diff.permission_name).to be_nil expect(diff.caveat_name).to be_nil end end - describe "SpiceDB::ReflectSchemaResult" do - it "holds definitions, caveats, and revision" do - result = SpiceDB::ReflectSchemaResult.new(definitions: [], caveats: [], revision: "rev1") + describe 'SpiceDB::ReflectSchemaResult' do + it 'holds definitions, caveats, and revision' do + result = SpiceDB::ReflectSchemaResult.new(definitions: [], caveats: [], revision: 'rev1') expect(result.definitions).to eq([]) expect(result.caveats).to eq([]) - expect(result.revision).to eq("rev1") + expect(result.revision).to eq('rev1') end end - describe "SpiceDB::RelationReference" do - it "holds definition_name, relation_name, and is_permission" do + describe 'SpiceDB::RelationReference' do + it 'holds definition_name, relation_name, and is_permission' do ref = SpiceDB::RelationReference.new( - definition_name: "document", - relation_name: "viewer", + definition_name: 'document', + relation_name: 'viewer', is_permission: true ) - expect(ref.definition_name).to eq("document") + expect(ref.definition_name).to eq('document') expect(ref.is_permission).to be true end end end - describe "method existence" do - let(:client) { described_class.new_plaintext("localhost:50051", "testtoken") } + describe 'method existence' do + let(:client) { described_class.new_plaintext('localhost:50051', 'testtoken') } # Checks it { expect(client).to respond_to(:check_permission) } diff --git a/spicedb-ruby/spec/consistency_spec.rb b/spicedb-ruby/spec/consistency_spec.rb index fc8b353..f5cb926 100644 --- a/spicedb-ruby/spec/consistency_spec.rb +++ b/spicedb-ruby/spec/consistency_spec.rb @@ -1,109 +1,109 @@ # frozen_string_literal: true -require_relative "../lib/spicedb" +require_relative '../lib/spicedb' RSpec.describe SpiceDB::Consistency do - describe ".full" do - it "returns a full consistency strategy" do + describe '.full' do + it 'returns a full consistency strategy' do cs = described_class.full expect(cs.type).to eq(:full) expect(cs.revision).to be_nil end - it "converts to proto with fully_consistent flag" do + it 'converts to proto with fully_consistent flag' do cs = described_class.full expect(cs.to_proto).to eq({ fully_consistent: true }) end end - describe ".min_latency" do - it "returns a min latency strategy" do + describe '.min_latency' do + it 'returns a min latency strategy' do cs = described_class.min_latency expect(cs.type).to eq(:min_latency) expect(cs.revision).to be_nil end - it "converts to proto with minimize_latency flag" do + it 'converts to proto with minimize_latency flag' do cs = described_class.min_latency expect(cs.to_proto).to eq({ minimize_latency: true }) end end - describe ".at_least" do - it "returns an at_least strategy with the given revision" do - cs = described_class.at_least("zedtoken123") + describe '.at_least' do + it 'returns an at_least strategy with the given revision' do + cs = described_class.at_least('zedtoken123') expect(cs.type).to eq(:at_least) - expect(cs.revision).to eq("zedtoken123") + expect(cs.revision).to eq('zedtoken123') end - it "converts to proto with at_least_as_fresh token" do - cs = described_class.at_least("zedtoken123") - expect(cs.to_proto).to eq({ at_least_as_fresh: { token: "zedtoken123" } }) + it 'converts to proto with at_least_as_fresh token' do + cs = described_class.at_least('zedtoken123') + expect(cs.to_proto).to eq({ at_least_as_fresh: { token: 'zedtoken123' } }) end end - describe ".snapshot" do - it "returns a snapshot strategy with the given revision" do - cs = described_class.snapshot("zedtoken456") + describe '.snapshot' do + it 'returns a snapshot strategy with the given revision' do + cs = described_class.snapshot('zedtoken456') expect(cs.type).to eq(:snapshot) - expect(cs.revision).to eq("zedtoken456") + expect(cs.revision).to eq('zedtoken456') end - it "converts to proto with at_exact_snapshot token" do - cs = described_class.snapshot("zedtoken456") - expect(cs.to_proto).to eq({ at_exact_snapshot: { token: "zedtoken456" } }) + it 'converts to proto with at_exact_snapshot token' do + cs = described_class.snapshot('zedtoken456') + expect(cs.to_proto).to eq({ at_exact_snapshot: { token: 'zedtoken456' } }) end end - describe ".at_least_or_full" do - it "returns Full when revision is nil" do + describe '.at_least_or_full' do + it 'returns Full when revision is nil' do cs = described_class.at_least_or_full(nil) expect(cs.type).to eq(:full) end - it "returns Full when revision is empty" do - cs = described_class.at_least_or_full("") + it 'returns Full when revision is empty' do + cs = described_class.at_least_or_full('') expect(cs.type).to eq(:full) end - it "returns AtLeast when revision is present" do - cs = described_class.at_least_or_full("zedtoken789") + it 'returns AtLeast when revision is present' do + cs = described_class.at_least_or_full('zedtoken789') expect(cs.type).to eq(:at_least) - expect(cs.revision).to eq("zedtoken789") + expect(cs.revision).to eq('zedtoken789') end end - describe ".at_least_or_min_latency" do - it "returns MinLatency when revision is nil" do + describe '.at_least_or_min_latency' do + it 'returns MinLatency when revision is nil' do cs = described_class.at_least_or_min_latency(nil) expect(cs.type).to eq(:min_latency) end - it "returns MinLatency when revision is empty" do - cs = described_class.at_least_or_min_latency("") + it 'returns MinLatency when revision is empty' do + cs = described_class.at_least_or_min_latency('') expect(cs.type).to eq(:min_latency) end - it "returns AtLeast when revision is present" do - cs = described_class.at_least_or_min_latency("zedtoken789") + it 'returns AtLeast when revision is present' do + cs = described_class.at_least_or_min_latency('zedtoken789') expect(cs.type).to eq(:at_least) - expect(cs.revision).to eq("zedtoken789") + expect(cs.revision).to eq('zedtoken789') end end - describe "Strategy" do - it "is frozen (immutable)" do + describe 'Strategy' do + it 'is frozen (immutable)' do cs = described_class.full expect(cs).to be_frozen end - it "supports equality based on field values" do + it 'supports equality based on field values' do a = described_class.full b = described_class.full expect(a).to eq(b) end - it "is not equal for different types" do + it 'is not equal for different types' do a = described_class.full b = described_class.min_latency expect(a).not_to eq(b) diff --git a/spicedb-ruby/spec/errors_spec.rb b/spicedb-ruby/spec/errors_spec.rb index a9c3145..6b38359 100644 --- a/spicedb-ruby/spec/errors_spec.rb +++ b/spicedb-ruby/spec/errors_spec.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -require_relative "../lib/spicedb" +require_relative '../lib/spicedb' -RSpec.describe "SpiceDB::Errors" do - describe "exception hierarchy" do - it "all errors inherit from SpiceDB::Error" do +RSpec.describe 'SpiceDB::Errors' do + describe 'exception hierarchy' do + it 'all errors inherit from SpiceDB::Error' do expect(SpiceDB::PermissionDeniedError.superclass).to eq(SpiceDB::Error) expect(SpiceDB::NotFoundError.superclass).to eq(SpiceDB::Error) expect(SpiceDB::AlreadyExistsError.superclass).to eq(SpiceDB::Error) @@ -16,25 +16,25 @@ expect(SpiceDB::ResourceExhaustedError.superclass).to eq(SpiceDB::Error) end - it "SpiceDB::Error inherits from StandardError" do + it 'SpiceDB::Error inherits from StandardError' do expect(SpiceDB::Error.superclass).to eq(StandardError) end - it "errors can be raised and rescued" do - expect { - raise SpiceDB::PermissionDeniedError, "access denied" - }.to raise_error(SpiceDB::Error, "access denied") + it 'errors can be raised and rescued' do + expect do + raise SpiceDB::PermissionDeniedError, 'access denied' + end.to raise_error(SpiceDB::Error, 'access denied') end - it "specific errors can be rescued individually" do - expect { - raise SpiceDB::NotFoundError, "not found" - }.to raise_error(SpiceDB::NotFoundError, "not found") + it 'specific errors can be rescued individually' do + expect do + raise SpiceDB::NotFoundError, 'not found' + end.to raise_error(SpiceDB::NotFoundError, 'not found') end end - describe "GRPC_CODE_TO_ERROR" do - it "maps gRPC status codes to error classes" do + describe 'GRPC_CODE_TO_ERROR' do + it 'maps gRPC status codes to error classes' do expect(SpiceDB::GRPC_CODE_TO_ERROR[1]).to eq(SpiceDB::CancelledError) expect(SpiceDB::GRPC_CODE_TO_ERROR[3]).to eq(SpiceDB::InvalidArgumentError) expect(SpiceDB::GRPC_CODE_TO_ERROR[4]).to eq(SpiceDB::DeadlineExceededError) @@ -46,62 +46,62 @@ expect(SpiceDB::GRPC_CODE_TO_ERROR[14]).to eq(SpiceDB::UnavailableError) end - it "is frozen" do + it 'is frozen' do expect(SpiceDB::GRPC_CODE_TO_ERROR).to be_frozen end end - describe "TRANSIENT_CODES" do - it "includes RESOURCE_EXHAUSTED, ABORTED, and UNAVAILABLE" do + describe 'TRANSIENT_CODES' do + it 'includes RESOURCE_EXHAUSTED, ABORTED, and UNAVAILABLE' do expect(SpiceDB::TRANSIENT_CODES).to include(8) # RESOURCE_EXHAUSTED expect(SpiceDB::TRANSIENT_CODES).to include(10) # ABORTED expect(SpiceDB::TRANSIENT_CODES).to include(14) # UNAVAILABLE end end - describe ".to_spicedb_error" do - it "converts a gRPC-like error to a typed exception" do - grpc_err = double("grpc_error", code: 7, details: "permission denied") + describe '.to_spicedb_error' do + it 'converts a gRPC-like error to a typed exception' do + grpc_err = double('grpc_error', code: 7, details: 'permission denied') err = SpiceDB.to_spicedb_error(grpc_err) expect(err).to be_a(SpiceDB::PermissionDeniedError) - expect(err.message).to eq("permission denied") + expect(err.message).to eq('permission denied') end - it "falls back to SpiceDB::Error for unknown codes" do - grpc_err = double("grpc_error", code: 99, details: "unknown error") + it 'falls back to SpiceDB::Error for unknown codes' do + grpc_err = double('grpc_error', code: 99, details: 'unknown error') err = SpiceDB.to_spicedb_error(grpc_err) expect(err).to be_a(SpiceDB::Error) - expect(err.message).to eq("unknown error") + expect(err.message).to eq('unknown error') end - it "handles errors without code method" do - plain_err = StandardError.new("plain error") + it 'handles errors without code method' do + plain_err = StandardError.new('plain error') err = SpiceDB.to_spicedb_error(plain_err) expect(err).to be_a(SpiceDB::Error) - expect(err.message).to eq("plain error") + expect(err.message).to eq('plain error') end end - describe ".transient?" do - it "returns true for unavailable errors" do + describe '.transient?' do + it 'returns true for unavailable errors' do expect(SpiceDB.transient?(SpiceDB::UnavailableError.new)).to be true end - it "returns true for resource exhausted errors" do + it 'returns true for resource exhausted errors' do expect(SpiceDB.transient?(SpiceDB::ResourceExhaustedError.new)).to be true end - it "returns false for permission denied errors" do + it 'returns false for permission denied errors' do expect(SpiceDB.transient?(SpiceDB::PermissionDeniedError.new)).to be false end - it "returns true for gRPC-like errors with transient codes" do - grpc_err = double("grpc_error", code: 14) + it 'returns true for gRPC-like errors with transient codes' do + grpc_err = double('grpc_error', code: 14) expect(SpiceDB.transient?(grpc_err)).to be true end - it "returns false for gRPC-like errors with non-transient codes" do - grpc_err = double("grpc_error", code: 7) + it 'returns false for gRPC-like errors with non-transient codes' do + grpc_err = double('grpc_error', code: 7) expect(SpiceDB.transient?(grpc_err)).to be false end end diff --git a/spicedb-ruby/spec/filter_spec.rb b/spicedb-ruby/spec/filter_spec.rb index 7469ffc..50e57a4 100644 --- a/spicedb-ruby/spec/filter_spec.rb +++ b/spicedb-ruby/spec/filter_spec.rb @@ -1,100 +1,100 @@ # frozen_string_literal: true -require_relative "../lib/spicedb" +require_relative '../lib/spicedb' RSpec.describe SpiceDB::Filter do - describe ".new" do - it "creates a filter with resource_type" do - f = described_class.new(resource_type: "document") - expect(f.resource_type).to eq("document") + describe '.new' do + it 'creates a filter with resource_type' do + f = described_class.new(resource_type: 'document') + expect(f.resource_type).to eq('document') expect(f.resource_id).to be_nil expect(f.relation).to be_nil expect(f.subject_type).to be_nil end - it "is frozen (immutable)" do - f = described_class.new(resource_type: "document") + it 'is frozen (immutable)' do + f = described_class.new(resource_type: 'document') expect(f).to be_frozen end end - describe "#with_resource_id" do - it "returns a new filter with the resource ID set" do - f = described_class.new(resource_type: "document").with_resource_id("doc1") - expect(f.resource_id).to eq("doc1") - expect(f.resource_type).to eq("document") + describe '#with_resource_id' do + it 'returns a new filter with the resource ID set' do + f = described_class.new(resource_type: 'document').with_resource_id('doc1') + expect(f.resource_id).to eq('doc1') + expect(f.resource_type).to eq('document') end end - describe "#with_resource_id_prefix" do - it "returns a new filter with the resource ID prefix set" do - f = described_class.new(resource_type: "document").with_resource_id_prefix("doc") - expect(f.resource_id_prefix).to eq("doc") + describe '#with_resource_id_prefix' do + it 'returns a new filter with the resource ID prefix set' do + f = described_class.new(resource_type: 'document').with_resource_id_prefix('doc') + expect(f.resource_id_prefix).to eq('doc') end end - describe "#with_relation" do - it "returns a new filter with the relation set" do - f = described_class.new(resource_type: "document").with_relation("viewer") - expect(f.relation).to eq("viewer") + describe '#with_relation' do + it 'returns a new filter with the relation set' do + f = described_class.new(resource_type: 'document').with_relation('viewer') + expect(f.relation).to eq('viewer') end end - describe "#with_subject_type" do - it "returns a new filter with the subject type set" do - f = described_class.new(resource_type: "document").with_subject_type("user") - expect(f.subject_type).to eq("user") + describe '#with_subject_type' do + it 'returns a new filter with the subject type set' do + f = described_class.new(resource_type: 'document').with_subject_type('user') + expect(f.subject_type).to eq('user') end end - describe "#with_subject_id" do - it "returns a new filter with the subject ID set" do - f = described_class.new(resource_type: "document").with_subject_id("alice") - expect(f.subject_id).to eq("alice") + describe '#with_subject_id' do + it 'returns a new filter with the subject ID set' do + f = described_class.new(resource_type: 'document').with_subject_id('alice') + expect(f.subject_id).to eq('alice') end end - describe "#with_subject_relation" do - it "returns a new filter with the subject relation set" do - f = described_class.new(resource_type: "document").with_subject_relation("member") - expect(f.subject_relation).to eq("member") + describe '#with_subject_relation' do + it 'returns a new filter with the subject relation set' do + f = described_class.new(resource_type: 'document').with_subject_relation('member') + expect(f.subject_relation).to eq('member') end end - describe "chained builders" do - it "supports method chaining for complex filters" do - f = described_class.new(resource_type: "document") - .with_resource_id("doc1") - .with_relation("viewer") - .with_subject_type("user") - .with_subject_id("alice") + describe 'chained builders' do + it 'supports method chaining for complex filters' do + f = described_class.new(resource_type: 'document') + .with_resource_id('doc1') + .with_relation('viewer') + .with_subject_type('user') + .with_subject_id('alice') - expect(f.resource_type).to eq("document") - expect(f.resource_id).to eq("doc1") - expect(f.relation).to eq("viewer") - expect(f.subject_type).to eq("user") - expect(f.subject_id).to eq("alice") + expect(f.resource_type).to eq('document') + expect(f.resource_id).to eq('doc1') + expect(f.relation).to eq('viewer') + expect(f.subject_type).to eq('user') + expect(f.subject_id).to eq('alice') end - it "does not mutate previous filters" do - f1 = described_class.new(resource_type: "document") - f2 = f1.with_resource_id("doc1") + it 'does not mutate previous filters' do + f1 = described_class.new(resource_type: 'document') + f2 = f1.with_resource_id('doc1') expect(f1.resource_id).to be_nil - expect(f2.resource_id).to eq("doc1") + expect(f2.resource_id).to eq('doc1') end end - describe "equality" do - it "is equal when all fields match" do - a = described_class.new(resource_type: "document").with_resource_id("doc1") - b = described_class.new(resource_type: "document").with_resource_id("doc1") + describe 'equality' do + it 'is equal when all fields match' do + a = described_class.new(resource_type: 'document').with_resource_id('doc1') + b = described_class.new(resource_type: 'document').with_resource_id('doc1') expect(a).to eq(b) end - it "is not equal when fields differ" do - a = described_class.new(resource_type: "document") - b = described_class.new(resource_type: "folder") + it 'is not equal when fields differ' do + a = described_class.new(resource_type: 'document') + b = described_class.new(resource_type: 'folder') expect(a).not_to eq(b) end end diff --git a/spicedb-ruby/spec/relationship_spec.rb b/spicedb-ruby/spec/relationship_spec.rb index 19abccb..a478a1f 100644 --- a/spicedb-ruby/spec/relationship_spec.rb +++ b/spicedb-ruby/spec/relationship_spec.rb @@ -1,167 +1,167 @@ # frozen_string_literal: true -require_relative "../lib/spicedb" +require_relative '../lib/spicedb' RSpec.describe SpiceDB::Relationship do let(:rel) do described_class.new( - resource_type: "document", - resource_id: "doc1", - resource_relation: "viewer", - subject_type: "user", - subject_id: "alice" + resource_type: 'document', + resource_id: 'doc1', + resource_relation: 'viewer', + subject_type: 'user', + subject_id: 'alice' ) end - describe ".new" do - it "creates a relationship with required fields" do - expect(rel.resource_type).to eq("document") - expect(rel.resource_id).to eq("doc1") - expect(rel.resource_relation).to eq("viewer") - expect(rel.subject_type).to eq("user") - expect(rel.subject_id).to eq("alice") - expect(rel.subject_relation).to eq("") + describe '.new' do + it 'creates a relationship with required fields' do + expect(rel.resource_type).to eq('document') + expect(rel.resource_id).to eq('doc1') + expect(rel.resource_relation).to eq('viewer') + expect(rel.subject_type).to eq('user') + expect(rel.subject_id).to eq('alice') + expect(rel.subject_relation).to eq('') end - it "defaults optional fields to nil" do + it 'defaults optional fields to nil' do expect(rel.caveat_name).to be_nil expect(rel.caveat_context).to be_nil expect(rel.expiration).to be_nil end - it "is frozen (immutable)" do + it 'is frozen (immutable)' do expect(rel).to be_frozen end - it "supports subject_relation" do + it 'supports subject_relation' do r = described_class.new( - resource_type: "document", - resource_id: "doc1", - resource_relation: "viewer", - subject_type: "group", - subject_id: "eng", - subject_relation: "member" + resource_type: 'document', + resource_id: 'doc1', + resource_relation: 'viewer', + subject_type: 'group', + subject_id: 'eng', + subject_relation: 'member' ) - expect(r.subject_relation).to eq("member") + expect(r.subject_relation).to eq('member') end end - describe ".from_triple" do - it "creates a relationship from triples" do + describe '.from_triple' do + it 'creates a relationship from triples' do r = described_class.from_triple( - "document", "doc1", "viewer", - "user", "alice", "" + 'document', 'doc1', 'viewer', + 'user', 'alice', '' ) - expect(r.resource_type).to eq("document") - expect(r.subject_id).to eq("alice") + expect(r.resource_type).to eq('document') + expect(r.subject_id).to eq('alice') end - it "defaults subject_relation to empty string" do + it 'defaults subject_relation to empty string' do r = described_class.from_triple( - "document", "doc1", "viewer", - "user", "alice" + 'document', 'doc1', 'viewer', + 'user', 'alice' ) - expect(r.subject_relation).to eq("") + expect(r.subject_relation).to eq('') end - it "raises InvalidArgumentError for empty resource_type" do - expect { - described_class.from_triple("", "doc1", "viewer", "user", "alice") - }.to raise_error(SpiceDB::InvalidArgumentError, /resource/) + it 'raises InvalidArgumentError for empty resource_type' do + expect do + described_class.from_triple('', 'doc1', 'viewer', 'user', 'alice') + end.to raise_error(SpiceDB::InvalidArgumentError, /resource/) end - it "raises InvalidArgumentError for empty resource_id" do - expect { - described_class.from_triple("document", "", "viewer", "user", "alice") - }.to raise_error(SpiceDB::InvalidArgumentError, /resource/) + it 'raises InvalidArgumentError for empty resource_id' do + expect do + described_class.from_triple('document', '', 'viewer', 'user', 'alice') + end.to raise_error(SpiceDB::InvalidArgumentError, /resource/) end - it "raises InvalidArgumentError for empty resource_relation" do - expect { - described_class.from_triple("document", "doc1", "", "user", "alice") - }.to raise_error(SpiceDB::InvalidArgumentError, /resource/) + it 'raises InvalidArgumentError for empty resource_relation' do + expect do + described_class.from_triple('document', 'doc1', '', 'user', 'alice') + end.to raise_error(SpiceDB::InvalidArgumentError, /resource/) end - it "raises InvalidArgumentError for empty subject_type" do - expect { - described_class.from_triple("document", "doc1", "viewer", "", "alice") - }.to raise_error(SpiceDB::InvalidArgumentError, /subject/) + it 'raises InvalidArgumentError for empty subject_type' do + expect do + described_class.from_triple('document', 'doc1', 'viewer', '', 'alice') + end.to raise_error(SpiceDB::InvalidArgumentError, /subject/) end - it "raises InvalidArgumentError for empty subject_id" do - expect { - described_class.from_triple("document", "doc1", "viewer", "user", "") - }.to raise_error(SpiceDB::InvalidArgumentError, /subject/) + it 'raises InvalidArgumentError for empty subject_id' do + expect do + described_class.from_triple('document', 'doc1', 'viewer', 'user', '') + end.to raise_error(SpiceDB::InvalidArgumentError, /subject/) end - it "raises InvalidArgumentError for nil fields" do - expect { - described_class.from_triple(nil, "doc1", "viewer", "user", "alice") - }.to raise_error(SpiceDB::InvalidArgumentError, /resource/) + it 'raises InvalidArgumentError for nil fields' do + expect do + described_class.from_triple(nil, 'doc1', 'viewer', 'user', 'alice') + end.to raise_error(SpiceDB::InvalidArgumentError, /resource/) end end - describe ".from_tuple" do - it "parses a basic tuple" do - r = described_class.from_tuple("document:doc1#viewer@user:alice") - expect(r.resource_type).to eq("document") - expect(r.resource_id).to eq("doc1") - expect(r.resource_relation).to eq("viewer") - expect(r.subject_type).to eq("user") - expect(r.subject_id).to eq("alice") - expect(r.subject_relation).to eq("") + describe '.from_tuple' do + it 'parses a basic tuple' do + r = described_class.from_tuple('document:doc1#viewer@user:alice') + expect(r.resource_type).to eq('document') + expect(r.resource_id).to eq('doc1') + expect(r.resource_relation).to eq('viewer') + expect(r.subject_type).to eq('user') + expect(r.subject_id).to eq('alice') + expect(r.subject_relation).to eq('') end - it "parses a tuple with subject relation" do - r = described_class.from_tuple("document:doc1#viewer@group:eng#member") - expect(r.subject_type).to eq("group") - expect(r.subject_id).to eq("eng") - expect(r.subject_relation).to eq("member") + it 'parses a tuple with subject relation' do + r = described_class.from_tuple('document:doc1#viewer@group:eng#member') + expect(r.subject_type).to eq('group') + expect(r.subject_id).to eq('eng') + expect(r.subject_relation).to eq('member') end - it "raises for missing @ separator" do - expect { - described_class.from_tuple("document:doc1#viewer") - }.to raise_error(SpiceDB::InvalidArgumentError, /@/) + it 'raises for missing @ separator' do + expect do + described_class.from_tuple('document:doc1#viewer') + end.to raise_error(SpiceDB::InvalidArgumentError, /@/) end - it "raises for missing # in resource" do - expect { - described_class.from_tuple("document:doc1@user:alice") - }.to raise_error(SpiceDB::InvalidArgumentError, /#/) + it 'raises for missing # in resource' do + expect do + described_class.from_tuple('document:doc1@user:alice') + end.to raise_error(SpiceDB::InvalidArgumentError, /#/) end - it "raises for missing : in resource type:id" do - expect { - described_class.from_tuple("document#viewer@user:alice") - }.to raise_error(SpiceDB::InvalidArgumentError, /:/) + it 'raises for missing : in resource type:id' do + expect do + described_class.from_tuple('document#viewer@user:alice') + end.to raise_error(SpiceDB::InvalidArgumentError, /:/) end - it "raises for missing : in subject type:id" do - expect { - described_class.from_tuple("document:doc1#viewer@alice") - }.to raise_error(SpiceDB::InvalidArgumentError, /:/) + it 'raises for missing : in subject type:id' do + expect do + described_class.from_tuple('document:doc1#viewer@alice') + end.to raise_error(SpiceDB::InvalidArgumentError, /:/) end end - describe "#with_caveat" do - it "returns a new relationship with a caveat" do - r = rel.with_caveat("is_owner", { "owner_id" => "alice" }) - expect(r.caveat_name).to eq("is_owner") - expect(r.caveat_context).to eq({ "owner_id" => "alice" }) + describe '#with_caveat' do + it 'returns a new relationship with a caveat' do + r = rel.with_caveat('is_owner', { 'owner_id' => 'alice' }) + expect(r.caveat_name).to eq('is_owner') + expect(r.caveat_context).to eq({ 'owner_id' => 'alice' }) # Original is unchanged expect(rel.caveat_name).to be_nil end - it "preserves other fields" do - r = rel.with_caveat("is_owner") - expect(r.resource_type).to eq("document") - expect(r.subject_id).to eq("alice") + it 'preserves other fields' do + r = rel.with_caveat('is_owner') + expect(r.resource_type).to eq('document') + expect(r.subject_id).to eq('alice') end end - describe "#with_expiration" do - it "returns a new relationship with an expiration" do + describe '#with_expiration' do + it 'returns a new relationship with an expiration' do t = Time.now + 3600 r = rel.with_expiration(t) expect(r.expiration).to eq(t) @@ -170,39 +170,39 @@ end end - describe "#to_filter" do + describe '#to_filter' do it "returns a filter matching the relationship's resource" do f = rel.to_filter expect(f).to be_a(SpiceDB::Filter) - expect(f.resource_type).to eq("document") - expect(f.resource_id).to eq("doc1") - expect(f.relation).to eq("viewer") - expect(f.subject_type).to eq("user") - expect(f.subject_id).to eq("alice") + expect(f.resource_type).to eq('document') + expect(f.resource_id).to eq('doc1') + expect(f.relation).to eq('viewer') + expect(f.subject_type).to eq('user') + expect(f.subject_id).to eq('alice') end end - describe "#to_s" do - it "returns the tuple string representation" do - expect(rel.to_s).to eq("document:doc1#viewer@user:alice") + describe '#to_s' do + it 'returns the tuple string representation' do + expect(rel.to_s).to eq('document:doc1#viewer@user:alice') end - it "includes subject_relation when present" do - r = described_class.from_tuple("document:doc1#viewer@group:eng#member") - expect(r.to_s).to eq("document:doc1#viewer@group:eng#member") + it 'includes subject_relation when present' do + r = described_class.from_tuple('document:doc1#viewer@group:eng#member') + expect(r.to_s).to eq('document:doc1#viewer@group:eng#member') end end - describe "equality" do - it "is equal when all fields match" do - a = described_class.from_tuple("document:doc1#viewer@user:alice") - b = described_class.from_tuple("document:doc1#viewer@user:alice") + describe 'equality' do + it 'is equal when all fields match' do + a = described_class.from_tuple('document:doc1#viewer@user:alice') + b = described_class.from_tuple('document:doc1#viewer@user:alice') expect(a).to eq(b) end - it "is not equal when fields differ" do - a = described_class.from_tuple("document:doc1#viewer@user:alice") - b = described_class.from_tuple("document:doc1#viewer@user:bob") + it 'is not equal when fields differ' do + a = described_class.from_tuple('document:doc1#viewer@user:alice') + b = described_class.from_tuple('document:doc1#viewer@user:bob') expect(a).not_to eq(b) end end diff --git a/spicedb-ruby/spec/transaction_spec.rb b/spicedb-ruby/spec/transaction_spec.rb index 4a8b9fd..48c50dc 100644 --- a/spicedb-ruby/spec/transaction_spec.rb +++ b/spicedb-ruby/spec/transaction_spec.rb @@ -1,24 +1,24 @@ # frozen_string_literal: true -require_relative "../lib/spicedb" +require_relative '../lib/spicedb' RSpec.describe SpiceDB::Transaction do let(:rel) do SpiceDB::Relationship.from_triple( - "document", "doc1", "viewer", - "user", "alice" + 'document', 'doc1', 'viewer', + 'user', 'alice' ) end let(:rel2) do SpiceDB::Relationship.from_triple( - "document", "doc2", "editor", - "user", "bob" + 'document', 'doc2', 'editor', + 'user', 'bob' ) end - describe "#create" do - it "adds a create operation" do + describe '#create' do + it 'adds a create operation' do txn = described_class.new txn.create(rel) @@ -27,15 +27,15 @@ expect(txn.updates[0][:relationship]).to eq(rel) end - it "returns self for chaining" do + it 'returns self for chaining' do txn = described_class.new result = txn.create(rel) expect(result).to be(txn) end end - describe "#touch" do - it "adds a touch operation" do + describe '#touch' do + it 'adds a touch operation' do txn = described_class.new txn.touch(rel) @@ -45,8 +45,8 @@ end end - describe "#delete" do - it "adds a delete operation" do + describe '#delete' do + it 'adds a delete operation' do txn = described_class.new txn.delete(rel) @@ -56,8 +56,8 @@ end end - describe "multiple operations" do - it "collects operations in order" do + describe 'multiple operations' do + it 'collects operations in order' do txn = described_class.new txn.create(rel) txn.touch(rel2) @@ -68,10 +68,10 @@ end end - describe "#must_not_match" do - it "adds a must_not_match precondition" do + describe '#must_not_match' do + it 'adds a must_not_match precondition' do txn = described_class.new - filter = SpiceDB::Filter.new(resource_type: "document") + filter = SpiceDB::Filter.new(resource_type: 'document') txn.must_not_match(filter) expect(txn.preconditions.length).to eq(1) @@ -79,18 +79,18 @@ expect(txn.preconditions[0][:filter]).to eq(filter) end - it "returns self for chaining" do + it 'returns self for chaining' do txn = described_class.new - filter = SpiceDB::Filter.new(resource_type: "document") + filter = SpiceDB::Filter.new(resource_type: 'document') result = txn.must_not_match(filter) expect(result).to be(txn) end end - describe "#must_match" do - it "adds a must_match precondition" do + describe '#must_match' do + it 'adds a must_match precondition' do txn = described_class.new - filter = SpiceDB::Filter.new(resource_type: "document") + filter = SpiceDB::Filter.new(resource_type: 'document') txn.must_match(filter) expect(txn.preconditions.length).to eq(1) @@ -99,12 +99,12 @@ end end - describe "#empty?" do - it "returns true for a new transaction" do + describe '#empty?' do + it 'returns true for a new transaction' do expect(described_class.new).to be_empty end - it "returns false after adding an operation" do + it 'returns false after adding an operation' do txn = described_class.new txn.create(rel) expect(txn).not_to be_empty diff --git a/spicedb-ruby/spicedb.gemspec b/spicedb-ruby/spicedb.gemspec index 5ff9585..6780765 100644 --- a/spicedb-ruby/spicedb.gemspec +++ b/spicedb-ruby/spicedb.gemspec @@ -1,29 +1,30 @@ # frozen_string_literal: true Gem::Specification.new do |spec| - spec.name = "spicedb" - spec.version = "0.1.0" - spec.authors = ["Authzed"] - spec.email = ["support@authzed.com"] + spec.name = 'spicedb' + spec.version = '0.1.0' + spec.authors = ['Authzed'] + spec.email = ['support@authzed.com'] - spec.summary = "Idiomatic Ruby client for SpiceDB" - spec.description = "A Ruby client library for SpiceDB, the database for " \ - "fine-grained authorization. Provides idiomatic Ruby " \ - "types and patterns — no protobuf knowledge required." - spec.homepage = "https://github.com/authzed/spicedb-clients" - spec.license = "Apache-2.0" - spec.required_ruby_version = ">= 3.2" + spec.summary = 'Idiomatic Ruby client for SpiceDB' + spec.description = 'A Ruby client library for SpiceDB, the database for ' \ + 'fine-grained authorization. Provides idiomatic Ruby ' \ + 'types and patterns — no protobuf knowledge required.' + spec.homepage = 'https://github.com/authzed/spicedb-clients' + spec.license = 'Apache-2.0' + spec.required_ruby_version = '>= 3.2' - spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = "https://github.com/authzed/spicedb-clients/tree/main/spicedb-ruby" - spec.metadata["changelog_uri"] = "https://github.com/authzed/spicedb-clients/blob/main/spicedb-ruby/DESIGN.md" + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = 'https://github.com/authzed/spicedb-clients/tree/main/spicedb-ruby' + spec.metadata['changelog_uri'] = 'https://github.com/authzed/spicedb-clients/blob/main/spicedb-ruby/DESIGN.md' + spec.metadata['rubygems_mfa_required'] = 'true' - spec.files = Dir["lib/**/*.rb", "DESIGN.md", "CLAUDE.md", "LICENSE"] - spec.require_paths = ["lib"] + spec.files = Dir['lib/**/*.rb', 'DESIGN.md', 'CLAUDE.md', 'LICENSE'] + spec.require_paths = ['lib'] - spec.add_dependency "grpc", "~> 1.60" - spec.add_dependency "spicedb-proto", "~> 0.1" + spec.add_dependency 'grpc', '~> 1.60' + spec.add_dependency 'spicedb-proto', '~> 0.1' - spec.add_development_dependency "rspec", "~> 3.13" - spec.add_development_dependency "rubocop", "~> 1.60" + spec.add_development_dependency 'rspec', '~> 3.13' + spec.add_development_dependency 'rubocop', '~> 1.60' end diff --git a/spicedb-rust/examples/bulk_operations.rs b/spicedb-rust/examples/bulk_operations.rs index 2c241d7..b0cf4fc 100644 --- a/spicedb-rust/examples/bulk_operations.rs +++ b/spicedb-rust/examples/bulk_operations.rs @@ -45,7 +45,10 @@ async fn main() { } let revision = client.write(&txn).await.expect("bulk write failed"); - println!("wrote {} relationships at revision: {revision}", users.len()); + println!( + "wrote {} relationships at revision: {revision}", + users.len() + ); assert!(!revision.is_empty(), "expected non-empty revision"); // Bulk check permissions diff --git a/spicedb-rust/examples/check_permission.rs b/spicedb-rust/examples/check_permission.rs index 21f36af..96f2dd0 100644 --- a/spicedb-rust/examples/check_permission.rs +++ b/spicedb-rust/examples/check_permission.rs @@ -35,7 +35,10 @@ async fn main() { .expect("invalid relationship"); let mut txn = Transaction::new(); txn.touch(&rel); - let revision = client.write(&txn).await.expect("write relationships failed"); + let revision = client + .write(&txn) + .await + .expect("write relationships failed"); // Check permission: alice should be able to view firstdoc let check_rel = Relationship::new("document", "firstdoc", "view", "user", "alice", "") @@ -45,6 +48,12 @@ async fn main() { .await .expect("check failed"); - println!("alice can view document:firstdoc: {}", result.has_permission); - assert!(result.has_permission, "expected alice to have view permission"); + println!( + "alice can view document:firstdoc: {}", + result.has_permission + ); + assert!( + result.has_permission, + "expected alice to have view permission" + ); } diff --git a/spicedb-rust/examples/lookup_resources.rs b/spicedb-rust/examples/lookup_resources.rs index 693c75d..b24b355 100644 --- a/spicedb-rust/examples/lookup_resources.rs +++ b/spicedb-rust/examples/lookup_resources.rs @@ -30,27 +30,22 @@ async fn main() { .expect("write schema failed"); // Write test data: alice can view firstdoc, alice can edit seconddoc - let alice_viewer = - Relationship::new("document", "firstdoc", "viewer", "user", "alice", "") - .expect("invalid relationship"); - let alice_editor = - Relationship::new("document", "seconddoc", "editor", "user", "alice", "") - .expect("invalid relationship"); + let alice_viewer = Relationship::new("document", "firstdoc", "viewer", "user", "alice", "") + .expect("invalid relationship"); + let alice_editor = Relationship::new("document", "seconddoc", "editor", "user", "alice", "") + .expect("invalid relationship"); let mut txn = Transaction::new(); txn.touch(&alice_viewer); txn.touch(&alice_editor); - client.write(&txn).await.expect("write relationships failed"); + client + .write(&txn) + .await + .expect("write relationships failed"); // Lookup all documents alice can view let resource_ids = client - .lookup_resources( - &consistency::full(), - "document", - "view", - "user", - "alice", - ) + .lookup_resources(&consistency::full(), "document", "view", "user", "alice") .await .expect("lookup failed"); diff --git a/spicedb-rust/examples/lookup_subjects.rs b/spicedb-rust/examples/lookup_subjects.rs index 5fe0889..31ff626 100644 --- a/spicedb-rust/examples/lookup_subjects.rs +++ b/spicedb-rust/examples/lookup_subjects.rs @@ -30,27 +30,22 @@ async fn main() { .expect("write schema failed"); // Write test data: alice is a viewer, bob is an editor - let alice_viewer = - Relationship::new("document", "firstdoc", "viewer", "user", "alice", "") - .expect("invalid relationship"); - let bob_editor = - Relationship::new("document", "firstdoc", "editor", "user", "bob", "") - .expect("invalid relationship"); + let alice_viewer = Relationship::new("document", "firstdoc", "viewer", "user", "alice", "") + .expect("invalid relationship"); + let bob_editor = Relationship::new("document", "firstdoc", "editor", "user", "bob", "") + .expect("invalid relationship"); let mut txn = Transaction::new(); txn.touch(&alice_viewer); txn.touch(&bob_editor); - client.write(&txn).await.expect("write relationships failed"); + client + .write(&txn) + .await + .expect("write relationships failed"); // Lookup all users who can view firstdoc let subject_ids = client - .lookup_subjects( - &consistency::full(), - "document", - "firstdoc", - "view", - "user", - ) + .lookup_subjects(&consistency::full(), "document", "firstdoc", "view", "user") .await .expect("lookup failed"); diff --git a/spicedb-rust/examples/read_relationships.rs b/spicedb-rust/examples/read_relationships.rs index ad20cc9..22b3f0c 100644 --- a/spicedb-rust/examples/read_relationships.rs +++ b/spicedb-rust/examples/read_relationships.rs @@ -30,17 +30,18 @@ async fn main() { .expect("write schema failed"); // Write test data - let alice = - Relationship::new("document", "firstdoc", "viewer", "user", "alice", "") - .expect("invalid relationship"); - let bob = - Relationship::new("document", "firstdoc", "viewer", "user", "bob", "") - .expect("invalid relationship"); + let alice = Relationship::new("document", "firstdoc", "viewer", "user", "alice", "") + .expect("invalid relationship"); + let bob = Relationship::new("document", "firstdoc", "viewer", "user", "bob", "") + .expect("invalid relationship"); let mut txn = Transaction::new(); txn.touch(&alice); txn.touch(&bob); - client.write(&txn).await.expect("write relationships failed"); + client + .write(&txn) + .await + .expect("write relationships failed"); // Read relationships matching a filter let filter = Filter::new("document") diff --git a/spicedb-rust/examples/relationship_counters.rs b/spicedb-rust/examples/relationship_counters.rs index c78eea3..182ad46 100644 --- a/spicedb-rust/examples/relationship_counters.rs +++ b/spicedb-rust/examples/relationship_counters.rs @@ -28,17 +28,18 @@ async fn main() { .await .expect("write schema failed"); - let alice = - Relationship::new("document", "firstdoc", "viewer", "user", "alice", "") - .expect("invalid relationship"); - let bob = - Relationship::new("document", "seconddoc", "viewer", "user", "bob", "") - .expect("invalid relationship"); + let alice = Relationship::new("document", "firstdoc", "viewer", "user", "alice", "") + .expect("invalid relationship"); + let bob = Relationship::new("document", "seconddoc", "viewer", "user", "bob", "") + .expect("invalid relationship"); let mut txn = Transaction::new(); txn.touch(&alice); txn.touch(&bob); - client.write(&txn).await.expect("write relationships failed"); + client + .write(&txn) + .await + .expect("write relationships failed"); // Unregister any existing counter from a prior run (ignore errors) let _ = client diff --git a/spicedb-rust/examples/schema_reflection.rs b/spicedb-rust/examples/schema_reflection.rs index 2ee1e7e..5d9c74e 100644 --- a/spicedb-rust/examples/schema_reflection.rs +++ b/spicedb-rust/examples/schema_reflection.rs @@ -80,10 +80,7 @@ async fn main() { .expect("dependent relations failed"); println!("\ndependent relations for document#view (revision: {revision}):"); - assert!( - !deps.is_empty(), - "expected at least one dependent relation" - ); + assert!(!deps.is_empty(), "expected at least one dependent relation"); for d in &deps { println!(" {}#{}", d.definition_name, d.relation_name); } diff --git a/spicedb-rust/examples/write_relationships.rs b/spicedb-rust/examples/write_relationships.rs index ae6d566..d91390c 100644 --- a/spicedb-rust/examples/write_relationships.rs +++ b/spicedb-rust/examples/write_relationships.rs @@ -29,12 +29,10 @@ async fn main() { .expect("write schema failed"); // Build a transaction with multiple operations and a precondition - let alice_viewer = - Relationship::new("document", "firstdoc", "viewer", "user", "alice", "") - .expect("invalid relationship"); - let bob_editor = - Relationship::new("document", "firstdoc", "editor", "user", "bob", "") - .expect("invalid relationship"); + let alice_viewer = Relationship::new("document", "firstdoc", "viewer", "user", "alice", "") + .expect("invalid relationship"); + let bob_editor = Relationship::new("document", "firstdoc", "editor", "user", "bob", "") + .expect("invalid relationship"); let mut txn = Transaction::new(); txn.touch(&alice_viewer); diff --git a/spicedb-rust/src/client.rs b/spicedb-rust/src/client.rs index f6955bd..4edd979 100644 --- a/spicedb-rust/src/client.rs +++ b/spicedb-rust/src/client.rs @@ -98,10 +98,7 @@ impl SpiceDBClient { } /// Returns a builder for configuring the client with custom options. - pub fn builder( - endpoint: impl Into, - token: impl Into, - ) -> SpiceDBClientBuilder { + pub fn builder(endpoint: impl Into, token: impl Into) -> SpiceDBClientBuilder { SpiceDBClientBuilder { endpoint: endpoint.into(), token: token.into(), @@ -250,15 +247,9 @@ impl SpiceDBClient { .iter() .map(|(op, rel)| { let operation = match op { - UpdateOperation::Create => { - proto::relationship_update::Operation::Create as i32 - } - UpdateOperation::Touch => { - proto::relationship_update::Operation::Touch as i32 - } - UpdateOperation::Delete => { - proto::relationship_update::Operation::Delete as i32 - } + UpdateOperation::Create => proto::relationship_update::Operation::Create as i32, + UpdateOperation::Touch => proto::relationship_update::Operation::Touch as i32, + UpdateOperation::Delete => proto::relationship_update::Operation::Delete as i32, }; proto::RelationshipUpdate { operation, @@ -368,10 +359,7 @@ impl SpiceDBClient { /// until the server reports all matching relationships are deleted. /// /// Returns the revision of the final deletion. - pub async fn delete_relationships( - &self, - filter: &Filter, - ) -> Result { + pub async fn delete_relationships(&self, filter: &Filter) -> Result { loop { let resp = self .retry(|| async { @@ -390,10 +378,7 @@ impl SpiceDBClient { .await?; let inner = resp.into_inner(); - let revision = inner - .deleted_at - .map(|z| z.token) - .unwrap_or_default(); + let revision = inner.deleted_at.map(|z| z.token).unwrap_or_default(); // DeletionProgress::Complete = 1 if inner.deletion_progress @@ -747,11 +732,7 @@ impl SpiceDBClient { let inner = resp.into_inner(); let revision = inner.read_at.map(|z| z.token).unwrap_or_default(); - let diffs = inner - .diffs - .iter() - .map(schema_diff_from_proto) - .collect(); + let diffs = inner.diffs.iter().map(schema_diff_from_proto).collect(); Ok((diffs, revision)) } diff --git a/spicedb-rust/src/consistency.rs b/spicedb-rust/src/consistency.rs index c1f12b3..16ef7cc 100644 --- a/spicedb-rust/src/consistency.rs +++ b/spicedb-rust/src/consistency.rs @@ -97,12 +97,8 @@ use spicedb_proto::authzed::api::v1 as proto; impl Strategy { pub(crate) fn to_proto(&self) -> proto::Consistency { let requirement = match self { - Strategy::Full => { - proto::consistency::Requirement::FullyConsistent(true) - } - Strategy::MinLatency => { - proto::consistency::Requirement::MinimizeLatency(true) - } + Strategy::Full => proto::consistency::Requirement::FullyConsistent(true), + Strategy::MinLatency => proto::consistency::Requirement::MinimizeLatency(true), Strategy::AtLeast(token) => { proto::consistency::Requirement::AtLeastAsFresh(proto::ZedToken { token: token.clone(), diff --git a/spicedb-rust/src/types.rs b/spicedb-rust/src/types.rs index 0364223..8f836c8 100644 --- a/spicedb-rust/src/types.rs +++ b/spicedb-rust/src/types.rs @@ -64,7 +64,14 @@ impl Relationship { subject_type: impl Into, subject_id: impl Into, ) -> Result { - Self::new(resource_type, resource_id, relation, subject_type, subject_id, "") + Self::new( + resource_type, + resource_id, + relation, + subject_type, + subject_id, + "", + ) } /// Parses a relationship from a tuple string in the format: @@ -79,9 +86,9 @@ impl Relationship { .split_once('#') .ok_or_else(|| RelationshipError::InvalidFormat("missing '#' in resource".into()))?; - let (resource_type, resource_id) = resource_type_id - .split_once(':') - .ok_or_else(|| RelationshipError::InvalidFormat("missing ':' in resource type:id".into()))?; + let (resource_type, resource_id) = resource_type_id.split_once(':').ok_or_else(|| { + RelationshipError::InvalidFormat("missing ':' in resource type:id".into()) + })?; let (subject_type, subject_id, subject_relation) = if let Some((subject_type_id, subject_rel)) = subject_part.split_once('#') { @@ -156,9 +163,7 @@ impl Relationship { let context = self.caveat_context.as_ref().map(|ctx| { let fields = ctx .iter() - .map(|(k, v)| { - (k.clone(), json_value_to_prost(v)) - }) + .map(|(k, v)| (k.clone(), json_value_to_prost(v))) .collect(); prost_types::Struct { fields } }); @@ -208,17 +213,22 @@ impl Relationship { (String::new(), None) }; - let expiration = pr.optional_expires_at.as_ref().and_then(|ts| { - DateTime::from_timestamp(ts.seconds, ts.nanos as u32) - }); + let expiration = pr + .optional_expires_at + .as_ref() + .and_then(|ts| DateTime::from_timestamp(ts.seconds, ts.nanos as u32)); Self { resource_type: resource.map(|r| r.object_type.clone()).unwrap_or_default(), resource_id: resource.map(|r| r.object_id.clone()).unwrap_or_default(), resource_relation: pr.relation.clone(), - subject_type: subject_obj.map(|o| o.object_type.clone()).unwrap_or_default(), + subject_type: subject_obj + .map(|o| o.object_type.clone()) + .unwrap_or_default(), subject_id: subject_obj.map(|o| o.object_id.clone()).unwrap_or_default(), - subject_relation: subject_ref.map(|s| s.optional_relation.clone()).unwrap_or_default(), + subject_relation: subject_ref + .map(|s| s.optional_relation.clone()) + .unwrap_or_default(), caveat_name, caveat_context, expiration, @@ -594,11 +604,9 @@ fn prost_value_to_json(v: &prost_types::Value) -> serde_json::Value { match &v.kind { Some(Kind::NullValue(_)) => serde_json::Value::Null, Some(Kind::BoolValue(b)) => serde_json::Value::Bool(*b), - Some(Kind::NumberValue(n)) => { - serde_json::Number::from_f64(*n) - .map(serde_json::Value::Number) - .unwrap_or(serde_json::Value::Null) - } + Some(Kind::NumberValue(n)) => serde_json::Number::from_f64(*n) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), Some(Kind::StringValue(s)) => serde_json::Value::String(s.clone()), Some(Kind::ListValue(list)) => { serde_json::Value::Array(list.values.iter().map(prost_value_to_json).collect()) diff --git a/spicedb-rust/tests/types_test.rs b/spicedb-rust/tests/types_test.rs index da78e8e..19b09d7 100644 --- a/spicedb-rust/tests/types_test.rs +++ b/spicedb-rust/tests/types_test.rs @@ -204,8 +204,14 @@ fn test_transaction_preconditions() { txn.must_not_match(f.clone()); txn.must_match(f); assert_eq!(txn.preconditions().len(), 2); - assert_eq!(txn.preconditions()[0].operation, PreconditionOperation::MustNotMatch); - assert_eq!(txn.preconditions()[1].operation, PreconditionOperation::MustMatch); + assert_eq!( + txn.preconditions()[0].operation, + PreconditionOperation::MustNotMatch + ); + assert_eq!( + txn.preconditions()[1].operation, + PreconditionOperation::MustMatch + ); } #[test] @@ -226,7 +232,9 @@ fn test_transaction_borrows_relationship() { #[test] fn test_check_result_must_use() { - let result = CheckResult { has_permission: true }; + let result = CheckResult { + has_permission: true, + }; assert!(result.has_permission); }