diff --git a/.github/scripts/check_design_approval.js b/.github/scripts/check_design_approval.js new file mode 100644 index 00000000..40aa9926 --- /dev/null +++ b/.github/scripts/check_design_approval.js @@ -0,0 +1,177 @@ +/* + * Checks that a PR has at least one linked issue carrying the `design/approved` label. + * If not, it applies a `needs-approved-issue` label and posts an explanatory comment. + * + * Usage (called by the workflow via actions/github-script): + * const script = require('./.github/scripts/check_design_approval.js') + * await script({ github, context, core }) + */ + +const REQUIRED_LABEL = 'design/approved' +const FLAG_LABEL = 'needs-approved-issue' + +// Fetch everything we need in a single GraphQL query: +// - Linked issues + their labels +// - Current labels on the PR +// - Existing bot comments on the PR (to avoid duplicate comments) +const QUERY = ` + query CheckDesignApproval($owner: String!, $repo: String!, $prNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + author { login } + labels(first: 10) { + nodes { name } + } + comments(last: 50) { + nodes { + body + author { + login + __typename + } + } + } + closingIssuesReferences(first: 10) { + nodes { + number + labels(first: 10) { + nodes { name } + } + } + } + } + } + } +` + +/** + * Ensure a label exists in the repo, creating it if necessary. + * @param {object} github - Octokit instance from github-script + * @param {object} repo - { owner, repo } + * @param {string} label + */ +async function ensureLabelExists (github, repo, label) { + try { + await github.rest.issues.getLabel({ ...repo, name: label }) + } catch (err) { + if (err.status === 404) { + await github.rest.issues.createLabel({ + ...repo, + name: label, + color: 'fbca04', + description: 'PR is not linked to a design/approved issue' + }) + } else { + throw err + } + } +} + +/** + * Entry point called by the actions/github-script workflow step. + */ +module.exports = async ({ github, context, core }) => { + const { owner, repo } = context.repo + const prNumber = context.payload.pull_request.number + + core.info(`Checking PR #${prNumber}`) + + // ── 1. Single GraphQL query that reads all data ───────────────────── + const { repository } = await github.graphql(QUERY, { + owner, + repo, + prNumber + }) + + const pr = repository.pullRequest + const prAuthor = pr.author.login + const currentLabelNames = pr.labels.nodes.map((l) => l.name) + const linkedIssues = pr.closingIssuesReferences.nodes + + core.info( + linkedIssues.length + ? `Found ${linkedIssues.length} linked issue(s): ${linkedIssues.map((i) => `#${i.number}`).join(', ')}` + : 'No linked issues found.' + ) + + // ── 2. Check linked issues for the required label ─────────────────── + let approvedIssue = null + for (const issue of linkedIssues) { + const issueLabels = issue.labels.nodes.map((l) => l.name) + core.info(`Issue #${issue.number} labels: [${issueLabels.join(', ')}]`) + if (issueLabels.includes(REQUIRED_LABEL)) { + core.info(`✅ Issue #${issue.number} has '${REQUIRED_LABEL}' – PR passes.`) + approvedIssue = issue + break + } + } + + const alreadyFlagged = currentLabelNames.includes(FLAG_LABEL) + + // ── 3a. PR passes; clean up flag label if previously applied ──────── + if (approvedIssue) { + if (alreadyFlagged) { + core.info(`Removing '${FLAG_LABEL}' label as the PR now passes.`) + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: prNumber, + name: FLAG_LABEL + }) + } + return + } + + // ── 3b. PR fails; apply label + comment ───────────────────────────── + core.info(`PR #${prNumber} does not meet requirements.`) + + await ensureLabelExists(github, { owner, repo }, FLAG_LABEL) + + if (!alreadyFlagged) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: prNumber, + labels: [FLAG_LABEL] + }) + core.info(`Applied '${FLAG_LABEL}' label.`) + } + + // Bot comments have __typename "Bot" on the author node in GraphQL. + const alreadyCommented = pr.comments.nodes.some( + (c) => c.author.__typename === 'Bot' && c.body.includes(REQUIRED_LABEL) + ) + if (!alreadyCommented) { + const message = + linkedIssues.length === 0 + ? `Hey @${prAuthor}, thanks for the contribution! 👋 + +This PR was flagged because it has no linked issues. Please link one using a closing keyword in the PR description; for example: + +\`\`\` +Closes # +\`\`\` + +The linked issue must also carry the \`${REQUIRED_LABEL}\` label. If no issue exists yet, please open one and get design approval from the maintainers first.` + : `Hey @${prAuthor}, thanks for the contribution! 👋 + +This PR was flagged because the linked issue(s) (${linkedIssues.map((i) => `#${i.number}`).join(', ')}) do not have the \`${REQUIRED_LABEL}\` label. + +Please ensure the linked issue has been through the design approval process and carries the \`${REQUIRED_LABEL}\` label before this PR can be merged.` + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: message + }) + core.info('Posted explanatory comment.') + } else { + core.info('Comment already exists; skipping to avoid spam.') + } + + // Signal failure so the check shows as ❌ in the PR's status checks. + core.setFailed( + `PR #${prNumber} is not linked to an issue with the '${REQUIRED_LABEL}' label.` + ) +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..d793d724 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,62 @@ +name: Build & Test + +on: + push: + branches: ['**'] + paths: + - 'lib/**' + - '*.gradle.kts' + - 'gradle/**' + - 'gradlew*' + - '.github/workflows/build.yml' + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + paths: + - 'lib/**' + - '*.gradle.kts' + - 'gradle/**' + - 'gradlew*' + - '.github/workflows/build.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build-and-test: + name: Build & Test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Check formatting + run: ./gradlew spotlessCheck + + - name: Assemble + run: ./gradlew assemble + + - name: Run tests + run: ./gradlew test + + - name: Upload test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: lib/build/reports/tests/test/ + retention-days: 14 diff --git a/.github/workflows/check_design_approval.yml b/.github/workflows/check_design_approval.yml new file mode 100644 index 00000000..12e50d67 --- /dev/null +++ b/.github/workflows/check_design_approval.yml @@ -0,0 +1,27 @@ +--- +name: Flag PRs that do not have associated design/approved issues + + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + + +permissions: + contents: read + issues: write + pull-requests: write + + +jobs: + check: + name: Linked issue has design/approved + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Run design-approval check + uses: actions/github-script@v9 + with: + script: |- + const script = require('./.github/scripts/check_design_approval.js') + await script({ github, context, core }) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..a9fb2c9a --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,56 @@ +name: Deploy Documentation + +on: + push: + branches: + - main + tags: + - "v*.*.*" + +permissions: + contents: write + +jobs: + deploy-latest: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install -r docs/requirements.txt + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + - name: Deploy latest + run: | + mike deploy --push --allow-empty --update-aliases latest + mike set-default --push latest + + deploy-version: + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install -r docs/requirements.txt + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + - name: Extract version + id: version + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + - name: Deploy version + run: | + mike deploy --push --allow-empty ${{ steps.version.outputs.VERSION }} diff --git a/.github/workflows/stale_not_approved.yml b/.github/workflows/stale_not_approved.yml new file mode 100644 index 00000000..e808c4c1 --- /dev/null +++ b/.github/workflows/stale_not_approved.yml @@ -0,0 +1,31 @@ +--- +name: >- + Mark PRs without design/approved linked issues as stale + and eventually close them + + +on: + schedule: + - cron: 30 1 * * * + + +permissions: + pull-requests: write + + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v10 + with: + stale-pr-message: |- + This PR has been automatically marked as stale due to inactivity. + It will be closed in 60 days if no further activity occurs. + days-before-stale: 14 + days-before-close: 60 + days-before-issue-stale: -1 + days-before-issue-close: -1 + stale-pr-label: stale + exempt-pr-labels: pinned,security + any-of-labels: needs-approved-issue diff --git a/.github/workflows/triage_label.yml b/.github/workflows/triage_label.yml new file mode 100644 index 00000000..66264a39 --- /dev/null +++ b/.github/workflows/triage_label.yml @@ -0,0 +1,24 @@ +--- +name: Auto-add needs-triage labels to new issues + + +on: + issues: + types: [opened] + + +permissions: + issues: write + + +jobs: + label: + runs-on: ubuntu-latest + steps: + - run: >- + gh issue + edit ${{ github.event.issue.number }} + --add-label needs-triage + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} diff --git a/.gitignore b/.gitignore index c7690edf..bb277d38 100644 --- a/.gitignore +++ b/.gitignore @@ -186,3 +186,4 @@ gradle-app.setting ### Custom ### .idea/ lib/bin/ +site/ diff --git a/README.md b/README.md index 84596f37..2cddc128 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ If Fabric allows you to keep your familiar programming language, then Hypernate No more low-level boilerplate code for key-value storage operations and other housekeeping tasks! Take advantage of Hypernate’s _high abstraction level,_ _aspect-oriented_ approaches and _extensibility_ to keep your critical business logic as clean as possible! -Enhance your chaincode with feaures, like: +Enhance your chaincode with features, like: * Object-oriented CRUD (create, read, update, delete) operations with explicit semantics * Declarative and flexible configuration of your entity keys * An extensible chain of middleware processors handling non-business stuff (caching, logging, tracing, etc.) @@ -60,6 +60,22 @@ public record Asset( String owner) {} ``` +#### Declaring a stable entity type + +By default, Hypernate uses the entity class name as the composite key object type. +To decouple the ledger key space from Java package/class renaming, define an explicit entity type with `@EntityType`: + +```java +@EntityType("Asset") +@PrimaryKey(@AttributeInfo(name = Asset.Fields.assetID)) +public record Asset( + String assetID, + String color, + int size, + int appraisedValue, + String owner) {} +``` + #### Polishing your primary key with mappers In the end, Fabric expects a string value as an entity key and the previous example used a string valued attribute as key (part), so all is good (hopefully). @@ -98,10 +114,10 @@ public record Asset( ``` Currently, the following mapper classes are available in the `hu.bme.mit.ftsrg.hypernate.mappers` package (but feel free to implement and use your own): -* `IntegerZeroPadder`: Pads numbers with `"0"`s to the lenght of `"2147483647"`, the maximum integer value. +* `IntegerZeroPadder`: Pads numbers with `"0"`s to the length of `"2147483647"`, the maximum integer value. * `IntegerFlipperAndZeroPadder`: flips the range of positive integers before padding them to their max length. Useful for constructing descending string key orders from originally ascending integer keys (in case you want to enumerate them in reverse order). -* `LongZeroPadder`: Pads numbers with `"0"`s to the lenght of `"9223372036854775807"`, the maximum long number value. +* `LongZeroPadder`: Pads numbers with `"0"`s to the length of `"9223372036854775807"`, the maximum long number value. * `LongFlipperAndZeroPadder`: flips the range of positive long numbers before padding them to their max length. Useful for constructing descending string key orders from originally ascending long number keys (in case you want to enumerate them in reverse order). * `ObjectToString`: simply call `toString` on the attribute value (the default behavior) @@ -158,7 +174,7 @@ public boolean AssetExists(final HypernateContext ctx, final String assetID) { ``` On the other hand, the following code snippet shows how to express that the operation _must_ be performed on this entity successfully. -By using the _must_ semantics, an exception will be thrown if the asset does not exists. +By using the _must_ semantics, an exception will be thrown if the asset does not exist. No more cluttering `if-else` or `try-catch` blocks for every ledger access operation! ```java @@ -197,7 +213,7 @@ The above declaration will result in two ChaincodeStub-like components intercept So it is possible that the original stub won’t even get the call, it is served from the local cache. > [!IMPORTANT] -> Hypernate context and middleware instances are specific to your individual TX executions/endoresements! +> Hypernate context and middleware instances are specific to your individual TX executions/endorsements! > Hypernate does not introduce dependencies between TXs, following the traditional (and important!) Fabric chaincode development practice. diff --git a/docs/api/annotations.md b/docs/api/annotations.md new file mode 100644 index 00000000..6acc1364 --- /dev/null +++ b/docs/api/annotations.md @@ -0,0 +1,3 @@ +# Annotations + +This section is coming soon. Check back after the next release. diff --git a/docs/api/registry.md b/docs/api/registry.md new file mode 100644 index 00000000..34dc5ed0 --- /dev/null +++ b/docs/api/registry.md @@ -0,0 +1,3 @@ +# Registry + +This section is coming soon. Check back after the next release. diff --git a/docs/concepts/annotations.md b/docs/concepts/annotations.md new file mode 100644 index 00000000..6acc1364 --- /dev/null +++ b/docs/concepts/annotations.md @@ -0,0 +1,3 @@ +# Annotations + +This section is coming soon. Check back after the next release. diff --git a/docs/concepts/index.md b/docs/concepts/index.md new file mode 100644 index 00000000..f8fdc4cf --- /dev/null +++ b/docs/concepts/index.md @@ -0,0 +1,104 @@ +# Concepts + +## Core Concepts + +Hypernate builds on Hyperledger Fabric by adding a higher-level programming model focused on +entities, typed operations, and reusable middleware. + +## Declarative Entity Keys + +Hypernate uses annotations to define key construction declaratively. + +### Declaring Primary Keys + +Use `@PrimaryKey` with one or more `@AttributeInfo` entries. + +```java +@FieldNameConstants +@PrimaryKey(@AttributeInfo(name = Asset.Fields.assetID)) +public record Asset( + String assetID, + String color, + int size, + int appraisedValue, + String owner) {} +``` + +### Key Mappers + +Fabric keys are strings, so key-part mapping matters for sorting and range scans. + +If numeric IDs are mapped with plain `toString`, lexical ordering can become semantically wrong. +For example, `9`, `10`, `11` become `"9"`, `"10"`, `"11"`, and lexical ordering puts `"10"` +before `"9"`. + +Hypernate supports mapper classes to preserve ordering semantics. Available mappers include: + +- `IntegerZeroPadder` +- `IntegerFlipperAndZeroPadder` +- `LongZeroPadder` +- `LongFlipperAndZeroPadder` +- `ObjectToString` + +### Composite Keys + +Hypernate supports ordered composite keys with multiple attributes. + +```java +@FieldNameConstants +@PrimaryKey({ + @AttributeInfo(name = Asset.Fields.owner), + @AttributeInfo(name = Asset.Fields.assetID, mapper = IntegerZeroPadder.class) +}) +public record Asset( + String owner, + int assetID, + String color, + int size, + int appraisedValue) {} +``` + +## CRUD Semantics with Registry + +Use `Registry` through `HypernateContext` for object-oriented ledger access. + +Methods use explicit semantics: + +- `must*` methods fail with exceptions when invariants are not satisfied. +- `try*` methods return graceful outcomes for non-fatal paths. + +```java +@Transaction(intent = EVALUATE) +public boolean AssetExists(final HypernateContext ctx, final String assetID) { + return ctx.getRegistry().tryRead(Asset.class, assetID) != null; +} +``` + +```java +Asset toDelete = reg.mustRead(Asset.class, assetID); +ctx.getRegistry().mustDelete(toDelete); +``` + +## Middleware Chain + +Hypernate middleware wraps `ChaincodeStub` behavior in reusable processors. + +Common middleware includes: + +- `LoggingStubMiddleware` +- `WriteBackCachedStubMiddleware` + +You can chain middleware with `@MiddlewareInfo`: + +```java +@MiddlewareInfo({ + LoggingStubMiddleware.class, + WriteBackCachedStubMiddleware.class +}) +public class MyBusinessContract implements HypernateContract +``` + +This allows call interception for logging, caching, and other cross-cutting behavior while +keeping business code focused. + +> Hypernate context and middleware instances are specific to individual transaction executions. diff --git a/docs/concepts/middleware.md b/docs/concepts/middleware.md new file mode 100644 index 00000000..9f736012 --- /dev/null +++ b/docs/concepts/middleware.md @@ -0,0 +1,3 @@ +# Middleware + +This section is coming soon. Check back after the next release. diff --git a/docs/concepts/registry.md b/docs/concepts/registry.md new file mode 100644 index 00000000..34dc5ed0 --- /dev/null +++ b/docs/concepts/registry.md @@ -0,0 +1,3 @@ +# Registry + +This section is coming soon. Check back after the next release. diff --git a/docs/contributing/index.md b/docs/contributing/index.md new file mode 100644 index 00000000..e278de0a --- /dev/null +++ b/docs/contributing/index.md @@ -0,0 +1,19 @@ +# Contributing + +## Developer Guide + +The preferred contribution flow is: + +1. Fork the repository. +2. Create a branch with a meaningful name. +3. Make changes using Conventional Commits. +4. Push the branch to your fork. +5. Create a pull request. + +Conventional Commits reference: +https://www.conventionalcommits.org/en/v1.0.0/#summary + +## Maintainers and Governance + +See the [Maintainers](maintainers.md) page for the current maintainer list and original +contributors. diff --git a/docs/contributing/maintainers.md b/docs/contributing/maintainers.md new file mode 100644 index 00000000..375659a1 --- /dev/null +++ b/docs/contributing/maintainers.md @@ -0,0 +1,20 @@ +# Contributors of Hypernate + +## Maintainers + +Below is a table of active Hypernate maintainers. + +| Name | GitHub | Discord | +| --------------------- | ------- | ------- | +| Bertalan Zoltán Péter | bzp99 | | +| Attila Klenik | aklenik | klenik | + +## Original Contributors + +- Bertalan Zoltán Péter +- Attila Klenik +- Imre Kocsis +- [Budapest University of Technology and Economics](https://www.bme.hu/en/) + - [Faculty of Electrical Engineering and Informatics](https://www.vik.bme.hu/en/) + - [Department of Artificial Intelligence and Systems Engineering](http://www.mit.bme.hu/eng/) + - [Critical Systems Research Group](https://ftsrg.mit.bme.hu/en/) diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md new file mode 100644 index 00000000..95f5a4c1 --- /dev/null +++ b/docs/getting-started/index.md @@ -0,0 +1,59 @@ +# Getting Started + +## Overview + +Hypernate is an entity framework for Hyperledger Fabric. + +If Fabric allows you to keep your familiar programming language, then Hypernate will allow you to +keep your familiar programming style. + +No more low-level boilerplate code for key-value storage operations and other housekeeping tasks. +Take advantage of Hypernate's high abstraction level, aspect-oriented approaches, and extensibility +to keep critical business logic as clean as possible. + +Enhance your chaincode with features like: + +- Object-oriented CRUD (create, read, update, delete) operations with explicit semantics +- Declarative and flexible configuration of entity keys +- An extensible chain of middleware processors handling non-business concerns + +Get access to these features using a simple incantation: + +```java +public class MyBusinessContract implements HypernateContract +``` + +More features are on the way: + +- Partial and range queries based on non-key attributes +- Range query support for composite keys +- Overall friendlier query support +- OpenTelemetry integration +- Support for data schemas + +## Setup Flow + +For complete examples, refer to the +[hypernate-samples](https://github.com/LF-Decentralized-Trust-labs/hypernate-samples) +repository. + +The typical setup flow is: + +1. Include the Hypernate library as a dependency in your project. +2. Use Hypernate annotations on your entities (DTOs or POJOs). +3. Use `HypernateContract` as the base class for your business `Contract` implementation. +4. Use the `Registry` class to handle annotated entities in a friendly way. + +The easiest integration path is using `HypernateContext` as your transaction `Context` +implementation so it can wire the registry and middleware setup automatically. + +## Next Steps + +- Continue to the [Concepts overview](../concepts/index.md) for design details. +- Follow the [Quickstart](quickstart.md) once available. +- Read [Installation](installation.md) for environment setup details. + +## License + +Hypernate uses the Apache License Version 2.0. +For details, see `NOTICES.md`, `MAINTAINERS.md`, and `LICENSE` in the repository root. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 00000000..d3ec4b12 --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,3 @@ +# Installation + +This section is coming soon. Check back after the next release. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md new file mode 100644 index 00000000..4c553d91 --- /dev/null +++ b/docs/getting-started/quickstart.md @@ -0,0 +1,3 @@ +# Quickstart + +This section is coming soon. Check back after the next release. diff --git a/docs/guides/cicd.md b/docs/guides/cicd.md new file mode 100644 index 00000000..5d06cfb8 --- /dev/null +++ b/docs/guides/cicd.md @@ -0,0 +1,69 @@ +# CI/CD + +This project uses GitHub Actions for continuous integration and delivery. + +## Workflows + +| Workflow | File | Purpose | +| ---------------- | -------------------------------------------- | --------------------------------------------------------- | +| Build & Test | `.github/workflows/build.yml` | Formatting checks, compilation, and tests | +| Triage Label | `.github/workflows/triage_label.yml` | Labels newly opened issues with `needs-triage` | +| Design Approval | `.github/workflows/check_design_approval.yml`| Flags PRs without a linked design/approved issue | +| Stale PRs | `.github/workflows/stale_not_approved.yml` | Marks and eventually closes stale, non-approved PRs | + +## Build & Test + +The Build & Test workflow runs `spotlessCheck`, `assemble`, and `test` on pushes and pull +requests that touch code-relevant files. + +Documentation-only changes are skipped via path filters. + +If a new commit is pushed while a run is already in progress for the same branch, the older run is +automatically canceled. + +## Triage Label + +When an issue is opened, the Triage Label workflow automatically adds the `needs-triage` label so +new issues are easy to find and route. + +## Design Approval + +On every pull request (opened, edited, synchronized, or reopened), the Design Approval workflow checks that the PR links to an issue carrying the `design/approved` label. +PRs that lack such a linked issue are flagged via the `needs-approved-issue` label. + +Because the workflow is triggered by changes to the PR, it will not rerun if a non-`design/approved` issue gets the `design/approved` label. +In these cases, either a maintainer can force rerun the check or you can make a dummy change to the PR body (like adding a newline) and it will trigger the workflow. + +## Stale PRs + +A scheduled daily job marks pull requests carrying the `needs-approved-issue` label as `stale` +after 14 days of inactivity and closes them after 60 days. PRs labeled `pinned` or `security` are +exempt. Issues are never affected. + +## Reading CI Results + +1. **Status check on PRs** - The workflow result appears as a check on the pull request page. +2. **Actions tab** - Open the repository Actions tab to view workflow runs. +3. **Test report artifact** - Every run uploads an HTML test report artifact retained for 14 days. + +## Troubleshooting + +### Formatting Check Failed + +The `spotlessCheck` step enforces Google Java Format. To fix locally: + +```bash +./gradlew spotlessApply +``` + +This reformats source files in place. Commit the changes and push again. + +### Test Failure + +Check the uploaded test report artifact: + +1. Open the failed workflow run in the Actions tab. +2. Scroll to the **Artifacts** section. +3. Download **test-report** and open `index.html` in a browser. + +The report shows failing tests with stack traces and assertion messages. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..5bd68e8d --- /dev/null +++ b/docs/index.md @@ -0,0 +1,34 @@ +# Hypernate Documentation + +Hypernate is an entity framework for Hyperledger Fabric that brings ORM-like convenience to +chaincode development. + +## What Hypernate Is + +Hypernate provides object-oriented CRUD operations, declarative entity key configuration, and an +extensible middleware chain for Fabric chaincode development. + +## Why It Exists + +Fabric is powerful, but many projects repeat the same low-level key-value boilerplate code. +Hypernate exists to reduce that repetition and keep business logic clear and focused. + +## Key Features + +- Object-oriented CRUD operations with explicit behavior. +- Flexible key declarations with annotations. +- Middleware chain support for logging, caching, and similar cross-cutting concerns. + +## Start Here + +- [Getting Started Overview](getting-started/index.md) +- [Concepts Overview](concepts/index.md) +- [API Reference - Registry](api/registry.md) +- [API Reference - Annotations](api/annotations.md) + +## Guides + +- [CI/CD](guides/cicd.md) +- [Contributing](contributing/index.md) +- [Maintainers](contributing/maintainers.md) +- [Citing](references/citing.md) diff --git a/docs/references/citing.md b/docs/references/citing.md new file mode 100644 index 00000000..07f8faa2 --- /dev/null +++ b/docs/references/citing.md @@ -0,0 +1,27 @@ +# Citing + +Please use the following information when you use or reference this project (or the related +research paper) in your own work. + +Paper link: +https://doi.org/10.21203/rs.3.rs-4606405/v1 + +## Text Citation + +Damaris Jepkurui Kangogo, Bertalan Zoltán Péter, Attila Klenik, Imre Kocsis. +_Practical runtime verification of cross-organizational smart contracts_, 11 July 2024, +PREPRINT (Version 1) available at Research Square +[https://doi.org/10.21203/rs.3.rs-4606405/v1] + +## BibTeX + +```bibtex +@unpublished{kangogo2024practical, + title={Practical runtime verification of cross-organizational smart contracts}, + author={Kangogo, Damaris Jepkurui and Péter, Bertalan Zoltán and Klenik, Attila + and Kocsis, Imre}, + year={2024}, + note={Preprint at \url{https://www.researchsquare.com/article/rs-4606405/latest}}, + doi={10.21203/rs.3.rs-4606405/v1} +} +``` diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..fa286e37 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +mkdocs-material==9.6.11 +mike==2.1.3 diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/annotations/EndorsementPolicy.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/annotations/EndorsementPolicy.java new file mode 100644 index 00000000..02d5e76a --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/annotations/EndorsementPolicy.java @@ -0,0 +1,13 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface EndorsementPolicy { + String value(); +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/annotations/EntityType.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/annotations/EntityType.java new file mode 100644 index 00000000..4b06ccf3 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/annotations/EntityType.java @@ -0,0 +1,31 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Defines the explicit entity type name of the annotated class to be used for Registry composite + * keys. + * + *

This annotation is optional. In its absence, the entity type name defaults to the fully + * qualified class name (FQCN) returned by {@link Class#getName()}. + * + *

Specifying a stable, explicit name is recommended to decouple the keys in the ledger from + * potential Java class/package renames. + * + *

Example: + * + *

{@code
+ * @EntityType("Asset")
+ * @PrimaryKey(@AttributeInfo(name = Asset.Fields.id))
+ * public record Asset(String id, String color, int size) {}
+ * }
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface EntityType { + String value(); +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeDeserializationException.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeDeserializationException.java new file mode 100644 index 00000000..e1e7babd --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeDeserializationException.java @@ -0,0 +1,23 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.context; + +import lombok.Getter; + +@Getter +public class CrossChaincodeDeserializationException extends RuntimeException { + private final String chaincodeId; + private final String functionName; + private final Class expectedType; + + public CrossChaincodeDeserializationException( + String chaincodeId, String functionName, Class expectedType, Throwable cause) { + super( + String.format( + "Failed to deserialize response from %s#%s into %s: %s", + chaincodeId, functionName, expectedType.getSimpleName(), cause.getMessage()), + cause); + this.chaincodeId = chaincodeId; + this.functionName = functionName; + this.expectedType = expectedType; + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeException.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeException.java new file mode 100644 index 00000000..8174735b --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/CrossChaincodeException.java @@ -0,0 +1,24 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.context; + +import lombok.Getter; + +@Getter +public class CrossChaincodeException extends RuntimeException { + private final String chaincodeId; + private final String functionName; + private final int statusCode; + private final String originalMessage; + + public CrossChaincodeException( + String chaincodeId, String functionName, int statusCode, String originalMessage) { + super( + String.format( + "Cross-chaincode call to %s#%s failed with status %d: %s", + chaincodeId, functionName, statusCode, originalMessage)); + this.chaincodeId = chaincodeId; + this.functionName = functionName; + this.statusCode = statusCode; + this.originalMessage = originalMessage; + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/HypernateContext.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/HypernateContext.java index 2893f3f7..9f7e1e32 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/HypernateContext.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/HypernateContext.java @@ -37,6 +37,17 @@ public HypernateContext(final StubMiddlewareChain middlewareChain) { this.registry = new Registry(middlewareChain.getFirst()); } + /** + * Start a cross-chaincode invocation builder. + * + * @param chaincodeName the target chaincode name + * @param functionName the target function name + * @return an {@link InvocationBuilder} instance + */ + public InvocationBuilder invoke(String chaincodeName, String functionName) { + return new InvocationBuilder<>(this, chaincodeName, functionName); + } + /** * Send a {@link HypernateNotification}. * diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/InvocationBuilder.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/InvocationBuilder.java new file mode 100644 index 00000000..dfa56d18 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/context/InvocationBuilder.java @@ -0,0 +1,116 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.context; + +import hu.bme.mit.ftsrg.hypernate.util.JSON; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.hyperledger.fabric.shim.Chaincode.Response; + +public class InvocationBuilder { + private final HypernateContext context; + private final String chaincodeName; + private final String functionName; + private final String channel; + private final Object[] typedArgs; + private final byte[][] rawArgs; + private final Class returnType; + private final boolean voidReturn; + + private InvocationBuilder( + HypernateContext context, + String chaincodeName, + String functionName, + String channel, + Object[] typedArgs, + byte[][] rawArgs, + Class returnType, + boolean voidReturn) { + this.context = context; + this.chaincodeName = chaincodeName; + this.functionName = functionName; + this.channel = channel; + this.typedArgs = typedArgs; + this.rawArgs = rawArgs; + this.returnType = returnType; + this.voidReturn = voidReturn; + } + + public InvocationBuilder(HypernateContext context, String chaincodeName, String functionName) { + this(context, chaincodeName, functionName, null, new Object[0], null, null, false); + } + + public InvocationBuilder onChannel(String channel) { + return new InvocationBuilder<>( + context, chaincodeName, functionName, channel, typedArgs, rawArgs, returnType, voidReturn); + } + + public InvocationBuilder withArgs(Object... args) { + return new InvocationBuilder<>( + context, chaincodeName, functionName, channel, args, rawArgs, returnType, voidReturn); + } + + public InvocationBuilder withRawArgs(byte[]... args) { + return new InvocationBuilder<>( + context, chaincodeName, functionName, channel, typedArgs, args, returnType, voidReturn); + } + + public InvocationBuilder returning(Class returnType) { + return new InvocationBuilder<>( + context, chaincodeName, functionName, channel, typedArgs, rawArgs, returnType, false); + } + + public InvocationBuilder returningVoid() { + return new InvocationBuilder<>( + context, chaincodeName, functionName, channel, typedArgs, rawArgs, null, true); + } + + public T execute() { + if (rawArgs != null && typedArgs != null && typedArgs.length > 0) { + throw new IllegalStateException( + "Cannot use both withArgs() and withRawArgs() on the same invocation."); + } + if (!voidReturn && returnType == null) { + throw new IllegalStateException( + "Call .returning(Class) or .returningVoid() before execute()."); + } + + List assembledArgs = new ArrayList<>(); + assembledArgs.add(functionName.getBytes(StandardCharsets.UTF_8)); + + if (rawArgs != null) { + assembledArgs.addAll(Arrays.asList(rawArgs)); + } else if (typedArgs != null) { + for (int i = 0; i < typedArgs.length; i++) { + try { + assembledArgs.add(JSON.serialize(typedArgs[i]).getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize typed argument at index " + i, e); + } + } + } + + Response response; + if (channel != null && !channel.isEmpty()) { + response = context.getFabricStub().invokeChaincode(chaincodeName, assembledArgs, channel); + } else { + response = context.getFabricStub().invokeChaincode(chaincodeName, assembledArgs); + } + + if (response.getStatus() != Response.Status.SUCCESS) { + throw new CrossChaincodeException( + chaincodeName, functionName, response.getStatus().getCode(), response.getMessage()); + } + + if (voidReturn) { + return null; + } + + try { + return JSON.deserialize(response.getStringPayload(), returnType); + } catch (Exception e) { + throw new CrossChaincodeDeserializationException(chaincodeName, functionName, returnType, e); + } + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicy.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicy.java new file mode 100644 index 00000000..627a7140 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicy.java @@ -0,0 +1,131 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.endorsement; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Stack; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.Getter; + +public class EndorsementPolicy { + @Getter private final String expression; + + private EndorsementPolicy(String expression) { + this.expression = expression; + } + + public static EndorsementPolicy of(String expression) { + validate(expression); + return new EndorsementPolicy(expression); + } + + public static EndorsementPolicyBuilder builder() { + return new EndorsementPolicyBuilder(); + } + + /** + * NOTE: A production implementation would compile this expression to a SignaturePolicyEnvelope + * protobuf byte array using Fabric's msp/policydsl package. This prototype uses UTF-8-encoded + * expression strings for simplicity, which is not compatible with + * stub.setStateValidationParameter() in a real network. + */ + public byte[] getPolicyBytes() { + return expression.getBytes(StandardCharsets.UTF_8); + } + + private static void validate(String expr) { + if (expr == null || expr.isBlank()) { + throw new InvalidEndorsementPolicyException("Expression cannot be blank"); + } + + Stack parenStack = new Stack<>(); + char[] chars = expr.toCharArray(); + for (int i = 0; i < chars.length; i++) { + if (chars[i] == '(') parenStack.push(i); + else if (chars[i] == ')') { + if (parenStack.isEmpty()) { + throw new InvalidEndorsementPolicyException( + "Unexpected closing parenthesis at position " + i); + } + parenStack.pop(); + } + } + if (!parenStack.isEmpty()) { + throw new InvalidEndorsementPolicyException( + "Unclosed parenthesis: " + parenStack.size() + " opening parenthesis(es) never closed"); + } + + Pattern funcPattern = Pattern.compile("([A-Za-z]+)\\("); + Matcher funcMatcher = funcPattern.matcher(expr); + while (funcMatcher.find()) { + String func = funcMatcher.group(1); + if (!func.equals("AND") && !func.equals("OR") && !func.equals("OutOf")) { + throw new InvalidEndorsementPolicyException( + "Unsupported policy function '" + func + "'. Only AND, OR, OutOf are allowed."); + } + if (func.equals("OutOf")) { + int contentStart = funcMatcher.end(); + int commaIndex = expr.indexOf(',', contentStart); + int closeParen = expr.indexOf(')', contentStart); + int firstArgEnd = (commaIndex != -1 && commaIndex < closeParen) ? commaIndex : closeParen; + String firstArg = expr.substring(contentStart, firstArgEnd).trim(); + if (!firstArg.matches("\\d+")) { + throw new InvalidEndorsementPolicyException( + "OutOf() requires an integer as its first argument, found '" + firstArg + "'"); + } + } + } + + Pattern mspPattern = Pattern.compile("'([^']+)'"); + Matcher mspMatcher = mspPattern.matcher(expr); + List foundMsps = new ArrayList<>(); + while (mspMatcher.find()) { + String msp = mspMatcher.group(1); + foundMsps.add(msp); + if (!msp.matches("^[A-Za-z0-9]+MSP\\.(peer|member|admin|client)$")) { + throw new InvalidEndorsementPolicyException( + "Invalid MSP principal '" + + msp + + "'. Expected format: OrgNameMSP.(peer|member|admin|client)"); + } + } + + if (foundMsps.isEmpty()) { + throw new InvalidEndorsementPolicyException("Expression contains no valid principals"); + } + + // Validate OutOf threshold semantics + if (expr.contains("OutOf(")) { + Matcher outOfMatcher = Pattern.compile("OutOf\\(\\s*(\\d+)\\s*,").matcher(expr); + if (outOfMatcher.find()) { + int threshold = Integer.parseInt(outOfMatcher.group(1)); + // Find closing paren matching this OutOf( + int openIdx = outOfMatcher.end() - 1; // position of comma + int stack = 1; + int closeIdx = -1; + for (int i = outOfMatcher.start() + 6; i < expr.length(); i++) { + if (expr.charAt(i) == '(') stack++; + if (expr.charAt(i) == ')') { + stack--; + if (stack == 0) { + closeIdx = i; + break; + } + } + } + if (closeIdx != -1) { + String outOfBody = expr.substring(outOfMatcher.end(), closeIdx); + Matcher innerMspMatcher = mspPattern.matcher(outOfBody); + int count = 0; + while (innerMspMatcher.find()) count++; + if (threshold > count) { + throw new InvalidEndorsementPolicyException( + "OutOf threshold " + threshold + " exceeds principal count " + count); + } + } + } + } + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicyBuilder.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicyBuilder.java new file mode 100644 index 00000000..07cad833 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/EndorsementPolicyBuilder.java @@ -0,0 +1,85 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.endorsement; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +public class EndorsementPolicyBuilder { + private String combinator; + private Integer threshold; + private final List clauses = new ArrayList<>(); + + public EndorsementPolicyBuilder or() { + this.combinator = "OR"; + return this; + } + + public EndorsementPolicyBuilder and() { + this.combinator = "AND"; + return this; + } + + public EndorsementPolicyBuilder outOf(int n) { + this.combinator = "OutOf"; + this.threshold = n; + return this; + } + + public OrgBuilder org(String orgName) { + return new OrgBuilder(orgName); + } + + public EndorsementPolicy build() { + if (clauses.isEmpty()) { + throw new IllegalStateException("No principals added"); + } + if (combinator == null) { + throw new IllegalStateException("Combinator (and/or/outOf) must be set"); + } + + String joinedClauses = + clauses.stream().map(c -> "'" + c + "'").collect(Collectors.joining(", ")); + + String expression; + if ("OutOf".equals(combinator)) { + if (threshold > clauses.size()) { + throw new IllegalStateException( + "OutOf threshold " + threshold + " exceeds number of principals " + clauses.size()); + } + expression = String.format("OutOf(%d, %s)", threshold, joinedClauses); + } else { + expression = String.format("%s(%s)", combinator, joinedClauses); + } + + return EndorsementPolicy.of(expression); + } + + public class OrgBuilder { + private final String orgName; + + private OrgBuilder(String orgName) { + this.orgName = orgName; + } + + public EndorsementPolicyBuilder peer() { + clauses.add(orgName + "MSP.peer"); + return EndorsementPolicyBuilder.this; + } + + public EndorsementPolicyBuilder member() { + clauses.add(orgName + "MSP.member"); + return EndorsementPolicyBuilder.this; + } + + public EndorsementPolicyBuilder admin() { + clauses.add(orgName + "MSP.admin"); + return EndorsementPolicyBuilder.this; + } + + public EndorsementPolicyBuilder client() { + clauses.add(orgName + "MSP.client"); + return EndorsementPolicyBuilder.this; + } + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/InvalidEndorsementPolicyException.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/InvalidEndorsementPolicyException.java new file mode 100644 index 00000000..a07a3b94 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/endorsement/InvalidEndorsementPolicyException.java @@ -0,0 +1,8 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.endorsement; + +public class InvalidEndorsementPolicyException extends RuntimeException { + public InvalidEndorsementPolicyException(String message) { + super(message); + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/middleware/StubMiddleware.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/middleware/StubMiddleware.java index 2fc0afe0..878e05a3 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/middleware/StubMiddleware.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/middleware/StubMiddleware.java @@ -28,6 +28,10 @@ public abstract class StubMiddleware implements ChaincodeStub, Subscriber { + private final List results; + private final String nextBookmark; + private final boolean hasMore; + + public PagedResult(List results, String nextBookmark, boolean hasMore) { + this.results = results; + this.nextBookmark = nextBookmark; + this.hasMore = hasMore; + } + + public List results() { + return results; + } + + public String nextBookmark() { + return nextBookmark; + } + + public boolean hasMore() { + return hasMore; + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/QueryResultCollector.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/QueryResultCollector.java new file mode 100644 index 00000000..64a55d0d --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/QueryResultCollector.java @@ -0,0 +1,20 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.registry; + +import java.util.List; +import org.hyperledger.fabric.shim.ledger.KeyValue; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class QueryResultCollector { + private static final Logger logger = LoggerFactory.getLogger(QueryResultCollector.class); + + static List collect(Iterable iterable, Class clazz) { + List results = new java.util.ArrayList<>(); + for (KeyValue kv : iterable) { + logger.debug("Deserializing entity at key: {}", kv.getKey()); + results.add(Registry.EntityUtil.fromBuffer(kv.getValue(), clazz)); + } + return results; + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RangeQueryBuilder.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RangeQueryBuilder.java new file mode 100644 index 00000000..e28e829e --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RangeQueryBuilder.java @@ -0,0 +1,130 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.registry; + +import hu.bme.mit.ftsrg.hypernate.registry.query.InvalidRangeQueryException; +import java.util.List; +import org.hyperledger.fabric.shim.ledger.QueryResultsIteratorWithMetadata; + +public class RangeQueryBuilder { + private final Registry registry; + private final Class clazz; + private Object[] prefixParts; + private boolean isFullScan = false; + private boolean isPaginated = false; + private int pageSize; + private String bookmark; + + public RangeQueryBuilder(Registry registry, Class clazz) { + this.registry = registry; + this.clazz = clazz; + } + + public RangeQueryBuilder withKeyPrefix(Object... prefixParts) { + validatePrimaryKeyConfiguration(); + + int expectedCount = Registry.EntityUtil.getPrimaryKeyCount(clazz); + if (prefixParts.length > expectedCount) { + throw new InvalidRangeQueryException( + String.format( + "Too many prefix parts for %s: provided %d, maximum is %d.", + clazz.getName(), prefixParts.length, expectedCount)); + } + + for (int i = 0; i < prefixParts.length; i++) { + if (prefixParts[i] == null) { + throw new InvalidRangeQueryException( + String.format( + "Null prefix component at index %d for %s. Null composite key parts produce silently incorrect range scans.", + i, clazz.getName())); + } + } + + this.prefixParts = prefixParts; + this.isFullScan = false; + return this; + } + + public RangeQueryBuilder fullScan() { + validatePrimaryKeyConfiguration(); + this.isFullScan = true; + this.prefixParts = new Object[0]; + return this; + } + + public RangeQueryBuilder paginated(int pageSize, String bookmark) { + if (pageSize <= 0) { + throw new IllegalArgumentException("pageSize must be > 0"); + } + this.isPaginated = true; + this.pageSize = pageSize; + this.bookmark = (bookmark == null) ? "" : bookmark; + return this; + } + + public List execute() { + if (!isFullScan && prefixParts == null) { + throw new InvalidRangeQueryException( + "execute() called without withKeyPrefix() or fullScan(). Configure the query before executing."); + } + + org.hyperledger.fabric.shim.ledger.CompositeKey compositeKey = buildPartialKey(); + + if (isPaginated) { + try (QueryResultsIteratorWithMetadata iter = + registry + .getStub() + .getStateByPartialCompositeKeyWithPagination(compositeKey, pageSize, bookmark)) { + return QueryResultCollector.collect(iter, clazz); + } catch (Exception e) { + throw new RuntimeException("Failed to close iterator or collect results", e); + } + } else { + try (org.hyperledger.fabric.shim.ledger.QueryResultsIterator< + org.hyperledger.fabric.shim.ledger.KeyValue> + iter = registry.getStub().getStateByPartialCompositeKey(compositeKey)) { + return QueryResultCollector.collect(iter, clazz); + } catch (Exception e) { + throw new RuntimeException("Failed to close iterator or collect results", e); + } + } + } + + public PagedResult executePaged() { + if (!isPaginated) { + throw new IllegalStateException( + "executePaged() requires .paginated() to be configured first."); + } + if (!isFullScan && prefixParts == null) { + throw new InvalidRangeQueryException( + "executePaged() called without withKeyPrefix() or fullScan()."); + } + + org.hyperledger.fabric.shim.ledger.CompositeKey compositeKey = buildPartialKey(); + try (QueryResultsIteratorWithMetadata iter = + registry + .getStub() + .getStateByPartialCompositeKeyWithPagination(compositeKey, pageSize, bookmark)) { + String nextBookmark = iter.getMetadata().getBookmark(); + boolean hasMore = !nextBookmark.isEmpty() || iter.getMetadata().getFetchedRecordsCount() > 0; + List results = QueryResultCollector.collect(iter, clazz); + return new PagedResult<>(results, nextBookmark, hasMore); + } catch (Exception e) { + throw new RuntimeException("Failed to close iterator or collect results", e); + } + } + + private void validatePrimaryKeyConfiguration() { + if (Registry.EntityUtil.getPrimaryKeyCount(clazz) == 0) { + throw new InvalidRangeQueryException( + String.format( + "Cannot range-query %s: no @PrimaryKey annotation found. Range queries require at least one composite key field.", + clazz.getName())); + } + } + + private org.hyperledger.fabric.shim.ledger.CompositeKey buildPartialKey() { + String type = Registry.EntityUtil.getType(clazz); + String[] mappedParts = Registry.EntityUtil.mapKeyPartsToString(clazz, prefixParts); + return registry.getStub().createCompositeKey(type, mappedParts); + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java index 46a6df4d..dc7b6440 100644 --- a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/Registry.java @@ -3,6 +3,7 @@ import com.jcabi.aspects.Loggable; import hu.bme.mit.ftsrg.hypernate.annotations.AttributeInfo; +import hu.bme.mit.ftsrg.hypernate.annotations.EntityType; import hu.bme.mit.ftsrg.hypernate.annotations.PrimaryKey; import hu.bme.mit.ftsrg.hypernate.util.JSON; import java.lang.reflect.Constructor; @@ -31,6 +32,10 @@ public Registry(final ChaincodeStub stub) { this.stub = stub; } + ChaincodeStub getStub() { + return stub; + } + /** * Create a new entity. * @@ -44,6 +49,7 @@ public void mustCreate(final T entity) throws EntityExistsException { final String key = getCompositeKey(entity); final byte[] buffer = EntityUtil.toBuffer(entity); stub.putState(key, buffer); + applyEndorsementPolicyIfPresent(entity, key); } /** @@ -77,6 +83,7 @@ public void mustUpdate(final T entity) throws EntityNotFoundException { final String key = getCompositeKey(entity); final byte[] buffer = EntityUtil.toBuffer(entity); stub.putState(key, buffer); + applyEndorsementPolicyIfPresent(entity, key); } /** @@ -190,22 +197,74 @@ public T tryRead(Class clazz, Object... keys) { */ public List readAll(final Class clazz) { final String key = stub.createCompositeKey(EntityUtil.getType(clazz)).toString(); - Iterator iterator = stub.getStateByPartialCompositeKey(key).iterator(); - Iterable iterable = () -> iterator; + try (org.hyperledger.fabric.shim.ledger.QueryResultsIterator iterator = + stub.getStateByPartialCompositeKey(key)) { + return collectResults(iterator, clazz); + } catch (Exception e) { + throw new RuntimeException("Failed to read all entities", e); + } + } + + /** + * Start a fluent CouchDB rich query for the given entity type. + * + * @param clazz the entity class + * @return a {@link RichQueryBuilder} instance + */ + public RichQueryBuilder query(Class clazz) { + return new RichQueryBuilder<>(this, clazz); + } + + /** + * Start a fluent composite key range scan for the given entity type. + * + * @param clazz the entity class + * @return a {@link RangeQueryBuilder} instance + */ + public RangeQueryBuilder rangeQuery(Class clazz) { + return new RangeQueryBuilder<>(this, clazz); + } + + List collectResults( + org.hyperledger.fabric.shim.ledger.QueryResultsIterator iterator, Class clazz) { + Iterable iterable = () -> iterator.iterator(); return StreamSupport.stream(iterable.spliterator(), false) .map( kv -> { final byte[] value = kv.getValue(); - logger.debug( - "Found value at partial key {}: {} -> {}", - key, - kv.getKey(), - Arrays.toString(value)); + logger.debug("Found value at key {}: {}", kv.getKey(), Arrays.toString(value)); return EntityUtil.fromBuffer(value, clazz); }) .collect(Collectors.toList()); } + /** + * Dynamically set a state-based endorsement policy for an asset. + * + * @param entity the entity to apply the policy to + * @param policy the compiled endorsement policy + * @param the entity type + */ + public void setEndorsementPolicy( + T entity, hu.bme.mit.ftsrg.hypernate.endorsement.EndorsementPolicy policy) { + if (!exists(entity)) { + throw new EntityNotFoundException("Cannot set endorsement policy: entity does not exist"); + } + stub.setStateValidationParameter(getCompositeKey(entity), policy.getPolicyBytes()); + } + + private void applyEndorsementPolicyIfPresent(T entity, String key) { + hu.bme.mit.ftsrg.hypernate.annotations.EndorsementPolicy annot = + entity + .getClass() + .getAnnotation(hu.bme.mit.ftsrg.hypernate.annotations.EndorsementPolicy.class); + if (annot != null) { + hu.bme.mit.ftsrg.hypernate.endorsement.EndorsementPolicy policy = + hu.bme.mit.ftsrg.hypernate.endorsement.EndorsementPolicy.of(annot.value()); + stub.setStateValidationParameter(key, policy.getPolicyBytes()); + } + } + @Loggable(Loggable.DEBUG) private boolean keyExists(final String key) { final byte[] valueOnLedger = stub.getState(key); @@ -237,7 +296,7 @@ private String getCompositeKey(final T ent) { } @UtilityClass - private class EntityUtil { + class EntityUtil { private final Logger logger = LoggerFactory.getLogger(EntityUtil.class); @@ -246,7 +305,19 @@ String getType(final T entity) { } String getType(final Class clazz) { - return clazz.getName().toUpperCase(); + final EntityType annot = clazz.getAnnotation(EntityType.class); + if (annot == null) { + return clazz.getName(); + } + + final String value = annot.value(); + if (value.isBlank()) { + throw new IllegalArgumentException( + String.format( + "The @EntityType annotation on class %s has an empty or blank value", + clazz.getName())); + } + return value; } int getPrimaryKeyCount(final Class clazz) { diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RichQueryBuilder.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RichQueryBuilder.java new file mode 100644 index 00000000..01304397 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/RichQueryBuilder.java @@ -0,0 +1,206 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.registry; + +import hu.bme.mit.ftsrg.hypernate.middleware.StubMiddleware; +import hu.bme.mit.ftsrg.hypernate.middleware.WriteBackCachedStubMiddleware; +import hu.bme.mit.ftsrg.hypernate.registry.query.UncommittedStateException; +import hu.bme.mit.ftsrg.hypernate.util.JSON; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.hyperledger.fabric.shim.ChaincodeStub; +import org.hyperledger.fabric.shim.ledger.QueryResultsIteratorWithMetadata; + +public class RichQueryBuilder { + + private final Registry registry; + private final Class clazz; + private final SelectorBuilder selectorBuilder = new SelectorBuilder(); + private final List> sort = new ArrayList<>(); + private String bookmark; + private List useIndex; + private Integer limit; + private Integer skip; + + public RichQueryBuilder(Registry registry, Class clazz) { + this.registry = registry; + this.clazz = clazz; + this.selectorBuilder.addCondition("docType", "$eq", Registry.EntityUtil.getType(clazz)); + } + + public ConditionBuilder where(String field) { + return new ConditionBuilder(field); + } + + public ConditionBuilder and(String field) { + return new ConditionBuilder(field); + } + + public RichQueryBuilder or(RichQueryBuilder... subQueries) { + List> subSelectors = new ArrayList<>(); + for (RichQueryBuilder sq : subQueries) { + subSelectors.add(sq.selectorBuilder.build()); + } + selectorBuilder.addOr(subSelectors); + return this; + } + + /** + * Add a sort condition. Note: CouchDB requires an index to exist on the sorted fields. If an + * index does not exist, Fabric CouchDB runtime will return an error during execution. + */ + public RichQueryBuilder sortBy(String field, SortOrder order) { + this.sort.add(Map.of(field, order == SortOrder.DESC ? "desc" : "asc")); + return this; + } + + public RichQueryBuilder useIndex(String designDoc, String indexName) { + this.useIndex = Arrays.asList(designDoc, indexName); + return this; + } + + public RichQueryBuilder bookmark(String token) { + this.bookmark = token; + return this; + } + + public RichQueryBuilder limit(int n) { + this.limit = n; + return this; + } + + public RichQueryBuilder skip(int n) { + this.skip = n; + return this; + } + + /** + * Execute the rich query. Note: Calling execute() with no conditions will result in a + * full-collection scan for the given entity type, matching all documents of this type. + */ + public List execute() throws UncommittedStateException { + checkUncommittedState(); + String queryString = buildQueryString(); + try (org.hyperledger.fabric.shim.ledger.QueryResultsIterator< + org.hyperledger.fabric.shim.ledger.KeyValue> + iter = registry.getStub().getQueryResult(queryString)) { + return QueryResultCollector.collect(iter, clazz); + } catch (Exception e) { + throw new RuntimeException("Failed to close iterator or collect results", e); + } + } + + public PagedResult executePaged() throws UncommittedStateException { + checkUncommittedState(); + String queryString = buildQueryString(); + int pageSize = (limit != null) ? limit : 25; + String bm = (bookmark != null) ? bookmark : ""; + + try (QueryResultsIteratorWithMetadata iter = + registry.getStub().getQueryResultWithPagination(queryString, pageSize, bm)) { + String nextBookmark = iter.getMetadata().getBookmark(); + boolean hasMore = !nextBookmark.isEmpty() || iter.getMetadata().getFetchedRecordsCount() > 0; + List results = QueryResultCollector.collect(iter, clazz); + return new PagedResult<>(results, nextBookmark, hasMore); + } catch (Exception e) { + throw new RuntimeException("Failed to close iterator or collect results", e); + } + } + + private String buildQueryString() { + Map query = new HashMap<>(); + query.put("selector", selectorBuilder.build()); + if (!sort.isEmpty()) query.put("sort", sort); + if (useIndex != null) query.put("use_index", useIndex); + if (bookmark != null) query.put("bookmark", bookmark); + if (limit != null) query.put("limit", limit); + if (skip != null) query.put("skip", skip); + + try { + return JSON.serialize(query); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize CouchDB query", e); + } + } + + private void checkUncommittedState() throws UncommittedStateException { + ChaincodeStub current = registry.getStub(); + while (current instanceof StubMiddleware sm) { + if (current instanceof WriteBackCachedStubMiddleware wbc) { + if (wbc.hasUncommittedState()) { + throw new UncommittedStateException( + "Rich query cannot proceed: uncommitted writes exist in the middleware cache. " + + "CouchDB reads committed ledger state only — buffered writes in WriteBackCachedStubMiddleware are invisible to getQueryResult(). " + + "Flush pending writes via ctx.notify(new TransactionEnd()) before querying, or restructure your transaction to query before writing."); + } + } + current = sm.getNextStub(); + } + } + + public class ConditionBuilder { + private final String field; + + private ConditionBuilder(String field) { + this.field = field; + } + + public RichQueryBuilder is(Object value) { + selectorBuilder.addCondition(field, "$eq", value); + return RichQueryBuilder.this; + } + + public RichQueryBuilder isNot(Object value) { + selectorBuilder.addCondition(field, "$ne", value); + return RichQueryBuilder.this; + } + + public RichQueryBuilder greaterThan(Object value) { + selectorBuilder.addCondition(field, "$gt", value); + return RichQueryBuilder.this; + } + + public RichQueryBuilder greaterThanOrEq(Object value) { + selectorBuilder.addCondition(field, "$gte", value); + return RichQueryBuilder.this; + } + + public RichQueryBuilder lessThan(Object value) { + selectorBuilder.addCondition(field, "$lt", value); + return RichQueryBuilder.this; + } + + public RichQueryBuilder lessThanOrEq(Object value) { + selectorBuilder.addCondition(field, "$lte", value); + return RichQueryBuilder.this; + } + + public RichQueryBuilder in(Object... values) { + selectorBuilder.addCondition(field, "$in", Arrays.asList(values)); + return RichQueryBuilder.this; + } + + public RichQueryBuilder notIn(Object... values) { + selectorBuilder.addCondition(field, "$nin", Arrays.asList(values)); + return RichQueryBuilder.this; + } + + public RichQueryBuilder exists(boolean present) { + selectorBuilder.addCondition(field, "$exists", present); + return RichQueryBuilder.this; + } + + public RichQueryBuilder regex(String pattern) { + selectorBuilder.addCondition(field, "$regex", pattern); + return RichQueryBuilder.this; + } + + public RichQueryBuilder between(Object low, Object high) { + selectorBuilder.addCondition(field, "$gte", low); + selectorBuilder.addCondition(field, "$lte", high); + return RichQueryBuilder.this; + } + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SelectorBuilder.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SelectorBuilder.java new file mode 100644 index 00000000..03433df5 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SelectorBuilder.java @@ -0,0 +1,66 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.registry; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +class SelectorBuilder { + private final Map selector = new HashMap<>(); + private final List> orConditions = new ArrayList<>(); + + @SuppressWarnings("unchecked") + void addCondition(String field, String operator, Object value) { + Object existing = selector.get(field); + + if (existing == null) { + if (operator.equals("$eq")) { + selector.put(field, value); + } else { + Map fieldConditions = new HashMap<>(); + fieldConditions.put(operator, value); + selector.put(field, fieldConditions); + } + } else if (existing instanceof Map && !((Map) existing).containsKey(operator)) { + ((Map) existing).put(operator, value); + } else { + List> andList; + if (selector.containsKey("$and")) { + andList = (List>) selector.get("$and"); + } else { + andList = new ArrayList<>(); + selector.put("$and", andList); + } + + Map newCond = new HashMap<>(); + if (operator.equals("$eq")) { + newCond.put(field, value); + } else { + newCond.put(field, Map.of(operator, value)); + } + andList.add(newCond); + } + } + + void addOr(List> subSelectors) { + if (subSelectors != null && !subSelectors.isEmpty()) { + orConditions.addAll(subSelectors); + } + } + + Map build() { + Map finalSelector = new HashMap<>(selector); + if (!orConditions.isEmpty()) { + if (finalSelector.containsKey("$or")) { + @SuppressWarnings("unchecked") + List> existingOrs = + (List>) finalSelector.get("$or"); + existingOrs.addAll(orConditions); + } else { + finalSelector.put("$or", new ArrayList<>(orConditions)); + } + } + return finalSelector; + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SortOrder.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SortOrder.java new file mode 100644 index 00000000..fda150b0 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/SortOrder.java @@ -0,0 +1,7 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.registry; + +public enum SortOrder { + ASC, + DESC +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/query/InvalidRangeQueryException.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/query/InvalidRangeQueryException.java new file mode 100644 index 00000000..86cb85ca --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/query/InvalidRangeQueryException.java @@ -0,0 +1,8 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.registry.query; + +public class InvalidRangeQueryException extends RuntimeException { + public InvalidRangeQueryException(String message) { + super(message); + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/query/UncommittedStateException.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/query/UncommittedStateException.java new file mode 100644 index 00000000..7e628ab7 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/query/UncommittedStateException.java @@ -0,0 +1,8 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.registry.query; + +public class UncommittedStateException extends Exception { + public UncommittedStateException(String message) { + super(message); + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/Asset.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/Asset.java new file mode 100644 index 00000000..29fbb514 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/Asset.java @@ -0,0 +1,102 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.sample; + +import hu.bme.mit.ftsrg.hypernate.annotations.AttributeInfo; +import hu.bme.mit.ftsrg.hypernate.annotations.PrimaryKey; + +@PrimaryKey({@AttributeInfo(name = "owner"), @AttributeInfo(name = "id")}) +public class Asset { + private String id; + private String color; + private int size; + private String owner; + private int value; + + private long timestamp; + private AssetStatus status; + + // Constructors + public Asset() {} + + public Asset(String id, String color, int size, String owner, int value) { + this.id = id; + this.color = color; + this.size = size; + this.owner = owner; + this.value = value; + } + + public Asset( + String id, + String color, + int size, + String owner, + int value, + long timestamp, + AssetStatus status) { + this.id = id; + this.color = color; + this.size = size; + this.owner = owner; + this.value = value; + this.timestamp = timestamp; + this.status = status; + } + + // Getters and Setters + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getColor() { + return color; + } + + public void setColor(String color) { + this.color = color; + } + + public int getSize() { + return size; + } + + public void setSize(int size) { + this.size = size; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public int getValue() { + return value; + } + + public void setValue(int value) { + this.value = value; + } + + public long getTimestamp() { + return timestamp; + } + + public void setTimestamp(long timestamp) { + this.timestamp = timestamp; + } + + public AssetStatus getStatus() { + return status; + } + + public void setStatus(AssetStatus status) { + this.status = status; + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/AssetStatus.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/AssetStatus.java new file mode 100644 index 00000000..2faa20bf --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/AssetStatus.java @@ -0,0 +1,8 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.sample; + +public enum AssetStatus { + ACTIVE, + PENDING_TRANSFER, + ARCHIVED +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingRequest.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingRequest.java new file mode 100644 index 00000000..5bdc041f --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingRequest.java @@ -0,0 +1,50 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.sample; + +public class PricingRequest { + private String assetId; + private int currentValue; + private String newOwner; + private int offeredPrice; + + public PricingRequest() {} + + public PricingRequest(String assetId, int currentValue, String newOwner, int offeredPrice) { + this.assetId = assetId; + this.currentValue = currentValue; + this.newOwner = newOwner; + this.offeredPrice = offeredPrice; + } + + public String assetId() { + return assetId; + } + + public int currentValue() { + return currentValue; + } + + public String newOwner() { + return newOwner; + } + + public int offeredPrice() { + return offeredPrice; + } + + public String getAssetId() { + return assetId; + } + + public int getCurrentValue() { + return currentValue; + } + + public String getNewOwner() { + return newOwner; + } + + public int getOfferedPrice() { + return offeredPrice; + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingResponse.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingResponse.java new file mode 100644 index 00000000..1c64d845 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/PricingResponse.java @@ -0,0 +1,40 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.sample; + +public class PricingResponse { + private boolean valid; + private int approvedPrice; + private String notes; + + public PricingResponse() {} + + public PricingResponse(boolean valid, int approvedPrice, String notes) { + this.valid = valid; + this.approvedPrice = approvedPrice; + this.notes = notes; + } + + public boolean valid() { + return valid; + } + + public int approvedPrice() { + return approvedPrice; + } + + public String notes() { + return notes; + } + + public boolean isValid() { + return valid; + } + + public int getApprovedPrice() { + return approvedPrice; + } + + public String getNotes() { + return notes; + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SampleChaincode.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SampleChaincode.java new file mode 100644 index 00000000..957cbbb5 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SampleChaincode.java @@ -0,0 +1,244 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.sample; + +import hu.bme.mit.ftsrg.hypernate.context.HypernateContext; +import hu.bme.mit.ftsrg.hypernate.endorsement.EndorsementPolicy; +import hu.bme.mit.ftsrg.hypernate.registry.Registry; +import hu.bme.mit.ftsrg.hypernate.registry.SortOrder; +import hu.bme.mit.ftsrg.hypernate.registry.query.UncommittedStateException; +import hu.bme.mit.ftsrg.hypernate.util.JSON; +import java.util.List; +import org.hyperledger.fabric.contract.Context; +import org.hyperledger.fabric.contract.ContractInterface; +import org.hyperledger.fabric.contract.annotation.Contract; +import org.hyperledger.fabric.contract.annotation.Default; +import org.hyperledger.fabric.contract.annotation.Info; +import org.hyperledger.fabric.contract.annotation.Transaction; + +@Contract( + name = "SampleChaincode", + info = + @Info( + title = "SampleChaincode", + description = "A sample Hypernate chaincode", + version = "1.0")) +@Default +public class SampleChaincode implements ContractInterface { + + @Transaction + public void createAsset( + HypernateContext ctx, String id, String color, int size, String owner, int value) { + if (id == null || id.isBlank()) + throw new IllegalArgumentException("Field 'id' must not be blank"); + if (color == null || color.isBlank()) + throw new IllegalArgumentException("Field 'color' must not be blank"); + if (owner == null || owner.isBlank()) + throw new IllegalArgumentException("Field 'owner' must not be blank"); + if (size <= 0) throw new IllegalArgumentException("Field 'size' must be positive"); + if (value <= 0) throw new IllegalArgumentException("Field 'value' must be positive"); + + long timestampMs = ctx.getStub().getTxTimestamp().toEpochMilli(); + Asset asset = new Asset(id, color, size, owner, value, timestampMs, AssetStatus.ACTIVE); + ctx.getRegistry().mustCreate(asset); + } + + @Transaction + public void createSensitiveAsset( + HypernateContext ctx, + String id, + String color, + int size, + String owner, + int value, + String classLevel, + String orgsJson) { + if (id == null || id.isBlank()) + throw new IllegalArgumentException("Field 'id' must not be blank"); + if (color == null || color.isBlank()) + throw new IllegalArgumentException("Field 'color' must not be blank"); + if (owner == null || owner.isBlank()) + throw new IllegalArgumentException("Field 'owner' must not be blank"); + if (size <= 0) throw new IllegalArgumentException("Field 'size' must be positive"); + if (value <= 0) throw new IllegalArgumentException("Field 'value' must be positive"); + + List orgs; + try { + @SuppressWarnings("unchecked") + List parsed = JSON.deserialize(orgsJson, List.class); + orgs = parsed; + } catch (Exception e) { + throw new IllegalArgumentException("Failed to deserialize orgsJson: " + e.getMessage(), e); + } + + long timestampMs = ctx.getStub().getTxTimestamp().toEpochMilli(); + + SensitiveAsset sensitiveAsset = + new SensitiveAsset( + id, color, size, owner, value, timestampMs, AssetStatus.ACTIVE, classLevel, orgs); + ctx.getRegistry().mustCreate(sensitiveAsset); + } + + @Transaction + public Asset getAsset(HypernateContext ctx, String owner, String id) { + return ctx.getRegistry().mustRead(Asset.class, owner, id); + } + + @Transaction + public List queryAssetsByColorAndOwner( + HypernateContext ctx, String color, String owner1, String owner2) { + try { + return ctx.getRegistry() + .query(Asset.class) + .where("color") + .is(color) + .and("owner") + .in(owner1, owner2) + .and("status") + .is(AssetStatus.ACTIVE.name()) + .sortBy("value", SortOrder.DESC) + .limit(100) + .execute(); + } catch (UncommittedStateException e) { + throw new RuntimeException(e); + } + } + + @Transaction + public List queryAssetsByValueRange(HypernateContext ctx, int minValue, int maxValue) { + if (minValue > maxValue) throw new IllegalArgumentException("minValue must be <= maxValue"); + try { + return ctx.getRegistry() + .query(Asset.class) + .where("value") + .between(minValue, maxValue) + .and("status") + .isNot(AssetStatus.ARCHIVED.name()) + .sortBy("value", SortOrder.ASC) + .execute(); + } catch (UncommittedStateException e) { + throw new RuntimeException(e); + } + } + + @Transaction + public List listAssetsByOwner(HypernateContext ctx, String owner) { + return ctx.getRegistry().rangeQuery(Asset.class).withKeyPrefix(owner).execute(); + } + + @Transaction + public List listAllAssets(HypernateContext ctx) { + return ctx.getRegistry().rangeQuery(Asset.class).fullScan().execute(); + } + + @Transaction + public Asset initiateTransfer( + HypernateContext ctx, String assetId, String currentOwner, String newOwner, int agreedPrice) { + Registry registry = ctx.getRegistry(); + Asset asset = registry.mustRead(Asset.class, currentOwner, assetId); + + if (asset.getStatus() != AssetStatus.ACTIVE) { + throw new RuntimeException( + String.format( + "Asset %s is not in ACTIVE status; current status: %s. Only ACTIVE assets can be transferred.", + assetId, asset.getStatus())); + } + if (!asset.getOwner().equals(currentOwner)) { + throw new RuntimeException( + String.format( + "Owner mismatch: asset %s is owned by %s, not %s", + assetId, asset.getOwner(), currentOwner)); + } + + PricingResponse pricing = + ctx.invoke("PricingChaincode", "validatePrice") + .withArgs(new PricingRequest(assetId, asset.getValue(), newOwner, agreedPrice)) + .returning(PricingResponse.class) + .execute(); + + if (!pricing.valid()) { + throw new RuntimeException("Transfer rejected by PricingChaincode: " + pricing.notes()); + } + + asset.setStatus(AssetStatus.PENDING_TRANSFER); + registry.mustUpdate(asset); + return asset; + } + + @Transaction + public Asset completeTransfer( + HypernateContext ctx, String assetId, String currentOwner, String newOwner) { + Registry registry = ctx.getRegistry(); + Asset asset = registry.mustRead(Asset.class, currentOwner, assetId); + + if (asset.getStatus() != AssetStatus.PENDING_TRANSFER) { + throw new RuntimeException( + String.format( + "Asset %s is not pending transfer; status: %s", assetId, asset.getStatus())); + } + + asset.setOwner(newOwner); + asset.setStatus(AssetStatus.ACTIVE); + asset.setTimestamp(ctx.getStub().getTxTimestamp().toEpochMilli()); + registry.mustUpdate(asset); + return asset; + } + + @Transaction + public void archiveAsset(HypernateContext ctx, String owner, String id) { + Asset asset = ctx.getRegistry().mustRead(Asset.class, owner, id); + if (asset.getStatus() == AssetStatus.ARCHIVED) { + throw new RuntimeException("Asset " + id + " is already archived"); + } + asset.setStatus(AssetStatus.ARCHIVED); + ctx.getRegistry().mustUpdate(asset); + } + + @Transaction + public void setSensitiveAssetPolicy( + HypernateContext ctx, String id, String owner, String expression) { + SensitiveAsset asset = ctx.getRegistry().mustRead(SensitiveAsset.class, owner, id); + EndorsementPolicy policy = EndorsementPolicy.of(expression); + ctx.getRegistry().setEndorsementPolicy(asset, policy); + } +} + +@Contract( + name = "PricingChaincode", + info = + @Info(title = "PricingChaincode", description = "Mock pricing chaincode", version = "1.0")) +class PricingChaincode implements ContractInterface { + + @Transaction + public String validatePrice(Context ctx, String pricingRequestJson) { + PricingRequest req; + try { + req = JSON.deserialize(pricingRequestJson, PricingRequest.class); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid JSON for PricingRequest: " + e.getMessage(), e); + } + + if (req.currentValue() > 1000) { + boolean approved = req.offeredPrice() >= req.currentValue() * 0.9; + if (!approved) { + try { + return JSON.serialize( + new PricingResponse( + false, + 0, + String.format( + "Offered price %d is below 90%% of market value %d", + req.offeredPrice(), req.currentValue()))); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + try { + return JSON.serialize( + new PricingResponse( + true, req.offeredPrice(), "Transfer approved at price " + req.offeredPrice())); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SensitiveAsset.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SensitiveAsset.java new file mode 100644 index 00000000..11e07ae2 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/SensitiveAsset.java @@ -0,0 +1,126 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.sample; + +import hu.bme.mit.ftsrg.hypernate.annotations.AttributeInfo; +import hu.bme.mit.ftsrg.hypernate.annotations.EndorsementPolicy; +import hu.bme.mit.ftsrg.hypernate.annotations.PrimaryKey; + +@PrimaryKey({@AttributeInfo(name = "owner"), @AttributeInfo(name = "id")}) +@EndorsementPolicy("OR('Org1MSP.peer', 'Org2MSP.peer')") +public class SensitiveAsset { + private String id; + private String color; + private int size; + private String owner; + private int value; + + private long timestamp; + private AssetStatus status; + private String classLevel; + private java.util.List orgs; + + // Constructors + public SensitiveAsset() {} + + public SensitiveAsset(String id, String color, int size, String owner, int value) { + this.id = id; + this.color = color; + this.size = size; + this.owner = owner; + this.value = value; + } + + public SensitiveAsset( + String id, + String color, + int size, + String owner, + int value, + long timestamp, + AssetStatus status, + String classLevel, + java.util.List orgs) { + this.id = id; + this.color = color; + this.size = size; + this.owner = owner; + this.value = value; + this.timestamp = timestamp; + this.status = status; + this.classLevel = classLevel; + this.orgs = orgs; + } + + // Getters and Setters + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getColor() { + return color; + } + + public void setColor(String color) { + this.color = color; + } + + public int getSize() { + return size; + } + + public void setSize(int size) { + this.size = size; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public int getValue() { + return value; + } + + public void setValue(int value) { + this.value = value; + } + + public long getTimestamp() { + return timestamp; + } + + public void setTimestamp(long timestamp) { + this.timestamp = timestamp; + } + + public AssetStatus getStatus() { + return status; + } + + public void setStatus(AssetStatus status) { + this.status = status; + } + + public String getClassLevel() { + return classLevel; + } + + public void setClassLevel(String classLevel) { + this.classLevel = classLevel; + } + + public java.util.List getOrgs() { + return orgs; + } + + public void setOrgs(java.util.List orgs) { + this.orgs = orgs; + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferRequest.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferRequest.java new file mode 100644 index 00000000..34d4cabc --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferRequest.java @@ -0,0 +1,50 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.sample; + +public class TransferRequest { + private String assetId; + private String currentOwner; + private String newOwner; + private int agreedPrice; + + public TransferRequest() {} + + public TransferRequest(String assetId, String currentOwner, String newOwner, int agreedPrice) { + this.assetId = assetId; + this.currentOwner = currentOwner; + this.newOwner = newOwner; + this.agreedPrice = agreedPrice; + } + + public String assetId() { + return assetId; + } + + public String currentOwner() { + return currentOwner; + } + + public String newOwner() { + return newOwner; + } + + public int agreedPrice() { + return agreedPrice; + } + + public String getAssetId() { + return assetId; + } + + public String getCurrentOwner() { + return currentOwner; + } + + public String getNewOwner() { + return newOwner; + } + + public int getAgreedPrice() { + return agreedPrice; + } +} diff --git a/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferResult.java b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferResult.java new file mode 100644 index 00000000..5d590992 --- /dev/null +++ b/lib/src/main/java/hu/bme/mit/ftsrg/hypernate/sample/TransferResult.java @@ -0,0 +1,40 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate.sample; + +public class TransferResult { + private boolean approved; + private String reason; + private int finalPrice; + + public TransferResult() {} + + public TransferResult(boolean approved, String reason, int finalPrice) { + this.approved = approved; + this.reason = reason; + this.finalPrice = finalPrice; + } + + public boolean approved() { + return approved; + } + + public String reason() { + return reason; + } + + public int finalPrice() { + return finalPrice; + } + + public boolean isApproved() { + return approved; + } + + public String getReason() { + return reason; + } + + public int getFinalPrice() { + return finalPrice; + } +} diff --git a/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/PrototypeTests.java b/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/PrototypeTests.java new file mode 100644 index 00000000..3bfcc015 --- /dev/null +++ b/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/PrototypeTests.java @@ -0,0 +1,604 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +package hu.bme.mit.ftsrg.hypernate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import hu.bme.mit.ftsrg.hypernate.context.CrossChaincodeException; +import hu.bme.mit.ftsrg.hypernate.context.HypernateContext; +import hu.bme.mit.ftsrg.hypernate.endorsement.EndorsementPolicy; +import hu.bme.mit.ftsrg.hypernate.endorsement.InvalidEndorsementPolicyException; +import hu.bme.mit.ftsrg.hypernate.middleware.StubMiddlewareChain; +import hu.bme.mit.ftsrg.hypernate.middleware.WriteBackCachedStubMiddleware; +import hu.bme.mit.ftsrg.hypernate.registry.EntityNotFoundException; +import hu.bme.mit.ftsrg.hypernate.registry.Registry; +import hu.bme.mit.ftsrg.hypernate.registry.SortOrder; +import hu.bme.mit.ftsrg.hypernate.registry.query.InvalidRangeQueryException; +import hu.bme.mit.ftsrg.hypernate.registry.query.UncommittedStateException; +import hu.bme.mit.ftsrg.hypernate.sample.Asset; +import hu.bme.mit.ftsrg.hypernate.sample.AssetStatus; +import hu.bme.mit.ftsrg.hypernate.sample.PricingResponse; +import hu.bme.mit.ftsrg.hypernate.sample.SampleChaincode; +import hu.bme.mit.ftsrg.hypernate.sample.SensitiveAsset; +import hu.bme.mit.ftsrg.hypernate.sample.TransferResult; +import hu.bme.mit.ftsrg.hypernate.util.JSON; +import java.util.Collections; +import java.util.List; +import org.hyperledger.fabric.shim.Chaincode.Response; +import org.hyperledger.fabric.shim.ChaincodeStub; +import org.hyperledger.fabric.shim.ledger.CompositeKey; +import org.hyperledger.fabric.shim.ledger.KeyValue; +import org.hyperledger.fabric.shim.ledger.QueryResultsIterator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@org.mockito.junit.jupiter.MockitoSettings(strictness = org.mockito.quality.Strictness.LENIENT) +public class PrototypeTests { + + @Test + public void testQueryBuilderSelector_SingleCondition() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class); + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); + + registry.query(Asset.class).where("color").is("blue").execute(); + + String json = queryCaptor.getValue(); + assertThat(json).contains("\"color\":\"blue\""); + assertThat(json).contains("\"docType\":\"HU.BME.MIT.FTSRG.HYPERNATE.SAMPLE.ASSET\""); + } + + @Test + public void testQueryBuilderSelector_MultiCondition() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class); + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); + + registry.query(Asset.class).where("color").is("blue").and("size").greaterThan(10).execute(); + + String json = queryCaptor.getValue(); + assertThat(json).contains("\"color\":\"blue\""); + assertThat(json).contains("\"size\":{\"$gt\":10}"); + } + + @Test + public void testQueryBuilderSelector_Between() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class); + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); + + registry.query(Asset.class).where("value").between(100, 500).execute(); + + String json = queryCaptor.getValue(); + assertThat(json).contains("\"$gte\":100"); + assertThat(json).contains("\"$lte\":500"); + } + + @Test + public void testQueryBuilderSelector_In() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class); + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); + + registry.query(Asset.class).and("owner").in("Alice", "Bob").execute(); + assertThat(queryCaptor.getValue()).contains("\"$in\":[\"Alice\",\"Bob\"]"); + } + + @Test + public void testQueryBuilderSelector_SortBy() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class); + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); + + registry + .query(Asset.class) + .sortBy("value", SortOrder.DESC) + .sortBy("color", SortOrder.ASC) + .execute(); + assertThat(queryCaptor.getValue()) + .contains("\"sort\":[{\"value\":\"desc\"},{\"color\":\"asc\"}]"); + } + + @Test + public void testQueryBuilderSelector_UseIndex() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class); + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); + + registry.query(Asset.class).useIndex("designDocName", "indexName").execute(); + assertThat(queryCaptor.getValue()).contains("\"use_index\":[\"designDocName\",\"indexName\"]"); + } + + @Test + public void testQueryBuilderSelector_LimitSkip() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class); + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); + + registry.query(Asset.class).limit(50).skip(10).execute(); + String json = queryCaptor.getValue(); + assertThat(json).contains("\"limit\":50"); + assertThat(json).contains("\"skip\":10"); + } + + @Test + public void testQueryBuilderSelector_DocTypeAutoFilter() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class); + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(queryCaptor.capture())).thenReturn(iter); + + registry.query(Asset.class).execute(); + assertThat(queryCaptor.getValue()) + .contains("\"docType\":\"HU.BME.MIT.FTSRG.HYPERNATE.SAMPLE.ASSET\""); + } + + @Test + public void testUncommittedStateException_ThrowsWhenDirty() { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + StubMiddlewareChain chain = + StubMiddlewareChain.builder(fabricStub).push(WriteBackCachedStubMiddleware.class).build(); + WriteBackCachedStubMiddleware mw = (WriteBackCachedStubMiddleware) chain.getFirst(); + mw.putState("k", "v".getBytes()); + + Registry registry = new Registry(chain.getFirst()); + assertThatThrownBy(() -> registry.query(Asset.class).where("color").is("x").execute()) + .isInstanceOf(UncommittedStateException.class) + .hasMessageContaining("uncommitted writes exist in the middleware cache") + .hasMessageContaining("invisible to getQueryResult") + .hasMessageContaining("TransactionEnd"); + } + + @Test + public void testUncommittedStateException_CleanCache() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + StubMiddlewareChain chain = + StubMiddlewareChain.builder(fabricStub).push(WriteBackCachedStubMiddleware.class).build(); + Registry registry = new Registry(chain.getFirst()); + + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(anyString())).thenReturn(iter); + + registry.query(Asset.class).where("color").is("x").execute(); + } + + @Test + public void testUncommittedStateException_NoMiddleware() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getQueryResult(anyString())).thenReturn(iter); + + registry.query(Asset.class).where("color").is("x").execute(); + } + + public static class NoPKEntity { + public String id; + } + + @Test + public void testRangeQueryGuard_NoPK() { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + assertThatThrownBy(() -> registry.rangeQuery(NoPKEntity.class).withKeyPrefix("x")) + .isInstanceOf(InvalidRangeQueryException.class) + .hasMessageContaining("no @PrimaryKey annotation found"); + } + + @Test + public void testRangeQueryGuard_TooManyPrefixParts() { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + assertThatThrownBy(() -> registry.rangeQuery(Asset.class).withKeyPrefix("a", "b", "c")) + .isInstanceOf(InvalidRangeQueryException.class) + .hasMessageContaining("Too many prefix parts"); + } + + @Test + public void testRangeQueryGuard_NullPrefix() { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + assertThatThrownBy(() -> registry.rangeQuery(Asset.class).withKeyPrefix("Alice", null)) + .isInstanceOf(InvalidRangeQueryException.class) + .hasMessageContaining("Null prefix component at index 1"); + } + + @Test + public void testRangeQueryGuard_FullScan() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))) + .thenReturn(new CompositeKey("test")); + Registry registry = new Registry(fabricStub); + + QueryResultsIterator iter = mock(QueryResultsIterator.class); + when(iter.iterator()).thenReturn(Collections.emptyIterator()); + when(fabricStub.getStateByPartialCompositeKey(any(CompositeKey.class))).thenReturn(iter); + + List result = registry.rangeQuery(Asset.class).fullScan().execute(); + assertThat(result).isEmpty(); + } + + @Test + public void testRangeQueryGuard_ExecuteWithoutConfig() { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + Registry registry = new Registry(fabricStub); + assertThatThrownBy(() -> registry.rangeQuery(Asset.class).execute()) + .isInstanceOf(InvalidRangeQueryException.class) + .hasMessageContaining("Configure the query before executing"); + } + + @Test + public void testCrossChaincode_Success() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + HypernateContext ctx = mock(HypernateContext.class); + when(ctx.getStub()).thenReturn(fabricStub); + when(ctx.getFabricStub()).thenReturn(fabricStub); + when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); + + Response resp = mock(Response.class); + when(resp.getStatus()).thenReturn(Response.Status.SUCCESS); + when(resp.getStringPayload()).thenReturn(JSON.serialize(new TransferResult(true, "OK", 100))); + when(fabricStub.invokeChaincode(eq("cc"), org.mockito.ArgumentMatchers.>any())) + .thenReturn(resp); + + TransferResult res = ctx.invoke("cc", "fn").returning(TransferResult.class).execute(); + assertThat(res.approved()).isTrue(); + } + + @Test + public void testCrossChaincode_ErrorPropagation() { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + HypernateContext ctx = mock(HypernateContext.class); + when(ctx.getStub()).thenReturn(fabricStub); + when(ctx.getFabricStub()).thenReturn(fabricStub); + when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); + + Response resp = mock(Response.class); + when(resp.getStatus()).thenReturn(Response.Status.INTERNAL_SERVER_ERROR); + when(resp.getMessage()).thenReturn("chaincode panicked"); + when(fabricStub.invokeChaincode(eq("testCC"), org.mockito.ArgumentMatchers.>any())) + .thenReturn(resp); + + assertThatThrownBy(() -> ctx.invoke("testCC", "doWork").returningVoid().execute()) + .isInstanceOf(CrossChaincodeException.class) + .satisfies( + e -> { + CrossChaincodeException ex = (CrossChaincodeException) e; + assertThat(ex.getChaincodeId()).isEqualTo("testCC"); + assertThat(ex.getFunctionName()).isEqualTo("doWork"); + assertThat(ex.getStatusCode()).isEqualTo(500); + assertThat(ex.getOriginalMessage()).isEqualTo("chaincode panicked"); + }); + } + + @Test + public void testCrossChaincode_ReturningVoid() { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + HypernateContext ctx = mock(HypernateContext.class); + when(ctx.getStub()).thenReturn(fabricStub); + when(ctx.getFabricStub()).thenReturn(fabricStub); + when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); + + Response resp = mock(Response.class); + when(resp.getStatus()).thenReturn(Response.Status.SUCCESS); + when(fabricStub.invokeChaincode(eq("cc"), org.mockito.ArgumentMatchers.>any())) + .thenReturn(resp); + + Void res = ctx.invoke("cc", "fn").returningVoid().execute(); + assertThat(res).isNull(); + verify(resp, never()).getStringPayload(); + } + + @Test + public void testCrossChaincode_NoReturnConfig() { + HypernateContext ctx = mock(HypernateContext.class); + ChaincodeStub dummyStub = mock(ChaincodeStub.class); + when(ctx.getStub()).thenReturn(dummyStub); + when(ctx.getFabricStub()).thenReturn(dummyStub); + when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); + assertThatThrownBy(() -> ctx.invoke("cc", "fn").execute()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("returning(Class) or .returningVoid()"); + } + + @Test + public void testCrossChaincode_MixedArgs() { + HypernateContext ctx = mock(HypernateContext.class); + when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); + assertThatThrownBy( + () -> + ctx.invoke("cc", "fn") + .withArgs("x") + .withRawArgs("y".getBytes()) + .returningVoid() + .execute()) + .isInstanceOf(IllegalStateException.class); + } + + @Test + public void testEndorsementPolicyValidation_Valid() { + EndorsementPolicy.of("OR('Org1MSP.peer', 'Org2MSP.peer')"); + EndorsementPolicy.of("AND('Org1MSP.member', 'Org2MSP.member')"); + EndorsementPolicy.of("OutOf(2, 'Org1MSP.peer', 'Org2MSP.peer', 'Org3MSP.peer')"); + } + + @Test + public void testEndorsementPolicyValidation_Unbalanced1() { + assertThatThrownBy(() -> EndorsementPolicy.of("OR('Org1MSP.peer'")) + .isInstanceOf(InvalidEndorsementPolicyException.class) + .hasMessageContaining("Unclosed parenthesis"); + } + + @Test + public void testEndorsementPolicyValidation_Unbalanced2() { + assertThatThrownBy(() -> EndorsementPolicy.of(")OR('Org1MSP.peer')")) + .isInstanceOf(InvalidEndorsementPolicyException.class) + .hasMessageContaining("Unexpected closing parenthesis"); + } + + @Test + public void testEndorsementPolicyValidation_UnsupportedFunction() { + assertThatThrownBy(() -> EndorsementPolicy.of("XOR('Org1MSP.peer')")) + .isInstanceOf(InvalidEndorsementPolicyException.class) + .hasMessageContaining("Unsupported policy function 'XOR'"); + } + + @Test + public void testEndorsementPolicyValidation_InvalidMSPFormat() { + assertThatThrownBy(() -> EndorsementPolicy.of("OR('Org1.peer')")) + .isInstanceOf(InvalidEndorsementPolicyException.class) + .hasMessageContaining("Invalid MSP principal"); + } + + @Test + public void testEndorsementPolicyValidation_InvalidRole() { + assertThatThrownBy(() -> EndorsementPolicy.of("OR('Org1MSP.validator')")) + .isInstanceOf(InvalidEndorsementPolicyException.class) + .hasMessageContaining("Invalid MSP principal"); + } + + @Test + public void testEndorsementPolicyValidation_Empty() { + assertThatThrownBy(() -> EndorsementPolicy.of("")) + .isInstanceOf(InvalidEndorsementPolicyException.class); + } + + @Test + public void testEndorsementPolicyValidation_OutOfArg() { + assertThatThrownBy(() -> EndorsementPolicy.of("OutOf(two, 'Org1MSP.peer')")) + .isInstanceOf(InvalidEndorsementPolicyException.class) + .hasMessageContaining("integer as its first argument"); + } + + @Test + public void testEndorsementPolicyBuilder_AND() { + EndorsementPolicy built = + EndorsementPolicy.builder().and().org("Org1").peer().org("Org2").peer().build(); + assertThat(built.getExpression()).isEqualTo("AND('Org1MSP.peer', 'Org2MSP.peer')"); + } + + @Test + public void testEndorsementPolicyBuilder_OutOf() { + EndorsementPolicy built = + EndorsementPolicy.builder() + .outOf(2) + .org("Org1") + .peer() + .org("Org2") + .peer() + .org("Org3") + .peer() + .build(); + assertThat(built.getExpression()) + .isEqualTo("OutOf(2, 'Org1MSP.peer', 'Org2MSP.peer', 'Org3MSP.peer')"); + } + + @Test + public void testEndorsementPolicyBuilder_EqualsDirect() { + String expr = "OR('Org1MSP.peer', 'Org2MSP.peer')"; + EndorsementPolicy direct = EndorsementPolicy.of(expr); + EndorsementPolicy built = + EndorsementPolicy.builder().or().org("Org1").peer().org("Org2").peer().build(); + assertThat(direct.getExpression()).isEqualTo(built.getExpression()); + } + + @Test + public void testEndorsementPolicyBuilder_Empty() { + assertThatThrownBy(() -> EndorsementPolicy.builder().build()) + .isInstanceOf(IllegalStateException.class); + } + + @Test + public void testEndorsementPolicyBuilder_ExceedsThreshold() { + assertThatThrownBy(() -> EndorsementPolicy.builder().outOf(3).org("Org1").peer().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("exceeds number of principals"); + } + + @Test + public void testRegistryEndorsement_mustCreateSensitive() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))) + .thenReturn(new CompositeKey("mock")); + when(fabricStub.getState(anyString())).thenReturn(null); + Registry registry = new Registry(fabricStub); + + SensitiveAsset sensitiveAsset = + new SensitiveAsset( + "id", "red", 1, "owner", 10, 0, AssetStatus.ACTIVE, "A", Collections.emptyList()); + registry.mustCreate(sensitiveAsset); + + verify(fabricStub).setStateValidationParameter(anyString(), any(byte[].class)); + } + + @Test + public void testRegistryEndorsement_mustCreateNormal() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))) + .thenReturn(new CompositeKey("mock")); + when(fabricStub.getState(anyString())).thenReturn(null); + Registry registry = new Registry(fabricStub); + + Asset asset = new Asset("id", "red", 1, "owner", 10, 0, AssetStatus.ACTIVE); + registry.mustCreate(asset); + + verify(fabricStub, never()).setStateValidationParameter(anyString(), any(byte[].class)); + } + + @Test + public void testRegistryEndorsement_setEndorsementPolicyNotFound() { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))) + .thenReturn(new CompositeKey("mock")); + when(fabricStub.getState(anyString())).thenReturn(new byte[0]); + Registry registry = new Registry(fabricStub); + + Asset asset = new Asset("id", "red", 1, "owner", 10); + EndorsementPolicy pol = EndorsementPolicy.of("OR('Org1MSP.peer')"); + + assertThatThrownBy(() -> registry.setEndorsementPolicy(asset, pol)) + .isInstanceOf(EntityNotFoundException.class) + .hasMessageContaining("Cannot set endorsement policy: entity does not exist"); + } + + @Test + public void testSampleChaincode_createAssetBlankId() { + SampleChaincode sc = new SampleChaincode(); + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + org.hyperledger.fabric.shim.ext.sbe.StateBasedEndorsement sbe = + mock(org.hyperledger.fabric.shim.ext.sbe.StateBasedEndorsement.class); + when(fabricStub.getTxTimestamp()).thenReturn(java.time.Instant.now()); + HypernateContext ctx = mock(HypernateContext.class); + when(ctx.getStub()).thenReturn(fabricStub); + when(ctx.getFabricStub()).thenReturn(fabricStub); + when(ctx.getRegistry()).thenReturn(new Registry(fabricStub)); + assertThatThrownBy(() -> sc.createAsset(ctx, "", "red", 10, "Alice", 100)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("id"); + } + + @Test + public void testSampleChaincode_createAssetNegativeSize() { + SampleChaincode sc = new SampleChaincode(); + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + when(fabricStub.getTxTimestamp()).thenReturn(java.time.Instant.now()); + HypernateContext ctx = mock(HypernateContext.class); + when(ctx.getStub()).thenReturn(fabricStub); + when(ctx.getFabricStub()).thenReturn(fabricStub); + when(ctx.getRegistry()).thenReturn(new Registry(fabricStub)); + assertThatThrownBy(() -> sc.createAsset(ctx, "a1", "red", -1, "Alice", 100)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("size"); + } + + @Test + public void testSampleChaincode_initiateTransferArchived() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))) + .thenReturn(new CompositeKey("mock")); + Asset archived = new Asset("id", "red", 1, "owner", 10, 0, AssetStatus.ARCHIVED); + when(fabricStub.getState(anyString())).thenReturn(JSON.serialize(archived).getBytes()); + + SampleChaincode sc = new SampleChaincode(); + HypernateContext ctx = mock(HypernateContext.class); + when(ctx.getRegistry()).thenReturn(new Registry(fabricStub)); + when(ctx.getStub()).thenReturn(fabricStub); + when(ctx.getFabricStub()).thenReturn(fabricStub); + when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); + + assertThatThrownBy(() -> sc.initiateTransfer(ctx, "id", "owner", "new", 10)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("not in ACTIVE status"); + } + + @Test + public void testSampleChaincode_initiateTransferRejected() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))) + .thenReturn(new CompositeKey("mock")); + Asset active = new Asset("id", "red", 1, "owner", 10, 0, AssetStatus.ACTIVE); + when(fabricStub.getState(anyString())).thenReturn(JSON.serialize(active).getBytes()); + + Response resp = mock(Response.class); + when(resp.getStatus()).thenReturn(Response.Status.SUCCESS); + when(resp.getStringPayload()) + .thenReturn(JSON.serialize(new PricingResponse(false, 0, "Too low"))); + when(fabricStub.invokeChaincode( + eq("PricingChaincode"), org.mockito.ArgumentMatchers.>any())) + .thenReturn(resp); + + SampleChaincode sc = new SampleChaincode(); + HypernateContext ctx = mock(HypernateContext.class); + when(ctx.getRegistry()).thenReturn(new Registry(fabricStub)); + when(ctx.getStub()).thenReturn(fabricStub); + when(ctx.getFabricStub()).thenReturn(fabricStub); + when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); + + assertThatThrownBy(() -> sc.initiateTransfer(ctx, "id", "owner", "new", 10)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Transfer rejected"); + } + + @Test + public void testSampleChaincode_initiateTransferSuccess() throws Exception { + ChaincodeStub fabricStub = mock(ChaincodeStub.class); + when(fabricStub.createCompositeKey(anyString(), any(String[].class))) + .thenReturn(new CompositeKey("mock")); + Asset active = new Asset("id", "red", 1, "owner", 10, 0, AssetStatus.ACTIVE); + when(fabricStub.getState(anyString())).thenReturn(JSON.serialize(active).getBytes()); + + Response resp = mock(Response.class); + when(resp.getStatus()).thenReturn(Response.Status.SUCCESS); + when(resp.getStringPayload()).thenReturn(JSON.serialize(new PricingResponse(true, 50, "OK"))); + when(fabricStub.invokeChaincode( + eq("PricingChaincode"), org.mockito.ArgumentMatchers.>any())) + .thenReturn(resp); + + SampleChaincode sc = new SampleChaincode(); + HypernateContext ctx = mock(HypernateContext.class); + when(ctx.getRegistry()).thenReturn(new Registry(fabricStub)); + when(ctx.getStub()).thenReturn(fabricStub); + when(ctx.getFabricStub()).thenReturn(fabricStub); + when(ctx.invoke(anyString(), anyString())).thenCallRealMethod(); + + Asset result = sc.initiateTransfer(ctx, "id", "owner", "new", 50); + assertThat(result.getStatus()).isEqualTo(AssetStatus.PENDING_TRANSFER); + verify(fabricStub).putState(anyString(), any(byte[].class)); // Must update + } +} diff --git a/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/RegistryTest.java b/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/RegistryTest.java index d808908c..3ff1453e 100644 --- a/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/RegistryTest.java +++ b/lib/src/test/java/hu/bme/mit/ftsrg/hypernate/RegistryTest.java @@ -8,6 +8,7 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import hu.bme.mit.ftsrg.hypernate.annotations.AttributeInfo; +import hu.bme.mit.ftsrg.hypernate.annotations.EntityType; import hu.bme.mit.ftsrg.hypernate.annotations.PrimaryKey; import hu.bme.mit.ftsrg.hypernate.registry.EntityExistsException; import hu.bme.mit.ftsrg.hypernate.registry.EntityNotFoundException; @@ -89,11 +90,26 @@ record KeylessTestEntity(String foo, Integer bar) {} }) private record TestEntity(String foo, Integer bar) {} + @FieldNameConstants + @EntityType("Asset") + @PrimaryKey(@AttributeInfo(name = AnnotatedEntity.Fields.id)) + private record AnnotatedEntity(String id) {} + + @FieldNameConstants + @EntityType("") + @PrimaryKey(@AttributeInfo(name = EmptyEntityTypeEntity.Fields.id)) + private record EmptyEntityTypeEntity(String id) {} + + @FieldNameConstants + @EntityType(" ") + @PrimaryKey(@AttributeInfo(name = BlankEntityTypeEntity.Fields.id)) + private record BlankEntityTypeEntity(String id) {} + @Nested - class given_empty_ledger { + class when_must_create { @Test - void when_must_create_then_call_putState() + void given_empty_ledger_then_call_putState() throws SerializationException, EntityExistsException { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); @@ -106,206 +122,144 @@ void when_must_create_then_call_putState() } @Test - void when_try_create_then_return_true_and_call_putState() throws SerializationException { + void given_existing_entity_then_throw_exists() { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(new byte[] {}); - - boolean result = registry.tryCreate(entity); + given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); - assertTrue(result); - then(stub).should().putState(eq(ENTITY_COMPOSITE_KEY_STR), any(byte[].class)); + assertThrows(EntityExistsException.class, () -> registry.mustCreate(entity)); verifyNoMoreInteractions(stub); } + } + + @Nested + class when_try_create { @Test - void when_must_update_then_throw_not_found() { + void given_empty_ledger_then_return_true_and_call_putState() throws SerializationException { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); given(stub.getState(anyString())).willReturn(new byte[] {}); - assertThrows(EntityNotFoundException.class, () -> registry.mustUpdate(entity)); + boolean result = registry.tryCreate(entity); + + assertTrue(result); + then(stub).should().putState(eq(ENTITY_COMPOSITE_KEY_STR), any(byte[].class)); verifyNoMoreInteractions(stub); } @Test - void when_try_update_then_return_false_and_do_nothing() { + void given_existing_entity_then_return_false_and_do_nothing() { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(new byte[] {}); + given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); AtomicBoolean result = new AtomicBoolean(false); - assertDoesNotThrow(() -> result.set(registry.tryUpdate(entity))); + assertDoesNotThrow(() -> result.set(registry.tryCreate(entity))); assertFalse(result.get()); verifyNoMoreInteractions(stub); } + } + + @Nested + class when_must_update { @Test - void when_must_delete_then_throw_not_found() { + void given_empty_ledger_then_throw_not_found() { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); given(stub.getState(anyString())).willReturn(new byte[] {}); - assertThrows(EntityNotFoundException.class, () -> registry.mustDelete(entity)); + assertThrows(EntityNotFoundException.class, () -> registry.mustUpdate(entity)); verifyNoMoreInteractions(stub); } @Test - void when_try_delete_then_return_false_and_do_nothing() { + void given_existing_entity_then_call_putState() + throws SerializationException, EntityNotFoundException { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(new byte[] {}); - - AtomicBoolean result = new AtomicBoolean(false); - assertDoesNotThrow(() -> result.set(registry.tryDelete(entity))); - assertFalse(result.get()); - verifyNoMoreInteractions(stub); - } - - @Test - void when_readAll_then_return_empty_list() throws SerializationException { - given(stub.createCompositeKey(anyString())).willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getStateByPartialCompositeKey(anyString())) - .willReturn( - new QueryResultsIterator<>() { - @Override - public void close() {} - - @Override - public @Nonnull Iterator iterator() { - return new Iterator<>() { - @Override - public boolean hasNext() { - return false; - } - - @Override - public KeyValue next() { - throw new UnsupportedOperationException(); - } - }; - } - }); + given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); - List results = registry.readAll(TestEntity.class); + registry.mustUpdate(entity); - then(stub).should().getStateByPartialCompositeKey(anyString()); - assertTrue(results.isEmpty()); + then(stub).should().putState(eq(ENTITY_COMPOSITE_KEY_STR), any(byte[].class)); verifyNoMoreInteractions(stub); } - - @Nested - class when_must_read { - - @Test - void with_insufficient_key_parts_then_throw_illegal_argument() { - assertThrows( - IllegalArgumentException.class, () -> registry.mustRead(TestEntity.class, entity.foo)); - verifyNoMoreInteractions(stub); - } - - @Test - void with_complete_key_then_throw_not_found() { - given(stub.createCompositeKey(anyString(), any(String[].class))) - .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(new byte[] {}); - - assertThrows( - EntityNotFoundException.class, - () -> registry.mustRead(TestEntity.class, entity.foo, entity.bar)); - verifyNoMoreInteractions(stub); - } - } - - @Nested - class when_try_read { - - @Test - void with_insufficient_key_parts_then_throw_illegal_argument() { - assertThrows( - IllegalArgumentException.class, () -> registry.tryRead(TestEntity.class, entity.foo)); - verifyNoMoreInteractions(stub); - } - - @Test - void with_complete_key_then_return_null() throws SerializationException { - given(stub.createCompositeKey(anyString(), any(String[].class))) - .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(new byte[] {}); - - TestEntity readEntity = registry.tryRead(TestEntity.class, entity.foo, entity.bar); - - assertNull(readEntity); - verifyNoMoreInteractions(stub); - } - } } @Nested - class given_existing_entity { + class when_try_update { @Test - void when_must_create_then_throw_exists() { + void given_empty_ledger_then_return_false_and_do_nothing() { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); + given(stub.getState(anyString())).willReturn(new byte[] {}); - assertThrows(EntityExistsException.class, () -> registry.mustCreate(entity)); + AtomicBoolean result = new AtomicBoolean(false); + assertDoesNotThrow(() -> result.set(registry.tryUpdate(entity))); + assertFalse(result.get()); verifyNoMoreInteractions(stub); } @Test - void when_try_create_then_return_false_and_do_nothing() { + void given_existing_entity_then_return_true_and_call_putState() throws SerializationException { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); - AtomicBoolean result = new AtomicBoolean(false); - assertDoesNotThrow(() -> result.set(registry.tryCreate(entity))); - assertFalse(result.get()); + boolean result = registry.tryUpdate(entity); + + assertTrue(result); + then(stub).should().putState(eq(ENTITY_COMPOSITE_KEY_STR), any(byte[].class)); verifyNoMoreInteractions(stub); } + } + + @Nested + class when_must_delete { @Test - void when_must_update_then_call_putState() - throws SerializationException, EntityNotFoundException { + void given_empty_ledger_then_throw_not_found() { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); - - registry.mustUpdate(entity); + given(stub.getState(anyString())).willReturn(new byte[] {}); - then(stub).should().putState(eq(ENTITY_COMPOSITE_KEY_STR), any(byte[].class)); + assertThrows(EntityNotFoundException.class, () -> registry.mustDelete(entity)); verifyNoMoreInteractions(stub); } @Test - void when_try_update_then_return_true_and_call_putState() throws SerializationException { + void given_existing_entity_then_call_delState() throws EntityNotFoundException { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); - boolean result = registry.tryUpdate(entity); + registry.mustDelete(entity); - assertTrue(result); - then(stub).should().putState(eq(ENTITY_COMPOSITE_KEY_STR), any(byte[].class)); + then(stub).should().delState(ENTITY_COMPOSITE_KEY_STR); verifyNoMoreInteractions(stub); } + } + + @Nested + class when_try_delete { @Test - void when_must_delete_then_call_delState() throws EntityNotFoundException { + void given_empty_ledger_then_return_false_and_do_nothing() { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); - - registry.mustDelete(entity); + given(stub.getState(anyString())).willReturn(new byte[] {}); - then(stub).should().delState(ENTITY_COMPOSITE_KEY_STR); + AtomicBoolean result = new AtomicBoolean(false); + assertDoesNotThrow(() -> result.set(registry.tryDelete(entity))); + assertFalse(result.get()); verifyNoMoreInteractions(stub); } @Test - void when_try_delete_then_return_true_and_call_delState() throws EntityNotFoundException { + void given_existing_entity_then_return_true_and_call_delState() throws EntityNotFoundException { given(stub.createCompositeKey(anyString(), any(String[].class))) .willReturn(ENTITY_COMPOSITE_KEY); given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); @@ -316,9 +270,45 @@ void when_try_delete_then_return_true_and_call_delState() throws EntityNotFoundE then(stub).should().delState(ENTITY_COMPOSITE_KEY_STR); verifyNoMoreInteractions(stub); } + } + + @Nested + class when_readAll { @Test - void when_readAll_then_return_one_long_list() throws SerializationException { + void given_empty_ledger_then_return_empty_list() throws SerializationException { + given(stub.createCompositeKey(anyString())).willReturn(ENTITY_COMPOSITE_KEY); + given(stub.getStateByPartialCompositeKey(anyString())) + .willReturn( + new QueryResultsIterator<>() { + @Override + public void close() {} + + @Override + public @Nonnull Iterator iterator() { + return new Iterator<>() { + @Override + public boolean hasNext() { + return false; + } + + @Override + public KeyValue next() { + throw new UnsupportedOperationException(); + } + }; + } + }); + + List results = registry.readAll(TestEntity.class); + + then(stub).should().getStateByPartialCompositeKey(anyString()); + assertTrue(results.isEmpty()); + verifyNoMoreInteractions(stub); + } + + @Test + void given_existing_entity_then_return_one_long_list() throws SerializationException { given(stub.createCompositeKey(anyString())).willReturn(ENTITY_COMPOSITE_KEY); given(stub.getStateByPartialCompositeKey(anyString())) .willReturn( @@ -371,54 +361,151 @@ public String getStringValue() { assertEquals(1, results.size()); verifyNoMoreInteractions(stub); } + } + + @Nested + class when_must_read { + + @Test + void given_empty_ledger_with_insufficient_key_parts_then_throw_illegal_argument() { + assertThrows( + IllegalArgumentException.class, () -> registry.mustRead(TestEntity.class, entity.foo)); + verifyNoMoreInteractions(stub); + } - @Nested - class when_must_read { - - @Test - void with_insufficient_key_parts_then_throw_illegal_argument() { - assertThrows( - IllegalArgumentException.class, () -> registry.mustRead(TestEntity.class, entity.foo)); - verifyNoMoreInteractions(stub); - } - - @Test - void with_complete_key_then_call_getState_and_return_entity() - throws SerializationException, EntityNotFoundException { - given(stub.createCompositeKey(anyString(), any(String[].class))) - .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); - - TestEntity result = registry.mustRead(TestEntity.class, entity.foo, entity.bar); - - then(stub).should().getState(ENTITY_COMPOSITE_KEY_STR); - assertEquals(entity, result); - verifyNoMoreInteractions(stub); - } + @Test + void given_empty_ledger_with_complete_key_then_throw_not_found() { + given(stub.createCompositeKey(anyString(), any(String[].class))) + .willReturn(ENTITY_COMPOSITE_KEY); + given(stub.getState(anyString())).willReturn(new byte[] {}); + + assertThrows( + EntityNotFoundException.class, + () -> registry.mustRead(TestEntity.class, entity.foo, entity.bar)); + verifyNoMoreInteractions(stub); } - @Nested - class when_try_read { + @Test + void given_annotated_entity_type_then_use_annotation_value_for_composite_key() { + // Mock the stub to return a CompositeKey when asked, and represent an empty ledger + given(stub.createCompositeKey(anyString(), any(String[].class))) + .willReturn(new CompositeKey("Asset", "id-1")); + given(stub.getState(anyString())).willReturn(new byte[] {}); - @Test - void with_insufficient_key_parts_then_throw_illegal_argument() { - assertThrows( - IllegalArgumentException.class, () -> registry.tryRead(TestEntity.class, entity.foo)); - verifyNoMoreInteractions(stub); - } + // This mustRead call is expected to fail with EntityNotFoundException because the ledger + // is empty. We are only executing mustRead here to force the call to key generation + // (which calls createCompositeKey internally) so that we can verify the key type name. + assertThrows( + EntityNotFoundException.class, () -> registry.mustRead(AnnotatedEntity.class, "id-1")); - @Test - void with_complete_key_then_call_getState_and_return_entity() throws SerializationException { - given(stub.createCompositeKey(anyString(), any(String[].class))) - .willReturn(ENTITY_COMPOSITE_KEY); - given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); + // Verify that the annotated entity type ("Asset") was indeed used for creating the composite + // key + then(stub).should().createCompositeKey(eq("Asset"), any(String[].class)); + verifyNoMoreInteractions(stub); + } - TestEntity result = registry.tryRead(TestEntity.class, entity.foo, entity.bar); + @Test + void given_no_entity_type_annotation_then_use_fqcn_for_composite_key() { + // Mock the stub to return a CompositeKey when asked, and represent an empty ledger + given(stub.createCompositeKey(anyString(), any(String[].class))) + .willReturn(new CompositeKey(TestEntity.class.getName(), "fooValue", "110")); + given(stub.getState(anyString())).willReturn(new byte[] {}); - then(stub).should().getState(ENTITY_COMPOSITE_KEY_STR); - assertEquals(entity, result); - verifyNoMoreInteractions(stub); - } + // This mustRead call is expected to fail with EntityNotFoundException because the ledger + // is empty. We are only executing mustRead here to force the call to key generation + // (which calls createCompositeKey internally) so that we can verify the key type name. + assertThrows( + EntityNotFoundException.class, + () -> registry.mustRead(TestEntity.class, entity.foo, entity.bar)); + + // Verify that the FQCN (not uppercased) was indeed used for creating the composite key + then(stub).should().createCompositeKey(eq(TestEntity.class.getName()), any(String[].class)); + verifyNoMoreInteractions(stub); + } + + @Test + void given_existing_entity_with_insufficient_key_parts_then_throw_illegal_argument() { + assertThrows( + IllegalArgumentException.class, () -> registry.mustRead(TestEntity.class, entity.foo)); + verifyNoMoreInteractions(stub); + } + + @Test + void given_existing_entity_with_complete_key_then_call_getState_and_return_entity() + throws SerializationException, EntityNotFoundException { + given(stub.createCompositeKey(anyString(), any(String[].class))) + .willReturn(ENTITY_COMPOSITE_KEY); + given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); + + TestEntity result = registry.mustRead(TestEntity.class, entity.foo, entity.bar); + + then(stub).should().getState(ENTITY_COMPOSITE_KEY_STR); + assertEquals(entity, result); + verifyNoMoreInteractions(stub); + } + } + + @Nested + class when_try_read { + + @Test + void given_empty_ledger_with_insufficient_key_parts_then_throw_illegal_argument() { + assertThrows( + IllegalArgumentException.class, () -> registry.tryRead(TestEntity.class, entity.foo)); + verifyNoMoreInteractions(stub); + } + + @Test + void given_empty_ledger_with_complete_key_then_return_null() throws SerializationException { + given(stub.createCompositeKey(anyString(), any(String[].class))) + .willReturn(ENTITY_COMPOSITE_KEY); + given(stub.getState(anyString())).willReturn(new byte[] {}); + + TestEntity readEntity = registry.tryRead(TestEntity.class, entity.foo, entity.bar); + + assertNull(readEntity); + verifyNoMoreInteractions(stub); + } + + @Test + void given_existing_entity_with_insufficient_key_parts_then_throw_illegal_argument() { + assertThrows( + IllegalArgumentException.class, () -> registry.tryRead(TestEntity.class, entity.foo)); + verifyNoMoreInteractions(stub); + } + + @Test + void given_existing_entity_with_complete_key_then_call_getState_and_return_entity() + throws SerializationException { + given(stub.createCompositeKey(anyString(), any(String[].class))) + .willReturn(ENTITY_COMPOSITE_KEY); + given(stub.getState(anyString())).willReturn(ENTITY_BUFFER); + + TestEntity result = registry.tryRead(TestEntity.class, entity.foo, entity.bar); + + then(stub).should().getState(ENTITY_COMPOSITE_KEY_STR); + assertEquals(entity, result); + verifyNoMoreInteractions(stub); + } + } + + @Nested + class when_entity_type_is_invalid { + + @Test + void given_empty_entity_type_annotation_value_then_throw_illegal_argument() { + assertThrows( + IllegalArgumentException.class, + () -> registry.tryRead(EmptyEntityTypeEntity.class, "id-1")); + verifyNoMoreInteractions(stub); + } + + @Test + void given_blank_entity_type_annotation_value_then_throw_illegal_argument() { + assertThrows( + IllegalArgumentException.class, + () -> registry.tryRead(BlankEntityTypeEntity.class, "id-1")); + verifyNoMoreInteractions(stub); } } } diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..4c69c45f --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,53 @@ +site_name: Hypernate +site_url: https://lf-decentralized-trust-labs.github.io/hypernate +repo_url: https://github.com/LF-Decentralized-Trust-labs/hypernate +repo_name: LF-Decentralized-Trust-labs/hypernate +edit_uri: edit/main/docs/ +strict: true + +theme: + name: material + palette: + primary: indigo + accent: indigo + features: + - navigation.tabs + - navigation.sections + - navigation.expand + - navigation.top + - search.highlight + - search.share + - toc.follow + +plugins: + - search + - mike: + alias_type: symlink + canonical_version: latest + +extra: + version: + provider: mike + default: latest + +nav: + - Home: index.md + - Getting Started: + - Overview: getting-started/index.md + - Installation: getting-started/installation.md + - Quickstart: getting-started/quickstart.md + - Concepts: + - Overview: concepts/index.md + - Registry: concepts/registry.md + - Middleware: concepts/middleware.md + - Annotations: concepts/annotations.md + - Guides: + - CI/CD: guides/cicd.md + - API Reference: + - Registry: api/registry.md + - Annotations: api/annotations.md + - Contributing: + - Overview: contributing/index.md + - Maintainers: contributing/maintainers.md + - References: + - Citing: references/citing.md diff --git a/settings.gradle.kts b/settings.gradle.kts index 4db73cc0..7379705f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} rootProject.name = "hypernate" include("lib")