diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 060724ef..6a245b44 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -2,7 +2,7 @@ name: Code Coverage on: push: - branches: [master] + branches: [main, master] pull_request: workflow_dispatch: @@ -91,3 +91,40 @@ jobs: with: recreate: true path: code-coverage-results.md + + # ---------------------------------------------------------------------- + # Publish HTML report to GitHub Pages (main pushes only). + # Java coverage is deployed alongside by jzswag.yml under /java/; this + # workflow writes /cpp/ + the root landing index.html. Both jobs use + # peaceiris/actions-gh-pages with keep_files=true so they coexist. + # ---------------------------------------------------------------------- + - name: Stage C++ report for GitHub Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + mkdir -p site/cpp + cp -r coverage/* site/cpp/ + # gcovr writes its entry point as coverage.html; an index.html + # redirect lets https://ndsev.github.io/zswag/cpp/ land on the report. + cat > site/cpp/index.html <<'EOF' + + EOF + # Root landing page linking to both language reports. + cat > site/index.html <<'EOF' + + zswag coverage + +

zswag coverage reports

+ +

Aggregated coverage is also tracked on Codecov.

+ EOF + + - name: Deploy to GitHub Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: site + keep_files: true diff --git a/.github/workflows/jzswag.yml b/.github/workflows/jzswag.yml new file mode 100644 index 00000000..f2682afd --- /dev/null +++ b/.github/workflows/jzswag.yml @@ -0,0 +1,152 @@ +name: jzswag (Java) + +on: + push: + branches: [main, master] + pull_request: + workflow_dispatch: + +jobs: + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up JDK 17 + # Gradle 9.x requires JVM 17+ to run the daemon; the build still targets Java 11 + # via sourceCompatibility/targetCompatibility in the per-module build.gradle files. + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle', '**/gradle-wrapper.properties') }} + restore-keys: ${{ runner.os }}-gradle- + + - name: Build & test + run: ./gradlew :libs:jzswag:jzswag-api:test :libs:jzswag:jzswag-shared:test :libs:jzswag:jzswag-jvm:test :libs:jzswag:jzswag-android:test :libs:jzswag:jzswag-test:assemble --console=plain --stacktrace + + - name: Upload JUnit reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: junit-${{ matrix.os }} + path: libs/jzswag/jzswag-*/build/test-results/test/*.xml + retention-days: 14 + + - name: Upload JaCoCo HTML reports + if: matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v4 + with: + name: jacoco-html + path: libs/jzswag/jzswag-*/build/reports/jacoco/test/html/ + retention-days: 14 + + coverage: + # Single-OS coverage upload. Matches the C++ coverage.yml pattern (Codecov flag + # per language, separate name) so Java coverage is tracked independently of C++. + name: coverage + runs-on: ubuntu-latest + needs: test + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up JDK 17 + # Gradle 9.x requires JVM 17+ to run the daemon; the build still targets Java 11 + # via sourceCompatibility/targetCompatibility in the per-module build.gradle files. + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle', '**/gradle-wrapper.properties') }} + restore-keys: ${{ runner.os }}-gradle- + + - name: Run tests with coverage + run: | + ./gradlew \ + :libs:jzswag:jzswag-api:test :libs:jzswag:jzswag-api:jacocoTestReport \ + :libs:jzswag:jzswag-shared:test :libs:jzswag:jzswag-shared:jacocoTestReport \ + :libs:jzswag:jzswag-jvm:test :libs:jzswag:jzswag-jvm:jacocoTestReport \ + :libs:jzswag:jzswag-android:test :libs:jzswag:jzswag-android:jacocoTestReport \ + --console=plain + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: libs/jzswag/jzswag-api/build/reports/jacoco/test/jacocoTestReport.xml,libs/jzswag/jzswag-shared/build/reports/jacoco/test/jacocoTestReport.xml,libs/jzswag/jzswag-jvm/build/reports/jacoco/test/jacocoTestReport.xml,libs/jzswag/jzswag-android/build/reports/jacoco/test/jacocoTestReport.xml + flags: unittests-java + name: codecov-java + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + - name: Coverage PR comment (informational) + if: github.event_name == 'pull_request' + uses: madrapps/jacoco-report@v1.7.1 + with: + paths: libs/jzswag/jzswag-api/build/reports/jacoco/test/jacocoTestReport.xml,libs/jzswag/jzswag-shared/build/reports/jacoco/test/jacocoTestReport.xml,libs/jzswag/jzswag-jvm/build/reports/jacoco/test/jacocoTestReport.xml,libs/jzswag/jzswag-android/build/reports/jacoco/test/jacocoTestReport.xml + token: ${{ secrets.GITHUB_TOKEN }} + title: Java Coverage (api / shared / jvm / android) + # Threshold per the parity goal — every module ships with ≥60% line coverage + # on its own tests. Ratchet up as more tests land. + min-coverage-overall: 60 + min-coverage-changed-files: 50 + update-comment: true + + # ---------------------------------------------------------------------- + # Publish JaCoCo HTML reports to GitHub Pages (main pushes only). + # Coexists with /cpp/ + root index.html written by coverage.yml; both + # workflows use keep_files=true to avoid clobbering each other. + # ---------------------------------------------------------------------- + - name: Stage Java reports for GitHub Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + mkdir -p site/java + for m in api shared jvm android; do + cp -r libs/jzswag/jzswag-$m/build/reports/jacoco/test/html site/java/$m + done + cat > site/java/index.html <<'EOF' + + jzswag JaCoCo coverage + +

jzswag JaCoCo coverage

+ + EOF + + - name: Deploy to GitHub Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: site + keep_files: true diff --git a/.gitignore b/.gitignore index 8ae66ff6..522f2755 100644 --- a/.gitignore +++ b/.gitignore @@ -142,4 +142,53 @@ dist/ _deps/ CLAUDE.md -links \ No newline at end of file +links + +# Java / Gradle +.gradle/ +.gradletasknamecache +gradle-app.setting +.kotlin/ +*.class +*.jar +*.war +*.ear +hs_err_pid* +# Negation must come after *.jar — gitignore evaluates rules top-to-bottom and a +# later pattern overrides earlier ones. The Gradle wrapper jar is checked in. +!gradle/wrapper/gradle-wrapper.jar + +# Android +local.properties +captures/ +*.apk +*.aab +*.aar +.cxx/ + +# Generated zserio sources (regenerated during build) +libs/jzswag/jzswag-test/src/main/java/calculator/ + +# Kotlin temporarily disabled +**/kotlin-disabled/ + +# IntelliJ IDEA +*.iml +*.ipr +*.iws +.idea/ +out/ + +# Eclipse +.classpath +.project +.settings/ +bin/ + +# NetBeans +nbproject/private/ +build/ +nbbuild/ +dist/ +nbdist/ +.nb-gradle/ \ No newline at end of file diff --git a/README.md b/README.md index 45a1c061..5549d497 100644 --- a/README.md +++ b/README.md @@ -7,1012 +7,374 @@ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=ndsev_zswag&metric=coverage)](https://sonarcloud.io/summary/new_code?id=ndsev_zswag) [![License](https://img.shields.io/github/license/ndsev/zswag)](https://github.com/ndsev/zswag/blob/master/LICENSE) -zswag is a set of libraries for using/hosting zserio services through OpenAPI. - -**Table of Contents:** - - * [Components](#components) - * [Setup](#setup) - + [For Python Users](#for-python-users) - + [For C++ Users](#for-c-users) - - [Offline/Disconnected Builds](#offlinedisconnected-builds) - * [CI/CD and Release Process](#cicd-and-release-process) - + [Continuous Integration](#continuous-integration) - + [Release Process](#release-process) - + [Development Snapshots](#development-snapshots) - + [Version Validation](#version-validation) - * [OpenAPI Generator CLI](#openapi-generator-cli) - + [Generator Usage Example](#generator-usage-example) - + [Documentation extraction](#documentation-extraction) - * [Server Component (Python)](#server-component) - * [Using the Python Client](#using-the-python-client) - * [C++ Client](#c-client) - * [Client Environment Settings](#client-environment-settings) - * [HTTP Proxies and Authentication](#persistent-http-headers-proxy-cookie-and-authentication) - * [Swagger User Interface](#swagger-user-interface) - * [Client Result Code Handling](#client-result-code-handling) - * [OpenAPI Options Interoperability](#openapi-options-interoperability) - + [HTTP method](#http-method) - + [Request Body](#request-body) - + [URL Blob Parameter](#url-blob-parameter) - + [URL Scalar Parameter](#url-scalar-parameter) - + [URL Array Parameter](#url-array-parameter) - + [URL Compound Parameter](#url-compound-parameter) - + [Server URL Base Path](#server-url-base-path) - + [Authentication Schemes](#authentication-schemes) +zswag is a set of libraries for using and hosting [zserio](http://zserio.org) services through OpenAPI/REST. It provides parallel client implementations in Python, C++, and Java that consume the same OpenAPI specification, plus a Python server layer for exposing zserio services. ## Components -The zswag repository contains two main libraries which provide -OpenAPI layers for zserio Python and C++ clients. For Python, there -is even a generic zserio OpenAPI server layer. +```mermaid +%%{init: { + 'theme':'base', + 'flowchart': {'curve': 'stepAfter', 'nodeSpacing': 50, 'rankSpacing': 70, 'padding': 20}, + 'themeVariables': { + 'fontFamily': 'system-ui, -apple-system, sans-serif', + 'fontSize': '14px', + 'lineColor': '#6c757d', + 'clusterBkg': '#fafbfc', + 'clusterBorder': '#cbd5e0' + } +}}%% +flowchart TB + gen["zswag.gen (Python CLI)
generates spec from a
zserio service definition"]:::py + spec(["OpenAPI spec · zserio-derived
shared contract: clients read it, server exposes it"]):::spec + zserio["zserio
schema compiler + runtimes
(C++ / Python / Java)"]:::ext + + subgraph clients["  Clients  "] + direction LR + py["Python
OAClient
zswag wheel"]:::py + cpp["C++
OAClient
zswagcl"]:::cpp + jvm["Java JVM
OAClient
jzswag-jvm"]:::java + andr["Java Android
OAClient
jzswag-android"]:::java + end + + subgraph server["  Server  "] + oaserver["OAServer (Python only)
request dispatch
+ auth enforcement"]:::py + end + + subgraph cppcore["  C++ core (shared with Python via pybind11)  "] + direction LR + zswagcl["zswagcl
OpenAPI parser
+ dispatch"]:::cpp + httpcl["httpcl
HTTP transport
+ OS keychain"]:::cpp + end + + subgraph javacore["  Java core  "] + direction LR + jshared["jzswag-shared
dispatch, encoding,
OAuth2 / YAML loader"]:::java + japi["jzswag-api
interfaces,
value types"]:::java + end + + subgraph extlibs["  External libraries  "] + direction LR + cpphttplib["cpp-httplib"]:::ext + keychain["keychain"]:::ext + flask["Flask /
Connexion"]:::ext + okhttp["OkHttp /
Android Keystore"]:::ext + end + + zserio --> gen + gen --> spec + spec --> clients + spec --> server + + py ==> zswagcl + cpp ==> zswagcl + zswagcl ==> httpcl + httpcl --> cpphttplib + httpcl --> keychain + + jvm ==> jshared + andr ==> jshared + jshared ==> japi + andr --> okhttp + + oaserver --> flask + + classDef py fill:#e7f5ec,stroke:#2f855a,stroke-width:2px,color:#1c4532 + classDef cpp fill:#e6f0fb,stroke:#2c5282,stroke-width:2px,color:#1a365d + classDef java fill:#fdf3e7,stroke:#9c4221,stroke-width:2px,color:#652b19 + classDef spec fill:#fffbea,stroke:#975a16,stroke-width:2.5px,color:#5f370e + classDef ext fill:#f1f3f5,stroke:#868e96,stroke-width:1.5px,color:#495057 +``` + +| Component | Language | Role | +|---|---|---| +| `zswagcl` | C++ | Core OpenAPI client (`OAClient`, `OpenApiClient`, `OpenApiConfig`); reused by the Python client via pybind11. | +| `httpcl` | C++ | HTTP wrapper around [cpp-httplib](https://github.com/yhirose/cpp-httplib); request configuration; OS keychain integration via [`keychain`](https://github.com/hrantzsch/keychain). | +| `zswag` | Python | Python `OAClient`, the Flask/Connexion-based `OAServer`, and the `zswag.gen` OpenAPI generator. | +| `pyzswagcl` | Python | pybind11 bindings exposing `zswagcl` to Python. Bundled inside the `zswag` wheel; not installed separately. | +| `jzswag-api` | Java | Platform-agnostic contracts (`HttpConfig`, `HttpSettings`, `OpenAPIParameter`, `IHttpClient`, `IKeychain`, …). No third-party deps. | +| `jzswag-shared` | Java | Portable core: OpenAPI dispatch, `x-zserio-request-part`, parameter encoding, OAuth2/OAuth1 token endpoint flow, YAML loader. Used by both platform modules. | +| `jzswag-jvm` | Java | JVM port using JDK 11 `HttpClient`. Runs on any standard JVM (server, desktop, lambda, CLI). Implements zserio's `ServiceClientInterface`. | +| `jzswag-android` | Java | Android port using OkHttp + Android Keystore + AES-GCM-encrypted SharedPreferences. Implements zserio's `ServiceClientInterface`. | + +## Per-language documentation + +Detailed guides for each client + the server + the generator: + +- [`docs/python.md`](docs/python.md) — Python `OAClient` and `OAServer`. +- [`docs/cpp.md`](docs/cpp.md) — C++ client and CMake integration. +- [`docs/java.md`](docs/java.md) — Java client. +- [`docs/openapi-generator.md`](docs/openapi-generator.md) — `zswag.gen` CLI reference. +- [`docs/release-process.md`](docs/release-process.md) — CI/CD platforms, release tagging, and dev snapshot conventions (maintainer-facing). + +The shared YAML format for `HTTP_SETTINGS_FILE` (used by all three clients) is documented in the [HTTP Settings File](#http-settings-file) section below. + +## Quick start + +### Python -The following UML diagram provides a more in-depth overview: - -![Component Overview](doc/zswag-architecture.png) - -Here are some brief descriptions of the main components: - -* `zswagcl` is a C++ Library which exposes the zserio OpenAPI service client `OAClient` - as well as the more generic `OpenApiClient` and `OpenApiConfig` classes. - The latter two are reused for the Python client library. -* `zswag` is a Python Library which provides both a zserio Python service client - (`OAClient`) as well as a zserio-OpenAPI server layer based on Flask/Connexion - (`OAServer`). It also contains the command-line tool `zswag.gen`, which can be - used to generate an OpenAPI specification from a zserio Python service class. -* `pyzswagcl` is the internal Python extension module bundled inside the `zswag` - wheel. It exposes the C++-based OpenApi parsing/request functionality to - Python and is not meant to be installed separately. -* `httpcl` is a wrapper around [cpp-httplib](https://github.com/yhirose/cpp-httplib), - HTTP request configuration and OS secret storage abilities based on - the [keychain](https://github.com/hrantzsch/keychain) library. - -## Setup - -### For Python Users - -Simply run `pip install zswag`. **Note: This only works with ...** - -* 64-bit Python 3.10-3.14, `pip --version` >= 19.3 -* Supported platforms: Linux (x86_64), macOS (x86_64 and arm64), Windows (x64) - -**Notes:** -* On Windows, make sure that you have the *Microsoft Visual C++ Redistributable Binaries* installed. You can find the x64 installer here: https://aka.ms/vs/16/release/vc_redist.x64.exe -* zswag for Python 3.10 is not supported on Apple Silicon (arm64) because no compatible GitHub Actions runner is available. - However, this is typically not an issue, as macOS includes more recent Python versions by default. - -### For C++ Users - -Using CMake, you can ... - -* 🌟run tests. -* 🌟build the zswag wheels for a custom Python version. -* 🌟[integrate the C++ client into a C++ project.](#c-client) - -Dependencies are managed via CMake's `FetchContent` mechanism. Make sure you have a recent version of CMake (>= 3.22.3) installed. - -The basic setup follows the usual CMake configure/build steps: -```bash -mkdir build && cd build -cmake .. -cmake --build . -``` - -**Note:** The Python environment used for configuration will be used -to build the resulting wheels. After building, you will find the Python -wheels under `build/bin/wheel`. - -**To run tests**, just execute CTest at the top of the build directory: -```bash -cd build && ctest --verbose -``` - -#### Offline/Disconnected Builds - -For environments without internet access or for reproducible builds, zswag supports offline builds using CMake's FetchContent mechanism. - -**Offline Build Process** - -For offline builds, you can pre-fetch all dependencies while online and then build without network access: - -```bash -# First, fetch all dependencies while online -mkdir build && cd build -cmake -DFETCHCONTENT_FULLY_DISCONNECTED=OFF .. -# This will download all dependencies - -# Then build offline -cmake -DFETCHCONTENT_FULLY_DISCONNECTED=ON .. -cmake --build . -``` - -The `FETCHCONTENT_FULLY_DISCONNECTED=ON` option tells CMake to use only the pre-fetched dependencies and never attempt network access. - -**Local Development with Custom Dependencies** - -For development, you can override specific dependencies with local sources: -```bash -mkdir build && cd build -cmake -DFETCHCONTENT_SOURCE_DIR_SPDLOG=/path/to/local/spdlog .. -cmake --build . -``` - -Available override variables: -- `FETCHCONTENT_SOURCE_DIR_ZLIB` - zlib compression library -- `FETCHCONTENT_SOURCE_DIR_SPDLOG` - spdlog logging library -- `FETCHCONTENT_SOURCE_DIR_YAML_CPP` - yaml-cpp parsing library -- `FETCHCONTENT_SOURCE_DIR_STX` - stx utility library -- `FETCHCONTENT_SOURCE_DIR_SPEEDYJ` - speedyj JSON library -- `FETCHCONTENT_SOURCE_DIR_HTTPLIB` - cpp-httplib HTTP library -- `FETCHCONTENT_SOURCE_DIR_OPENSSL` - OpenSSL cryptography library -- `FETCHCONTENT_SOURCE_DIR_PYBIND11` - pybind11 (when `ZSWAG_BUILD_WHEELS=ON`) -- `FETCHCONTENT_SOURCE_DIR_PYTHON_CMAKE_WHEEL` - python-cmake-wheel (when `ZSWAG_BUILD_WHEELS=ON`) -- `FETCHCONTENT_SOURCE_DIR_ZSERIO_CMAKE_HELPER` - zserio build helpers -- `FETCHCONTENT_SOURCE_DIR_KEYCHAIN` - keychain library (when `ZSWAG_KEYCHAIN_SUPPORT=ON`) -- `FETCHCONTENT_SOURCE_DIR_CATCH2` - Catch2 testing framework (when `ZSWAG_ENABLE_TESTING=ON`) - -**Build Options** - -Common build configuration options: -```bash -# Minimal build (no wheels, no keychain, no tests) -cmake -DZSWAG_BUILD_WHEELS=OFF -DZSWAG_KEYCHAIN_SUPPORT=OFF -DZSWAG_ENABLE_TESTING=OFF .. - -# Offline build with custom spdlog -cmake -DFETCHCONTENT_FULLY_DISCONNECTED=ON -DFETCHCONTENT_SOURCE_DIR_SPDLOG=/path/to/spdlog .. - -# Development build with wheels enabled -cmake -DZSWAG_BUILD_WHEELS=ON -DZSWAG_ENABLE_TESTING=ON .. -``` - -#### Code Coverage - -[![codecov](https://codecov.io/gh/ndsev/zswag/branch/master/graph/badge.svg)](https://codecov.io/gh/ndsev/zswag) - -zswag includes comprehensive C++ test coverage analysis for the `httpcl` and `zswagcl` libraries. Coverage is automatically collected in CI and reported to [Codecov](https://codecov.io/gh/ndsev/zswag). - -**📊 [View HTML Coverage Report](https://ndsev.github.io/zswag/coverage/)** - Browsable coverage report hosted on GitHub Pages - -**Local Coverage Analysis** - -To generate coverage reports locally, you'll need: -- GCC or Clang compiler -- `lcov` and `genhtml` tools (install with `sudo apt-get install lcov` on Ubuntu/Debian) - -Build with coverage enabled: -```bash -mkdir build && cd build -cmake -DCMAKE_BUILD_TYPE=Debug \ - -DZSWAG_ENABLE_COVERAGE=ON \ - -DZSWAG_ENABLE_TESTING=ON \ - -DZSWAG_BUILD_WHEELS=OFF \ - -DZSWAG_KEYCHAIN_SUPPORT=OFF .. -cmake --build . -``` - -**Note:** The flags `-DZSWAG_BUILD_WHEELS=OFF` and `-DZSWAG_KEYCHAIN_SUPPORT=OFF` disable features that aren't needed for coverage analysis and avoid requiring Python development headers or system keychain libraries. - -Generate coverage reports: -```bash -# Run tests to generate coverage data -ctest --output-on-failure - -# Generate HTML coverage report -cmake --build . --target coverage-report -``` - -The HTML coverage report will be available at `build/coverage/html/index.html`. - -**Available Coverage Targets:** -- `coverage-clean` - Remove all coverage data -- `coverage-report` - Generate coverage report from existing test runs -- `coverage` - Clean, run tests, and generate report (all-in-one) - -**Coverage Configuration:** -- **Goal:** 70%+ line coverage (initial), near 100% line and branch coverage (ultimate) -- **Scope:** Coverage is tracked only for library source files (`libs/httpcl`, `libs/zswagcl`) -- **Not included:** Test code, dependencies, generated code (zserio) - -**Troubleshooting Coverage Builds:** - -If you get "gcov not found" warnings: -```bash -# Check if versioned gcov exists -which gcov-13 gcov-12 gcov-11 - -# Create symlink (example for gcov-13) -sudo ln -s /usr/bin/gcov-13 /usr/bin/gcov - -# Or install gcc package -sudo apt-get install gcc -``` - -If you want to build coverage **with** wheel support (requires Python development headers): ```bash -cmake -DCMAKE_BUILD_TYPE=Debug \ - -DZSWAG_ENABLE_COVERAGE=ON \ - -DZSWAG_ENABLE_TESTING=ON \ - -DZSWAG_BUILD_WHEELS=ON \ - -DZSWAG_KEYCHAIN_SUPPORT=OFF .. -``` -Note: This requires `python3-dev` package (Ubuntu/Debian) or equivalent on your system. - -## CI/CD and Release Process - -### Continuous Integration - -The project uses GitHub Actions for automated building and deployment: - -- **Platforms**: Linux (x86_64), macOS (Intel x86_64 and Apple Silicon arm64), Windows (x64) -- **Python versions**: 3.10, 3.11, 3.12, 3.13, 3.14 -- **Triggers**: Pull requests, pushes to main branch, and version tags - -### Release Process - -Releases are automated through the CI/CD pipeline: - -1. **Update version**: Modify `ZSWAG_VERSION` in `CMakeLists.txt` -2. **Create release tag**: Tag the commit with `v{version}` (e.g., `v1.7.2`) -3. **Automatic deployment**: The CI pipeline will: - - Validate that the tag version matches the CMake version - - Build wheels for all supported platforms - - Deploy to PyPI automatically - -### Development Snapshots - -Pushes to the main branch automatically create development releases: -- Version format: `{base_version}.dev{commit_count}` (e.g., `1.7.2.dev3`) -- Automatically deployed to PyPI for testing - -### Version Validation - -The build process ensures version consistency: -- Git tags must match the version in `CMakeLists.txt` -- Mismatched versions will cause the build to fail -- This prevents accidental deployment of incorrect versions - -## OpenAPI Generator CLI - -After installing `zswag` via pip as [described above](#for-python-users), -you can run `python -m zswag.gen`, a CLI to generate OpenAPI YAML files. -The CLI offers the following options - -``` -usage: Zserio OpenApi YAML Generator [-h] -s service-identifier -i - zserio-or-python-path - [-r zserio-src-root-dir] - [-p top-level-package] [-c tags [tags ...]] - [-o output] [-b BASE_CONFIG_YAML] - -optional arguments: - -h, --help - show this help message and exit - -s service-identifier, --service service-identifier - - Fully qualified zserio service identifier. - - Example: - -s my.package.ServiceClass - - -i zserio-or-python-path, --input zserio-or-python-path - - Can be either ... - (A) Path to a zserio .zs file. Must be either a top- - level entrypoint (e.g. all.zs), or a subpackage - (e.g. services/myservice.zs) in conjunction with - a "--zserio-source-root|-r " argument. - (B) Path to parent dir of a zserio Python package. - - Examples: - -i path/to/schema/main.zs (A) - -i path/to/python/package/parent (B) - - -r zserio-src-root-dir, --zserio-source-root zserio-src-root-dir - - When -i specifies a zs file (Option A), indicate the - directory for the zserio -src directory argument. If - not specified, the parent directory of the zs file - will be used. - - -p top-level-package, --package top-level-package - - When -i specifies a zs file (Option A), indicate - that a specific top-level zserio package name - should be used. - - Examples: - -p zserio_pkg_name - - -c tags [tags ...], --config tags [tags ...] - - Configuration tags for a specific or all methods. - The argument syntax follows this pattern: - - [(service-method-name):](comma-separated-tags) - - Note: The -c argument may be applied multiple times. - The `comma-separated-tags` must be a list of tags - which indicate OpenApi method generator preferences. - The following tags are supported: - - get|put|post|delete : HTTP method tags - query|path| : Parameter location tags - header|body - flat|blob : Flatten request object, - or pass it as whole blob. - (param-specifier) : Specify parameter name, format - and location for a specific - request-part. See below. - security=(name) : Set a particular security - scheme to be used. The scheme - details must be provided through - the --base-config-yaml. - path=(method-path) : Set a particular method path. - May contain placeholders for - path params. - - A (param-specifier) tag has the following schema: - - (field?name=... - &in=[path|body|query|header] - &format=[binary|base64|hex] - [&style=...] - [&explode=...]) - - Examples: - - Expose all methods as POST, but `getLayerByTileId` - as GET with flat path-parameters: - - `-c post getLayerByTileId:get,flat,path` - - For myMethod, put the whole request blob into the a - query "data" parameter as base64: - - `-c myMethod:*?name=data&in=query&format=base64` - - For myMethod, set the "AwesomeAuth" auth scheme: - - `-c myMethod:security=AwesomeAuth` - - For myMethod, provide the path and place myField - explicitely in a path placeholder: - - `-c 'myMethod:path=/my-method/{param},... - myField?name=param&in=path&format=string'` - - Note: - * The HTTP-method defaults to `post`. - * The parameter 'in' defaults to `query` for - `get`, `body` otherwise. - * If a method uses a parameter specifier, the - `flat`, `body`, `query`, `path`, `header` and - `body`-tags are ignored. - * The `flat` tag is only meaningful in conjunction - with `query` or `path`. - * An unspecific tag list (no service-method-name) - affects the defaults only for following, not - preceding specialized tag assignments. - - -o output, --output output - - Output file path. If not specified, the output will be - written to stdout. - - -b BASE_CONFIG_YAML, --base-config-yaml BASE_CONFIG_YAML - - Base configuration file. Can be used to fully or partially - substitute --config arguments, and to provide additional - OpenAPI information. The YAML file must look like this: - - method: # Optional method tags dictionary - : - securitySchemes: ... # Optional OpenAPI securitySchemes - info: ... # Optional OpenAPI info section - servers: ... # Optional OpenAPI servers section - security: ... # Optional OpenAPI global security +pip install zswag ``` -### Generator Usage example - -Let's consider the following zserio service saved under `myapp/services.zs`: +```python +from zswag import OAClient +import services.api as services +client = services.MyService.Client(OAClient("http://localhost:5000/openapi.json")) +client.my_api(services.Request(1)) ``` -package services; -struct Request { - int32 value; -}; +### C++ -struct Response { - int32 value; -}; +In your `CMakeLists.txt`: -service MyService { - Response myApi(Request); -}; -``` +```cmake +FetchContent_Declare(zswag + GIT_REPOSITORY https://github.com/ndsev/zswag.git + GIT_TAG v1.14.0) +FetchContent_MakeAvailable(zswag) -An OpenAPI file `api.yaml` for `MyService` can now be -created with the following `zswag.gen` invocation: +add_zserio_library(myapp-zserio-cpp + WITH_REFLECTION + ROOT "${CMAKE_CURRENT_SOURCE_DIR}" + ENTRY services.zs + TOP_LEVEL_PKG myapp_services) -```bash -cd myapp -python -m zswag.gen -s services.MyService -i services.zs -o api.yaml +target_link_libraries(myapp myapp-zserio-cpp zswagcl) ``` -You can further customize the generation using `-c` configuration -arguments. For example, `-c get,flat,path` will recursively "flatten" -the zserio request object into it's compound scalar fields using -[x-zserio-request-part](#url-scalar-parameter) for all methods. -If you want to change OpenAPI parameters only for one particular -method, you can prefix the tag config argument with the method -name (`-c methodName:tags...`). - -### Documentation Extraction - -When invoking `zswag.gen` with `-i zserio-file` an attempt -will be made to populate the service/method/request/response -descriptions with doc-strings that are extracted from the zserio -sources. - -For structs and services, the documentation is expected to be -enclosed by `/*! .... !*/` markers preceding the declaration: - -```C -/*! -### My Markdown Struct Doc -I choose to __highlight__ this word. -!*/ - -struct MyStruct { - ... -}; +```cpp +auto httpClient = std::make_unique(); +auto config = zswagcl::fetchOpenAPIConfig("http://localhost:5000/openapi.json", *httpClient); +auto transport = zswagcl::OAClient(config, std::move(httpClient)); +auto client = MyService::Client(transport); +auto resp = client.myApiMethod(Request(1)); ``` -For service methods, a single-line doc-string is parsed which -immediately precedes the declaration: +### Java (JVM) -```C -/** This method is documented. */ -ReturnType myMethod(ArgumentType); +```gradle +dependencies { + implementation project(':libs:jzswag:jzswag-jvm') + implementation "io.github.ndsev:zserio-runtime:2.16.1" +} ``` -## Server Component - -The `OAServer` component gives you the power to marry a zserio-generated app -server class with a user-written app controller and a fitting OpenAPI specification. -It is based on [Flask](https://flask.palletsprojects.com/en/1.1.x/) and -[Connexion](https://connexion.readthedocs.io/en/latest/). - -**Implementation choice regarding HTTP response codes:** The server as implemented -here will return HTTP code `400` (Bad Request) when the user request could not -be parsed, and `500` (Internal Server Error) when a different exception occurred while -generating the response/running the user's controller implementation. - -### Integration Example - -We consider the same `myapp` directory with a `services.zs` zserio file -as already used in the [OpenAPI Generator Example](#generator-usage-example). - -**Note:** +```java +import io.github.ndsev.zswag.jvm.OAClient; -* `myapp` must be available as a module (it must be -possible to `import myapp`). -* We recommend to run the zserio Python generator invocation - inside the `myapp` module's `__init__.py`, like this: - -```py -import zserio -from os.path import dirname, abspath - -working_dir = dirname(abspath(__file__)) -zserio.generate( - zs_dir=working_dir, - main_zs_file="services.zs", - gen_dir=working_dir) +OAClient transport = new OAClient("http://localhost:5000/openapi.json"); +MyService.MyServiceClient client = new MyService.MyServiceClient(transport); +Response r = client.myApiMethod(new Request(1)); ``` -A server script like `myapp/server.py` might then look as follows: - -```py -import zswag -import myapp.controller as controller -from myapp import working_dir - -# This import only works after zserio generation. -import services.api as services - -app = zswag.OAServer( - controller_module=controller, - service_type=services.MyService.Service, - yaml_path=working_dir+"/api.yaml", - zs_pkg_path=working_dir) +### Java (Android) -if __name__ == "__main__": - app.run() +```gradle +dependencies { + implementation project(':libs:jzswag:jzswag-android') + implementation "io.github.ndsev:zserio-runtime:2.16.1" +} ``` -The server script above references two important components: -* An **OpenAPI file** (`myapp/api.yaml`): Upon startup, `OAServer` - will output an error message if this file does not exist. The - error message already contains the correct command to - invoke the [OpenAPI Generator CLI](#openapi-generator-cli) - to generate `myapp/api.yaml`. -* A **controller module** (`myapp/controller.py`): This file provides - the actual implementations for your service endpoints. - -For the current example, `controller.py` might look as follows: - -```py -import services.api as services +```java +import io.github.ndsev.zswag.android.OAClient; -# Written by you -def my_api(request: services.Request): - return services.Response(request.value * 42) +OAClient transport = new OAClient(context, "http://localhost:5000/openapi.json"); +MyService.MyServiceClient client = new MyService.MyServiceClient(transport); +Response r = client.myApiMethod(new Request(1)); ``` -## Using the Python Client - -The generic Python client talks to any zserio service that is running -via HTTP/REST, and provides an OpenAPI specification of it's interface. - -### Integration Example - -As an example, consider a Python module called `myapp` which has the -same `myapp/__init__.py` and `myapp/services.zs` zserio definition as -[previously mentioned](#generator-usage-example). We consider -that the server is providing its OpenAPI spec under `localhost:5000/openapi.json`. - -In this setting, a client `myapp/client.py` might look as follows: - -```python -from zswag import OAClient -import services.api as services - -openapi_url = "http://localhost:5000/openapi.json" - -# The client reads per-method HTTP details from the OpenAPI URL. -# You can also pass a local file by setting the `is_local_file` argument -# of the OAClient constructor. -client = services.MyService.Client(OAClient(openapi_url)) - -# This will trigger an HTTP request under the hood. -client.my_api(services.Request(1)) -``` +The only difference is the `Context` parameter on the constructor — needed so `AndroidKeychain` can reach `SharedPreferences` for credential storage. -As you can see, an instance of `OAClient` is passed into the constructor -for zserio to use as the service client's transport implementation. - -**Note:** While connecting, the client will also use ... -1. [Persistent HTTP configuration](#persistent-http-headers-proxy-cookie-and-authentication). -2. Additional HTTP query/header/cookie/proxy/basic-auth configs passed - into the `OAClient` constructor using an instance of `zswag.HTTPConfig`. - For example: - - ```python - from zswag import OAClient, HTTPConfig - import services.api as services - config = HTTPConfig() \ - .header(key="X-My-Header", val="value") \ # Can be specified - .cookie(key="MyCookie", val="value") \ # multiple times. - .query(key="MyCookie", val="value") \ # - .proxy(host="localhost", port=5050, user="john", pw="doe") \ - .basic_auth(user="john", pw="doe") \ - .bearer("bearer-token") \ - .api_key("token") - - client = services.MyService.Client( - OAClient("http://localhost:8080/openapi.", config=config)) - - # Alternative when specifying api-key or bearer - client = services.MyService.Client( - OAClient("http://localhost:8080/openapi.", api_key="token", bearer="token")) - ``` - - **Note:** The additional `config` will only enrich, not overwrite the - default persistent configuration. If you would like to prevent persistent - config from being considered at all, set `HTTP_SETTINGS_FILE` to empty, - e.g. via `os.environ['HTTP_SETTINGS_FILE']=''` - -## C++ Client - -The generic C++ client talks to any zserio service that is running -via HTTP/REST, and provides an OpenAPI specification of its interface. -When using the C++ `OAClient` with your zserio schema, make sure -that the flags [`-withTypeInfoCode` and `-withReflectionCode`](http://zserio.org/doc/ZserioUserGuide.html#zserio-command-line-interface) are passed to the zserio C++ emitter. - -### Integration Example - -As an example, we consider the `myapp` directory which contains a `services.zs` -zserio definition as [previously mentioned](#generator-usage-example). - -We assume that zswag is added to `myapp` as a [Git submodule](https://git-scm.com/book/en/v2/Git-Tools-Submodules) -under `myapp/zswag`. - -Next to `myapp/services.zs`, we place a `myapp/CMakeLists.txt` which describes our project: +## Setup details -```cmake -project(myapp) - -# If you are not interested in building zswag Python -# wheels, you can set the following option: -# set(ZSWAG_BUILD_WHEELS OFF) - -# If your compilation environment does not provide -# libsecret, the following switch will disable keychain integration: -# set(ZSWAG_KEYCHAIN_SUPPORT OFF) - -# Optional: For offline/disconnected builds, you can -# predefine dependency sources using FETCHCONTENT_SOURCE_DIR_* -# variables (see README offline builds section for details) - -# This is how C++ will know about the zswag lib -# and its dependencies, such as zserio. -if (NOT TARGET zswag) - FetchContent_Declare(zswag - GIT_REPOSITORY "https://github.com/ndsev/zswag.git" - GIT_TAG "v1.6.7" - GIT_SHALLOW ON) - FetchContent_MakeAvailable(zswag) -endif() - -find_package(OpenSSL CONFIG REQUIRED) -target_link_libraries(httplib INTERFACE OpenSSL::SSL) - -# This command is provided by zswag to easily create -# a CMake C++ reflection library from zserio code. -add_zserio_library(${PROJECT_NAME}-zserio-cpp - WITH_REFLECTION - ROOT "${CMAKE_CURRENT_SOURCE_DIR}" - ENTRY services.zs - TOP_LEVEL_PKG myapp_services) - -# We create a myapp client executable which links to -# the generated zserio C++ library and the zswag client -# library. -add_executable(${PROJECT_NAME} client.cpp) - -# Make sure to link to the `zswagcl` target -target_link_libraries(${PROJECT_NAME} - ${PROJECT_NAME}-zserio-cpp zswagcl) -``` +### Python users -**Note:** OpenSSL is assumed to be installed or built using the `lib` (not `lib64`) directory name. +Wheels are published for 64-bit Python 3.10-3.14 on Linux (x86_64), macOS (x86_64 / arm64), and Windows (x64). On Windows install the [Microsoft Visual C++ Redistributable](https://aka.ms/vs/16/release/vc_redist.x64.exe). -The `add_executable` command above references the file `myapp/client.cpp`, -which contains the code to actually use the zswag C++ client. +### C++ users -```cpp -#include "zswagcl/oaclient.hpp" -#include -#include "myapp_services/services/MyService.h" - -using namespace zswagcl; -using namespace httpcl; -namespace MyService = myapp_services::services::MyService; - -int main (int argc, char* argv[]) -{ - // Assume that the server provides its OpenAPI definition here - auto openApiUrl = "http://localhost:5000/openapi.json"; - - // Create an HTTP client to be used by our OpenAPI client - auto httpClient = std::make_unique(); - - // Fetch the OpenAPI configuration using the HTTP client - auto openApiConfig = fetchOpenAPIConfig(openApiUrl, *httpClient); - - // Create a Zserio reflection-based OpenAPI client that - // uses the OpenAPI configuration we just retrieved. - auto openApiClient = OAClient(openApiConfig, std::move(httpClient)); - - // Create a MyService client based on the OpenApi-Client - // implementation of the zserio::IServiceClient interface. - auto myServiceClient = MyService::Client(openApiClient); - - // Create the request object - auto request = myapp_services::services::Request(2); - - // Invoke the REST endpoint. Mind that your method- - // name from the schema is appended with a "...Method" suffix. - auto response = myServiceClient.myApiMethod(request); - - // Print the response - std::cout << "Got " << response.getValue() << std::endl; -} -``` +zswag uses CMake's `FetchContent` for dependencies; CMake ≥ 3.22.3 required. See [`docs/cpp.md`](docs/cpp.md) for full build options including offline / disconnected builds and code coverage. -**Note:** While connecting, `HttpLibHttpClient` will also use ... -1. [Persistent HTTP configuration](#persistent-http-headers-proxy-cookie-and-authentication). -2. Additional HTTP query/header/cookie/proxy/basic-auth configs passed - into the `OAClient` constructor using an instance of `httpcl::Config`. - You can include this class via `#include "httpcl/http-settings.hpp"`. - The additional `Config` will only enrich, not overwrite the - default persistent configuration. If you would like to prevent persistent - config from being considered at all, set `HTTP_SETTINGS_FILE` to empty, - e.g. via `setenv`. +### Java users -## Client Environment Settings +Java 11+ source/target. The integration test depends on `pip install zswag` for its counterparty server. See [`docs/java.md`](docs/java.md). -Both the Python and C++ Clients can be configured using the following -environment variables: +## Client environment variables -| Variable Name | Details | -| ------------- | --------- | -| `HTTP_SETTINGS_FILE` | Path to settings file for HTTP proxies and authentication, see [next section](#persistent-http-headers-proxy-cookie-and-authentication) | -| `HTTP_LOG_LEVEL` | Verbosity level for console/log output. Set to `debug` for detailed output. | -| `HTTP_LOG_FILE` | Logfile-path (including filename) to redirect console output. The log will rotate with three files (`HTTP_LOG_FILE`, `HTTP_LOG_FILE-1`, `HTTP_LOG_FILE-2`). | -| `HTTP_LOG_FILE_MAXSIZE` | Maximum size of the logfile, in bytes. Defaults to 1GB. | -| `HTTP_TIMEOUT` | Timeout for HTTP requests (connection+transfer) in seconds. Defaults to 60s. | -| `HTTP_SSL_STRICT` | Set to any nonempty value for strict SSL certificate validation. | +| Variable | Effect | +|---|---| +| `HTTP_SETTINGS_FILE` | Path to YAML settings file (see [HTTP Settings File](#http-settings-file) below). Empty/unset → no persistent config. | +| `HTTP_LOG_LEVEL` | Verbosity (`debug`, `trace`). Useful for OAuth2 troubleshooting. | +| `HTTP_LOG_FILE` | Logfile path with rotation (3-file window: `FILE`, `FILE-1`, `FILE-2`). Supported by all clients (C++/Python via spdlog, Java via logback `RollingFileAppender`). | +| `HTTP_LOG_FILE_MAXSIZE` | Rotation size in bytes; default 1 GB. Supported by all clients. | +| `HTTP_TIMEOUT` | Request timeout (connect + transfer) in seconds. Default 60. | +| `HTTP_SSL_STRICT` | Set to any non-empty value (e.g. `1`) to enable strict SSL certificate validation. Unset or empty disables. Note: this is "any-non-empty enables," not a boolean — `HTTP_SSL_STRICT=0` also enables. | -## Persistent HTTP Headers, Proxy, Cookie and Authentication -Both the Python `OAClient` and C++ `HttpLibHttpClient` read a YAML file -stored under a path which is given by the `HTTP_SETTINGS_FILE` environment -variable. +## HTTP Settings File -### HTTP Settings File Format +The Python (`OAClient` / `HttpLibHttpClient`), C++, and Java clients all read a YAML file pointed to by the `HTTP_SETTINGS_FILE` environment variable. The format is identical across all three clients — the same file works for all of them. -The YAML file contains a list of HTTP-related configs that are -applied to HTTP requests based on a regular expression which is matched -against the requested URL. +If `HTTP_SETTINGS_FILE` is unset or empty, no persistent settings are applied. -For example, the following entry would match all requests due to the `*` -url-match-pattern for the `scope` field: +### Schema ```yaml http-settings: - # Under http-settings, a list of settings is defined for specific URL scopes. - - scope: * # URL scope - e.g. https://*.nds.live/* or *.google.com. - basic-auth: # Basic auth credentials for matching requests. + - scope: "*" # URL match pattern (glob), e.g. https://*.example.com/* + # Use 'url:' instead for raw regex. + basic-auth: # Basic auth credentials for matching requests. user: johndoe - keychain: keychain-service-string - password: cleartext-password # alternative to keychain - proxy: # Proxy settings for matching requests. + keychain: keychain-service-string # OR + password: cleartext-password + proxy: # HTTP proxy. host: localhost port: 8888 - user: test - keychain: ... - password: cleartext-password # alternative to keychain - cookies: # Additional Cookies for matching requests. + user: test # optional + keychain: ... # OR + password: cleartext-password + cookies: # Additional cookies for matching requests. key: value - headers: # Additional Headers for matching requests. - key: value - query: # Additional Query parameters for matching requests. - key: value - api-key: value # API Key as required by OpenAPI config - see description below. + headers: # Additional headers. + X-Trace: enabled + query: # Additional query parameters. + api_version: v2 + api-key: value # API key — auto-routed to header/query/cookie based on the + # OpenAPI scheme's 'in:' (see Authentication Schemes section). oauth2: - # REQUIRED fields - clientId: my-client-id # REQUIRED: OAuth2 client identifier - - # REQUIRED if useForSpecFetch=true (default), OPTIONAL otherwise - tokenUrl: https://issuer.example.com/oauth/token # Token endpoint URL (see precedence rules below) - - # Client secret (choose one method) - clientSecretKeychain: keychain-service-string # RECOMMENDED: Load secret from OS keychain - clientSecret: cleartext-secret # DISCOURAGED: Cleartext secret (use keychain instead) - - # OPTIONAL fields (with defaults/precedence) - refreshUrl: https://issuer.example.com/oauth/token # Optional override; defaults to refreshUrl from OpenAPI, then tokenUrl - audience: https://api.example.com/ # Optional: audience parameter (required by some providers) - scope: ["orders.read", ...] # Optional: scope override; defaults to OpenAPI spec's per-operation scopes - useForSpecFetch: true # Optional: acquire token before fetching OpenAPI spec (default: true) - tokenEndpointAuth: # Optional: token endpoint authentication method - method: rfc6749-client-secret-basic # Options: rfc6749-client-secret-basic (default), rfc5849-oauth1-signature - nonceLength: 16 # For rfc5849-oauth1-signature: nonce length (8-64, default: 16) + clientId: my-client-id # REQUIRED + clientSecretKeychain: kc-string # RECOMMENDED — load from keychain + clientSecret: cleartext-secret # OR cleartext (discouraged) + tokenUrl: https://issuer/oauth/token + refreshUrl: https://issuer/oauth/token # optional; defaults to tokenUrl + audience: https://api.example.com/ # optional + scope: ["api.read", "api.write"] # optional override of per-operation scopes + useForSpecFetch: true # optional, default true + tokenEndpointAuth: + method: rfc6749-client-secret-basic # OR rfc5849-oauth1-signature + nonceLength: 16 # only for rfc5849, range 8..64 ``` -**Note:** For `proxy` configs, the credentials are optional. - -### OAuth2 Configuration: Required vs Optional Fields - -**Important:** Zswag only supports the **OAuth2 `clientCredentials` flow**. Other flows (`authorizationCode`, `implicit`, `password`) are not supported. - -**Field Requirements:** - -| Field | Required? | Notes | -|-------|-----------|-------| -| `clientId` | ✅ Always | OAuth2 client identifier | -| `tokenUrl` | ⚠️ Conditional | **REQUIRED** when `useForSpecFetch: true` (default)
**OPTIONAL** when `useForSpecFetch: false` (defaults to OpenAPI spec) | -| `clientSecret` / `clientSecretKeychain` | ⚠️ Conditional | **REQUIRED** for confidential clients
**OPTIONAL** for public clients (if omitted, client_id is sent in request body) | -| `refreshUrl` | ❌ Optional | Defaults to `refreshUrl` from OpenAPI spec, then `tokenUrl` | -| `scope` | ❌ Optional | Defaults to scopes from OpenAPI spec's per-operation `security` requirements | -| `audience` | ❌ Optional | Only required by some OAuth2 providers | -| `useForSpecFetch` | ❌ Optional | Default: `true` (acquire token before fetching OpenAPI spec) | -| `tokenEndpointAuth` | ❌ Optional | Default: `rfc6749-client-secret-basic` | +A multi-scope file simply has multiple list entries; for a given request URL, **all matching scopes are merged** in declaration order, with later scopes overriding scalar fields. Multi-valued fields (`headers`, `query`, `cookies`) are unioned. -**Precedence Rules (http-settings.yaml vs OpenAPI spec):** +For `proxy` configs, `user` is optional; if `user` is set, then `password` or `keychain` is required. -When both http-settings.yaml and the OpenAPI specification provide values, the following precedence applies: +### Scope matching -1. **`tokenUrl`**: http-settings.yaml `tokenUrl` **overrides** OpenAPI spec's `flows.clientCredentials.tokenUrl` -2. **`refreshUrl`**: http-settings.yaml `refreshUrl` **overrides** OpenAPI spec's `flows.clientCredentials.refreshUrl` -3. **`scope`**: http-settings.yaml `scope` **overrides** OpenAPI spec's per-operation `security` scopes +`scope:` is a shell-style glob with `*` as the only wildcard, matched against the full request URL after request building. Examples: -**Common Scenarios:** +- `"*"` — matches all requests. +- `"https://*.foo.com/*"` — matches `https://api.foo.com/data` (the dot before `foo` is literal — `https://foo.com/` does NOT match). +- `"http://localhost:5555/*"` — matches local dev servers on a specific port. -| Scenario | `useForSpecFetch` | `tokenUrl` in http-settings | `tokenUrl` in OpenAPI spec | Result | -|----------|-------------------|----------------------------|---------------------------|--------| -| **Protected OpenAPI spec** | `true` (default) | ✅ Required | Used as fallback | http-settings value used | -| **Public OpenAPI spec** | `false` | ❌ Optional | ✅ Required in spec | OpenAPI spec value used | -| **Override spec settings** | `true` or `false` | ✅ Provided | Any | http-settings value **always wins** | +To match by raw regex instead, use `url:` in place of `scope:`: -### OAuth2 Token Endpoint Authentication Methods +```yaml +http-settings: + - url: "^https?://(api|admin)\\.example\\.com/.*$" + headers: ... +``` -The `tokenEndpointAuth` field controls how the client authenticates when requesting OAuth2 tokens. Two methods are supported: +### OAuth2 -**`rfc6749-client-secret-basic` (default):** HTTP Basic Authentication (RFC 6749 Section 2.3.1) -- Both `clientId` and `clientSecret` are sent in the `Authorization: Basic` header -- Works with most OAuth2 providers +Only the `clientCredentials` flow is supported across all zswag clients. Other flows (`authorizationCode`, `implicit`, `password`) and OpenID Connect cause the spec parser to reject the security scheme. -**`rfc5849-oauth1-signature`:** OAuth 1.0 HMAC-SHA256 request signing (RFC 5849) -- `clientId` is sent as `oauth_consumer_key` in both header and body -- `clientSecret` is used only for HMAC-SHA256 signature computation (never transmitted) -- Required by some providers that use OAuth 1.0 signature-based token authentication -- Provides enhanced security through cryptographic request signing -- **Note:** Only HMAC-SHA256 signature method is supported +#### Field requirements -### OAuth2-Authenticated OpenAPI Spec Fetching +| Field | Required? | Notes | +|---|---|---| +| `clientId` | Always | OAuth2 client identifier. | +| `tokenUrl` | When `useForSpecFetch: true` (default) | If `false`, the URL falls back to the spec's `flows.clientCredentials.tokenUrl`. | +| `clientSecret` / `clientSecretKeychain` | For confidential clients | Omit both for public clients (`client_id` goes in the request body). | +| `refreshUrl` | Optional | Defaults to spec value, then to `tokenUrl`. | +| `scope` | Optional | Defaults to per-operation scopes from the OpenAPI spec. | +| `audience` | Provider-specific | Some IdPs require it. | +| `useForSpecFetch` | Optional | Default `true`. Set `false` if the OpenAPI spec endpoint is publicly readable. | +| `tokenEndpointAuth` | Optional | Default `rfc6749-client-secret-basic`. | -By default, when OAuth2 is configured, zswag will acquire an OAuth2 access token **before** fetching the OpenAPI specification. This solves the "chicken-and-egg" problem where the OpenAPI spec endpoint itself requires authentication. +#### Precedence rules -**How it works:** +When both `http-settings.yaml` and the OpenAPI spec specify a value: -1. **With `useForSpecFetch: true` (default):** - - Client acquires OAuth2 token using configured authentication method - - Token is included as `Authorization: Bearer ` header when fetching OpenAPI spec - - OpenAPI spec fetch succeeds even if endpoint requires authentication - - Same token is cached and reused for subsequent API calls +1. **`tokenUrl`** — `http-settings.yaml` overrides the spec's `flows.clientCredentials.tokenUrl`. +2. **`refreshUrl`** — `http-settings.yaml` overrides the spec's `flows.clientCredentials.refreshUrl`. +3. **`scope`** — `http-settings.yaml` overrides the per-operation `security` scopes. -2. **With `useForSpecFetch: false`:** - - OpenAPI spec is fetched without OAuth2 token (plain HTTP GET) - - OAuth2 token acquisition is deferred until first API method call - - Use this when the OpenAPI spec endpoint is public (doesn't require authentication) +#### Token endpoint authentication methods -**Configuration:** +Two authentication methods for the request **to the token endpoint** itself: -```yaml -- scope: https://api.example.com/* - oauth2: - clientId: your-client-id - clientSecret: your-client-secret - tokenUrl: https://api.example.com/oauth/token - useForSpecFetch: true # Default: true. Set to false if spec is public. -``` +**`rfc6749-client-secret-basic` (default)** — RFC 6749 §2.3.1: `client_id:client_secret` in the `Authorization: Basic` header. Works with most providers. -**When to use `useForSpecFetch: false`:** -- OpenAPI spec endpoint is publicly accessible -- Avoids unnecessary token acquisition if only the spec is needed -- Improves performance by deferring OAuth2 flow until first API call +**`rfc5849-oauth1-signature`** — RFC 5849: OAuth 1.0 HMAC-SHA256 signature. The token request is signed using the client secret; the secret itself is never transmitted. `nonceLength` controls the random nonce length (8–64). Required by some providers that use OAuth 1.0 signature-based token authentication. -**When to keep `useForSpecFetch: true` (default):** -- OpenAPI spec endpoint requires authentication -- Service returns 401/403 when fetching spec without credentials -- Most secure option (ensures token is available from the start) +#### Spec fetch protection -**Debugging OAuth2 Issues:** +By default (`useForSpecFetch: true`), the OAuth2 token is acquired **before** fetching the OpenAPI specification, so a spec endpoint that itself requires authentication can be reached. Set `useForSpecFetch: false` if your spec is public — this defers token acquisition to the first API call, which is faster. -To troubleshoot OAuth2 authentication problems, enable detailed logging: +#### Debugging OAuth2 ```bash -export HTTP_LOG_LEVEL=debug # Shows OAuth2 flow (token acquisition, cache hits/misses) -export HTTP_LOG_LEVEL=trace # Shows additional details (request/response bodies, signatures) +export HTTP_LOG_LEVEL=debug # OAuth2 flow (mint/cache/refresh/auth method) +export HTTP_LOG_LEVEL=trace # adds request/response bodies, signatures ``` -The logs will show: -- Token endpoint authentication method being used -- Token request/response status -- Token cache hit/miss/expired events -- OAuth2 configuration status for OpenAPI spec fetch -- Whether OAuth2 token is being used for spec fetch +### Keychain integration -### Testing OAuth 1.0 Signature with Your Service +Storing cleartext secrets in `http-settings.yaml` works but is discouraged. Use the `keychain:` field instead and pre-load the secret with the platform's native tool. The keychain "package" is `lib.openapi.zserio.client` (this is hardcoded across all zswag clients so secrets stored by one are visible to the others). -To verify OAuth 1.0 signature authentication with your service: +| Platform | Tool | C++ / Python | Java | Example | +|---|---|---|---|---| +| Linux | [`secret-tool`](https://www.marian-dan.ro/blog/storing-secrets-using-secret-tool) | ✓ | ✓ | `secret-tool store --label='zswag dev' package lib.openapi.zserio.client service my-service user my-user` | +| macOS | [`add-generic-password`](https://www.netmeister.org/blog/keychain-passwords.html) | ✓ | ✓ | `security add-generic-password -s my-service -a my-user -w 'thepassword'` | +| Windows | [`cmdkey`](https://www.scriptinglibrary.com/languages/powershell/how-to-manage-secrets-and-passwords-with-credentialmanager-and-powershell/) | ✓ | ❌ — Java keychain lookup on Windows throws `KeychainException`. Use cleartext `password:` in `http-settings.yaml`, or configure credentials adhoc via `HttpConfig.basicAuth(...)` instead. | `cmdkey /generic:lib.openapi.zserio.client /user:my-user /pass:thepassword` | -**1. Install zswag:** - -For official releases: -```bash -pip install zswag -``` - -For custom builds or development snapshots: -```bash -pip install /path/to/zswag-*.whl -``` +### Disabling persistent settings programmatically -**2. Create `http-settings.yaml`** with your service details: -```yaml -- scope: https://your-api.example.com/* - oauth2: - clientId: your-client-id - clientSecret: your-client-secret - tokenUrl: https://your-api.example.com/oauth/token - tokenEndpointAuth: - method: rfc5849-oauth1-signature - nonceLength: 16 -``` +To disable persistent settings (e.g. in tests), set the env var to empty: -**3. Create a test script** (`test_oauth1.py`): ```python import os -from zswag import OAClient - -# Point to your http-settings file -os.environ['HTTP_SETTINGS_FILE'] = '/path/to/http-settings.yaml' - -# Create client with your OpenAPI spec -client = OAClient("https://your-api.example.com/openapi.json") - -# Import your generated zserio service -import your_service.api as api - -# Create service client and make a test call -service = api.YourService.Client(client) -request = api.YourRequest(...) -response = service.your_method(request) - -print(f"Success! Response: {response}") +os.environ['HTTP_SETTINGS_FILE'] = '' ``` -**4. Run the test:** -```bash -python test_oauth1.py +```cpp +setenv("HTTP_SETTINGS_FILE", "", 1); ``` -**Verification:** Check your server logs to confirm OAuth 1.0 HMAC-SHA256 signatures are being validated correctly and the token endpoint receives properly signed requests. - -The **`api-key`** setting will be applied under the correct -cookie/header/query parameter, if the service -you are connecting to uses an [OpenAPI `apiKey` auth scheme](#authentication-schemes). - -Passwords can be stored in clear text by setting a `password` field instead -of the `keychain` field. Keychain entries can be made with different tools -on each platform: - -* [Linux `secret-tool`](https://www.marian-dan.ro/blog/storing-secrets-using-secret-tool) -* [macOS `add-generic-password`](https://www.netmeister.org/blog/keychain-passwords.html) -* [Windows `cmdkey`](https://www.scriptinglibrary.com/languages/powershell/how-to-manage-secrets-and-passwords-with-credentialmanager-and-powershell/) +```java +// Java: pass HttpSettings.empty() explicitly to the client constructor. +``` -## Client Result Code Handling -Both clients (Python and C++) will treat any HTTP response code other than `200` as an error since zserio services are expected to return a parsable response object. The client will throw an exception with a descriptive message if the response code is not `200`. +## Result code handling -In case applications want to utilize for example the `204 (No Content)` response code, they have to catch the exception and handle it accordingly. +All clients treat any HTTP response other than `200` as an error and raise/throw a typed exception with a descriptive message. To accept other codes (e.g. `204 No Content`), catch the exception and inspect its status code. -## Swagger User Interface +## Swagger UI -If you have installed `pip install "connexion[swagger-ui]"`, you can view -API docs of your service under `[/prefix]/ui`. +If `pip install "connexion[swagger-ui]"` is available, `OAServer` exposes API docs at `[/prefix]/ui`. ## OpenAPI Options Interoperability -The Server, Clients and Generator offer various degrees of freedom -regarding the OpenAPI YAML file. The following sections detail which -components support which aspects of OpenAPI. The difference in compliance -is mostly due to limited development scopes. If you are missing a particular -OpenAPI feature for a particular component, feel free to create an issue! +The Server, Clients, and Generator support different subsets of OpenAPI. The tables below detail which feature is supported by which component. Differences are mostly due to limited development scope — open an issue if you need something missing. -**Note:** For all options that are not supported by `zswag.gen`, you -will need to manually edit the OpenAPI YAML file to achieve the desired -configuration. You will also need to edit the file manually to fill in -meta-info (provider name, service version, etc.). +For options not supported by `zswag.gen`, edit the OpenAPI YAML by hand. You'll also need to edit it manually for spec-level metadata (provider name, service version, etc.). ### HTTP method -To change the **HTTP method**, the desired method name is placed -as the key under the method path, such as in the following example: +To change the HTTP method, place the desired method name as the key under the method path: + ```yaml paths: /methodName: @@ -1020,23 +382,16 @@ paths: ... ``` -#### Component Support - -| Feature | C++ Client | Python Client | OAServer | zswag.gen | -| ------------------ | ---------- | ------------- | -------- | --------- | -| `get` `post` `put` `delete` | ✔️ | ✔️ | ✔️ | ✔️ | -| `patch` | ❌️ | ❌️ | ❌️ | ❌️ | +| Feature | C++ Client | Python Client | Java Client | OAServer | zswag.gen | +|---|---|---|---|---|---| +| `get` `post` `put` `delete` | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| `patch` | ❌️ | ❌️ | ❌️ | ❌️ | ❌️ | -**Note:** Patch is unsupported, because the required semantics of -a partial object update cannot be realized in the zserio transport -layer interface. +`patch` is intentionally unsupported across the stack: the partial-object-update semantics it implies cannot be realised in the zserio transport layer interface. -### Request Body +### Request body -A server can instruct clients to transmit their zserio request object in the -request body when using HTTP `post`, `put` or `delete`. -This is done by setting the OpenAPI `requestBody/content` to -`application/x-zserio-object`: +Set `requestBody/content` to `application/x-zserio-object` to instruct clients to send the zserio request object in the body when using `post`/`put`/`delete`: ```yaml requestBody: @@ -1046,25 +401,17 @@ requestBody: type: string ``` -#### Component Support - -| Feature | C++ Client | Python Client | OAServer | zswag.gen | -| ------------------ | ---------- | ------------- | -------- | --------- | -| `application/x-zserio-object` | ✔️ | ✔️ | ✔️ | ✔️ | +| Feature | C++ Client | Python Client | Java Client | OAServer | zswag.gen | +|---|---|---|---|---|---| +| `application/x-zserio-object` | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ### URL Blob Parameter -Zswag tools support an additional OpenAPI method parameter -field called `x-zserio-request-part`. Through this field, -a service provider can express that a certain request parameter -only contains a part of, or the whole zserio request object. -When parameter contains the whole request object, `x-zserio-request-part` -should be set to an asterisk (`*`): +`x-zserio-request-part: "*"` indicates a parameter holds the whole zserio request as a blob: ```yaml parameters: -- description: '' - in: query|path|header +- in: query|path|header name: parameterName required: true x-zserio-request-part: "*" @@ -1072,34 +419,28 @@ parameters: format: string|byte|base64|base64url|hex|binary ``` -About the `format` specifier value: -* Both `string` and `binary` result in a raw URL-encoded string buffer. -* Both `byte` and `base64` result in a standard Base64-encoded value. - The `base64url` option indicates URL-safe Base64 format. -* The `hex` encoding produces a hexadecimal encoding of the request blob. - -**Note:** When a parameter is passed with `in=path`, its value -**must not be empty**. This holds true for strings and bytes, -but also for arrays (see below). +About `format`: +- `string` and `binary` produce a raw URL-encoded buffer. +- `byte` and `base64` produce standard Base64. +- `base64url` is URL-safe Base64. +- `hex` is hexadecimal. -#### Component Support +When a parameter is in `path`, its value must not be empty (also applies to arrays). -| Feature | C++ Client | Python Client | OAServer | zswag.gen | -| ------------------ | ---------- | ------------- | -------- | --------- | -| `x-zserio-request-part: *` | ✔️ | ✔️ | ✔️ | ✔️ | -| `format: string` | ✔️ | ✔️ | ✔️ | ✔️ | -| `format: byte` | ✔️ | ✔️ | ✔️ | ✔️ | -| `format: hex` | ✔️ | ✔️ | ✔️ | ✔️ | +| Feature | C++ Client | Python Client | Java Client | OAServer | zswag.gen | +|---|---|---|---|---|---| +| `x-zserio-request-part: *` | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| `format: string` | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| `format: byte` | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| `format: hex` | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ### URL Scalar Parameter -Using `x-zserio-request-part`, it is also possible to transfer -only a single scalar (nested) member of the request object: +`x-zserio-request-part` can also point to a scalar (nested) member of the request: ```yaml parameters: -- description: '' - in: query|path|header +- in: query|path|header name: parameterName required: true x-zserio-request-part: "[parent.]*member" @@ -1107,28 +448,19 @@ parameters: format: string|byte|base64|base64url|hex|binary ``` -In this case, `x-zserio-request-part` should point to a scalar type, -such as `uint8`, `float32`, `string` etc. - -The `format` value effect remains as explained above. A small -difference exists for integer types: Their hexadecimal representation -will be the natural numeric one, not binary. +For integer types, hex is the natural numeric representation, not binary. -#### Component Support - -| Feature | C++ Client | Python Client | OAServer | zswag.gen | -| ------------------ | ---------- | ------------- | -------- | --------- | -| `x-zserio-request-part: <[parent.]*member>` | ✔️ | ✔️ | ✔️ | ✔️ | +| Feature | C++ Client | Python Client | Java Client | OAServer | zswag.gen | +|---|---|---|---|---|---| +| `x-zserio-request-part: <[parent.]*member>` | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ### URL Array Parameter -The `x-zserio-request-part` may also point to an array member of -the zserio request struct, like so: +`x-zserio-request-part` can point to an array member: ```yaml parameters: -- description: '' - in: query|path|header +- in: query|path|header style: form|simple|label|matrix explode: true|false name: parameterName @@ -1138,51 +470,39 @@ parameters: format: string|byte|base64|base64url|hex|binary ``` -In this case, `x-zserio-request-part` should point to an array of -scalar types. The array will be encoded according -to the [format, style and explode](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md#parameter-object) -specifiers. - -| Feature | C++ Client | Python Client | OAServer | zswag.gen | -| ------------------ | ---------- | ------------- | -------- | --------- | -| `x-zserio-request-part: <[parent.]*array_member>` | ✔️ | ✔️ | ✔️ | ✔️ | -| `style: simple` | ✔️ | ✔️ | ✔️ | ✔️ | -| `style: form` | ✔️ | ✔️ | ✔️ | ✔️ | -| `style: label` | ✔️ | ✔️ | ❌ | ✔️ | -| `style: matrix` | ✔️ | ✔️ | ❌ | ✔️ | -| `explode: true` | ✔️ | ✔️ | ✔️ | ✔️ | -| `explode: false` | ✔️ | ✔️ | ✔️ | ✔️ | +The array is encoded according to `format`, `style`, and `explode` per [the OpenAPI 3.1 spec](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md#parameter-object). -### URL Compound Parameter +| Feature | C++ Client | Python Client | Java Client | OAServer | zswag.gen | +|---|---|---|---|---|---| +| `x-zserio-request-part: <[parent.]*array_member>` | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| `style: simple` | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| `style: form` | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| `style: label` | ✔️ | ✔️ | ✔️ | ❌ | ✔️ | +| `style: matrix` | ✔️ | ✔️ | ✔️ | ❌ | ✔️ | +| `explode: true` | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| `explode: false` | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -In this case, `x-zserio-request-part` points to a zserio compound struct -instead of a field with a scalar value. **This is currently not supported.** +### URL Compound Parameter -#### Component Support +Compound (struct-typed) `x-zserio-request-part` is unsupported across all components. -| Feature | C++ Client | Python Client | OAServer | zswag.gen | -| ------------------ | ---------- | ------------- | -------- | --------- | -| `x-zserio-request-part: <[parent.]*compound_member>` | ❌️ | ❌️ | ❌️ | ❌️ | +| Feature | C++ Client | Python Client | Java Client | OAServer | zswag.gen | +|---|---|---|---|---|---| +| `x-zserio-request-part: <[parent.]*compound_member>` | ❌️ | ❌️ | ❌️ | ❌️ | ❌️ | -### Server URL Base Path +### Server URL base path -OpenAPI allows for a `servers` field in the spec that lists URL path prefixes -under which the specified API may be reached. The OpenAPI clients -looks into this list to determine a URL base path from -the first entry in this list. Per OpenAPI 3.0+ (clarified in -[3.2.0 §4.5.2.1](https://spec.openapis.org/oas/v3.2.0.html#examples-of-api-base-url-determination)), -three URL forms are supported, all resolved against the spec URL via -RFC 3986 §5.3 reference resolution: +Each client takes the URL base path from `servers[N]` (default `N = 0`). Per OpenAPI 3.0+ (clarified in [3.2.0 §4.5.2.1](https://spec.openapis.org/oas/v3.2.0.html#examples-of-api-base-url-determination)), three URL forms are supported, all resolved against the spec URL via RFC 3986 §5.3 reference resolution: ```yaml servers: - # 1. Absolute — used as-is + # 1. Absolute - used as-is - url: https://api.example.com/v1 - # 2. Server-relative path — host+scheme from the spec URL, given path + # 2. Server-relative path - host+scheme from the spec URL, given path - url: /v1 - # 3. Document-relative — resolved against the spec's directory: + # 3. Document-relative - resolved against the spec's directory: - url: . # spec at https://x/foo/openapi.json -> https://x/foo/ - url: ./v2 # -> https://x/foo/v2 - url: ../v2 # -> https://x/v2 @@ -1192,109 +512,49 @@ servers: An absent or empty `servers` array defaults to `[{ "url": "/" }]` (server-relative to the spec's origin root). -#### Component Support - -| Feature | C++ Client | Python Client | OAServer | zswag.gen | -| ------------------ | ---------- | ------------- | -------- | --------- | -| `servers` (absolute URL) | ✔️ | ✔️ | ✔️ | ✔️ | -| `servers` (server-relative `/path`) | ✔️ | ✔️ | ✔️ | ✔️ | -| `servers` (document-relative `.`, `./v2`, `../v2`) | ✔️ | ✔️ | n/a | n/a | - -### Authentication Schemes - -To facilitate the communication of authentication needs for the whole or parts -of a service, OpenAPI allows for `securitySchemes` and `security` fields in the spec. -Please refer to the relevant parts of the [OpenAPI 3 specification](https://swagger.io/docs/specification/authentication/) for some -examples on how to integrate these fields into your spec. - -#### When Security Schemes Are Applied - -**Important:** Security schemes (including OAuth2) are **only applied when explicitly declared** in the OpenAPI specification. Zswag clients respect the security requirements defined in the spec according to the [OpenAPI 3.0 Security Requirement specification](https://spec.openapis.org/oas/v3.0.3#security-requirement-object). - -**Security Configuration Levels:** - -1. **Global Security** - Applied to all endpoints by default (root-level `security` field): - ```yaml - components: - securitySchemes: - HeaderAuth: - type: apiKey - in: header - name: X-Generic-Token - - security: - - HeaderAuth: [] # Applied to all endpoints by default - - paths: - /methodWithGlobalAuth: - get: - # Uses global HeaderAuth - ``` - -2. **Per-Endpoint Security** - Override global security for specific operations: - ```yaml - paths: - /protected: - post: - security: - - oauth2: [read, write] # Overrides global security - /admin: - post: - security: - - oauth2: [admin] # Different scopes for admin endpoint - ``` - -3. **No Authentication** - Explicitly disable security for public endpoints: - ```yaml - paths: - /public: - get: - security: [] # Explicitly no authentication required (overrides global) - ``` - -**Precedence Rule:** Per-operation `security` settings **override** global `security` settings. If an operation specifies its own security requirements (including `security: []`), the global security configuration is ignored for that operation. - -**Complete Working Examples:** - -- **OAuth2 with per-endpoint security**: See [`libs/zswagcl/test/testdata/oauth2-openapi.yaml`](libs/zswagcl/test/testdata/oauth2-openapi.yaml) which demonstrates different OAuth2 scopes per endpoint and public endpoints without authentication. -- **Global security with overrides**: See [`libs/zswag/test/calc/api.yaml`](libs/zswag/test/calc/api.yaml) which shows global `HeaderAuth` security with per-endpoint overrides and explicit no-auth declarations. - -Zswag currently understands the following authentication schemes: - -* **HTTP Basic Authorization:** If a called endpoint requires HTTP basic auth, - zswag will verify that the HTTP config contains basic-auth credentials. - If there are none, zswag will throw a descriptive runtime error. -* **HTTP Bearer Authorization:** If a called endpoint requires HTTP bearer auth, - zswag will verify that the HTTP config contains a header with the - key name `Authorization` and the value `Bearer `, *case-sensitive*. -* **API-Key Cookie:** If a called endpoint requires a Cookie API-Key, - zswag will either apply [the `api-key` setting](#persistent-http-headers-proxy-cookie-and-authentication), or verify that the - HTTP config contains a cookie with the required name, *case-sensitive*. -* **API-Key Query Parameter:** If a called endpoint requires a Query API-Key, - zswag will either apply the `api-key` setting, or verify that the - HTTP config contains a query key-value pair with the required name, *case-sensitive*. -* **API-Key Header:** If a called endpoint requires an API-Key Header, - zswag will either apply the `api-key` setting, or verify that the - HTTP config contains a header key-value pair with the required name, *case-sensitive*. -* **OAuth2 Client Credentials:** If a called endpoint requires OAuth2 authentication, - zswag will **automatically acquire, cache, and refresh** access tokens from the configured - OAuth2 token endpoint. The client handles the entire OAuth2 client credentials flow - transparently, including token expiry and refresh. **Note:** Only the `clientCredentials` - flow is supported. See the [OAuth2 configuration section](#persistent-http-headers-proxy-cookie-and-authentication) - for detailed setup instructions. - -**Note**: If you don't want to pass your Basic-Auth/Bearer/Query/Cookie/Header -credential through your [persistent config](#persistent-http-headers-proxy-cookie-and-authentication), -you can pass a `httpcl::Config`/[`HTTPConfig`](#using-the-python-client) object to the `OAClient`/[`OAClient`](#using-the-python-client). -constructor in C++/Python with the relevant detail. - -#### Component Support - -| Feature | C++ Client | Python Client | OAServer | zswag.gen | -|----------------------------------------------------------------------------------------| ---------- | ------------- | -------- | --------- | -| `HTTP Basic-Auth` `HTTP Bearer-Auth` `Cookie API-Key` `Header API-Key` `Query API-Key` | ✔️ | ✔️ | ✔️(**) | ✔️ | -| `OAuth2[clientCredentials]` | ✔️ | ✔️ | ✔️(**) | ✔️ | -| `OpenID Connect` `OAuth2[authorizationCode]` `OAuth2[implicit]` `OAuth2[password]` | ❌️ | ❌️ | ✔️(**) | ❌️ | - -**(\*\*)**: The server support for all authentication schemes depends on your -configuration of the WSGI server (Apache/Nginx/...) which wraps the zswag Flask app. +To target a non-default server entry, pass `serverIndex` / `server_index`: + +```cpp +// C++ +auto client = zswagcl::OAClient(config, std::move(httpClient), httpConfig, /*serverIndex=*/1); +``` + +```python +# Python +client = OAClient("http://host/openapi.json", server_index=1) +``` + +```java +// Java (JVM) +OAClient transport = new OAClient(url, persistent, adhoc, /*serverIndex=*/ 1); +// Java (Android) +OAClient transport = new OAClient(context, url, persistent, adhoc, 1); +``` + +| Feature | C++ Client | Python Client | Java Client | OAServer | zswag.gen | +|---|---|---|---|---|---| +| `servers` (absolute URL) | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| `servers` (server-relative `/path`) | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| `servers` (document-relative `.`, `./v2`, `../v2`) | ✔️ | ✔️ | ✔️ | n/a | n/a | +| Selecting `servers[N]` (multi-server) | ✔️ | ✔️ | ✔️ | n/a | n/a | + +### Authentication schemes + +OpenAPI's `securitySchemes` and `security` fields drive auth. Per-operation `security:` overrides the root-level one; `security: []` explicitly disables auth for an operation. + +Supported schemes: + +- **HTTP Basic** — credentials checked from `httpcl::Config::auth` / `HttpConfig.auth` / Python `HTTPConfig.basic_auth`. Throws if missing. +- **HTTP Bearer** — verifies an `Authorization: Bearer ` header is present. Throws if missing. +- **API key (cookie/header/query)** — applies the configured `api-key` to the matching location, or verifies the user has provided it directly. +- **OAuth2 client credentials** — clients automatically acquire, cache, refresh access tokens from the configured token endpoint. Two token-endpoint authentication methods are supported: `rfc6749-client-secret-basic` (default) and `rfc5849-oauth1-signature` (HMAC-SHA256). See [HTTP Settings File](#http-settings-file) above for full configuration. + +If you don't want to put credentials in [`HTTP_SETTINGS_FILE`](#http-settings-file), pass `httpcl::Config` (C++) / `HTTPConfig` (Python) / `HttpConfig` (Java) directly to the client constructor. + +| Feature | C++ Client | Python Client | Java Client | OAServer | zswag.gen | +|---|---|---|---|---|---| +| HTTP Basic / HTTP Bearer / Cookie API-Key / Header API-Key / Query API-Key | ✔️ | ✔️ | ✔️ | ✔️(\*\*) | ✔️ | +| `OAuth2[clientCredentials]` | ✔️ | ✔️ | ✔️ | ✔️(\*\*) | ✔️ | +| `OpenID Connect` `OAuth2[authorizationCode]` `OAuth2[implicit]` `OAuth2[password]` | ❌️ | ❌️ | ❌️ | ✔️(\*\*) | ❌️ | + +**(\*\*)** OAServer's actual support depends on your WSGI server (Apache/Nginx/...) wrapping the Flask app. diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..3e408719 --- /dev/null +++ b/build.gradle @@ -0,0 +1,43 @@ +// Root build.gradle for zswag Java modules + +buildscript { + ext { + kotlin_version = '2.1.0' + zserio_version = '2.16.1' + } + repositories { + mavenCentral() + google() + } + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + // AGP version chosen for Gradle 9 compatibility (the project uses Gradle 9.2.1). + classpath 'com.android.tools.build:gradle:8.7.2' + } +} + +allprojects { + group = 'io.github.ndsev.zswag' + version = '1.14.0' + + repositories { + mavenCentral() + google() + } +} + +// Pin every Java subproject to a Temurin 17 toolchain. Gradle auto-downloads the +// JDK if it isn't installed locally — contributors only need *some* JDK 17+ on +// PATH to launch the daemon, and Gradle takes care of compile/test JDK from +// there. The per-module `sourceCompatibility/targetCompatibility = VERSION_11` +// blocks keep the produced bytecode at Java 11 for runtime compatibility. +subprojects { + plugins.withId('java') { + java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + vendor = JvmVendorSpec.ADOPTIUM + } + } + } +} diff --git a/doc/zswag-architecture.mdj b/doc/zswag-architecture.mdj deleted file mode 100644 index 4de12e64..00000000 --- a/doc/zswag-architecture.mdj +++ /dev/null @@ -1,6982 +0,0 @@ -{ - "_type": "Project", - "_id": "AAAAAAFF+h6SjaM2Hec=", - "name": "Untitled", - "ownedElements": [ - { - "_type": "UMLModel", - "_id": "AAAAAAFF+qBWK6M3Z8Y=", - "_parent": { - "$ref": "AAAAAAFF+h6SjaM2Hec=" - }, - "name": "Model", - "ownedElements": [ - { - "_type": "UMLClassDiagram", - "_id": "AAAAAAFF+qBtyKM79qY=", - "_parent": { - "$ref": "AAAAAAFF+qBWK6M3Z8Y=" - }, - "name": "Main", - "defaultDiagram": true, - "ownedViews": [ - { - "_type": "UMLPackageView", - "_id": "AAAAAAF4bgLBtacVT74=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bgLBtacTlEc=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4bgLBtacWYPw=", - "_parent": { - "$ref": "AAAAAAF4bgLBtacVT74=" - }, - "model": { - "$ref": "AAAAAAF4bgLBtacTlEc=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4bgLBtacXBLs=", - "_parent": { - "$ref": "AAAAAAF4bgLBtacWYPw=" - }, - "visible": false, - "fillColor": "#d8ffeb", - "font": "Arial;15;0", - "left": -464, - "top": -32, - "height": 15 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgLBtacYgmk=", - "_parent": { - "$ref": "AAAAAAF4bgLBtacWYPw=" - }, - "fillColor": "#d8ffeb", - "font": "Arial;15;1", - "left": 765, - "top": 158, - "width": 391, - "height": 15, - "text": "zswag" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgLBtacZaQE=", - "_parent": { - "$ref": "AAAAAAF4bgLBtacWYPw=" - }, - "visible": false, - "fillColor": "#d8ffeb", - "font": "Arial;15;0", - "left": -464, - "top": -32, - "width": 85.01220703125, - "height": 15, - "text": "(from Model)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgLBtacawkg=", - "_parent": { - "$ref": "AAAAAAF4bgLBtacWYPw=" - }, - "visible": false, - "fillColor": "#d8ffeb", - "font": "Arial;15;0", - "left": -464, - "top": -32, - "height": 15, - "horizontalAlignment": 1 - } - ], - "fillColor": "#d8ffeb", - "font": "Arial;15;0", - "left": 760, - "top": 151, - "width": 401, - "height": 27, - "stereotypeLabel": { - "$ref": "AAAAAAF4bgLBtacXBLs=" - }, - "nameLabel": { - "$ref": "AAAAAAF4bgLBtacYgmk=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4bgLBtacZaQE=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bgLBtacawkg=" - } - } - ], - "containedViews": [ - { - "$ref": "AAAAAAF4bgYiBqf0des=" - }, - { - "$ref": "AAAAAAF4bgtpFKjwhnI=" - }, - { - "$ref": "AAAAAAF4bg3JFKmRnS8=" - }, - { - "$ref": "AAAAAAF4biS/lPNILu0=" - } - ], - "fillColor": "#d8ffeb", - "font": "Arial;15;0", - "containerChangeable": true, - "left": 760, - "top": 136, - "width": 401, - "height": 417, - "nameCompartment": { - "$ref": "AAAAAAF4bgLBtacWYPw=" - } - }, - { - "_type": "UMLPackageView", - "_id": "AAAAAAF4bgAZMqaVouk=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bf/wKqZsitI=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4bgAZMqaWu78=", - "_parent": { - "$ref": "AAAAAAF4bgAZMqaVouk=" - }, - "model": { - "$ref": "AAAAAAF4bf/wKqZsitI=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4bgAZMqaXu/I=", - "_parent": { - "$ref": "AAAAAAF4bgAZMqaWu78=" - }, - "visible": false, - "fillColor": "#d8f2ff", - "font": "Arial;15;0", - "left": -556, - "top": -352, - "height": 15 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgAZMqaYy9U=", - "_parent": { - "$ref": "AAAAAAF4bgAZMqaWu78=" - }, - "fillColor": "#d8f2ff", - "font": "Arial;15;1", - "left": 357, - "top": 398, - "width": 375, - "height": 15, - "text": "httpcl" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgAZMqaZKVU=", - "_parent": { - "$ref": "AAAAAAF4bgAZMqaWu78=" - }, - "visible": false, - "fillColor": "#d8f2ff", - "font": "Arial;15;0", - "left": -556, - "top": -352, - "width": 85.01220703125, - "height": 15, - "text": "(from Model)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgAZMqaak6Y=", - "_parent": { - "$ref": "AAAAAAF4bgAZMqaWu78=" - }, - "visible": false, - "fillColor": "#d8f2ff", - "font": "Arial;15;0", - "left": -556, - "top": -352, - "height": 15, - "horizontalAlignment": 1 - } - ], - "fillColor": "#d8f2ff", - "font": "Arial;15;0", - "left": 352, - "top": 391, - "width": 385, - "height": 27, - "stereotypeLabel": { - "$ref": "AAAAAAF4bgAZMqaXu/I=" - }, - "nameLabel": { - "$ref": "AAAAAAF4bgAZMqaYy9U=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4bgAZMqaZKVU=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bgAZMqaak6Y=" - } - } - ], - "containedViews": [ - { - "$ref": "AAAAAAF4bgMKEKcwhN4=" - }, - { - "$ref": "AAAAAAF4bgPmmadioEM=" - }, - { - "$ref": "AAAAAAF6qThnCAOTYh0=" - }, - { - "$ref": "AAAAAAF6qTP76/P0gDU=" - } - ], - "fillColor": "#d8f2ff", - "font": "Arial;15;0", - "containerChangeable": true, - "left": 352, - "top": 376, - "width": 385, - "height": 177, - "nameCompartment": { - "$ref": "AAAAAAF4bgAZMqaWu78=" - } - }, - { - "_type": "UMLPackageView", - "_id": "AAAAAAF4bgIuhabf/vw=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bgIuhKbdLYk=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4bgIuhabgQ2g=", - "_parent": { - "$ref": "AAAAAAF4bgIuhabf/vw=" - }, - "model": { - "$ref": "AAAAAAF4bgIuhKbdLYk=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4bgIuhabhH8Y=", - "_parent": { - "$ref": "AAAAAAF4bgIuhabgQ2g=" - }, - "visible": false, - "fillColor": "#d8f2ff", - "font": "Arial;15;0", - "left": 32, - "height": 15 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgIuhabiBHc=", - "_parent": { - "$ref": "AAAAAAF4bgIuhabgQ2g=" - }, - "fillColor": "#d8f2ff", - "font": "Arial;15;1", - "left": 357, - "top": 158, - "width": 375, - "height": 15, - "text": "zswagcl" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgIuhabjdJ4=", - "_parent": { - "$ref": "AAAAAAF4bgIuhabgQ2g=" - }, - "visible": false, - "fillColor": "#d8f2ff", - "font": "Arial;15;0", - "left": 32, - "width": 85.01220703125, - "height": 15, - "text": "(from Model)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgIuhqbkJkk=", - "_parent": { - "$ref": "AAAAAAF4bgIuhabgQ2g=" - }, - "visible": false, - "fillColor": "#d8f2ff", - "font": "Arial;15;0", - "left": 32, - "height": 15, - "horizontalAlignment": 1 - } - ], - "fillColor": "#d8f2ff", - "font": "Arial;15;0", - "left": 352, - "top": 151, - "width": 385, - "height": 27, - "stereotypeLabel": { - "$ref": "AAAAAAF4bgIuhabhH8Y=" - }, - "nameLabel": { - "$ref": "AAAAAAF4bgIuhabiBHc=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4bgIuhabjdJ4=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bgIuhqbkJkk=" - } - } - ], - "containedViews": [ - { - "$ref": "AAAAAAF4bgc7NqgnkmU=" - }, - { - "$ref": "AAAAAAF4bge2pKhVmUU=" - }, - { - "$ref": "AAAAAAF4bgoCwaiVju8=" - }, - { - "$ref": "AAAAAAF4bhSO3K5EvQQ=" - } - ], - "fillColor": "#d8f2ff", - "font": "Arial;15;0", - "containerChangeable": true, - "left": 352, - "top": 136, - "width": 385, - "height": 225, - "nameCompartment": { - "$ref": "AAAAAAF4bgIuhabgQ2g=" - } - }, - { - "_type": "UMLPackageView", - "_id": "AAAAAAF4bgKNKab7Iso=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bgKNKab5WDU=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4bgKNKab8zrM=", - "_parent": { - "$ref": "AAAAAAF4bgKNKab7Iso=" - }, - "model": { - "$ref": "AAAAAAF4bgKNKab5WDU=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4bgKNKab9pB0=", - "_parent": { - "$ref": "AAAAAAF4bgKNKab8zrM=" - }, - "visible": false, - "fillColor": "#d8f2ff", - "font": "Arial;15;0", - "left": 96, - "top": 64, - "height": 15 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgKNKab+j3U=", - "_parent": { - "$ref": "AAAAAAF4bgKNKab8zrM=" - }, - "fillColor": "#d8f2ff", - "font": "Arial;15;1", - "left": 829, - "top": 206, - "width": 239, - "height": 15, - "text": "pyzswagcl" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgKNKab/ySM=", - "_parent": { - "$ref": "AAAAAAF4bgKNKab8zrM=" - }, - "visible": false, - "fillColor": "#d8f2ff", - "font": "Arial;15;0", - "left": 96, - "top": 64, - "width": 85.01220703125, - "height": 15, - "text": "(from Model)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgKNKqcACOI=", - "_parent": { - "$ref": "AAAAAAF4bgKNKab8zrM=" - }, - "visible": false, - "fillColor": "#d8f2ff", - "font": "Arial;15;0", - "left": 96, - "top": 64, - "height": 15, - "horizontalAlignment": 1 - } - ], - "fillColor": "#d8f2ff", - "font": "Arial;15;0", - "left": 824, - "top": 199, - "width": 249, - "height": 27, - "stereotypeLabel": { - "$ref": "AAAAAAF4bgKNKab9pB0=" - }, - "nameLabel": { - "$ref": "AAAAAAF4bgKNKab+j3U=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4bgKNKab/ySM=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bgKNKqcACOI=" - } - } - ], - "containedViews": [ - { - "$ref": "AAAAAAF4bgSxm6fAlds=" - } - ], - "fillColor": "#d8f2ff", - "font": "Arial;15;0", - "containerChangeable": true, - "left": 824, - "top": 184, - "width": 249, - "height": 153, - "nameCompartment": { - "$ref": "AAAAAAF4bgKNKab8zrM=" - } - }, - { - "_type": "UMLClassView", - "_id": "AAAAAAF4bgMKEKcwhN4=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bgMKEKcupgk=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4bgMKEacx2eQ=", - "_parent": { - "$ref": "AAAAAAF4bgMKEKcwhN4=" - }, - "model": { - "$ref": "AAAAAAF4bgMKEKcupgk=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4bgMKEacyznA=", - "_parent": { - "$ref": "AAAAAAF4bgMKEacx2eQ=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -176, - "top": -392, - "height": 13 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgMKEaczZx8=", - "_parent": { - "$ref": "AAAAAAF4bgMKEacx2eQ=" - }, - "font": "Arial;13;1", - "left": 373, - "top": 487, - "width": 111, - "height": 13, - "text": "HttpLibHttpClient" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgMKEac0C7c=", - "_parent": { - "$ref": "AAAAAAF4bgMKEacx2eQ=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -176, - "top": -392, - "width": 73.67724609375, - "height": 13, - "text": "(from httpcl)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgMKEac1L+4=", - "_parent": { - "$ref": "AAAAAAF4bgMKEacx2eQ=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -176, - "top": -392, - "height": 13, - "horizontalAlignment": 1 - } - ], - "font": "Arial;13;0", - "left": 368, - "top": 480, - "width": 121, - "height": 25, - "stereotypeLabel": { - "$ref": "AAAAAAF4bgMKEacyznA=" - }, - "nameLabel": { - "$ref": "AAAAAAF4bgMKEaczZx8=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4bgMKEac0C7c=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bgMKEac1L+4=" - } - }, - { - "_type": "UMLAttributeCompartmentView", - "_id": "AAAAAAF4bgMKEac2uYI=", - "_parent": { - "$ref": "AAAAAAF4bgMKEKcwhN4=" - }, - "model": { - "$ref": "AAAAAAF4bgMKEKcupgk=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 552, - "top": 473, - "width": 352, - "height": 10 - }, - { - "_type": "UMLOperationCompartmentView", - "_id": "AAAAAAF4bgMKEac3Kcg=", - "_parent": { - "$ref": "AAAAAAF4bgMKEKcwhN4=" - }, - "model": { - "$ref": "AAAAAAF4bgMKEKcupgk=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 552, - "top": 473, - "width": 352, - "height": 10 - }, - { - "_type": "UMLReceptionCompartmentView", - "_id": "AAAAAAF4bgMKEac4Cls=", - "_parent": { - "$ref": "AAAAAAF4bgMKEKcwhN4=" - }, - "model": { - "$ref": "AAAAAAF4bgMKEKcupgk=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 80, - "top": -216, - "width": 10, - "height": 10 - }, - { - "_type": "UMLTemplateParameterCompartmentView", - "_id": "AAAAAAF4bgMKEac5cjQ=", - "_parent": { - "$ref": "AAAAAAF4bgMKEKcwhN4=" - }, - "model": { - "$ref": "AAAAAAF4bgMKEKcupgk=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 80, - "top": -216, - "width": 10, - "height": 10 - } - ], - "containerView": { - "$ref": "AAAAAAF4bgAZMqaVouk=" - }, - "font": "Arial;13;0", - "containerChangeable": true, - "left": 368, - "top": 480, - "width": 121, - "height": 25, - "nameCompartment": { - "$ref": "AAAAAAF4bgMKEacx2eQ=" - }, - "suppressAttributes": true, - "suppressOperations": true, - "attributeCompartment": { - "$ref": "AAAAAAF4bgMKEac2uYI=" - }, - "operationCompartment": { - "$ref": "AAAAAAF4bgMKEac3Kcg=" - }, - "receptionCompartment": { - "$ref": "AAAAAAF4bgMKEac4Cls=" - }, - "templateParameterCompartment": { - "$ref": "AAAAAAF4bgMKEac5cjQ=" - } - }, - { - "_type": "UMLClassView", - "_id": "AAAAAAF4bgRJ5aeSzhQ=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bgRJ5aeQAJc=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4bgRJ5aeT1Z0=", - "_parent": { - "$ref": "AAAAAAF4bgRJ5aeSzhQ=" - }, - "model": { - "$ref": "AAAAAAF4bgRJ5aeQAJc=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4bgRJ5aeUMvY=", - "_parent": { - "$ref": "AAAAAAF4bgRJ5aeT1Z0=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 176, - "top": 48, - "height": 13 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgRJ5aeVCrk=", - "_parent": { - "$ref": "AAAAAAF4bgRJ5aeT1Z0=" - }, - "font": "Arial;13;1", - "left": 853, - "top": 247, - "width": 239, - "height": 13, - "text": "OAClient" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgRJ5aeWik4=", - "_parent": { - "$ref": "AAAAAAF4bgRJ5aeT1Z0=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 176, - "top": 48, - "width": 73.67724609375, - "height": 13, - "text": "(from Model)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgRJ5aeXyFs=", - "_parent": { - "$ref": "AAAAAAF4bgRJ5aeT1Z0=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 176, - "top": 48, - "height": 13, - "horizontalAlignment": 1 - } - ], - "font": "Arial;13;0", - "left": 848, - "top": 240, - "width": 249, - "height": 25, - "stereotypeLabel": { - "$ref": "AAAAAAF4bgRJ5aeUMvY=" - }, - "nameLabel": { - "$ref": "AAAAAAF4bgRJ5aeVCrk=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4bgRJ5aeWik4=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bgRJ5aeXyFs=" - } - }, - { - "_type": "UMLAttributeCompartmentView", - "_id": "AAAAAAF4bgRJ5aeYbNc=", - "_parent": { - "$ref": "AAAAAAF4bgRJ5aeSzhQ=" - }, - "model": { - "$ref": "AAAAAAF4bgRJ5aeQAJc=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 896, - "top": 265, - "width": 65.61181640625, - "height": 10 - }, - { - "_type": "UMLOperationCompartmentView", - "_id": "AAAAAAF4bgRJ5aeZMko=", - "_parent": { - "$ref": "AAAAAAF4bgRJ5aeSzhQ=" - }, - "model": { - "$ref": "AAAAAAF4bgRJ5aeQAJc=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 896, - "top": 265, - "width": 65.61181640625, - "height": 10 - }, - { - "_type": "UMLReceptionCompartmentView", - "_id": "AAAAAAF4bgRJ5qeaAUc=", - "_parent": { - "$ref": "AAAAAAF4bgRJ5aeSzhQ=" - }, - "model": { - "$ref": "AAAAAAF4bgRJ5aeQAJc=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 88, - "top": 24, - "width": 10, - "height": 10 - }, - { - "_type": "UMLTemplateParameterCompartmentView", - "_id": "AAAAAAF4bgRJ5qebWj8=", - "_parent": { - "$ref": "AAAAAAF4bgRJ5aeSzhQ=" - }, - "model": { - "$ref": "AAAAAAF4bgRJ5aeQAJc=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 88, - "top": 24, - "width": 10, - "height": 10 - } - ], - "font": "Arial;13;0", - "containerChangeable": true, - "left": 848, - "top": 240, - "width": 249, - "height": 25, - "nameCompartment": { - "$ref": "AAAAAAF4bgRJ5aeT1Z0=" - }, - "suppressAttributes": true, - "suppressOperations": true, - "attributeCompartment": { - "$ref": "AAAAAAF4bgRJ5aeYbNc=" - }, - "operationCompartment": { - "$ref": "AAAAAAF4bgRJ5aeZMko=" - }, - "receptionCompartment": { - "$ref": "AAAAAAF4bgRJ5qeaAUc=" - }, - "templateParameterCompartment": { - "$ref": "AAAAAAF4bgRJ5qebWj8=" - } - }, - { - "_type": "UMLClassView", - "_id": "AAAAAAF4bgSxm6fAlds=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bgSxm6e+DdM=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4bgSxm6fB6ns=", - "_parent": { - "$ref": "AAAAAAF4bgSxm6fAlds=" - }, - "model": { - "$ref": "AAAAAAF4bgSxm6e+DdM=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4bgSxm6fCgb0=", - "_parent": { - "$ref": "AAAAAAF4bgSxm6fB6ns=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 104, - "top": -368, - "height": 13 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgSxm6fDldw=", - "_parent": { - "$ref": "AAAAAAF4bgSxm6fB6ns=" - }, - "font": "Arial;13;1", - "left": 853, - "top": 287, - "width": 167, - "height": 13, - "text": "OAConfig" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgSxm6fEIEA=", - "_parent": { - "$ref": "AAAAAAF4bgSxm6fB6ns=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 104, - "top": -368, - "width": 98.236328125, - "height": 13, - "text": "(from pyzswagcl)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgSxm6fFMDQ=", - "_parent": { - "$ref": "AAAAAAF4bgSxm6fB6ns=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 104, - "top": -368, - "height": 13, - "horizontalAlignment": 1 - } - ], - "font": "Arial;13;0", - "left": 848, - "top": 280, - "width": 177, - "height": 25, - "stereotypeLabel": { - "$ref": "AAAAAAF4bgSxm6fCgb0=" - }, - "nameLabel": { - "$ref": "AAAAAAF4bgSxm6fDldw=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4bgSxm6fEIEA=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bgSxm6fFMDQ=" - } - }, - { - "_type": "UMLAttributeCompartmentView", - "_id": "AAAAAAF4bgSxm6fGMz0=", - "_parent": { - "$ref": "AAAAAAF4bgSxm6fAlds=" - }, - "model": { - "$ref": "AAAAAAF4bgSxm6e+DdM=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 944, - "top": 577, - "width": 70.65185546875, - "height": 10 - }, - { - "_type": "UMLOperationCompartmentView", - "_id": "AAAAAAF4bgSxm6fH/vo=", - "_parent": { - "$ref": "AAAAAAF4bgSxm6fAlds=" - }, - "model": { - "$ref": "AAAAAAF4bgSxm6e+DdM=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 944, - "top": 587, - "width": 70.65185546875, - "height": 10 - }, - { - "_type": "UMLReceptionCompartmentView", - "_id": "AAAAAAF4bgSxm6fIcbA=", - "_parent": { - "$ref": "AAAAAAF4bgSxm6fAlds=" - }, - "model": { - "$ref": "AAAAAAF4bgSxm6e+DdM=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 144, - "top": 88, - "width": 10, - "height": 10 - }, - { - "_type": "UMLTemplateParameterCompartmentView", - "_id": "AAAAAAF4bgSxm6fJf3M=", - "_parent": { - "$ref": "AAAAAAF4bgSxm6fAlds=" - }, - "model": { - "$ref": "AAAAAAF4bgSxm6e+DdM=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 144, - "top": 88, - "width": 10, - "height": 10 - } - ], - "containerView": { - "$ref": "AAAAAAF4bgKNKab7Iso=" - }, - "font": "Arial;13;0", - "containerChangeable": true, - "left": 848, - "top": 280, - "width": 177, - "height": 25, - "nameCompartment": { - "$ref": "AAAAAAF4bgSxm6fB6ns=" - }, - "suppressAttributes": true, - "suppressOperations": true, - "attributeCompartment": { - "$ref": "AAAAAAF4bgSxm6fGMz0=" - }, - "operationCompartment": { - "$ref": "AAAAAAF4bgSxm6fH/vo=" - }, - "receptionCompartment": { - "$ref": "AAAAAAF4bgSxm6fIcbA=" - }, - "templateParameterCompartment": { - "$ref": "AAAAAAF4bgSxm6fJf3M=" - } - }, - { - "_type": "UMLClassView", - "_id": "AAAAAAF4bgPmmadioEM=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bgPmmKdgqXA=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4bgPmmadjtFE=", - "_parent": { - "$ref": "AAAAAAF4bgPmmadioEM=" - }, - "model": { - "$ref": "AAAAAAF4bgPmmKdgqXA=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4bgPmmadko9s=", - "_parent": { - "$ref": "AAAAAAF4bgPmmadjtFE=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 336, - "top": -304, - "height": 13 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgPmmadlHps=", - "_parent": { - "$ref": "AAAAAAF4bgPmmadjtFE=" - }, - "font": "Arial;13;1", - "left": 565, - "top": 487, - "width": 159, - "height": 13, - "text": "Settings" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgPmmadmKm4=", - "_parent": { - "$ref": "AAAAAAF4bgPmmadjtFE=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 336, - "top": -304, - "width": 89.578125, - "height": 13, - "text": "(from httpcl)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgPmmadn03Y=", - "_parent": { - "$ref": "AAAAAAF4bgPmmadjtFE=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 336, - "top": -304, - "height": 13, - "horizontalAlignment": 1 - } - ], - "font": "Arial;13;0", - "left": 560, - "top": 480, - "width": 169, - "height": 25, - "stereotypeLabel": { - "$ref": "AAAAAAF4bgPmmadko9s=" - }, - "nameLabel": { - "$ref": "AAAAAAF4bgPmmadlHps=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4bgPmmadmKm4=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bgPmmadn03Y=" - } - }, - { - "_type": "UMLAttributeCompartmentView", - "_id": "AAAAAAF4bgPmmadogZM=", - "_parent": { - "$ref": "AAAAAAF4bgPmmadioEM=" - }, - "model": { - "$ref": "AAAAAAF4bgPmmKdgqXA=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 592, - "top": 529, - "width": 87.27001953125, - "height": 10 - }, - { - "_type": "UMLOperationCompartmentView", - "_id": "AAAAAAF4bgPmmadpBMY=", - "_parent": { - "$ref": "AAAAAAF4bgPmmadioEM=" - }, - "model": { - "$ref": "AAAAAAF4bgPmmKdgqXA=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 592, - "top": 529, - "width": 87.27001953125, - "height": 10 - }, - { - "_type": "UMLReceptionCompartmentView", - "_id": "AAAAAAF4bgPmmadqGBo=", - "_parent": { - "$ref": "AAAAAAF4bgPmmadioEM=" - }, - "model": { - "$ref": "AAAAAAF4bgPmmKdgqXA=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 184, - "top": -144, - "width": 10, - "height": 10 - }, - { - "_type": "UMLTemplateParameterCompartmentView", - "_id": "AAAAAAF4bgPmmadryzY=", - "_parent": { - "$ref": "AAAAAAF4bgPmmadioEM=" - }, - "model": { - "$ref": "AAAAAAF4bgPmmKdgqXA=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 184, - "top": -144, - "width": 10, - "height": 10 - } - ], - "containerView": { - "$ref": "AAAAAAF4bgAZMqaVouk=" - }, - "font": "Arial;13;0", - "containerChangeable": true, - "left": 560, - "top": 480, - "width": 169, - "height": 25, - "nameCompartment": { - "$ref": "AAAAAAF4bgPmmadjtFE=" - }, - "suppressAttributes": true, - "suppressOperations": true, - "attributeCompartment": { - "$ref": "AAAAAAF4bgPmmadogZM=" - }, - "operationCompartment": { - "$ref": "AAAAAAF4bgPmmadpBMY=" - }, - "receptionCompartment": { - "$ref": "AAAAAAF4bgPmmadqGBo=" - }, - "templateParameterCompartment": { - "$ref": "AAAAAAF4bgPmmadryzY=" - } - }, - { - "_type": "UMLClassView", - "_id": "AAAAAAF4bgc7NqgnkmU=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bgc7NqglZeE=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4bgc7NqgoCOU=", - "_parent": { - "$ref": "AAAAAAF4bgc7NqgnkmU=" - }, - "model": { - "$ref": "AAAAAAF4bgc7NqglZeE=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4bgc7Nqgp2H4=", - "_parent": { - "$ref": "AAAAAAF4bgc7NqgoCOU=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 112, - "top": 184, - "height": 13 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgc7Nqgq+1o=", - "_parent": { - "$ref": "AAAAAAF4bgc7NqgoCOU=" - }, - "font": "Arial;13;1", - "left": 461, - "top": 247, - "width": 199, - "height": 13, - "text": "OpenApiClient" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgc7NqgrabU=", - "_parent": { - "$ref": "AAAAAAF4bgc7NqgoCOU=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 112, - "top": 184, - "width": 84.50634765625, - "height": 13, - "text": "(from zswagcl)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgc7Nqgsk4M=", - "_parent": { - "$ref": "AAAAAAF4bgc7NqgoCOU=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 112, - "top": 184, - "height": 13, - "horizontalAlignment": 1 - } - ], - "font": "Arial;13;0", - "left": 456, - "top": 240, - "width": 209, - "height": 25, - "stereotypeLabel": { - "$ref": "AAAAAAF4bgc7Nqgp2H4=" - }, - "nameLabel": { - "$ref": "AAAAAAF4bgc7Nqgq+1o=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4bgc7NqgrabU=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bgc7Nqgsk4M=" - } - }, - { - "_type": "UMLAttributeCompartmentView", - "_id": "AAAAAAF4bgc7NqgtrqY=", - "_parent": { - "$ref": "AAAAAAF4bgc7NqgnkmU=" - }, - "model": { - "$ref": "AAAAAAF4bgc7NqglZeE=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 456, - "top": 485, - "width": 100.2763671875, - "height": 10 - }, - { - "_type": "UMLOperationCompartmentView", - "_id": "AAAAAAF4bgc7NqguXZs=", - "_parent": { - "$ref": "AAAAAAF4bgc7NqgnkmU=" - }, - "model": { - "$ref": "AAAAAAF4bgc7NqglZeE=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 456, - "top": 495, - "width": 100.2763671875, - "height": 10 - }, - { - "_type": "UMLReceptionCompartmentView", - "_id": "AAAAAAF4bgc7NqgvHsU=", - "_parent": { - "$ref": "AAAAAAF4bgc7NqgnkmU=" - }, - "model": { - "$ref": "AAAAAAF4bgc7NqglZeE=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 48, - "top": 212, - "width": 10, - "height": 10 - }, - { - "_type": "UMLTemplateParameterCompartmentView", - "_id": "AAAAAAF4bgc7NqgwnMM=", - "_parent": { - "$ref": "AAAAAAF4bgc7NqgnkmU=" - }, - "model": { - "$ref": "AAAAAAF4bgc7NqglZeE=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 48, - "top": 212, - "width": 10, - "height": 10 - } - ], - "containerView": { - "$ref": "AAAAAAF4bgIuhabf/vw=" - }, - "font": "Arial;13;0", - "containerChangeable": true, - "left": 456, - "top": 240, - "width": 209, - "height": 25, - "nameCompartment": { - "$ref": "AAAAAAF4bgc7NqgoCOU=" - }, - "suppressAttributes": true, - "suppressOperations": true, - "attributeCompartment": { - "$ref": "AAAAAAF4bgc7NqgtrqY=" - }, - "operationCompartment": { - "$ref": "AAAAAAF4bgc7NqguXZs=" - }, - "receptionCompartment": { - "$ref": "AAAAAAF4bgc7NqgvHsU=" - }, - "templateParameterCompartment": { - "$ref": "AAAAAAF4bgc7NqgwnMM=" - } - }, - { - "_type": "UMLClassView", - "_id": "AAAAAAF4bge2pKhVmUU=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bge2pKhT/bw=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4bge2pKhW9Z0=", - "_parent": { - "$ref": "AAAAAAF4bge2pKhVmUU=" - }, - "model": { - "$ref": "AAAAAAF4bge2pKhT/bw=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4bge2pKhXCXQ=", - "_parent": { - "$ref": "AAAAAAF4bge2pKhW9Z0=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -264, - "top": -208, - "height": 13 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bge2pKhYMY4=", - "_parent": { - "$ref": "AAAAAAF4bge2pKhW9Z0=" - }, - "font": "Arial;13;1", - "left": 373, - "top": 207, - "width": 239, - "height": 13, - "text": "OAClient" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bge2pKhZoAQ=", - "_parent": { - "$ref": "AAAAAAF4bge2pKhW9Z0=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -264, - "top": -208, - "width": 84.50634765625, - "height": 13, - "text": "(from zswagcl)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bge2pKhaASs=", - "_parent": { - "$ref": "AAAAAAF4bge2pKhW9Z0=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -264, - "top": -208, - "height": 13, - "horizontalAlignment": 1 - } - ], - "font": "Arial;13;0", - "left": 368, - "top": 200, - "width": 249, - "height": 25, - "stereotypeLabel": { - "$ref": "AAAAAAF4bge2pKhXCXQ=" - }, - "nameLabel": { - "$ref": "AAAAAAF4bge2pKhYMY4=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4bge2pKhZoAQ=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bge2pKhaASs=" - } - }, - { - "_type": "UMLAttributeCompartmentView", - "_id": "AAAAAAF4bge2pKhbrtg=", - "_parent": { - "$ref": "AAAAAAF4bge2pKhVmUU=" - }, - "model": { - "$ref": "AAAAAAF4bge2pKhT/bw=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 360, - "top": 225, - "width": 66.341796875, - "height": 10 - }, - { - "_type": "UMLOperationCompartmentView", - "_id": "AAAAAAF4bge2pahcOwo=", - "_parent": { - "$ref": "AAAAAAF4bge2pKhVmUU=" - }, - "model": { - "$ref": "AAAAAAF4bge2pKhT/bw=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 360, - "top": 235, - "width": 66.341796875, - "height": 10 - }, - { - "_type": "UMLReceptionCompartmentView", - "_id": "AAAAAAF4bge2pahdWwI=", - "_parent": { - "$ref": "AAAAAAF4bge2pKhVmUU=" - }, - "model": { - "$ref": "AAAAAAF4bge2pKhT/bw=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -184, - "top": -104, - "width": 10, - "height": 10 - }, - { - "_type": "UMLTemplateParameterCompartmentView", - "_id": "AAAAAAF4bge2pahenEA=", - "_parent": { - "$ref": "AAAAAAF4bge2pKhVmUU=" - }, - "model": { - "$ref": "AAAAAAF4bge2pKhT/bw=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -184, - "top": -104, - "width": 10, - "height": 10 - } - ], - "containerView": { - "$ref": "AAAAAAF4bgIuhabf/vw=" - }, - "font": "Arial;13;0", - "containerChangeable": true, - "left": 368, - "top": 200, - "width": 249, - "height": 25, - "nameCompartment": { - "$ref": "AAAAAAF4bge2pKhW9Z0=" - }, - "suppressAttributes": true, - "suppressOperations": true, - "attributeCompartment": { - "$ref": "AAAAAAF4bge2pKhbrtg=" - }, - "operationCompartment": { - "$ref": "AAAAAAF4bge2pahcOwo=" - }, - "receptionCompartment": { - "$ref": "AAAAAAF4bge2pahdWwI=" - }, - "templateParameterCompartment": { - "$ref": "AAAAAAF4bge2pahenEA=" - } - }, - { - "_type": "UMLClassView", - "_id": "AAAAAAF4bgoCwaiVju8=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bgoCwKiTY0c=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4bgoCwaiWNw8=", - "_parent": { - "$ref": "AAAAAAF4bgoCwaiVju8=" - }, - "model": { - "$ref": "AAAAAAF4bgoCwKiTY0c=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4bgoCwaiXz5M=", - "_parent": { - "$ref": "AAAAAAF4bgoCwaiWNw8=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 224, - "top": -184, - "height": 13 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgoCwaiYC+4=", - "_parent": { - "$ref": "AAAAAAF4bgoCwaiWNw8=" - }, - "font": "Arial;13;1", - "left": 613, - "top": 287, - "width": 111, - "height": 13, - "text": "OpenApiConfig" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgoCwaiZgiM=", - "_parent": { - "$ref": "AAAAAAF4bgoCwaiWNw8=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 224, - "top": -184, - "width": 84.50634765625, - "height": 13, - "text": "(from zswagcl)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgoCwaiamPc=", - "_parent": { - "$ref": "AAAAAAF4bgoCwaiWNw8=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 224, - "top": -184, - "height": 13, - "horizontalAlignment": 1 - } - ], - "font": "Arial;13;0", - "left": 608, - "top": 280, - "width": 121, - "height": 25, - "stereotypeLabel": { - "$ref": "AAAAAAF4bgoCwaiXz5M=" - }, - "nameLabel": { - "$ref": "AAAAAAF4bgoCwaiYC+4=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4bgoCwaiZgiM=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bgoCwaiamPc=" - } - }, - { - "_type": "UMLAttributeCompartmentView", - "_id": "AAAAAAF4bgoCwaibJz8=", - "_parent": { - "$ref": "AAAAAAF4bgoCwaiVju8=" - }, - "model": { - "$ref": "AAAAAAF4bgoCwKiTY0c=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 640, - "top": 393, - "width": 105.31640625, - "height": 10 - }, - { - "_type": "UMLOperationCompartmentView", - "_id": "AAAAAAF4bgoCwaicA/Y=", - "_parent": { - "$ref": "AAAAAAF4bgoCwaiVju8=" - }, - "model": { - "$ref": "AAAAAAF4bgoCwKiTY0c=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 640, - "top": 403, - "width": 105.31640625, - "height": 10 - }, - { - "_type": "UMLReceptionCompartmentView", - "_id": "AAAAAAF4bgoCwaidtX4=", - "_parent": { - "$ref": "AAAAAAF4bgoCwaiVju8=" - }, - "model": { - "$ref": "AAAAAAF4bgoCwKiTY0c=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 168, - "top": -48, - "width": 10, - "height": 10 - }, - { - "_type": "UMLTemplateParameterCompartmentView", - "_id": "AAAAAAF4bgoCwaieUds=", - "_parent": { - "$ref": "AAAAAAF4bgoCwaiVju8=" - }, - "model": { - "$ref": "AAAAAAF4bgoCwKiTY0c=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 168, - "top": -48, - "width": 10, - "height": 10 - } - ], - "containerView": { - "$ref": "AAAAAAF4bgIuhabf/vw=" - }, - "font": "Arial;13;0", - "containerChangeable": true, - "left": 608, - "top": 280, - "width": 121, - "height": 25, - "nameCompartment": { - "$ref": "AAAAAAF4bgoCwaiWNw8=" - }, - "suppressAttributes": true, - "suppressOperations": true, - "attributeCompartment": { - "$ref": "AAAAAAF4bgoCwaibJz8=" - }, - "operationCompartment": { - "$ref": "AAAAAAF4bgoCwaicA/Y=" - }, - "receptionCompartment": { - "$ref": "AAAAAAF4bgoCwaidtX4=" - }, - "templateParameterCompartment": { - "$ref": "AAAAAAF4bgoCwaieUds=" - } - }, - { - "_type": "UMLPackageView", - "_id": "AAAAAAF4bgwBvakoRnQ=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bgwBvKkmSIc=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4bgwBvakpfhw=", - "_parent": { - "$ref": "AAAAAAF4bgwBvakoRnQ=" - }, - "model": { - "$ref": "AAAAAAF4bgwBvKkmSIc=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4bgwBvakqsVQ=", - "_parent": { - "$ref": "AAAAAAF4bgwBvakpfhw=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": -48, - "top": -336, - "height": 13 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgwBvakrj/8=", - "_parent": { - "$ref": "AAAAAAF4bgwBvakpfhw=" - }, - "fillColor": "#e2e2e2", - "font": "Arial;13;1", - "left": 381, - "top": 550, - "width": 151, - "height": 13, - "text": "cpp-httplib" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgwBvaksSG0=", - "_parent": { - "$ref": "AAAAAAF4bgwBvakpfhw=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": -48, - "top": -336, - "width": 73.67724609375, - "height": 13, - "text": "(from Model)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgwBvaktpbs=", - "_parent": { - "$ref": "AAAAAAF4bgwBvakpfhw=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": -48, - "top": -336, - "height": 13, - "horizontalAlignment": 1 - } - ], - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": 376, - "top": 543, - "width": 161, - "height": 25, - "stereotypeLabel": { - "$ref": "AAAAAAF4bgwBvakqsVQ=" - }, - "nameLabel": { - "$ref": "AAAAAAF4bgwBvakrj/8=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4bgwBvaksSG0=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bgwBvaktpbs=" - } - } - ], - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "containerChangeable": true, - "left": 376, - "top": 528, - "width": 161, - "height": 40, - "nameCompartment": { - "$ref": "AAAAAAF4bgwBvakpfhw=" - } - }, - { - "_type": "UMLPackageView", - "_id": "AAAAAAF4bgwwp6lCBZw=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bgwwp6lAGRw=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4bgwwp6lDP5M=", - "_parent": { - "$ref": "AAAAAAF4bgwwp6lCBZw=" - }, - "model": { - "$ref": "AAAAAAF4bgwwp6lAGRw=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4bgwwp6lEvUs=", - "_parent": { - "$ref": "AAAAAAF4bgwwp6lDP5M=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": 304, - "top": -464, - "height": 13 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgwwp6lFULI=", - "_parent": { - "$ref": "AAAAAAF4bgwwp6lDP5M=" - }, - "fillColor": "#e2e2e2", - "font": "Arial;13;1", - "left": 557, - "top": 550, - "width": 159, - "height": 13, - "text": "keychain" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgwwp6lGAYQ=", - "_parent": { - "$ref": "AAAAAAF4bgwwp6lDP5M=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": 304, - "top": -464, - "width": 73.67724609375, - "height": 13, - "text": "(from Model)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgwwp6lHQ9A=", - "_parent": { - "$ref": "AAAAAAF4bgwwp6lDP5M=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": 304, - "top": -464, - "height": 13, - "horizontalAlignment": 1 - } - ], - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": 552, - "top": 543, - "width": 169, - "height": 25, - "stereotypeLabel": { - "$ref": "AAAAAAF4bgwwp6lEvUs=" - }, - "nameLabel": { - "$ref": "AAAAAAF4bgwwp6lFULI=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4bgwwp6lGAYQ=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bgwwp6lHQ9A=" - } - } - ], - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "containerChangeable": true, - "left": 552, - "top": 528, - "width": 169, - "height": 41, - "nameCompartment": { - "$ref": "AAAAAAF4bgwwp6lDP5M=" - } - }, - { - "_type": "UMLPackageView", - "_id": "AAAAAAF4bgz4wal0D3U=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bgz4walyB+M=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4bgz4wal1i/0=", - "_parent": { - "$ref": "AAAAAAF4bgz4wal0D3U=" - }, - "model": { - "$ref": "AAAAAAF4bgz4walyB+M=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4bgz4wal2zqY=", - "_parent": { - "$ref": "AAAAAAF4bgz4wal1i/0=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": -192, - "top": 576, - "height": 13 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgz4wal3Yvc=", - "_parent": { - "$ref": "AAAAAAF4bgz4wal1i/0=" - }, - "fillColor": "#e2e2e2", - "font": "Arial;13;1", - "left": 997, - "top": 550, - "width": 135, - "height": 13, - "text": "Connexion" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgz4wql45Fo=", - "_parent": { - "$ref": "AAAAAAF4bgz4wal1i/0=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": -192, - "top": 576, - "width": 73.67724609375, - "height": 13, - "text": "(from Model)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgz4wql5dKI=", - "_parent": { - "$ref": "AAAAAAF4bgz4wal1i/0=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": -192, - "top": 576, - "height": 13, - "horizontalAlignment": 1 - } - ], - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": 992, - "top": 543, - "width": 145, - "height": 25, - "stereotypeLabel": { - "$ref": "AAAAAAF4bgz4wal2zqY=" - }, - "nameLabel": { - "$ref": "AAAAAAF4bgz4wal3Yvc=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4bgz4wql45Fo=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bgz4wql5dKI=" - } - } - ], - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "containerChangeable": true, - "left": 992, - "top": 528, - "width": 145, - "height": 41, - "nameCompartment": { - "$ref": "AAAAAAF4bgz4wal1i/0=" - } - }, - { - "_type": "UMLNoteView", - "_id": "AAAAAAF4bg9unanX7Uc=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "fillColor": "#d8f2ff", - "font": "Arial;18;0", - "left": 352, - "top": 96, - "width": 121, - "height": 30, - "text": "C++" - }, - { - "_type": "UMLClassView", - "_id": "AAAAAAF4bgYiBqf0des=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bgYiBafyU4A=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4bgYiBqf1+Hw=", - "_parent": { - "$ref": "AAAAAAF4bgYiBqf0des=" - }, - "model": { - "$ref": "AAAAAAF4bgYiBafyU4A=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4bgYiBqf2AfA=", - "_parent": { - "$ref": "AAAAAAF4bgYiBqf1+Hw=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -528, - "top": 416, - "height": 13 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgYiB6f3DOM=", - "_parent": { - "$ref": "AAAAAAF4bgYiBqf1+Hw=" - }, - "font": "Arial;13;1", - "left": 813, - "top": 439, - "width": 279, - "height": 13, - "text": "OAServer" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgYiB6f4CwY=", - "_parent": { - "$ref": "AAAAAAF4bgYiBqf1+Hw=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -528, - "top": 416, - "width": 75.1181640625, - "height": 13, - "text": "(from zswag)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgYiB6f5aFY=", - "_parent": { - "$ref": "AAAAAAF4bgYiBqf1+Hw=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -528, - "top": 416, - "height": 13, - "horizontalAlignment": 1 - } - ], - "font": "Arial;13;0", - "left": 808, - "top": 432, - "width": 289, - "height": 25, - "stereotypeLabel": { - "$ref": "AAAAAAF4bgYiBqf2AfA=" - }, - "nameLabel": { - "$ref": "AAAAAAF4bgYiB6f3DOM=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4bgYiB6f4CwY=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bgYiB6f5aFY=" - } - }, - { - "_type": "UMLAttributeCompartmentView", - "_id": "AAAAAAF4bgYiB6f68b0=", - "_parent": { - "$ref": "AAAAAAF4bgYiBqf0des=" - }, - "model": { - "$ref": "AAAAAAF4bgYiBafyU4A=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 832, - "top": 441, - "width": 69.97900390625, - "height": 10 - }, - { - "_type": "UMLOperationCompartmentView", - "_id": "AAAAAAF4bgYiB6f7fhw=", - "_parent": { - "$ref": "AAAAAAF4bgYiBqf0des=" - }, - "model": { - "$ref": "AAAAAAF4bgYiBafyU4A=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 832, - "top": 451, - "width": 69.97900390625, - "height": 10 - }, - { - "_type": "UMLReceptionCompartmentView", - "_id": "AAAAAAF4bgYiB6f8DVQ=", - "_parent": { - "$ref": "AAAAAAF4bgYiBqf0des=" - }, - "model": { - "$ref": "AAAAAAF4bgYiBafyU4A=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -272, - "top": 200, - "width": 10, - "height": 10 - }, - { - "_type": "UMLTemplateParameterCompartmentView", - "_id": "AAAAAAF4bgYiB6f9yTc=", - "_parent": { - "$ref": "AAAAAAF4bgYiBqf0des=" - }, - "model": { - "$ref": "AAAAAAF4bgYiBafyU4A=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -272, - "top": 200, - "width": 10, - "height": 10 - } - ], - "containerView": { - "$ref": "AAAAAAF4bgLBtacVT74=" - }, - "font": "Arial;13;0", - "containerChangeable": true, - "left": 808, - "top": 432, - "width": 289, - "height": 25, - "nameCompartment": { - "$ref": "AAAAAAF4bgYiBqf1+Hw=" - }, - "suppressAttributes": true, - "suppressOperations": true, - "attributeCompartment": { - "$ref": "AAAAAAF4bgYiB6f68b0=" - }, - "operationCompartment": { - "$ref": "AAAAAAF4bgYiB6f7fhw=" - }, - "receptionCompartment": { - "$ref": "AAAAAAF4bgYiB6f8DVQ=" - }, - "templateParameterCompartment": { - "$ref": "AAAAAAF4bgYiB6f9yTc=" - } - }, - { - "_type": "UMLAssociationView", - "_id": "AAAAAAF4bhMv1apvJyY=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bhMv1aprKh8=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhMv1apwg3U=", - "_parent": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - }, - "model": { - "$ref": "AAAAAAF4bhMv1aprKh8=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 409, - "top": 249, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhMv1apx6U0=", - "_parent": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - }, - "model": { - "$ref": "AAAAAAF4bhMv1aprKh8=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 394, - "top": 249, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhMv1apy0bQ=", - "_parent": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - }, - "model": { - "$ref": "AAAAAAF4bhMv1aprKh8=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 438, - "top": 250, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhMv1apz+ME=", - "_parent": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - }, - "model": { - "$ref": "AAAAAAF4bhMv1apsTW0=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 430, - "top": 264, - "height": 13, - "alpha": 0.5235987755982988, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhMv1ap0A94=", - "_parent": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - }, - "model": { - "$ref": "AAAAAAF4bhMv1apsTW0=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 427, - "top": 278, - "height": 13, - "alpha": 0.7853981633974483, - "distance": 40, - "hostEdge": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhMv1ap10oY=", - "_parent": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - }, - "model": { - "$ref": "AAAAAAF4bhMv1apsTW0=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 434, - "top": 237, - "height": 13, - "alpha": -0.5235987755982988, - "distance": 25, - "hostEdge": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhMv1ap2OVs=", - "_parent": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - }, - "model": { - "$ref": "AAAAAAF4bhMv1apt2jw=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 409, - "top": 243, - "height": 13, - "alpha": -0.5235987755982988, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - } - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhMv1ap3Wto=", - "_parent": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - }, - "model": { - "$ref": "AAAAAAF4bhMv1apt2jw=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 395, - "top": 246, - "height": 13, - "alpha": -0.7853981633974483, - "distance": 40, - "hostEdge": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - } - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhMv1ap44SQ=", - "_parent": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - }, - "model": { - "$ref": "AAAAAAF4bhMv1apt2jw=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 436, - "top": 239, - "height": 13, - "alpha": 0.5235987755982988, - "distance": 25, - "hostEdge": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - } - }, - { - "_type": "UMLQualifierCompartmentView", - "_id": "AAAAAAF4bhMv1ap5zAs=", - "_parent": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - }, - "model": { - "$ref": "AAAAAAF4bhMv1apsTW0=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -8, - "top": 16, - "width": 10, - "height": 10 - }, - { - "_type": "UMLQualifierCompartmentView", - "_id": "AAAAAAF4bhMv1ap6vMM=", - "_parent": { - "$ref": "AAAAAAF4bhMv1apvJyY=" - }, - "model": { - "$ref": "AAAAAAF4bhMv1apt2jw=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -8, - "top": 16, - "width": 10, - "height": 10 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF4bge2pKhVmUU=" - }, - "tail": { - "$ref": "AAAAAAF4bgc7NqgnkmU=" - }, - "points": "456:256;424:256;424:224", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF4bhMv1apwg3U=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF4bhMv1apx6U0=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bhMv1apy0bQ=" - }, - "tailRoleNameLabel": { - "$ref": "AAAAAAF4bhMv1apz+ME=" - }, - "tailPropertyLabel": { - "$ref": "AAAAAAF4bhMv1ap0A94=" - }, - "tailMultiplicityLabel": { - "$ref": "AAAAAAF4bhMv1ap10oY=" - }, - "headRoleNameLabel": { - "$ref": "AAAAAAF4bhMv1ap2OVs=" - }, - "headPropertyLabel": { - "$ref": "AAAAAAF4bhMv1ap3Wto=" - }, - "headMultiplicityLabel": { - "$ref": "AAAAAAF4bhMv1ap44SQ=" - }, - "tailQualifiersCompartment": { - "$ref": "AAAAAAF4bhMv1ap5zAs=" - }, - "headQualifiersCompartment": { - "$ref": "AAAAAAF4bhMv1ap6vMM=" - } - }, - { - "_type": "UMLAssociationView", - "_id": "AAAAAAF4bhN/2KriJuA=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bhN/16reWD0=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhN/2Krj8us=", - "_parent": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - }, - "model": { - "$ref": "AAAAAAF4bhN/16reWD0=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 505, - "top": 289, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhN/2KrkjA4=", - "_parent": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - }, - "model": { - "$ref": "AAAAAAF4bhN/16reWD0=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 490, - "top": 289, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhN/2Krl1qI=", - "_parent": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - }, - "model": { - "$ref": "AAAAAAF4bhN/16reWD0=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 534, - "top": 290, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhN/2KrmOlY=", - "_parent": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - }, - "model": { - "$ref": "AAAAAAF4bhN/2KrfQQY=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 582, - "top": 304, - "height": 13, - "alpha": 0.5235987755982988, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhN/2KrnaPk=", - "_parent": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - }, - "model": { - "$ref": "AAAAAAF4bhN/2KrfQQY=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 579, - "top": 318, - "height": 13, - "alpha": 0.7853981633974483, - "distance": 40, - "hostEdge": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhN/2KrojfQ=", - "_parent": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - }, - "model": { - "$ref": "AAAAAAF4bhN/2KrfQQY=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 586, - "top": 277, - "height": 13, - "alpha": -0.5235987755982988, - "distance": 25, - "hostEdge": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhN/2KrprLE=", - "_parent": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - }, - "model": { - "$ref": "AAAAAAF4bhN/2KrgWIM=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 505, - "top": 283, - "height": 13, - "alpha": -0.5235987755982988, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - } - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhN/2arqxnQ=", - "_parent": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - }, - "model": { - "$ref": "AAAAAAF4bhN/2KrgWIM=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 491, - "top": 286, - "height": 13, - "alpha": -0.7853981633974483, - "distance": 40, - "hostEdge": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - } - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhN/2arrBz8=", - "_parent": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - }, - "model": { - "$ref": "AAAAAAF4bhN/2KrgWIM=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 532, - "top": 279, - "height": 13, - "alpha": 0.5235987755982988, - "distance": 25, - "hostEdge": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - } - }, - { - "_type": "UMLQualifierCompartmentView", - "_id": "AAAAAAF4bhN/2arsdxw=", - "_parent": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - }, - "model": { - "$ref": "AAAAAAF4bhN/2KrfQQY=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -8, - "top": 16, - "width": 10, - "height": 10 - }, - { - "_type": "UMLQualifierCompartmentView", - "_id": "AAAAAAF4bhN/2artW5Q=", - "_parent": { - "$ref": "AAAAAAF4bhN/2KriJuA=" - }, - "model": { - "$ref": "AAAAAAF4bhN/2KrgWIM=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -8, - "top": 16, - "width": 10, - "height": 10 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF4bgc7NqgnkmU=" - }, - "tail": { - "$ref": "AAAAAAF4bgoCwaiVju8=" - }, - "points": "608:296;520:296;520:264", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF4bhN/2Krj8us=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF4bhN/2KrkjA4=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bhN/2Krl1qI=" - }, - "tailRoleNameLabel": { - "$ref": "AAAAAAF4bhN/2KrmOlY=" - }, - "tailPropertyLabel": { - "$ref": "AAAAAAF4bhN/2KrnaPk=" - }, - "tailMultiplicityLabel": { - "$ref": "AAAAAAF4bhN/2KrojfQ=" - }, - "headRoleNameLabel": { - "$ref": "AAAAAAF4bhN/2KrprLE=" - }, - "headPropertyLabel": { - "$ref": "AAAAAAF4bhN/2arqxnQ=" - }, - "headMultiplicityLabel": { - "$ref": "AAAAAAF4bhN/2arrBz8=" - }, - "tailQualifiersCompartment": { - "$ref": "AAAAAAF4bhN/2arsdxw=" - }, - "headQualifiersCompartment": { - "$ref": "AAAAAAF4bhN/2artW5Q=" - } - }, - { - "_type": "UMLAssociationView", - "_id": "AAAAAAF4bhPkPqwx/Zo=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bhPkPawtqHk=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhPkPqwyXNI=", - "_parent": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - }, - "model": { - "$ref": "AAAAAAF4bhPkPawtqHk=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 457, - "top": 325, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhPkPqwzi14=", - "_parent": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - }, - "model": { - "$ref": "AAAAAAF4bhPkPawtqHk=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 442, - "top": 325, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhPkPqw0R9c=", - "_parent": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - }, - "model": { - "$ref": "AAAAAAF4bhPkPawtqHk=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 486, - "top": 326, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhPkPqw1TwY=", - "_parent": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - }, - "model": { - "$ref": "AAAAAAF4bhPkPqwuZBo=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 457, - "top": 368, - "height": 13, - "alpha": 0.5235987755982988, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhPkPqw2JC0=", - "_parent": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - }, - "model": { - "$ref": "AAAAAAF4bhPkPqwuZBo=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 443, - "top": 365, - "height": 13, - "alpha": 0.7853981633974483, - "distance": 40, - "hostEdge": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhPkPqw3et0=", - "_parent": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - }, - "model": { - "$ref": "AAAAAAF4bhPkPqwuZBo=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 484, - "top": 372, - "height": 13, - "alpha": -0.5235987755982988, - "distance": 25, - "hostEdge": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhPkPqw4rkk=", - "_parent": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - }, - "model": { - "$ref": "AAAAAAF4bhPkPqwvhGs=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 457, - "top": 283, - "height": 13, - "alpha": -0.5235987755982988, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - } - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhPkPqw52U4=", - "_parent": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - }, - "model": { - "$ref": "AAAAAAF4bhPkPqwvhGs=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 443, - "top": 286, - "height": 13, - "alpha": -0.7853981633974483, - "distance": 40, - "hostEdge": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - } - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhPkPqw64FE=", - "_parent": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - }, - "model": { - "$ref": "AAAAAAF4bhPkPqwvhGs=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 484, - "top": 279, - "height": 13, - "alpha": 0.5235987755982988, - "distance": 25, - "hostEdge": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - } - }, - { - "_type": "UMLQualifierCompartmentView", - "_id": "AAAAAAF4bhPkPqw7a28=", - "_parent": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - }, - "model": { - "$ref": "AAAAAAF4bhPkPqwuZBo=" - }, - "visible": false, - "font": "Arial;13;0", - "top": -32, - "width": 10, - "height": 10 - }, - { - "_type": "UMLQualifierCompartmentView", - "_id": "AAAAAAF4bhPkPqw878A=", - "_parent": { - "$ref": "AAAAAAF4bhPkPqwx/Zo=" - }, - "model": { - "$ref": "AAAAAAF4bhPkPqwvhGs=" - }, - "visible": false, - "font": "Arial;13;0", - "top": -32, - "width": 10, - "height": 10 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF4bgc7NqgnkmU=" - }, - "tail": { - "$ref": "AAAAAAF6qThnCAOTYh0=" - }, - "points": "472:400;472:264", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF4bhPkPqwyXNI=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF4bhPkPqwzi14=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bhPkPqw0R9c=" - }, - "tailRoleNameLabel": { - "$ref": "AAAAAAF4bhPkPqw1TwY=" - }, - "tailPropertyLabel": { - "$ref": "AAAAAAF4bhPkPqw2JC0=" - }, - "tailMultiplicityLabel": { - "$ref": "AAAAAAF4bhPkPqw3et0=" - }, - "headRoleNameLabel": { - "$ref": "AAAAAAF4bhPkPqw4rkk=" - }, - "headPropertyLabel": { - "$ref": "AAAAAAF4bhPkPqw52U4=" - }, - "headMultiplicityLabel": { - "$ref": "AAAAAAF4bhPkPqw64FE=" - }, - "tailQualifiersCompartment": { - "$ref": "AAAAAAF4bhPkPqw7a28=" - }, - "headQualifiersCompartment": { - "$ref": "AAAAAAF4bhPkPqw878A=" - } - }, - { - "_type": "UMLDependencyView", - "_id": "AAAAAAF4bhVg5bD7EKY=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bhVg5LD5DiU=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhVg5bD8NFw=", - "_parent": { - "$ref": "AAAAAAF4bhVg5bD7EKY=" - }, - "model": { - "$ref": "AAAAAAF4bhVg5LD5DiU=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 350, - "top": 209, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4bhVg5bD7EKY=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhVg5bD94so=", - "_parent": { - "$ref": "AAAAAAF4bhVg5bD7EKY=" - }, - "model": { - "$ref": "AAAAAAF4bhVg5LD5DiU=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 365, - "top": 209, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4bhVg5bD7EKY=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhVg5bD+6mQ=", - "_parent": { - "$ref": "AAAAAAF4bhVg5bD7EKY=" - }, - "model": { - "$ref": "AAAAAAF4bhVg5LD5DiU=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 321, - "top": 210, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4bhVg5bD7EKY=" - }, - "edgePosition": 1 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF4bhSO3K5EvQQ=" - }, - "tail": { - "$ref": "AAAAAAF4bge2pKhVmUU=" - }, - "points": "368:216;336:216;336:240", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF4bhVg5bD8NFw=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF4bhVg5bD94so=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bhVg5bD+6mQ=" - } - }, - { - "_type": "UMLDependencyView", - "_id": "AAAAAAF4bhYT6rPLCkU=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bhYT6rPJUxA=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhYT6rPM85A=", - "_parent": { - "$ref": "AAAAAAF4bhYT6rPLCkU=" - }, - "model": { - "$ref": "AAAAAAF4bhYT6rPJUxA=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 606, - "top": 509, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4bhYT6rPLCkU=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhYT6rPNLFI=", - "_parent": { - "$ref": "AAAAAAF4bhYT6rPLCkU=" - }, - "model": { - "$ref": "AAAAAAF4bhYT6rPJUxA=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 621, - "top": 509, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4bhYT6rPLCkU=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhYT6rPO3Jk=", - "_parent": { - "$ref": "AAAAAAF4bhYT6rPLCkU=" - }, - "model": { - "$ref": "AAAAAAF4bhYT6rPJUxA=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 577, - "top": 510, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4bhYT6rPLCkU=" - }, - "edgePosition": 1 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF4bgwwp6lCBZw=" - }, - "tail": { - "$ref": "AAAAAAF4bgPmmadioEM=" - }, - "points": "592:504;592:528", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF4bhYT6rPM85A=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF4bhYT6rPNLFI=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bhYT6rPO3Jk=" - } - }, - { - "_type": "UMLDependencyView", - "_id": "AAAAAAF4bhZM3bSSzP4=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bhZM3bSQXaU=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhZM3bSTMCQ=", - "_parent": { - "$ref": "AAAAAAF4bhZM3bSSzP4=" - }, - "model": { - "$ref": "AAAAAAF4bhZM3bSQXaU=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 422, - "top": 509, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4bhZM3bSSzP4=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhZM3bSU/7g=", - "_parent": { - "$ref": "AAAAAAF4bhZM3bSSzP4=" - }, - "model": { - "$ref": "AAAAAAF4bhZM3bSQXaU=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 437, - "top": 509, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4bhZM3bSSzP4=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bhZM3bSVy08=", - "_parent": { - "$ref": "AAAAAAF4bhZM3bSSzP4=" - }, - "model": { - "$ref": "AAAAAAF4bhZM3bSQXaU=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 393, - "top": 510, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4bhZM3bSSzP4=" - }, - "edgePosition": 1 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF4bgwBvakoRnQ=" - }, - "tail": { - "$ref": "AAAAAAF4bgMKEKcwhN4=" - }, - "points": "408:504;408:528", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF4bhZM3bSTMCQ=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF4bhZM3bSU/7g=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bhZM3bSVy08=" - } - }, - { - "_type": "UMLAssociationView", - "_id": "AAAAAAF4biACh9VL52g=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4biACh9VH9Vs=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biACh9VMI0M=", - "_parent": { - "$ref": "AAAAAAF4biACh9VL52g=" - }, - "model": { - "$ref": "AAAAAAF4biACh9VH9Vs=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 756, - "top": 235, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4biACh9VL52g=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biACh9VNSnc=", - "_parent": { - "$ref": "AAAAAAF4biACh9VL52g=" - }, - "model": { - "$ref": "AAAAAAF4biACh9VH9Vs=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 756, - "top": 220, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4biACh9VL52g=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biACh9VOEU4=", - "_parent": { - "$ref": "AAAAAAF4biACh9VL52g=" - }, - "model": { - "$ref": "AAAAAAF4biACh9VH9Vs=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 756, - "top": 265, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4biACh9VL52g=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biACh9VPE04=", - "_parent": { - "$ref": "AAAAAAF4biACh9VL52g=" - }, - "model": { - "$ref": "AAAAAAF4biACh9VIum0=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 689, - "top": 235, - "height": 13, - "alpha": 0.5235987755982988, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4biACh9VL52g=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biACh9VQhBs=", - "_parent": { - "$ref": "AAAAAAF4biACh9VL52g=" - }, - "model": { - "$ref": "AAAAAAF4biACh9VIum0=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 692, - "top": 221, - "height": 13, - "alpha": 0.7853981633974483, - "distance": 40, - "hostEdge": { - "$ref": "AAAAAAF4biACh9VL52g=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biACh9VRoAg=", - "_parent": { - "$ref": "AAAAAAF4biACh9VL52g=" - }, - "model": { - "$ref": "AAAAAAF4biACh9VIum0=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 685, - "top": 262, - "height": 13, - "alpha": -0.5235987755982988, - "distance": 25, - "hostEdge": { - "$ref": "AAAAAAF4biACh9VL52g=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biACh9VSBOk=", - "_parent": { - "$ref": "AAAAAAF4biACh9VL52g=" - }, - "model": { - "$ref": "AAAAAAF4biACh9VJOno=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 822, - "top": 235, - "height": 13, - "alpha": -0.5235987755982988, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4biACh9VL52g=" - } - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biACh9VTInA=", - "_parent": { - "$ref": "AAAAAAF4biACh9VL52g=" - }, - "model": { - "$ref": "AAAAAAF4biACh9VJOno=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 819, - "top": 221, - "height": 13, - "alpha": -0.7853981633974483, - "distance": 40, - "hostEdge": { - "$ref": "AAAAAAF4biACh9VL52g=" - } - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biACh9VUq3g=", - "_parent": { - "$ref": "AAAAAAF4biACh9VL52g=" - }, - "model": { - "$ref": "AAAAAAF4biACh9VJOno=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 826, - "top": 262, - "height": 13, - "alpha": 0.5235987755982988, - "distance": 25, - "hostEdge": { - "$ref": "AAAAAAF4biACh9VL52g=" - } - }, - { - "_type": "UMLQualifierCompartmentView", - "_id": "AAAAAAF4biACh9VVpbs=", - "_parent": { - "$ref": "AAAAAAF4biACh9VL52g=" - }, - "model": { - "$ref": "AAAAAAF4biACh9VIum0=" - }, - "visible": false, - "font": "Arial;13;0", - "width": 10, - "height": 10 - }, - { - "_type": "UMLQualifierCompartmentView", - "_id": "AAAAAAF4biACiNVW1UE=", - "_parent": { - "$ref": "AAAAAAF4biACh9VL52g=" - }, - "model": { - "$ref": "AAAAAAF4biACh9VJOno=" - }, - "visible": false, - "font": "Arial;13;0", - "width": 10, - "height": 10 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF4bgRJ5aeSzhQ=" - }, - "tail": { - "$ref": "AAAAAAF4bgc7NqgnkmU=" - }, - "points": "664:256;848:256", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF4biACh9VMI0M=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF4biACh9VNSnc=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4biACh9VOEU4=" - }, - "tailRoleNameLabel": { - "$ref": "AAAAAAF4biACh9VPE04=" - }, - "tailPropertyLabel": { - "$ref": "AAAAAAF4biACh9VQhBs=" - }, - "tailMultiplicityLabel": { - "$ref": "AAAAAAF4biACh9VRoAg=" - }, - "headRoleNameLabel": { - "$ref": "AAAAAAF4biACh9VSBOk=" - }, - "headPropertyLabel": { - "$ref": "AAAAAAF4biACh9VTInA=" - }, - "headMultiplicityLabel": { - "$ref": "AAAAAAF4biACh9VUq3g=" - }, - "tailQualifiersCompartment": { - "$ref": "AAAAAAF4biACh9VVpbs=" - }, - "headQualifiersCompartment": { - "$ref": "AAAAAAF4biACiNVW1UE=" - } - }, - { - "_type": "UMLAssociationView", - "_id": "AAAAAAF4biAs99ZS0dM=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4biAs99ZO+5U=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biAs99ZTFoE=", - "_parent": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - }, - "model": { - "$ref": "AAAAAAF4biAs99ZO+5U=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 788, - "top": 275, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biAs+NZUS5A=", - "_parent": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - }, - "model": { - "$ref": "AAAAAAF4biAs99ZO+5U=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 788, - "top": 260, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biAs+NZVkoQ=", - "_parent": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - }, - "model": { - "$ref": "AAAAAAF4biAs99ZO+5U=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 788, - "top": 305, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biAs+NZWJzU=", - "_parent": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - }, - "model": { - "$ref": "AAAAAAF4biAs99ZP7b4=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 753, - "top": 275, - "height": 13, - "alpha": 0.5235987755982988, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biAs+NZXIMg=", - "_parent": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - }, - "model": { - "$ref": "AAAAAAF4biAs99ZP7b4=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 756, - "top": 261, - "height": 13, - "alpha": 0.7853981633974483, - "distance": 40, - "hostEdge": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biAs+NZYiE0=", - "_parent": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - }, - "model": { - "$ref": "AAAAAAF4biAs99ZP7b4=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 749, - "top": 302, - "height": 13, - "alpha": -0.5235987755982988, - "distance": 25, - "hostEdge": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biAs+dZZKLY=", - "_parent": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - }, - "model": { - "$ref": "AAAAAAF4biAs99ZQ5CI=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 822, - "top": 275, - "height": 13, - "alpha": -0.5235987755982988, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - } - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biAs+dZaTmg=", - "_parent": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - }, - "model": { - "$ref": "AAAAAAF4biAs99ZQ5CI=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 819, - "top": 261, - "height": 13, - "alpha": -0.7853981633974483, - "distance": 40, - "hostEdge": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - } - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biAs+dZb7IU=", - "_parent": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - }, - "model": { - "$ref": "AAAAAAF4biAs99ZQ5CI=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 826, - "top": 302, - "height": 13, - "alpha": 0.5235987755982988, - "distance": 25, - "hostEdge": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - } - }, - { - "_type": "UMLQualifierCompartmentView", - "_id": "AAAAAAF4biAs+dZcO6k=", - "_parent": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - }, - "model": { - "$ref": "AAAAAAF4biAs99ZP7b4=" - }, - "visible": false, - "font": "Arial;13;0", - "width": 10, - "height": 10 - }, - { - "_type": "UMLQualifierCompartmentView", - "_id": "AAAAAAF4biAs+dZd78o=", - "_parent": { - "$ref": "AAAAAAF4biAs99ZS0dM=" - }, - "model": { - "$ref": "AAAAAAF4biAs99ZQ5CI=" - }, - "visible": false, - "font": "Arial;13;0", - "width": 10, - "height": 10 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF4bgSxm6fAlds=" - }, - "tail": { - "$ref": "AAAAAAF4bgoCwaiVju8=" - }, - "points": "728:296;848:296", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF4biAs99ZTFoE=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF4biAs+NZUS5A=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4biAs+NZVkoQ=" - }, - "tailRoleNameLabel": { - "$ref": "AAAAAAF4biAs+NZWJzU=" - }, - "tailPropertyLabel": { - "$ref": "AAAAAAF4biAs+NZXIMg=" - }, - "tailMultiplicityLabel": { - "$ref": "AAAAAAF4biAs+NZYiE0=" - }, - "headRoleNameLabel": { - "$ref": "AAAAAAF4biAs+dZZKLY=" - }, - "headPropertyLabel": { - "$ref": "AAAAAAF4biAs+dZaTmg=" - }, - "headMultiplicityLabel": { - "$ref": "AAAAAAF4biAs+dZb7IU=" - }, - "tailQualifiersCompartment": { - "$ref": "AAAAAAF4biAs+dZcO6k=" - }, - "headQualifiersCompartment": { - "$ref": "AAAAAAF4biAs+dZd78o=" - } - }, - { - "_type": "UMLPackageView", - "_id": "AAAAAAF4bgtpFKjwhnI=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bgtpE6juStg=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4bgtpFKjx0o8=", - "_parent": { - "$ref": "AAAAAAF4bgtpFKjwhnI=" - }, - "model": { - "$ref": "AAAAAAF4bgtpE6juStg=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4bgtpFKjysKU=", - "_parent": { - "$ref": "AAAAAAF4bgtpFKjx0o8=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": -784, - "top": 544, - "height": 13 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgtpFKjzLxQ=", - "_parent": { - "$ref": "AAAAAAF4bgtpFKjx0o8=" - }, - "fillColor": "#e2e2e2", - "font": "Arial;13;1", - "left": 837, - "top": 550, - "width": 134, - "height": 13, - "text": "Flask" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgtpFKj0DQI=", - "_parent": { - "$ref": "AAAAAAF4bgtpFKjx0o8=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": -784, - "top": 544, - "width": 75.1181640625, - "height": 13, - "text": "(from zswag)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bgtpFKj1KHQ=", - "_parent": { - "$ref": "AAAAAAF4bgtpFKjx0o8=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": -784, - "top": 544, - "height": 13, - "horizontalAlignment": 1 - } - ], - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": 832, - "top": 543, - "width": 144, - "height": 25, - "stereotypeLabel": { - "$ref": "AAAAAAF4bgtpFKjysKU=" - }, - "nameLabel": { - "$ref": "AAAAAAF4bgtpFKjzLxQ=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4bgtpFKj0DQI=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bgtpFKj1KHQ=" - } - } - ], - "containerView": { - "$ref": "AAAAAAF4bgLBtacVT74=" - }, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "containerChangeable": true, - "left": 832, - "top": 528, - "width": 144, - "height": 41, - "nameCompartment": { - "$ref": "AAAAAAF4bgtpFKjx0o8=" - } - }, - { - "_type": "UMLAssociationView", - "_id": "AAAAAAF4biHeFd9RyvU=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4biHeFN9N1N8=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biHeFd9S+xc=", - "_parent": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - }, - "model": { - "$ref": "AAAAAAF4biHeFN9N1N8=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 952, - "top": 361, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biHeFd9T2yg=", - "_parent": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - }, - "model": { - "$ref": "AAAAAAF4biHeFN9N1N8=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 967, - "top": 361, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biHeFd9UFWU=", - "_parent": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - }, - "model": { - "$ref": "AAAAAAF4biHeFN9N1N8=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 923, - "top": 362, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biHeFd9Vl10=", - "_parent": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - }, - "model": { - "$ref": "AAAAAAF4biHeFN9OHMk=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 953, - "top": 323, - "height": 13, - "alpha": 0.5235987755982988, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biHeFd9Wp4c=", - "_parent": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - }, - "model": { - "$ref": "AAAAAAF4biHeFN9OHMk=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 966, - "top": 326, - "height": 13, - "alpha": 0.7853981633974483, - "distance": 40, - "hostEdge": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biHeFd9X/pY=", - "_parent": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - }, - "model": { - "$ref": "AAAAAAF4biHeFN9OHMk=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 925, - "top": 319, - "height": 13, - "alpha": -0.5235987755982988, - "distance": 25, - "hostEdge": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biHeFd9Y6zg=", - "_parent": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - }, - "model": { - "$ref": "AAAAAAF4biHeFN9PVzM=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 953, - "top": 400, - "height": 13, - "alpha": -0.5235987755982988, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - } - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biHeFd9Z9Bc=", - "_parent": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - }, - "model": { - "$ref": "AAAAAAF4biHeFN9PVzM=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 966, - "top": 397, - "height": 13, - "alpha": -0.7853981633974483, - "distance": 40, - "hostEdge": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - } - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biHeFd9adpM=", - "_parent": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - }, - "model": { - "$ref": "AAAAAAF4biHeFN9PVzM=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 925, - "top": 404, - "height": 13, - "alpha": 0.5235987755982988, - "distance": 25, - "hostEdge": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - } - }, - { - "_type": "UMLQualifierCompartmentView", - "_id": "AAAAAAF4biHeFd9blJg=", - "_parent": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - }, - "model": { - "$ref": "AAAAAAF4biHeFN9OHMk=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 80, - "width": 10, - "height": 10 - }, - { - "_type": "UMLQualifierCompartmentView", - "_id": "AAAAAAF4biHeFd9cxnw=", - "_parent": { - "$ref": "AAAAAAF4biHeFd9RyvU=" - }, - "model": { - "$ref": "AAAAAAF4biHeFN9PVzM=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 80, - "width": 10, - "height": 10 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF4bgYiBqf0des=" - }, - "tail": { - "$ref": "AAAAAAF4bgSxm6fAlds=" - }, - "points": "938:304;938:432", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF4biHeFd9S+xc=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF4biHeFd9T2yg=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4biHeFd9UFWU=" - }, - "tailRoleNameLabel": { - "$ref": "AAAAAAF4biHeFd9Vl10=" - }, - "tailPropertyLabel": { - "$ref": "AAAAAAF4biHeFd9Wp4c=" - }, - "tailMultiplicityLabel": { - "$ref": "AAAAAAF4biHeFd9X/pY=" - }, - "headRoleNameLabel": { - "$ref": "AAAAAAF4biHeFd9Y6zg=" - }, - "headPropertyLabel": { - "$ref": "AAAAAAF4biHeFd9Z9Bc=" - }, - "headMultiplicityLabel": { - "$ref": "AAAAAAF4biHeFd9adpM=" - }, - "tailQualifiersCompartment": { - "$ref": "AAAAAAF4biHeFd9blJg=" - }, - "headQualifiersCompartment": { - "$ref": "AAAAAAF4biHeFd9cxnw=" - } - }, - { - "_type": "UMLPackageView", - "_id": "AAAAAAF4bg3JFKmRnS8=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bg3JFKmP9aY=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4bg3JFKmSoew=", - "_parent": { - "$ref": "AAAAAAF4bg3JFKmRnS8=" - }, - "model": { - "$ref": "AAAAAAF4bg3JFKmP9aY=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4bg3JFKmTQ2Q=", - "_parent": { - "$ref": "AAAAAAF4bg3JFKmSoew=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": -176, - "top": -464, - "height": 13 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bg3JFKmUChk=", - "_parent": { - "$ref": "AAAAAAF4bg3JFKmSoew=" - }, - "fillColor": "#e2e2e2", - "font": "Arial;13;1", - "left": 973, - "top": 334, - "width": 119, - "height": 13, - "text": "pybind11" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bg3JFKmVx+k=", - "_parent": { - "$ref": "AAAAAAF4bg3JFKmSoew=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": -176, - "top": -464, - "width": 98.236328125, - "height": 13, - "text": "(from zswag)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bg3JFKmW+dA=", - "_parent": { - "$ref": "AAAAAAF4bg3JFKmSoew=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": -176, - "top": -464, - "height": 13, - "horizontalAlignment": 1 - } - ], - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": 968, - "top": 327, - "width": 129, - "height": 25, - "stereotypeLabel": { - "$ref": "AAAAAAF4bg3JFKmTQ2Q=" - }, - "nameLabel": { - "$ref": "AAAAAAF4bg3JFKmUChk=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4bg3JFKmVx+k=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bg3JFKmW+dA=" - } - } - ], - "containerView": { - "$ref": "AAAAAAF4bgLBtacVT74=" - }, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "containerChangeable": true, - "left": 968, - "top": 312, - "width": 129, - "height": 40, - "nameCompartment": { - "$ref": "AAAAAAF4bg3JFKmSoew=" - } - }, - { - "_type": "UMLNoteView", - "_id": "AAAAAAF4biKvX+V58bw=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "fillColor": "#d8ffeb", - "font": "Arial;18;0", - "left": 760, - "top": 96, - "width": 145, - "height": 30, - "text": "Python" - }, - { - "_type": "UMLPackageView", - "_id": "AAAAAAF4biS/lPNILu0=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4biS/lPNG5z8=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4biS/lPNJtNI=", - "_parent": { - "$ref": "AAAAAAF4biS/lPNILu0=" - }, - "model": { - "$ref": "AAAAAAF4biS/lPNG5z8=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4biS/lPNKncs=", - "_parent": { - "$ref": "AAAAAAF4biS/lPNJtNI=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": -24, - "top": -160, - "height": 13 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4biS/lfNLLLc=", - "_parent": { - "$ref": "AAAAAAF4biS/lPNJtNI=" - }, - "fillColor": "#e2e2e2", - "font": "Arial;13;1", - "left": 1085, - "top": 150, - "width": 135, - "height": 13, - "text": "zserio PyPI package" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4biS/lfNMXCg=", - "_parent": { - "$ref": "AAAAAAF4biS/lPNJtNI=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": -24, - "top": -160, - "width": 75.1181640625, - "height": 13, - "text": "(from zswag)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4biS/lfNN100=", - "_parent": { - "$ref": "AAAAAAF4biS/lPNJtNI=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": -24, - "top": -160, - "height": 13, - "horizontalAlignment": 1 - } - ], - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": 1080, - "top": 143, - "width": 145, - "height": 25, - "stereotypeLabel": { - "$ref": "AAAAAAF4biS/lPNKncs=" - }, - "nameLabel": { - "$ref": "AAAAAAF4biS/lfNLLLc=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4biS/lfNMXCg=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4biS/lfNN100=" - } - } - ], - "containerView": { - "$ref": "AAAAAAF4bgLBtacVT74=" - }, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "containerChangeable": true, - "left": 1080, - "top": 128, - "width": 145, - "height": 40, - "nameCompartment": { - "$ref": "AAAAAAF4biS/lPNJtNI=" - } - }, - { - "_type": "UMLDependencyView", - "_id": "AAAAAAF4biVV3/XwiTo=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4biVV3/XuI+s=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biVV3/XxzbY=", - "_parent": { - "$ref": "AAAAAAF4biVV3/XwiTo=" - }, - "model": { - "$ref": "AAAAAAF4biVV3/XuI+s=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 1111, - "top": 377, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4biVV3/XwiTo=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biVV4PXyNJU=", - "_parent": { - "$ref": "AAAAAAF4biVV3/XwiTo=" - }, - "model": { - "$ref": "AAAAAAF4biVV3/XuI+s=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 1111, - "top": 392, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4biVV3/XwiTo=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biVV4PXz9GU=", - "_parent": { - "$ref": "AAAAAAF4biVV3/XwiTo=" - }, - "model": { - "$ref": "AAAAAAF4biVV3/XuI+s=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 1112, - "top": 347, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4biVV3/XwiTo=" - }, - "edgePosition": 1 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF4bg3JFKmRnS8=" - }, - "tail": { - "$ref": "AAAAAAF4bgKNKab7Iso=" - }, - "points": "1072:312;1112:312;1112:368;1040:368;1040:351", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF4biVV3/XxzbY=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF4biVV4PXyNJU=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4biVV4PXz9GU=" - } - }, - { - "_type": "UMLDependencyView", - "_id": "AAAAAAF4biXwU/yeG6M=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4biXwU/yc5Hk=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biXwU/yfUcw=", - "_parent": { - "$ref": "AAAAAAF4biXwU/yeG6M=" - }, - "model": { - "$ref": "AAAAAAF4biXwU/yc5Hk=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 1161, - "top": 249, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4biXwU/yeG6M=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biXwU/ygzcE=", - "_parent": { - "$ref": "AAAAAAF4biXwU/yeG6M=" - }, - "model": { - "$ref": "AAAAAAF4biXwU/yc5Hk=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 1146, - "top": 249, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4biXwU/yeG6M=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biXwU/yhwsw=", - "_parent": { - "$ref": "AAAAAAF4biXwU/yeG6M=" - }, - "model": { - "$ref": "AAAAAAF4biXwU/yc5Hk=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 1190, - "top": 250, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4biXwU/yeG6M=" - }, - "edgePosition": 1 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF4biS/lPNILu0=" - }, - "tail": { - "$ref": "AAAAAAF4bgRJ5aeSzhQ=" - }, - "points": "1096:256;1176:256;1176:167", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF4biXwU/yfUcw=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF4biXwU/ygzcE=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4biXwU/yhwsw=" - } - }, - { - "_type": "UMLDependencyView", - "_id": "AAAAAAF4biZDaf+9ZVw=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4biZDaP+7N0U=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biZDaf++IVw=", - "_parent": { - "$ref": "AAAAAAF4biZDaf+9ZVw=" - }, - "model": { - "$ref": "AAAAAAF4biZDaP+7N0U=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 1177, - "top": 437, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4biZDaf+9ZVw=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biZDaf+/R70=", - "_parent": { - "$ref": "AAAAAAF4biZDaf+9ZVw=" - }, - "model": { - "$ref": "AAAAAAF4biZDaP+7N0U=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 1162, - "top": 437, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4biZDaf+9ZVw=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4biZDaf/AN0E=", - "_parent": { - "$ref": "AAAAAAF4biZDaf+9ZVw=" - }, - "model": { - "$ref": "AAAAAAF4biZDaP+7N0U=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 1207, - "top": 438, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4biZDaf+9ZVw=" - }, - "edgePosition": 1 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF4biS/lPNILu0=" - }, - "tail": { - "$ref": "AAAAAAF4bgYiBqf0des=" - }, - "points": "1096:444;1192:444;1192:167", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF4biZDaf++IVw=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF4biZDaf+/R70=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4biZDaf/AN0E=" - } - }, - { - "_type": "UMLDependencyView", - "_id": "AAAAAAF4bibcyQOFcac=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bibcyQODLBE=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bibcyQOG8Ew=", - "_parent": { - "$ref": "AAAAAAF4bibcyQOFcac=" - }, - "model": { - "$ref": "AAAAAAF4bibcyQODLBE=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 878, - "top": 485, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4bibcyQOFcac=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bibcyQOHYp4=", - "_parent": { - "$ref": "AAAAAAF4bibcyQOFcac=" - }, - "model": { - "$ref": "AAAAAAF4bibcyQODLBE=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 893, - "top": 485, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4bibcyQOFcac=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bibcyQOI8Nc=", - "_parent": { - "$ref": "AAAAAAF4bibcyQOFcac=" - }, - "model": { - "$ref": "AAAAAAF4bibcyQODLBE=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 849, - "top": 486, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4bibcyQOFcac=" - }, - "edgePosition": 1 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF4bgtpFKjwhnI=" - }, - "tail": { - "$ref": "AAAAAAF4bgYiBqf0des=" - }, - "points": "864:456;864:528", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF4bibcyQOG8Ew=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF4bibcyQOHYp4=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bibcyQOI8Nc=" - } - }, - { - "_type": "UMLDependencyView", - "_id": "AAAAAAF4bib9vQT9Z4g=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bib9vQT7pUQ=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bib9vQT+flw=", - "_parent": { - "$ref": "AAAAAAF4bib9vQT9Z4g=" - }, - "model": { - "$ref": "AAAAAAF4bib9vQT7pUQ=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 1030, - "top": 485, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4bib9vQT9Z4g=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bib9vQT/MQA=", - "_parent": { - "$ref": "AAAAAAF4bib9vQT9Z4g=" - }, - "model": { - "$ref": "AAAAAAF4bib9vQT7pUQ=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 1045, - "top": 485, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF4bib9vQT9Z4g=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF4bib9vQUABcc=", - "_parent": { - "$ref": "AAAAAAF4bib9vQT9Z4g=" - }, - "model": { - "$ref": "AAAAAAF4bib9vQT7pUQ=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 1001, - "top": 486, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF4bib9vQT9Z4g=" - }, - "edgePosition": 1 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF4bgz4wal0D3U=" - }, - "tail": { - "$ref": "AAAAAAF4bgYiBqf0des=" - }, - "points": "1016:456;1016:528", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF4bib9vQT+flw=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF4bib9vQT/MQA=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bib9vQUABcc=" - } - }, - { - "_type": "UMLPackageView", - "_id": "AAAAAAF4bhSO3K5EvQQ=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF4bhSO3K5C/vg=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF4bhSO3K5FXmA=", - "_parent": { - "$ref": "AAAAAAF4bhSO3K5EvQQ=" - }, - "model": { - "$ref": "AAAAAAF4bhSO3K5C/vg=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF4bhSO3q5G5mQ=", - "_parent": { - "$ref": "AAAAAAF4bhSO3K5FXmA=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": 32, - "top": -256, - "height": 13 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bhSO3q5HEYM=", - "_parent": { - "$ref": "AAAAAAF4bhSO3K5FXmA=" - }, - "fillColor": "#e2e2e2", - "font": "Arial;13;1", - "left": 317, - "top": 262, - "width": 79, - "height": 13, - "text": "zserio" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bhSO3q5IdYk=", - "_parent": { - "$ref": "AAAAAAF4bhSO3K5FXmA=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": 32, - "top": -256, - "width": 84.50634765625, - "height": 13, - "text": "(from zswagcl)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF4bhSO3q5JtdI=", - "_parent": { - "$ref": "AAAAAAF4bhSO3K5FXmA=" - }, - "visible": false, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": 32, - "top": -256, - "height": 13, - "horizontalAlignment": 1 - } - ], - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "left": 312, - "top": 255, - "width": 89, - "height": 25, - "stereotypeLabel": { - "$ref": "AAAAAAF4bhSO3q5G5mQ=" - }, - "nameLabel": { - "$ref": "AAAAAAF4bhSO3q5HEYM=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF4bhSO3q5IdYk=" - }, - "propertyLabel": { - "$ref": "AAAAAAF4bhSO3q5JtdI=" - } - } - ], - "containerView": { - "$ref": "AAAAAAF4bgIuhabf/vw=" - }, - "fillColor": "#e2e2e2", - "font": "Arial;13;0", - "containerChangeable": true, - "left": 312, - "top": 240, - "width": 89, - "height": 40, - "nameCompartment": { - "$ref": "AAAAAAF4bhSO3K5FXmA=" - } - }, - { - "_type": "UMLDependencyView", - "_id": "AAAAAAF6qS8bI9+J2jI=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF6qS8bIt+HF/0=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qS8bI9+K9sw=", - "_parent": { - "$ref": "AAAAAAF6qS8bI9+J2jI=" - }, - "model": { - "$ref": "AAAAAAF6qS8bIt+HF/0=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 497, - "top": 489, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF6qS8bI9+J2jI=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qS8bI9+Lr8k=", - "_parent": { - "$ref": "AAAAAAF6qS8bI9+J2jI=" - }, - "model": { - "$ref": "AAAAAAF6qS8bIt+HF/0=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 482, - "top": 489, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF6qS8bI9+J2jI=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qS8bI9+MCUE=", - "_parent": { - "$ref": "AAAAAAF6qS8bI9+J2jI=" - }, - "model": { - "$ref": "AAAAAAF6qS8bIt+HF/0=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 526, - "top": 490, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF6qS8bI9+J2jI=" - }, - "edgePosition": 1 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF6qTP76/P0gDU=" - }, - "tail": { - "$ref": "AAAAAAF4bgMKEKcwhN4=" - }, - "points": "488:496;512:496;512:464", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF6qS8bI9+K9sw=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF6qS8bI9+Lr8k=" - }, - "propertyLabel": { - "$ref": "AAAAAAF6qS8bI9+MCUE=" - } - }, - { - "_type": "UMLAssociationView", - "_id": "AAAAAAF6qS/WZOSTonY=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF6qS/WZOSPeYI=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qS/WZOSU8UQ=", - "_parent": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - }, - "model": { - "$ref": "AAAAAAF6qS/WZOSPeYI=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 577, - "top": 365, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qS/WZOSV5dU=", - "_parent": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - }, - "model": { - "$ref": "AAAAAAF6qS/WZOSPeYI=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 562, - "top": 365, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qS/WZOSW7QY=", - "_parent": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - }, - "model": { - "$ref": "AAAAAAF6qS/WZOSPeYI=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 607, - "top": 366, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qS/WZOSX2pg=", - "_parent": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - }, - "model": { - "$ref": "AAAAAAF6qS/WZOSQBj4=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 577, - "top": 448, - "height": 13, - "alpha": 0.5235987755982988, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qS/WZOSYRks=", - "_parent": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - }, - "model": { - "$ref": "AAAAAAF6qS/WZOSQBj4=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 563, - "top": 445, - "height": 13, - "alpha": 0.7853981633974483, - "distance": 40, - "hostEdge": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qS/WZOSZGsE=", - "_parent": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - }, - "model": { - "$ref": "AAAAAAF6qS/WZOSQBj4=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 604, - "top": 452, - "height": 13, - "alpha": -0.5235987755982988, - "distance": 25, - "hostEdge": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qS/WZOSa6ZM=", - "_parent": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - }, - "model": { - "$ref": "AAAAAAF6qS/WZOSRcq8=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 577, - "top": 283, - "height": 13, - "alpha": -0.5235987755982988, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - } - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qS/WZOSbQtc=", - "_parent": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - }, - "model": { - "$ref": "AAAAAAF6qS/WZOSRcq8=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 563, - "top": 286, - "height": 13, - "alpha": -0.7853981633974483, - "distance": 40, - "hostEdge": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - } - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qS/WZOScnlo=", - "_parent": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - }, - "model": { - "$ref": "AAAAAAF6qS/WZOSRcq8=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 604, - "top": 279, - "height": 13, - "alpha": 0.5235987755982988, - "distance": 25, - "hostEdge": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - } - }, - { - "_type": "UMLQualifierCompartmentView", - "_id": "AAAAAAF6qS/WZOSdbSI=", - "_parent": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - }, - "model": { - "$ref": "AAAAAAF6qS/WZOSQBj4=" - }, - "visible": false, - "font": "Arial;13;0", - "width": 10, - "height": 10 - }, - { - "_type": "UMLQualifierCompartmentView", - "_id": "AAAAAAF6qS/WZeSes3k=", - "_parent": { - "$ref": "AAAAAAF6qS/WZOSTonY=" - }, - "model": { - "$ref": "AAAAAAF6qS/WZOSRcq8=" - }, - "visible": false, - "font": "Arial;13;0", - "width": 10, - "height": 10 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF4bgc7NqgnkmU=" - }, - "tail": { - "$ref": "AAAAAAF4bgPmmadioEM=" - }, - "points": "592:480;592:264", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF6qS/WZOSU8UQ=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF6qS/WZOSV5dU=" - }, - "propertyLabel": { - "$ref": "AAAAAAF6qS/WZOSW7QY=" - }, - "tailRoleNameLabel": { - "$ref": "AAAAAAF6qS/WZOSX2pg=" - }, - "tailPropertyLabel": { - "$ref": "AAAAAAF6qS/WZOSYRks=" - }, - "tailMultiplicityLabel": { - "$ref": "AAAAAAF6qS/WZOSZGsE=" - }, - "headRoleNameLabel": { - "$ref": "AAAAAAF6qS/WZOSa6ZM=" - }, - "headPropertyLabel": { - "$ref": "AAAAAAF6qS/WZOSbQtc=" - }, - "headMultiplicityLabel": { - "$ref": "AAAAAAF6qS/WZOScnlo=" - }, - "tailQualifiersCompartment": { - "$ref": "AAAAAAF6qS/WZOSdbSI=" - }, - "headQualifiersCompartment": { - "$ref": "AAAAAAF6qS/WZeSes3k=" - } - }, - { - "_type": "UMLAssociationView", - "_id": "AAAAAAF6qTVEu/jtcMQ=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF6qTVEuvjpxVg=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qTVEu/ju07w=", - "_parent": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - }, - "model": { - "$ref": "AAAAAAF6qTVEuvjpxVg=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 513, - "top": 489, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qTVEu/jvqko=", - "_parent": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - }, - "model": { - "$ref": "AAAAAAF6qTVEuvjpxVg=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 498, - "top": 489, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qTVEu/jwNgw=", - "_parent": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - }, - "model": { - "$ref": "AAAAAAF6qTVEuvjpxVg=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 542, - "top": 490, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qTVEu/jxJ+I=", - "_parent": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - }, - "model": { - "$ref": "AAAAAAF6qTVEuvjqBH4=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 534, - "top": 504, - "height": 13, - "alpha": 0.5235987755982988, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qTVEu/jyyQQ=", - "_parent": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - }, - "model": { - "$ref": "AAAAAAF6qTVEuvjqBH4=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 531, - "top": 518, - "height": 13, - "alpha": 0.7853981633974483, - "distance": 40, - "hostEdge": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qTVEu/jz/ZQ=", - "_parent": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - }, - "model": { - "$ref": "AAAAAAF6qTVEuvjqBH4=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 538, - "top": 477, - "height": 13, - "alpha": -0.5235987755982988, - "distance": 25, - "hostEdge": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - }, - "edgePosition": 2 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qTVEu/j0q/s=", - "_parent": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - }, - "model": { - "$ref": "AAAAAAF6qTVEuvjreqY=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 513, - "top": 483, - "height": 13, - "alpha": -0.5235987755982988, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - } - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qTVEu/j1cwg=", - "_parent": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - }, - "model": { - "$ref": "AAAAAAF6qTVEuvjreqY=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 499, - "top": 486, - "height": 13, - "alpha": -0.7853981633974483, - "distance": 40, - "hostEdge": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - } - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qTVEu/j2Uro=", - "_parent": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - }, - "model": { - "$ref": "AAAAAAF6qTVEuvjreqY=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 540, - "top": 479, - "height": 13, - "alpha": 0.5235987755982988, - "distance": 25, - "hostEdge": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - } - }, - { - "_type": "UMLQualifierCompartmentView", - "_id": "AAAAAAF6qTVEu/j3Joo=", - "_parent": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - }, - "model": { - "$ref": "AAAAAAF6qTVEuvjqBH4=" - }, - "visible": false, - "font": "Arial;13;0", - "width": 10, - "height": 10 - }, - { - "_type": "UMLQualifierCompartmentView", - "_id": "AAAAAAF6qTVEu/j4/tg=", - "_parent": { - "$ref": "AAAAAAF6qTVEu/jtcMQ=" - }, - "model": { - "$ref": "AAAAAAF6qTVEuvjreqY=" - }, - "visible": false, - "font": "Arial;13;0", - "width": 10, - "height": 10 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF6qTP76/P0gDU=" - }, - "tail": { - "$ref": "AAAAAAF4bgPmmadioEM=" - }, - "points": "560:496;528:496;528:464", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF6qTVEu/ju07w=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF6qTVEu/jvqko=" - }, - "propertyLabel": { - "$ref": "AAAAAAF6qTVEu/jwNgw=" - }, - "tailRoleNameLabel": { - "$ref": "AAAAAAF6qTVEu/jxJ+I=" - }, - "tailPropertyLabel": { - "$ref": "AAAAAAF6qTVEu/jyyQQ=" - }, - "tailMultiplicityLabel": { - "$ref": "AAAAAAF6qTVEu/jz/ZQ=" - }, - "headRoleNameLabel": { - "$ref": "AAAAAAF6qTVEu/j0q/s=" - }, - "headPropertyLabel": { - "$ref": "AAAAAAF6qTVEu/j1cwg=" - }, - "headMultiplicityLabel": { - "$ref": "AAAAAAF6qTVEu/j2Uro=" - }, - "tailQualifiersCompartment": { - "$ref": "AAAAAAF6qTVEu/j3Joo=" - }, - "headQualifiersCompartment": { - "$ref": "AAAAAAF6qTVEu/j4/tg=" - } - }, - { - "_type": "UMLDependencyView", - "_id": "AAAAAAF6qTY1I/wkDfE=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF6qTY1Ivwi96g=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qTY1I/wlGJ0=", - "_parent": { - "$ref": "AAAAAAF6qTY1I/wkDfE=" - }, - "model": { - "$ref": "AAAAAAF6qTY1Ivwi96g=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 510, - "top": 345, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF6qTY1I/wkDfE=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qTY1I/wmWTY=", - "_parent": { - "$ref": "AAAAAAF6qTY1I/wkDfE=" - }, - "model": { - "$ref": "AAAAAAF6qTY1Ivwi96g=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 525, - "top": 345, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF6qTY1I/wkDfE=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qTY1I/wn49s=", - "_parent": { - "$ref": "AAAAAAF6qTY1I/wkDfE=" - }, - "model": { - "$ref": "AAAAAAF6qTY1Ivwi96g=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 481, - "top": 346, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF6qTY1I/wkDfE=" - }, - "edgePosition": 1 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF6qTP76/P0gDU=" - }, - "tail": { - "$ref": "AAAAAAF4bgc7NqgnkmU=" - }, - "points": "496:264;496:440", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF6qTY1I/wlGJ0=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF6qTY1I/wmWTY=" - }, - "propertyLabel": { - "$ref": "AAAAAAF6qTY1I/wn49s=" - } - }, - { - "_type": "UMLClassView", - "_id": "AAAAAAF6qThnCAOTYh0=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF6qThnCAORKcU=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF6qThnCAOUTYU=", - "_parent": { - "$ref": "AAAAAAF6qThnCAOTYh0=" - }, - "model": { - "$ref": "AAAAAAF6qThnCAORKcU=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF6qThnCAOVN7A=", - "_parent": { - "$ref": "AAAAAAF6qThnCAOUTYU=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 384, - "top": -64, - "height": 13 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF6qThnCAOWZ+4=", - "_parent": { - "$ref": "AAAAAAF6qThnCAOUTYU=" - }, - "font": "Arial;13;1", - "left": 373, - "top": 407, - "width": 103, - "height": 13, - "text": "IHttpClient" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF6qThnCAOXgF4=", - "_parent": { - "$ref": "AAAAAAF6qThnCAOUTYU=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 384, - "top": -64, - "width": 73.67724609375, - "height": 13, - "text": "(from httpcl)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF6qThnCAOY4Tc=", - "_parent": { - "$ref": "AAAAAAF6qThnCAOUTYU=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 384, - "top": -64, - "height": 13, - "horizontalAlignment": 1 - } - ], - "font": "Arial;13;0", - "left": 368, - "top": 400, - "width": 113, - "height": 25, - "stereotypeLabel": { - "$ref": "AAAAAAF6qThnCAOVN7A=" - }, - "nameLabel": { - "$ref": "AAAAAAF6qThnCAOWZ+4=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF6qThnCAOXgF4=" - }, - "propertyLabel": { - "$ref": "AAAAAAF6qThnCAOY4Tc=" - } - }, - { - "_type": "UMLAttributeCompartmentView", - "_id": "AAAAAAF6qThnCQOZMcQ=", - "_parent": { - "$ref": "AAAAAAF6qThnCAOTYh0=" - }, - "model": { - "$ref": "AAAAAAF6qThnCAORKcU=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 496, - "top": 425, - "width": 75.7109375, - "height": 10 - }, - { - "_type": "UMLOperationCompartmentView", - "_id": "AAAAAAF6qThnCQOanv0=", - "_parent": { - "$ref": "AAAAAAF6qThnCAOTYh0=" - }, - "model": { - "$ref": "AAAAAAF6qThnCAORKcU=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 496, - "top": 435, - "width": 75.7109375, - "height": 10 - }, - { - "_type": "UMLReceptionCompartmentView", - "_id": "AAAAAAF6qThnCQObphE=", - "_parent": { - "$ref": "AAAAAAF6qThnCAOTYh0=" - }, - "model": { - "$ref": "AAAAAAF6qThnCAORKcU=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 256, - "top": -32, - "width": 10, - "height": 10 - }, - { - "_type": "UMLTemplateParameterCompartmentView", - "_id": "AAAAAAF6qThnCQOcruA=", - "_parent": { - "$ref": "AAAAAAF6qThnCAOTYh0=" - }, - "model": { - "$ref": "AAAAAAF6qThnCAORKcU=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 256, - "top": -32, - "width": 10, - "height": 10 - } - ], - "containerView": { - "$ref": "AAAAAAF4bgAZMqaVouk=" - }, - "font": "Arial;13;0", - "containerChangeable": true, - "left": 368, - "top": 400, - "width": 113, - "height": 25, - "nameCompartment": { - "$ref": "AAAAAAF6qThnCAOUTYU=" - }, - "suppressAttributes": true, - "suppressOperations": true, - "attributeCompartment": { - "$ref": "AAAAAAF6qThnCQOZMcQ=" - }, - "operationCompartment": { - "$ref": "AAAAAAF6qThnCQOanv0=" - }, - "receptionCompartment": { - "$ref": "AAAAAAF6qThnCQObphE=" - }, - "templateParameterCompartment": { - "$ref": "AAAAAAF6qThnCQOcruA=" - } - }, - { - "_type": "UMLClassView", - "_id": "AAAAAAF6qTP76/P0gDU=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF6qTP76/PyhLY=" - }, - "subViews": [ - { - "_type": "UMLNameCompartmentView", - "_id": "AAAAAAF6qTP76/P1n+E=", - "_parent": { - "$ref": "AAAAAAF6qTP76/P0gDU=" - }, - "model": { - "$ref": "AAAAAAF6qTP76/PyhLY=" - }, - "subViews": [ - { - "_type": "LabelView", - "_id": "AAAAAAF6qTP77PP2Od0=", - "_parent": { - "$ref": "AAAAAAF6qTP76/P1n+E=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -464, - "top": -128, - "height": 13 - }, - { - "_type": "LabelView", - "_id": "AAAAAAF6qTP77PP3QoM=", - "_parent": { - "$ref": "AAAAAAF6qTP76/P1n+E=" - }, - "font": "Arial;13;1", - "left": 453, - "top": 447, - "width": 119, - "height": 13, - "text": "Config" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF6qTP77PP4F9g=", - "_parent": { - "$ref": "AAAAAAF6qTP76/P1n+E=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -464, - "top": -128, - "width": 73.67724609375, - "height": 13, - "text": "(from httpcl)" - }, - { - "_type": "LabelView", - "_id": "AAAAAAF6qTP77PP5Z9Q=", - "_parent": { - "$ref": "AAAAAAF6qTP76/P1n+E=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -464, - "top": -128, - "height": 13, - "horizontalAlignment": 1 - } - ], - "font": "Arial;13;0", - "left": 448, - "top": 440, - "width": 129, - "height": 25, - "stereotypeLabel": { - "$ref": "AAAAAAF6qTP77PP2Od0=" - }, - "nameLabel": { - "$ref": "AAAAAAF6qTP77PP3QoM=" - }, - "namespaceLabel": { - "$ref": "AAAAAAF6qTP77PP4F9g=" - }, - "propertyLabel": { - "$ref": "AAAAAAF6qTP77PP5Z9Q=" - } - }, - { - "_type": "UMLAttributeCompartmentView", - "_id": "AAAAAAF6qTP77PP6Oh4=", - "_parent": { - "$ref": "AAAAAAF6qTP76/P0gDU=" - }, - "model": { - "$ref": "AAAAAAF6qTP76/PyhLY=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 272, - "top": 433, - "width": 51.919921875, - "height": 10 - }, - { - "_type": "UMLOperationCompartmentView", - "_id": "AAAAAAF6qTP77PP7M3M=", - "_parent": { - "$ref": "AAAAAAF6qTP76/P0gDU=" - }, - "model": { - "$ref": "AAAAAAF6qTP76/PyhLY=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 272, - "top": 433, - "width": 51.919921875, - "height": 10 - }, - { - "_type": "UMLReceptionCompartmentView", - "_id": "AAAAAAF6qTP77PP8V98=", - "_parent": { - "$ref": "AAAAAAF6qTP76/P0gDU=" - }, - "model": { - "$ref": "AAAAAAF6qTP76/PyhLY=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -320, - "top": -80, - "width": 10, - "height": 10 - }, - { - "_type": "UMLTemplateParameterCompartmentView", - "_id": "AAAAAAF6qTP77PP9Yu0=", - "_parent": { - "$ref": "AAAAAAF6qTP76/P0gDU=" - }, - "model": { - "$ref": "AAAAAAF6qTP76/PyhLY=" - }, - "visible": false, - "font": "Arial;13;0", - "left": -320, - "top": -80, - "width": 10, - "height": 10 - } - ], - "containerView": { - "$ref": "AAAAAAF4bgAZMqaVouk=" - }, - "font": "Arial;13;0", - "containerChangeable": true, - "left": 448, - "top": 440, - "width": 129, - "height": 25, - "nameCompartment": { - "$ref": "AAAAAAF6qTP76/P1n+E=" - }, - "suppressAttributes": true, - "suppressOperations": true, - "attributeCompartment": { - "$ref": "AAAAAAF6qTP77PP6Oh4=" - }, - "operationCompartment": { - "$ref": "AAAAAAF6qTP77PP7M3M=" - }, - "receptionCompartment": { - "$ref": "AAAAAAF6qTP77PP8V98=" - }, - "templateParameterCompartment": { - "$ref": "AAAAAAF6qTP77PP9Yu0=" - } - }, - { - "_type": "UMLGeneralizationView", - "_id": "AAAAAAF6qTyvOSFOwk8=", - "_parent": { - "$ref": "AAAAAAFF+qBtyKM79qY=" - }, - "model": { - "$ref": "AAAAAAF6qTyvOSFM/J8=" - }, - "subViews": [ - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qTyvOSFPeCU=", - "_parent": { - "$ref": "AAAAAAF6qTyvOSFOwk8=" - }, - "model": { - "$ref": "AAAAAAF6qTyvOSFM/J8=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 393, - "top": 445, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF6qTyvOSFOwk8=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qTyvOSFQ/mA=", - "_parent": { - "$ref": "AAAAAAF6qTyvOSFOwk8=" - }, - "model": { - "$ref": "AAAAAAF6qTyvOSFM/J8=" - }, - "visible": null, - "font": "Arial;13;0", - "left": 378, - "top": 445, - "height": 13, - "alpha": 1.5707963267948966, - "distance": 30, - "hostEdge": { - "$ref": "AAAAAAF6qTyvOSFOwk8=" - }, - "edgePosition": 1 - }, - { - "_type": "EdgeLabelView", - "_id": "AAAAAAF6qTyvOSFRg68=", - "_parent": { - "$ref": "AAAAAAF6qTyvOSFOwk8=" - }, - "model": { - "$ref": "AAAAAAF6qTyvOSFM/J8=" - }, - "visible": false, - "font": "Arial;13;0", - "left": 422, - "top": 446, - "height": 13, - "alpha": -1.5707963267948966, - "distance": 15, - "hostEdge": { - "$ref": "AAAAAAF6qTyvOSFOwk8=" - }, - "edgePosition": 1 - } - ], - "font": "Arial;13;0", - "head": { - "$ref": "AAAAAAF6qThnCAOTYh0=" - }, - "tail": { - "$ref": "AAAAAAF4bgMKEKcwhN4=" - }, - "points": "408:480;408:424", - "showVisibility": true, - "nameLabel": { - "$ref": "AAAAAAF6qTyvOSFPeCU=" - }, - "stereotypeLabel": { - "$ref": "AAAAAAF6qTyvOSFQ/mA=" - }, - "propertyLabel": { - "$ref": "AAAAAAF6qTyvOSFRg68=" - } - } - ] - }, - { - "_type": "UMLClass", - "_id": "AAAAAAF4bf+vZqZDGs0=", - "_parent": { - "$ref": "AAAAAAFF+qBWK6M3Z8Y=" - }, - "name": "Class1" - }, - { - "_type": "UMLPackage", - "_id": "AAAAAAF4bf/wKqZsitI=", - "_parent": { - "$ref": "AAAAAAFF+qBWK6M3Z8Y=" - }, - "name": "httpcl", - "ownedElements": [ - { - "_type": "UMLClass", - "_id": "AAAAAAF4bgMKEKcupgk=", - "_parent": { - "$ref": "AAAAAAF4bf/wKqZsitI=" - }, - "name": "HttpLibHttpClient", - "ownedElements": [ - { - "_type": "UMLAssociation", - "_id": "AAAAAAF4bhPkPawtqHk=", - "_parent": { - "$ref": "AAAAAAF4bgMKEKcupgk=" - }, - "end1": { - "_type": "UMLAssociationEnd", - "_id": "AAAAAAF4bhPkPqwuZBo=", - "_parent": { - "$ref": "AAAAAAF4bhPkPawtqHk=" - }, - "reference": { - "$ref": "AAAAAAF6qThnCAORKcU=" - } - }, - "end2": { - "_type": "UMLAssociationEnd", - "_id": "AAAAAAF4bhPkPqwvhGs=", - "_parent": { - "$ref": "AAAAAAF4bhPkPawtqHk=" - }, - "reference": { - "$ref": "AAAAAAF4bgc7NqglZeE=" - }, - "aggregation": "composite" - } - }, - { - "_type": "UMLDependency", - "_id": "AAAAAAF4bhZM3bSQXaU=", - "_parent": { - "$ref": "AAAAAAF4bgMKEKcupgk=" - }, - "source": { - "$ref": "AAAAAAF4bgMKEKcupgk=" - }, - "target": { - "$ref": "AAAAAAF4bgwBvKkmSIc=" - } - }, - { - "_type": "UMLDependency", - "_id": "AAAAAAF6qS8bIt+HF/0=", - "_parent": { - "$ref": "AAAAAAF4bgMKEKcupgk=" - }, - "source": { - "$ref": "AAAAAAF4bgMKEKcupgk=" - }, - "target": { - "$ref": "AAAAAAF6qTP76/PyhLY=" - } - }, - { - "_type": "UMLGeneralization", - "_id": "AAAAAAF6qTk+zQeyD34=", - "_parent": { - "$ref": "AAAAAAF4bgMKEKcupgk=" - }, - "source": { - "$ref": "AAAAAAF4bgMKEKcupgk=" - }, - "target": { - "$ref": "AAAAAAF6qThnCAORKcU=" - } - }, - { - "_type": "UMLGeneralization", - "_id": "AAAAAAF6qTyvOSFM/J8=", - "_parent": { - "$ref": "AAAAAAF4bgMKEKcupgk=" - }, - "source": { - "$ref": "AAAAAAF4bgMKEKcupgk=" - }, - "target": { - "$ref": "AAAAAAF6qThnCAORKcU=" - } - } - ] - }, - { - "_type": "UMLClass", - "_id": "AAAAAAF4bgPmmKdgqXA=", - "_parent": { - "$ref": "AAAAAAF4bf/wKqZsitI=" - }, - "name": "Settings", - "ownedElements": [ - { - "_type": "UMLAssociation", - "_id": "AAAAAAF4bhQY0a0zUkY=", - "_parent": { - "$ref": "AAAAAAF4bgPmmKdgqXA=" - }, - "end1": { - "_type": "UMLAssociationEnd", - "_id": "AAAAAAF4bhQY0a00jZQ=", - "_parent": { - "$ref": "AAAAAAF4bhQY0a0zUkY=" - }, - "reference": { - "$ref": "AAAAAAF4bgPmmKdgqXA=" - } - }, - "end2": { - "_type": "UMLAssociationEnd", - "_id": "AAAAAAF4bhQY0a01ku4=", - "_parent": { - "$ref": "AAAAAAF4bhQY0a0zUkY=" - }, - "reference": { - "$ref": "AAAAAAF4bgMKEKcupgk=" - }, - "aggregation": "composite" - } - }, - { - "_type": "UMLDependency", - "_id": "AAAAAAF4bhYT6rPJUxA=", - "_parent": { - "$ref": "AAAAAAF4bgPmmKdgqXA=" - }, - "source": { - "$ref": "AAAAAAF4bgPmmKdgqXA=" - }, - "target": { - "$ref": "AAAAAAF4bgwwp6lAGRw=" - } - }, - { - "_type": "UMLAssociation", - "_id": "AAAAAAF6qS/WZOSPeYI=", - "_parent": { - "$ref": "AAAAAAF4bgPmmKdgqXA=" - }, - "end1": { - "_type": "UMLAssociationEnd", - "_id": "AAAAAAF6qS/WZOSQBj4=", - "_parent": { - "$ref": "AAAAAAF6qS/WZOSPeYI=" - }, - "reference": { - "$ref": "AAAAAAF4bgPmmKdgqXA=" - } - }, - "end2": { - "_type": "UMLAssociationEnd", - "_id": "AAAAAAF6qS/WZOSRcq8=", - "_parent": { - "$ref": "AAAAAAF6qS/WZOSPeYI=" - }, - "reference": { - "$ref": "AAAAAAF4bgc7NqglZeE=" - }, - "aggregation": "composite" - } - } - ] - }, - { - "_type": "UMLClass", - "_id": "AAAAAAF6qThnCAORKcU=", - "_parent": { - "$ref": "AAAAAAF4bf/wKqZsitI=" - }, - "name": "IHttpClient" - }, - { - "_type": "UMLClass", - "_id": "AAAAAAF6qTP76/PyhLY=", - "_parent": { - "$ref": "AAAAAAF4bf/wKqZsitI=" - }, - "name": "Config", - "ownedElements": [ - { - "_type": "UMLAssociation", - "_id": "AAAAAAF6qTVEuvjpxVg=", - "_parent": { - "$ref": "AAAAAAF6qTP76/PyhLY=" - }, - "end1": { - "_type": "UMLAssociationEnd", - "_id": "AAAAAAF6qTVEuvjqBH4=", - "_parent": { - "$ref": "AAAAAAF6qTVEuvjpxVg=" - }, - "reference": { - "$ref": "AAAAAAF4bgPmmKdgqXA=" - } - }, - "end2": { - "_type": "UMLAssociationEnd", - "_id": "AAAAAAF6qTVEuvjreqY=", - "_parent": { - "$ref": "AAAAAAF6qTVEuvjpxVg=" - }, - "reference": { - "$ref": "AAAAAAF6qTP76/PyhLY=" - }, - "aggregation": "composite" - } - } - ] - } - ] - }, - { - "_type": "UMLPackage", - "_id": "AAAAAAF4bgIuhKbdLYk=", - "_parent": { - "$ref": "AAAAAAFF+qBWK6M3Z8Y=" - }, - "name": "zswagcl", - "ownedElements": [ - { - "_type": "UMLClass", - "_id": "AAAAAAF4bgc7NqglZeE=", - "_parent": { - "$ref": "AAAAAAF4bgIuhKbdLYk=" - }, - "name": "OpenApiClient", - "ownedElements": [ - { - "_type": "UMLAssociation", - "_id": "AAAAAAF4bhMv1aprKh8=", - "_parent": { - "$ref": "AAAAAAF4bgc7NqglZeE=" - }, - "end1": { - "_type": "UMLAssociationEnd", - "_id": "AAAAAAF4bhMv1apsTW0=", - "_parent": { - "$ref": "AAAAAAF4bhMv1aprKh8=" - }, - "reference": { - "$ref": "AAAAAAF4bgc7NqglZeE=" - } - }, - "end2": { - "_type": "UMLAssociationEnd", - "_id": "AAAAAAF4bhMv1apt2jw=", - "_parent": { - "$ref": "AAAAAAF4bhMv1aprKh8=" - }, - "reference": { - "$ref": "AAAAAAF4bge2pKhT/bw=" - }, - "aggregation": "composite" - } - }, - { - "_type": "UMLAssociation", - "_id": "AAAAAAF4biACh9VH9Vs=", - "_parent": { - "$ref": "AAAAAAF4bgc7NqglZeE=" - }, - "end1": { - "_type": "UMLAssociationEnd", - "_id": "AAAAAAF4biACh9VIum0=", - "_parent": { - "$ref": "AAAAAAF4biACh9VH9Vs=" - }, - "reference": { - "$ref": "AAAAAAF4bgc7NqglZeE=" - } - }, - "end2": { - "_type": "UMLAssociationEnd", - "_id": "AAAAAAF4biACh9VJOno=", - "_parent": { - "$ref": "AAAAAAF4biACh9VH9Vs=" - }, - "reference": { - "$ref": "AAAAAAF4bgRJ5aeQAJc=" - }, - "aggregation": "composite" - } - }, - { - "_type": "UMLDependency", - "_id": "AAAAAAF6qTY1Ivwi96g=", - "_parent": { - "$ref": "AAAAAAF4bgc7NqglZeE=" - }, - "source": { - "$ref": "AAAAAAF4bgc7NqglZeE=" - }, - "target": { - "$ref": "AAAAAAF6qTP76/PyhLY=" - } - } - ] - }, - { - "_type": "UMLClass", - "_id": "AAAAAAF4bge2pKhT/bw=", - "_parent": { - "$ref": "AAAAAAF4bgIuhKbdLYk=" - }, - "name": "OAClient", - "ownedElements": [ - { - "_type": "UMLDependency", - "_id": "AAAAAAF4bhVg5LD5DiU=", - "_parent": { - "$ref": "AAAAAAF4bge2pKhT/bw=" - }, - "source": { - "$ref": "AAAAAAF4bge2pKhT/bw=" - }, - "target": { - "$ref": "AAAAAAF4bhSO3K5C/vg=" - } - }, - { - "_type": "UMLDependency", - "_id": "AAAAAAF4bjI7OU1o+pM=", - "_parent": { - "$ref": "AAAAAAF4bge2pKhT/bw=" - }, - "source": { - "$ref": "AAAAAAF4bge2pKhT/bw=" - }, - "target": { - "$ref": "AAAAAAF4bjBw30GmDwg=" - } - } - ] - }, - { - "_type": "UMLClass", - "_id": "AAAAAAF4bgoCwKiTY0c=", - "_parent": { - "$ref": "AAAAAAF4bgIuhKbdLYk=" - }, - "name": "OpenApiConfig", - "ownedElements": [ - { - "_type": "UMLAssociation", - "_id": "AAAAAAF4bhN/16reWD0=", - "_parent": { - "$ref": "AAAAAAF4bgoCwKiTY0c=" - }, - "end1": { - "_type": "UMLAssociationEnd", - "_id": "AAAAAAF4bhN/2KrfQQY=", - "_parent": { - "$ref": "AAAAAAF4bhN/16reWD0=" - }, - "reference": { - "$ref": "AAAAAAF4bgoCwKiTY0c=" - } - }, - "end2": { - "_type": "UMLAssociationEnd", - "_id": "AAAAAAF4bhN/2KrgWIM=", - "_parent": { - "$ref": "AAAAAAF4bhN/16reWD0=" - }, - "reference": { - "$ref": "AAAAAAF4bgc7NqglZeE=" - }, - "aggregation": "composite" - } - }, - { - "_type": "UMLAssociation", - "_id": "AAAAAAF4biAs99ZO+5U=", - "_parent": { - "$ref": "AAAAAAF4bgoCwKiTY0c=" - }, - "end1": { - "_type": "UMLAssociationEnd", - "_id": "AAAAAAF4biAs99ZP7b4=", - "_parent": { - "$ref": "AAAAAAF4biAs99ZO+5U=" - }, - "reference": { - "$ref": "AAAAAAF4bgoCwKiTY0c=" - } - }, - "end2": { - "_type": "UMLAssociationEnd", - "_id": "AAAAAAF4biAs99ZQ5CI=", - "_parent": { - "$ref": "AAAAAAF4biAs99ZO+5U=" - }, - "reference": { - "$ref": "AAAAAAF4bgSxm6e+DdM=" - }, - "aggregation": "composite" - } - } - ] - }, - { - "_type": "UMLPackage", - "_id": "AAAAAAF4bhSO3K5C/vg=", - "_parent": { - "$ref": "AAAAAAF4bgIuhKbdLYk=" - }, - "name": "zserio" - } - ] - }, - { - "_type": "UMLPackage", - "_id": "AAAAAAF4bgKNKab5WDU=", - "_parent": { - "$ref": "AAAAAAFF+qBWK6M3Z8Y=" - }, - "name": "pyzswagcl", - "ownedElements": [ - { - "_type": "UMLClass", - "_id": "AAAAAAF4bgSxm6e+DdM=", - "_parent": { - "$ref": "AAAAAAF4bgKNKab5WDU=" - }, - "name": "OAConfig", - "ownedElements": [ - { - "_type": "UMLAssociation", - "_id": "AAAAAAF4biHeFN9N1N8=", - "_parent": { - "$ref": "AAAAAAF4bgSxm6e+DdM=" - }, - "end1": { - "_type": "UMLAssociationEnd", - "_id": "AAAAAAF4biHeFN9OHMk=", - "_parent": { - "$ref": "AAAAAAF4biHeFN9N1N8=" - }, - "reference": { - "$ref": "AAAAAAF4bgSxm6e+DdM=" - } - }, - "end2": { - "_type": "UMLAssociationEnd", - "_id": "AAAAAAF4biHeFN9PVzM=", - "_parent": { - "$ref": "AAAAAAF4biHeFN9N1N8=" - }, - "reference": { - "$ref": "AAAAAAF4bgYiBafyU4A=" - }, - "aggregation": "composite" - } - } - ] - }, - { - "_type": "UMLDependency", - "_id": "AAAAAAF4bh9tG9KRfNM=", - "_parent": { - "$ref": "AAAAAAF4bgKNKab5WDU=" - }, - "source": { - "$ref": "AAAAAAF4bgKNKab5WDU=" - }, - "target": { - "$ref": "AAAAAAF4bg3JFKmP9aY=" - } - }, - { - "_type": "UMLDependency", - "_id": "AAAAAAF4biVV3/XuI+s=", - "_parent": { - "$ref": "AAAAAAF4bgKNKab5WDU=" - }, - "source": { - "$ref": "AAAAAAF4bgKNKab5WDU=" - }, - "target": { - "$ref": "AAAAAAF4bg3JFKmP9aY=" - } - } - ] - }, - { - "_type": "UMLPackage", - "_id": "AAAAAAF4bgLBtacTlEc=", - "_parent": { - "$ref": "AAAAAAFF+qBWK6M3Z8Y=" - }, - "name": "zswag", - "ownedElements": [ - { - "_type": "UMLClass", - "_id": "AAAAAAF4bgYiBafyU4A=", - "_parent": { - "$ref": "AAAAAAF4bgLBtacTlEc=" - }, - "name": "OAServer", - "ownedElements": [ - { - "_type": "UMLDependency", - "_id": "AAAAAAF4biZDaP+7N0U=", - "_parent": { - "$ref": "AAAAAAF4bgYiBafyU4A=" - }, - "source": { - "$ref": "AAAAAAF4bgYiBafyU4A=" - }, - "target": { - "$ref": "AAAAAAF4biS/lPNG5z8=" - } - }, - { - "_type": "UMLDependency", - "_id": "AAAAAAF4bibcyQODLBE=", - "_parent": { - "$ref": "AAAAAAF4bgYiBafyU4A=" - }, - "source": { - "$ref": "AAAAAAF4bgYiBafyU4A=" - }, - "target": { - "$ref": "AAAAAAF4bgtpE6juStg=" - } - }, - { - "_type": "UMLDependency", - "_id": "AAAAAAF4bib9vQT7pUQ=", - "_parent": { - "$ref": "AAAAAAF4bgYiBafyU4A=" - }, - "source": { - "$ref": "AAAAAAF4bgYiBafyU4A=" - }, - "target": { - "$ref": "AAAAAAF4bgz4walyB+M=" - } - } - ] - }, - { - "_type": "UMLPackage", - "_id": "AAAAAAF4bgtpE6juStg=", - "_parent": { - "$ref": "AAAAAAF4bgLBtacTlEc=" - }, - "name": "Flask" - }, - { - "_type": "UMLPackage", - "_id": "AAAAAAF4bg3JFKmP9aY=", - "_parent": { - "$ref": "AAAAAAF4bgLBtacTlEc=" - }, - "name": "pybind11" - }, - { - "_type": "UMLPackage", - "_id": "AAAAAAF4biS/lPNG5z8=", - "_parent": { - "$ref": "AAAAAAF4bgLBtacTlEc=" - }, - "name": "zserio PyPI package" - } - ] - }, - { - "_type": "UMLClass", - "_id": "AAAAAAF4bgRJ5aeQAJc=", - "_parent": { - "$ref": "AAAAAAFF+qBWK6M3Z8Y=" - }, - "name": "OAClient", - "ownedElements": [ - { - "_type": "UMLDependency", - "_id": "AAAAAAF4biXwU/yc5Hk=", - "_parent": { - "$ref": "AAAAAAF4bgRJ5aeQAJc=" - }, - "source": { - "$ref": "AAAAAAF4bgRJ5aeQAJc=" - }, - "target": { - "$ref": "AAAAAAF4biS/lPNG5z8=" - } - } - ] - }, - { - "_type": "UMLSubsystem", - "_id": "AAAAAAF4bgtJJ6jFRPw=", - "_parent": { - "$ref": "AAAAAAFF+qBWK6M3Z8Y=" - }, - "name": "Subsystem1" - }, - { - "_type": "UMLSubsystem", - "_id": "AAAAAAF4bgu0gqkLUm4=", - "_parent": { - "$ref": "AAAAAAFF+qBWK6M3Z8Y=" - }, - "name": "Connexion" - }, - { - "_type": "UMLPackage", - "_id": "AAAAAAF4bgwBvKkmSIc=", - "_parent": { - "$ref": "AAAAAAFF+qBWK6M3Z8Y=" - }, - "name": "cpp-httplib" - }, - { - "_type": "UMLPackage", - "_id": "AAAAAAF4bgwwp6lAGRw=", - "_parent": { - "$ref": "AAAAAAFF+qBWK6M3Z8Y=" - }, - "name": "keychain" - }, - { - "_type": "UMLPackage", - "_id": "AAAAAAF4bgz4walyB+M=", - "_parent": { - "$ref": "AAAAAAFF+qBWK6M3Z8Y=" - }, - "name": "Connexion" - }, - { - "_type": "UMLPackage", - "_id": "AAAAAAF4bg7rpKmurgA=", - "_parent": { - "$ref": "AAAAAAFF+qBWK6M3Z8Y=" - }, - "name": "Package1" - }, - { - "_type": "UMLClass", - "_id": "AAAAAAF4biSgyvHzDGY=", - "_parent": { - "$ref": "AAAAAAFF+qBWK6M3Z8Y=" - }, - "name": "zserio" - }, - { - "_type": "UMLPackage", - "_id": "AAAAAAF4bjBw30GmDwg=", - "_parent": { - "$ref": "AAAAAAFF+qBWK6M3Z8Y=" - }, - "name": "zserio-cpp-reflection" - } - ] - } - ] -} \ No newline at end of file diff --git a/doc/zswag-architecture.png b/doc/zswag-architecture.png deleted file mode 100644 index 40f48025..00000000 Binary files a/doc/zswag-architecture.png and /dev/null differ diff --git a/docs/cpp.md b/docs/cpp.md new file mode 100644 index 00000000..b3bd7168 --- /dev/null +++ b/docs/cpp.md @@ -0,0 +1,182 @@ +# C++ Client + +The C++ client talks to any zserio service exposed via OpenAPI/REST. The relevant types live in `libs/zswagcl/` (high-level `OAClient`, `OpenApiClient`, `OpenApiConfig`) and `libs/httpcl/` (HTTP wrapper around [cpp-httplib](https://github.com/yhirose/cpp-httplib) plus OS keychain integration via [`keychain`](https://github.com/hrantzsch/keychain)). + +## Requirements + +- **CMake ≥ 3.22.3** +- **C++17** compiler +- The zserio C++ generator must be invoked with `-withTypeInfoCode -withReflectionCode` so the runtime reflection on which `OAClient` depends is available. + +## Building + +zswag uses CMake's `FetchContent` for dependencies; the basic flow is: + +```bash +mkdir build && cd build +cmake .. +cmake --build . +``` + +For tests: + +```bash +ctest --verbose +``` + +For wheels (Python wheels for the `zswag`/`pyzswagcl` packages — these embed parts of the C++ stack): + +```bash +cmake -DZSWAG_BUILD_WHEELS=ON .. +cmake --build . +# Output under build/bin/wheel/ +``` + +The Python environment used at CMake configure time is the one wheels are built against. + +### Common build options + +| Option | Default | Effect | +|---|---|---| +| `ZSWAG_BUILD_WHEELS` | `ON` | Produces wheels under `build/bin/wheel/`. | +| `ZSWAG_KEYCHAIN_SUPPORT` | `ON` | Builds OS keychain support. Set OFF on systems without `libsecret`. | +| `ZSWAG_ENABLE_TESTING` | `ON` (when top-level) | Builds and registers tests. | +| `ZSWAG_ENABLE_COVERAGE` | `OFF` | Coverage targets — Debug build, `lcov` required. Scoped to `libs/httpcl` and `libs/zswagcl`. | +| `FETCHCONTENT_FULLY_DISCONNECTED=ON` | — | Offline build (pre-fetch online first). | + +For offline / disconnected builds: + +```bash +# 1. Fetch dependencies once while online: +mkdir build && cd build +cmake -DFETCHCONTENT_FULLY_DISCONNECTED=OFF .. + +# 2. Subsequent builds offline: +cmake -DFETCHCONTENT_FULLY_DISCONNECTED=ON .. +cmake --build . +``` + +Override individual deps with `-DFETCHCONTENT_SOURCE_DIR_=/path/to/local`. Available names: `ZLIB`, `SPDLOG`, `YAML_CPP`, `STX`, `SPEEDYJ`, `HTTPLIB`, `OPENSSL`, `PYBIND11`, `PYTHON_CMAKE_WHEEL`, `ZSERIO_CMAKE_HELPER`, `KEYCHAIN`, `CATCH2`. + +## Integrating into your project + +In your project's `CMakeLists.txt`: + +```cmake +project(myapp) + +# Optional knobs for building zswag inside your project: +# set(ZSWAG_BUILD_WHEELS OFF) # if you don't need Python wheels +# set(ZSWAG_KEYCHAIN_SUPPORT OFF) # if libsecret isn't available + +if (NOT TARGET zswag) + FetchContent_Declare(zswag + GIT_REPOSITORY "https://github.com/ndsev/zswag.git" + GIT_TAG "v1.14.0" + GIT_SHALLOW ON) + FetchContent_MakeAvailable(zswag) +endif() + +find_package(OpenSSL CONFIG REQUIRED) +target_link_libraries(httplib INTERFACE OpenSSL::SSL) + +# zswag provides this helper to build a zserio C++ reflection library: +add_zserio_library(${PROJECT_NAME}-zserio-cpp + WITH_REFLECTION + ROOT "${CMAKE_CURRENT_SOURCE_DIR}" + ENTRY services.zs + TOP_LEVEL_PKG myapp_services) + +add_executable(${PROJECT_NAME} client.cpp) +target_link_libraries(${PROJECT_NAME} + ${PROJECT_NAME}-zserio-cpp zswagcl) +``` + +Note: OpenSSL is assumed to be installed or built using the `lib` (not `lib64`) directory name. + +## Client usage + +```cpp +#include "zswagcl/oaclient.hpp" +#include +#include "myapp_services/services/MyService.h" + +using namespace zswagcl; +using namespace httpcl; +namespace MyService = myapp_services::services::MyService; + +int main(int argc, char* argv[]) +{ + auto openApiUrl = "http://localhost:5000/openapi.json"; + + // HTTP client to be used by OAClient + auto httpClient = std::make_unique(); + + // Fetch OpenAPI configuration + auto openApiConfig = fetchOpenAPIConfig(openApiUrl, *httpClient); + + // Build a zserio reflection-based OpenAPI transport + auto openApiClient = OAClient(openApiConfig, std::move(httpClient)); + + // Create the typed service client (zserio-generated) + auto myServiceClient = MyService::Client(openApiClient); + + // Make a typed call. Note: zserio C++ codegen suffixes method names with "Method". + auto request = myapp_services::services::Request(2); + auto response = myServiceClient.myApiMethod(request); + + std::cout << "Got " << response.getValue() << std::endl; +} +``` + +You can pass an adhoc `httpcl::Config` to `OAClient` (third argument) for per-instance headers, auth, and proxy: + +```cpp +#include "httpcl/http-settings.hpp" + +httpcl::Config adhoc; +adhoc.headers.insert({"X-Trace", "yes"}); +adhoc.auth = httpcl::Config::BasicAuthentication{"alice", "secret", ""}; + +auto openApiClient = OAClient(openApiConfig, std::move(httpClient), adhoc); +``` + +The adhoc config layers on top of the [persistent settings](../README.md#http-settings-file) loaded from `HTTP_SETTINGS_FILE`. + +## Code coverage + +[![codecov](https://codecov.io/github/ndsev/zswag/graph/badge.svg?token=5DTX2M8IDE)](https://codecov.io/github/ndsev/zswag) + +Coverage is automatically collected in CI and reported to [Codecov](https://codecov.io/gh/ndsev/zswag). Browsable HTML report at . + +Locally: + +```bash +mkdir build && cd build +cmake -DCMAKE_BUILD_TYPE=Debug \ + -DZSWAG_ENABLE_COVERAGE=ON \ + -DZSWAG_ENABLE_TESTING=ON \ + -DZSWAG_BUILD_WHEELS=OFF \ + -DZSWAG_KEYCHAIN_SUPPORT=OFF .. +cmake --build . + +ctest --output-on-failure +cmake --build . --target coverage-report +# HTML at build/coverage/html/index.html +``` + +Targets: `coverage-clean`, `coverage-report`, `coverage` (clean+test+report). + +If you hit "gcov not found" warnings, symlink the versioned binary: + +```bash +sudo ln -s /usr/bin/gcov-13 /usr/bin/gcov +``` + +## Persistent HTTP settings + +See [HTTP Settings File in README.md](../README.md#http-settings-file). `HttpLibHttpClient` auto-loads `HTTP_SETTINGS_FILE` on construction and applies it per-request based on URL scope matching. + +## OpenAPI feature support + +See [the interop matrix in README.md](../README.md#openapi-options-interoperability) for the full ✅/❌ table. diff --git a/docs/java.md b/docs/java.md new file mode 100644 index 00000000..7ccab6ea --- /dev/null +++ b/docs/java.md @@ -0,0 +1,263 @@ +# Java Client + +The Java port of the zswag client ships in two flavours: **JVM** (`jzswag-jvm`, for servers / desktops / CLIs / lambdas) and **Android** (`jzswag-android`). Both implement zserio's `ServiceClientInterface`, so a zserio-Java-generated `XClient` accepts either one as its transport — the same idiom as Python's `services.MyService.Client(OAClient(url))`. + +## Modules + +| Module | Role | +|---|---| +| `jzswag-api` | Platform-agnostic contracts: `HttpConfig`, `HttpSettings`, `OpenAPIParameter`, `SecurityScheme`, `IHttpClient`, `IKeychain`. No third-party dependencies. | +| `jzswag-shared` | Portable core: `OpenApiClient` (request decomposition + dispatch), `OpenAPIParser`, `ParameterEncoder`, `OAuth2Handler`, `OAuth1Signature` (RFC 5849 HMAC-SHA256 token-endpoint auth), `HttpSettingsLoader`. Used by both platform modules. | +| `jzswag-jvm` | JVM platform module on top of the JDK 11 `HttpClient`. Provides `OAClient`, `JvmHttpClient`, `Keychain` (Linux `secret-tool` / macOS `security`). | +| `jzswag-android` | Android platform module on top of OkHttp. Provides `OAClient`, `AndroidHttpClient`, `AndroidKeychain` (Android Keystore + AES-GCM-encrypted SharedPreferences). | +| `jzswag-test` | Cross-stack integration tests (Java client ↔ Python Calculator server). | + +## Requirements + +- **Java 11+** (source/target) +- **Gradle 7+** (the wrapper is committed) +- The zserio Java generator must run on your service's `.zs` files. No special flags required — zswag uses POJO getter reflection on the generated classes, not zserio's `withReflectionCode` / `withTypeInfoCode` (which zserio-Java doesn't yet expose at runtime). + +## Quick start + +```bash +./gradlew :libs:jzswag:jzswag-jvm:build # or :libs:jzswag:jzswag-android:build +``` + +In your project, depend on the platform module that matches your target: + +```gradle +dependencies { + // JVM (server / desktop / CLI / lambda) + implementation project(':libs:jzswag:jzswag-jvm') + + // OR — Android + implementation project(':libs:jzswag:jzswag-android') + + implementation "io.github.ndsev:zserio-runtime:2.16.1" +} +``` + +The platform module pulls in `jzswag-shared` and `jzswag-api` transitively. Both platforms expose the same `OAClient` API; on Android the constructor takes a `Context` so that `AndroidKeychain` can reach `SharedPreferences`. + +(Until artifacts are published to Maven Central, depend on the source modules.) + +## The canonical idiom + +Given a zserio service like: + +``` +package services; + +struct Request { int32 value; }; +struct Response { int32 value; }; + +service MyService { + Response myApi(Request); +}; +``` + +Run zserio-Java codegen on `services.zs`, then: + +```java +// JVM +import io.github.ndsev.zswag.jvm.OAClient; +import services.MyService; + +OAClient transport = new OAClient("http://localhost:5000/openapi.json"); +MyService.MyServiceClient client = new MyService.MyServiceClient(transport); + +Response r = client.myApiMethod(new Request(42)); +``` + +```java +// Android — same idiom, plus a Context for AndroidKeychain +import io.github.ndsev.zswag.android.OAClient; +import services.MyService; + +OAClient transport = new OAClient(context, "http://localhost:5000/openapi.json"); +MyService.MyServiceClient client = new MyService.MyServiceClient(transport); + +Response r = client.myApiMethod(new Request(42)); +``` + +`OAClient` implements `zserio.runtime.service.ServiceClientInterface`. The zserio-generated `XClient` constructor (in this case `MyServiceClient`) accepts that interface, so the wiring is symmetric with Python's `MyService.Client(OAClient(url))` and C++'s `MyService::Client(openApiClient)`. + +## Configuration model + +Two types describe HTTP configuration: + +- **`HttpConfig`** — per-request adhoc config: extra headers, query parameters, cookies, basic-auth, proxy, OAuth2, API key. Mirrors C++ `httpcl::Config` and Python `HTTPConfig`. Immutable; build via `HttpConfig.builder()`. +- **`HttpSettings`** — multi-scope persistent registry, loaded from `HTTP_SETTINGS_FILE`. Each entry has a URL scope (glob pattern); for a given request URL, all matching entries are merged into one effective `HttpConfig`. Mirrors C++ `httpcl::Settings`. + +The merge rule on a request: `effective = persistentSettings.forUrl(url) | adhocConfig`. Multi-valued fields (headers, query) union; scalar fields (auth, proxy, oauth2, apiKey) take from the right-hand operand if present. + +## Persistent HTTP settings + +Set the environment variable `HTTP_SETTINGS_FILE` to point at a YAML file in the format documented in [HTTP Settings File](../README.md#http-settings-file) in the README. The file format is shared with the Python and C++ clients — the same file works for all three. + +```yaml +http-settings: + - scope: https://*.api.example.com/* + basic-auth: + user: alice + keychain: example-api-secret + headers: + X-Trace: enabled + + - scope: "https://*.dev.example.com/*" + oauth2: + clientId: my-client-id + clientSecretKeychain: dev-oauth-secret + tokenUrl: https://issuer.example.com/oauth/token + scope: ["api.read", "api.write"] +``` + +Settings are loaded automatically on `JvmHttpClient` construction: + +```java +OAClient transport = new OAClient(specUrl); // reads HTTP_SETTINGS_FILE +``` + +To pass an explicit settings registry: + +```java +HttpSettings settings = HttpSettingsLoader.loadFromFile(Paths.get("custom.yaml")); +OAClient transport = new OAClient(specUrl, settings); +``` + +To layer a per-instance adhoc config on top: + +```java +HttpConfig adhoc = HttpConfig.builder() + .header("X-Request-Id", UUID.randomUUID().toString()) + .build(); +OAClient transport = new OAClient(specUrl, settings, adhoc); +``` + +## Authentication + +zswag honours the `securitySchemes` declared in the OpenAPI spec. The relevant credentials must be present in the merged config (persistent + adhoc); otherwise the dispatch throws a descriptive `HttpException` before sending the request. + +| Scheme type | Configure via | +|---|---| +| HTTP Basic | `HttpConfig.basicAuth(user, password)` or `basic-auth` in YAML (with `password` or `keychain`) | +| HTTP Bearer | `HttpConfig.bearerToken(token)` (sets `Authorization: Bearer …`) | +| API key in header | API-key in YAML with the scheme's matching name; auto-routed to the right header | +| API key in cookie | Same — auto-routed into the `Cookie` header | +| API key in query | Same — auto-routed into the URL query | +| OAuth2 (client credentials) | YAML `oauth2:` block — see below | + +### OAuth2 + +zswag supports the OAuth2 `clientCredentials` flow only (matching C++/Python). Other flows in the spec are rejected at parse time. + +```yaml +http-settings: + - scope: https://api.example.com/* + oauth2: + clientId: my-client + clientSecretKeychain: my-oauth-secret # OR clientSecret: cleartext + tokenUrl: https://issuer.example.com/oauth/token # overrides spec value + audience: https://api.example.com/ # optional (some providers require) + scope: ["read", "write"] # overrides per-operation spec scopes + useForSpecFetch: true # acquire token before fetching openapi.json (default) + tokenEndpointAuth: + method: rfc6749-client-secret-basic # or rfc5849-oauth1-signature + nonceLength: 16 # for OAuth1 signature (8..64) +``` + +The handler caches tokens in-process keyed by `(tokenUrl, clientId, audience, scopeKey)`, refreshes via `refresh_token` when present, and falls back to a fresh mint if refresh fails. + +For the OAuth1-signature variant, the request to the token endpoint is signed with HMAC-SHA256 per RFC 5849 (used by some providers that require signed token requests). + +For public clients (no client secret), simply omit `clientSecret` and `clientSecretKeychain`. The `client_id` is then sent in the request body. + +### Keychain + +To store credentials in the OS keychain rather than cleartext: + +- **Linux**: store with `secret-tool store --label='zswag dev secret' package lib.openapi.zserio.client service my-service user my-user`, reference as `keychain: my-service`. +- **macOS**: store with `security add-generic-password -s my-service -a my-user -w 'thepassword'`, reference as `keychain: my-service`. +- **Windows**: keychain lookup is **not yet implemented** in the Java client (C++ / Python clients DO support it via the underlying `keychain` C library + DPAPI). Workaround: cleartext `password:` in `http-settings.yaml`, or `HttpConfig.basicAuth(user, password)` adhoc. + +Keychain lookups happen lazily when the request is dispatched. Failures (tool missing on PATH, no entry, timeout) raise `KeychainException` with a clear message. + +## How request decomposition works + +zswag's defining feature is the `x-zserio-request-part` extension: each OpenAPI parameter declares which field of the zserio request it carries (e.g. `base.value`, or `*` for the whole serialized blob). On dispatch, `OAClient`: + +1. Looks up the OpenAPI operation by `methodName`. +2. For each declared parameter, resolves its `x-zserio-request-part` path against the typed zserio request via JavaBean getter reflection (`getBase().getValue()`). zserio enums are unwrapped to their numeric value via `ZserioEnum.getGenericValue()`. +3. Encodes each value per the parameter's `format` (string/hex/base64/base64url/byte/binary) and `style`/`explode`, into path / query / header / cookie. +4. If the operation declares an `application/x-zserio-object` request body, serializes the whole request via `Writer.write(BitStreamWriter)`. +5. Applies the `Authorization` header, cookies, and query keys driven by the operation's `security:` requirements. +6. Sends the request; expects HTTP 200 (strict); deserializes the response via the zserio-generated client. + +zserio Java field naming matters here: a `.zs` field `enum_value` becomes `getEnumValue()` in Java; the reflection layer normalises snake_case → lowerCamel automatically. If your zserio source uses unconventional naming, verify the `x-zserio-request-part` paths resolve via `ZserioReflection.toGetterName(...)`. + +## Environment variables + +| Variable | Effect | +|---|---| +| `HTTP_SETTINGS_FILE` | Path to YAML settings file. Empty/unset → no persistent config. Hot-reloaded on mtime change. | +| `HTTP_TIMEOUT` | Request connection+transfer timeout in seconds. Default `60`. | +| `HTTP_SSL_STRICT` | Any non-empty value enables strict SSL certificate validation (matches C++/Python). Unset or empty disables. Surprising consequence: `HTTP_SSL_STRICT=0` enables (any non-empty does). | +| `HTTP_LOG_LEVEL` | `debug` / `trace` for OAuth2 flow logging. Maps to logback root level. | +| `HTTP_LOG_FILE` | Logfile path. The Java client attaches a logback `RollingFileAppender` to the root logger with a 3-file window (`FILE`, `FILE-1`, `FILE-2`), matching C++. | +| `HTTP_LOG_FILE_MAXSIZE` | Rotation size in bytes (default 1 GB, matches C++). | + +## Error handling + +Non-200 responses raise `HttpException` carrying the status code, response body, and a context string with method + URL. Connection failures and timeouts also surface as `HttpException`. + +Strict 200 matches C++; if your service uses 204 or 206 successfully, catch `HttpException` and inspect `getStatusCode()`. + +## Code coverage + +[![codecov](https://codecov.io/github/ndsev/zswag/graph/badge.svg?token=5DTX2M8IDE)](https://codecov.io/github/ndsev/zswag) + +JaCoCo runs per module on every CI build. Coverage is uploaded to [Codecov](https://codecov.io/gh/ndsev/zswag) under flag `unittests-java`. Browsable HTML reports per module at . + +## OpenAPI feature support + +The Java client matches the C++/Python clients in feature coverage. See [the interop matrix in README.md](../README.md#openapi-options-interoperability) for the exhaustive ✅/❌ table across all clients. + +Highlights: +- HTTP `GET`, `POST`, `PUT`, `DELETE` (no `PATCH` — design constraint, applies to all zswag clients). +- All `x-zserio-request-part` forms: whole-blob (`*`), scalar, array. Compound `x-zserio-request-part` is unsupported by all clients. +- All formats: `string`, `byte`, `base64`, `base64url`, `hex`, `binary`. +- All array styles: `simple`, `label`, `matrix`, `form` × `explode: true|false`. +- Server URL base path resolution; multi-server selection via the `serverIndex` constructor parameter (matches C++ `OAClient(..., uint32_t serverIndex)` and Python `OAClient(..., server_index=N)`). +- All security schemes: HTTP Basic, HTTP Bearer, API key (cookie/header/query), OAuth2 client credentials with both `rfc6749-client-secret-basic` and `rfc5849-oauth1-signature` token-endpoint auth. OpenID Connect is not supported (unsupported across all zswag clients). + +## Running the integration test + +```bash +# 1. Install the Python wheel for the test server (any zswag release works) +python3 -m venv .venv && source .venv/bin/activate +pip install zswag + +# 2. Run the test harness +./libs/jzswag/jzswag-test/test-java-client.bash +``` + +The script starts the Python `zswag.test.calc` server on port 5555, builds the Java client, and runs `CalculatorTestClient` end-to-end. All 10 tests should pass. + +## Troubleshooting + +**`zswag.test.calc` not found**: install the Python wheel into your active venv (`pip install zswag`) — the integration test depends on it as the counterparty server. + +**Gradle wrapper missing**: bootstrap with `gradle wrapper --gradle-version 9.2.1`. The repo currently includes `gradle-wrapper.properties` only. + +**`Required parameter ... resolved to null via x-zserio-request-part`**: the path in the OpenAPI spec doesn't resolve to a non-null field on the zserio request object. Check that the field name matches (snake_case in the OpenAPI side maps to lowerCamel via `getXxx`). + +**`OAuth2 client-credentials: tokenUrl is missing in spec and http-settings`**: the spec didn't declare `flows.clientCredentials.tokenUrl` AND your `http-settings.yaml` doesn't override `oauth2.tokenUrl`. Provide one. + +**`keychain: 'secret-tool' is not installed or not on PATH`**: install `libsecret-tools` (Linux) or use cleartext `password:` for non-production setups. + +## Looking deeper + +- [HTTP Settings File in README.md](../README.md#http-settings-file) — full spec of the HTTP_SETTINGS_FILE YAML format, shared with Python and C++ clients. +- [`../libs/jzswag/jzswag-test/src/main/java/com/ndsev/zswag/test/CalculatorTestClient.java`](../libs/jzswag/jzswag-test/src/main/java/com/ndsev/zswag/test/CalculatorTestClient.java) — exhaustive working examples covering each parameter style, format, and authentication scheme. +- [`../libs/zswag/test/calc/api.yaml`](../libs/zswag/test/calc/api.yaml) — the OpenAPI spec the integration test uses; useful reference for what `x-zserio-request-part` looks like in practice. diff --git a/docs/openapi-generator.md b/docs/openapi-generator.md new file mode 100644 index 00000000..ff5014f5 --- /dev/null +++ b/docs/openapi-generator.md @@ -0,0 +1,179 @@ +# OpenAPI Generator (`zswag.gen`) + +After installing `zswag` (see [`python.md`](python.md)), the command `python -m zswag.gen` generates an OpenAPI YAML from a zserio service definition. The generated YAML is what `OAServer` serves and what all zswag clients consume. + +## Synopsis + +``` +usage: Zserio OpenApi YAML Generator [-h] -s service-identifier -i zserio-or-python-path + [-r zserio-src-root-dir] + [-p top-level-package] + [-c tags [tags ...]] + [-o output] + [-b BASE_CONFIG_YAML] +``` + +## Options + +### `-s` / `--service` (required) + +Fully qualified zserio service identifier. + +``` +-s my.package.ServiceClass +``` + +### `-i` / `--input` (required) + +Either: + +- **(A)** Path to a zserio `.zs` file. Must be either a top-level entrypoint (e.g. `all.zs`) or a subpackage (e.g. `services/myservice.zs`) used together with `--zserio-source-root|-r `. +- **(B)** Path to the parent dir of a zserio Python package. + +``` +-i path/to/schema/main.zs # (A) +-i path/to/python/package/parent # (B) +``` + +### `-r` / `--zserio-source-root` + +When `-i` specifies a `.zs` file (Option A), indicates the directory passed to zserio's `-src` flag. Defaults to the parent dir of the given file. + +### `-p` / `--package` + +When `-i` specifies a `.zs` file (Option A), indicates a specific top-level zserio package name. + +``` +-p zserio_pkg_name +``` + +### `-c` / `--config` + +Configuration tags. Syntax: + +``` +[(service-method-name):](comma-separated-tags) +``` + +Supported tags: + +| Tag | Effect | +|---|---| +| `get` `put` `post` `delete` | HTTP method (default: `post`). | +| `query` `path` `header` `body` | Parameter location (default: `query` for `get`, `body` otherwise). | +| `flat` `blob` | Flatten the request object into its scalar fields, OR pass it whole as a blob. | +| `(param-specifier)` | Specify name/format/location for a specific request part — see below. | +| `security=(name)` | Use a specific security scheme. The scheme details must be provided via `--base-config-yaml`. | +| `path=(method-path)` | Override the method path. May contain placeholders for path params. | + +A **param-specifier** has the schema: + +``` +(field?name=... + &in=[path|body|query|header] + &format=[binary|base64|hex] + [&style=...] + [&explode=...]) +``` + +Examples: + +```bash +# Expose all methods as POST, but getLayerByTileId as GET with flat path-parameters: +-c post getLayerByTileId:get,flat,path + +# For myMethod, put the whole request blob into a query "data" parameter as base64: +-c myMethod:*?name=data&in=query&format=base64 + +# For myMethod, set the "AwesomeAuth" auth scheme: +-c myMethod:security=AwesomeAuth + +# For myMethod, provide a path with a placeholder for myField: +-c 'myMethod:path=/my-method/{param}, myField?name=param&in=path&format=string' +``` + +Notes: + +- HTTP method defaults to `post`. +- `in` defaults to `query` for `get`, `body` otherwise. +- If a method uses a parameter specifier, the `flat`, `body`, `query`, `path`, `header`, and body tags are ignored. +- `flat` is meaningful only with `query` or `path`. +- An unspecific tag list (no method name) affects defaults only for following, not preceding, specialised assignments. + +### `-o` / `--output` + +Output file path. Defaults to stdout. + +### `-b` / `--base-config-yaml` + +Base YAML for fully or partially substituting `--config`, plus extra OpenAPI metadata. Schema: + +```yaml +method: # optional method tags dictionary + : +securitySchemes: ... # optional OpenAPI securitySchemes +info: ... # optional OpenAPI info section +servers: ... # optional OpenAPI servers section +security: ... # optional OpenAPI global security +``` + +## End-to-end example + +Given: + +``` +package services; + +struct Request { int32 value; }; +struct Response { int32 value; }; + +service MyService { + Response myApi(Request); +}; +``` + +Generate `api.yaml`: + +```bash +cd myapp +python -m zswag.gen -s services.MyService -i services.zs -o api.yaml +``` + +Customise via `-c`: + +```bash +# All methods as GET, flat path-parameters: +python -m zswag.gen -s services.MyService -i services.zs -c get,flat,path -o api.yaml +``` + +To override only one method: + +```bash +python -m zswag.gen -s services.MyService -i services.zs \ + -c post getLayerByTileId:get,flat,path \ + -o api.yaml +``` + +## Documentation extraction + +When invoked with `-i `, `zswag.gen` populates the OpenAPI service / method / request / response descriptions from doc-strings extracted from the zserio sources. + +For structs and services, the documentation is expected to be enclosed by `/*! .... !*/` markers preceding the declaration: + +```c +/*! +### My Markdown Struct Doc +I choose to __highlight__ this word. +!*/ + +struct MyStruct { + ... +}; +``` + +For service methods, a single-line doc-string immediately precedes the declaration: + +```c +/** This method is documented. */ +ReturnType myMethod(ArgumentType); +``` diff --git a/docs/python.md b/docs/python.md new file mode 100644 index 00000000..abfa8180 --- /dev/null +++ b/docs/python.md @@ -0,0 +1,135 @@ +# Python Client and Server + +The Python module `zswag` provides: + +- **`OAClient`** — a client transport that talks to any zserio service exposed via OpenAPI/REST. +- **`OAServer`** — a Flask/Connexion-based server layer that wraps a zserio-Python service controller. +- **`zswag.gen`** — a CLI for generating an OpenAPI YAML from a zserio service. See [`openapi-generator.md`](openapi-generator.md). + +## Install + +```bash +pip install zswag +``` + +Wheels are published for 64-bit Python 3.10-3.14 on Linux (x86_64), macOS (x86_64 / arm64), and Windows (x64). On Windows make sure the [Microsoft Visual C++ Redistributable](https://aka.ms/vs/16/release/vc_redist.x64.exe) is installed. + +## Client usage + +The Python client talks to any zserio service running over HTTP/REST that publishes an OpenAPI spec. Given a `myapp` Python module containing zserio-generated code (e.g. from `services.zs`): + +```python +from zswag import OAClient +import services.api as services + +openapi_url = "http://localhost:5000/openapi.json" + +# OAClient reads per-method HTTP details from the spec. +# is_local_file=True if the URL is a filesystem path instead. +client = services.MyService.Client(OAClient(openapi_url)) + +# This triggers an HTTP request under the hood. +client.my_api(services.Request(1)) +``` + +You can pass an adhoc `HTTPConfig` for per-call headers/auth/proxy: + +```python +from zswag import OAClient, HTTPConfig + +config = (HTTPConfig() + .header(key="X-My-Header", val="value") + .cookie(key="MyCookie", val="value") + .query(key="MyQuery", val="value") + .proxy(host="localhost", port=5050, user="john", pw="doe") + .basic_auth(user="john", pw="doe") + .bearer("bearer-token") + .api_key("token")) + +client = services.MyService.Client( + OAClient("http://localhost:8080/openapi.json", config=config)) + +# Shortcuts for the two most common forms: +client = services.MyService.Client( + OAClient("http://localhost:8080/openapi.json", api_key="token", bearer="token")) +``` + +The adhoc `config` enriches the [persistent settings](../README.md#http-settings-file) loaded from `HTTP_SETTINGS_FILE`; it does not replace them. To suppress persistent settings (e.g. in tests), set `HTTP_SETTINGS_FILE` to empty. + +## Server usage + +`OAServer` marries a zserio-generated service skeleton with a user-written controller and an OpenAPI spec. It's based on Flask and Connexion. + +A typical server script: + +```python +import zswag +import myapp.controller as controller +from myapp import working_dir + +# This import only resolves after zserio Python codegen has run. +import services.api as services + +app = zswag.OAServer( + controller_module=controller, + service_type=services.MyService.Service, + yaml_path=working_dir + "/api.yaml", + zs_pkg_path=working_dir) + +if __name__ == "__main__": + app.run() +``` + +We recommend invoking the zserio Python generator from your `__init__.py`: + +```python +import zserio +from os.path import dirname, abspath + +working_dir = dirname(abspath(__file__)) +zserio.generate( + zs_dir=working_dir, + main_zs_file="services.zs", + gen_dir=working_dir) +``` + +Two things `OAServer` looks for at startup: + +- **OpenAPI spec** (`yaml_path`): if missing, the error message contains the exact `zswag.gen` invocation that would generate it. See [`openapi-generator.md`](openapi-generator.md). +- **Controller module**: a Python module whose top-level functions match the service method names and accept the typed zserio request: + +```python +# myapp/controller.py +import services.api as services + +def my_api(request: services.Request): + return services.Response(request.value * 42) +``` + +### Response codes + +`OAServer` returns: + +- `400 Bad Request` — when the user request can't be parsed. +- `500 Internal Server Error` — when the controller raises an unhandled exception. +- `200 OK` — on success. + +If a Connexion-supported `[swagger-ui]` extra is installed (`pip install "connexion[swagger-ui]"`), the API docs become available at `[/prefix]/ui`. + +## Persistent HTTP settings + +See [HTTP Settings File in README.md](../README.md#http-settings-file) for the YAML format. The Python client auto-loads `HTTP_SETTINGS_FILE` and applies it to every request whose URL matches a registered scope. + +## Environment variables + +See the [client environment variables table](../README.md#client-environment-variables) in the README. + +## OpenAPI feature support + +See [the interop matrix in README.md](../README.md#openapi-options-interoperability) for the full ✅/❌ table comparing Python with C++ and Java. + +## Where things live in the repo + +- `libs/zswag/` — the Python package proper (`OAServer`, `OAClient`, `zswag.gen`). +- `libs/pyzswagcl/` — pybind11 bindings exposing the C++ `zswagcl` core to Python; treat as internal. +- `libs/zswag/test/calc/` — the canonical end-to-end fixture (Calculator service, OpenAPI YAML, Python server, Python client, used by C++ and Java integration tests too). diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 00000000..caf169db --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,30 @@ +# CI/CD and Release Process + +Maintainer-facing notes on how zswag is built, tested, and released. End users do not need to read this. + +## CI/CD + +The project uses GitHub Actions for automated build, test, and deploy: + +- **Platforms:** Linux (x86_64), macOS (Intel x86_64 and Apple Silicon arm64), Windows (x64). +- **Python versions:** 3.10, 3.11, 3.12, 3.13, 3.14. +- **Java toolchain:** Temurin 17 (auto-provisioned by Gradle via the Foojay resolver — see `build.gradle` and `settings.gradle`). +- **Triggers:** pull requests, pushes to `main`, version tags. + +Three top-level workflows: + +| Workflow | What | +|---|---| +| `build-deploy.yml` | C++ + Python wheels; PyPI deploy on tagged releases. | +| `coverage.yml` | C++ code coverage (lcov / SonarCloud / Codecov). | +| `jzswag.yml` | Java build, JaCoCo coverage, Codecov upload (Linux + macOS). | + +## Release process + +1. Update the version in `CMakeLists.txt` (and the matching version in root `build.gradle`). +2. Tag the commit: `git tag v{version}` (e.g. `v1.14.0`), then `git push origin v{version}`. +3. CI validates that the tag version matches the CMake version and deploys wheels to PyPI. + +## Development snapshots + +Pushes to `main` create development releases -- version format `{base_version}.dev{commit_count}` (e.g. `1.14.0.dev3`) -- automatically deployed to PyPI for testing. No tag required. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..0e130c74 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,6 @@ +# Android Gradle Plugin needs AndroidX enabled for the security-crypto / test deps. +android.useAndroidX=true + +# Increase memory headroom for the Gradle daemon — the Android module can be +# memory-hungry when compiling against api-34 platform sources. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..f8e1ee31 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..23449a2b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..adff685a --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..e509b2dd --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/libs/jzswag/jzswag-android/README.md b/libs/jzswag/jzswag-android/README.md new file mode 100644 index 00000000..228f7aa7 --- /dev/null +++ b/libs/jzswag/jzswag-android/README.md @@ -0,0 +1,43 @@ +# jzswag-android + +Android port of the zswag client. Built on OkHttp + the platform Keystore. Pulls in `jzswag-shared` for the platform-agnostic core (OpenAPI dispatch, parameter encoding, OAuth2 flow, YAML loader); only the HTTP transport, keychain, and logging are Android-specific. + +## Role in the project + +- Implements zserio's `zserio.runtime.service.ServiceClientInterface` via `OAClient`, so a zserio-Java-generated `XClient` accepts an instance as its transport — same idiom as the JVM port and Python's `services.MyService.Client(OAClient(url))`. +- Performs the same `x-zserio-request-part` decomposition the JVM client does (logic lives in `jzswag-shared`). +- Handles the same authentication schemes: HTTP Basic, HTTP Bearer, API key (header/query/cookie), OAuth2 client credentials with both token-endpoint auth methods. +- Loads the same `HTTP_SETTINGS_FILE` YAML format as the C++/Python/JVM clients. +- Stores credentials in the platform Keystore: an AES-256-GCM key generated in the secure enclave (TEE / StrongBox where available) encrypts per-credential entries that live in a private `SharedPreferences` file. + +## Public API + +- `OAClient(Context, String url[, HttpSettings persistent[, HttpConfig adhoc]])` — main entry point. The `Context` parameter is the only public-API difference from the JVM port; needed so `AndroidKeychain` can reach `SharedPreferences`. +- `AndroidHttpClient` — `IHttpClient` implementation on top of OkHttp. +- `AndroidKeychain` — `IKeychain` implementation on top of the Android Keystore + AES-GCM-encrypted SharedPreferences. Apps store credentials via `AndroidKeychain.store(service, user, secret)`; zswag itself only ever loads. +- `AndroidLogging.init()` — symmetric to the JVM `JzswagLogging.init()`. On Android, log filtering is logcat-driven (`setprop log.tag.`); the call is a near-noop. + +## Build trade-off (read this) + +This module uses the plain `java-library` Gradle plugin instead of `com.android.library`. The reason: Google currently ships only x86_64-Linux `aapt2` binaries, and on aarch64 Linux build hosts the AGP-driven build fails with "AAPT2 daemon startup failed" on resource-free library modules. There is no community aarch64 build of `aapt2` either. + +Effect: +- Output is a JAR rather than an AAR. Android consumers can still depend on it (just less idiomatically). +- AndroidX dependencies are unavailable (java-library can't consume AAR deps). `AndroidKeychain` therefore uses raw Android Keystore APIs + AES manually instead of `EncryptedSharedPreferences`. +- `android.*` references compile against the Robolectric `android-all` stub jar; the real framework is provided at runtime by the consuming app. + +On an x86_64 build host (or with Rosetta on Apple Silicon Macs), the module can be flipped back to `com.android.library` for AAR output with no source changes — the existing `local.properties` + Android SDK install setup are already there. + +## Dependencies + +- `jzswag-shared` (transitively pulls `jzswag-api`, zserio-runtime, SnakeYAML, Gson, slf4j-api). +- OkHttp 4.12.0 — HTTP transport. +- `uk.uuid.slf4j:slf4j-android` 2.0.9-0 — SLF4J binding routing through `android.util.Log`. Marked `runtimeOnly` so it doesn't appear on the test classpath (where `android.util.Log` isn't available). + +## Testing + +```bash +./gradlew :libs:jzswag:jzswag-android:test +``` + +Line coverage ≥60%, but with caveats: AndroidHttpClient has full coverage via OkHttp's `MockWebServer` (it's pure Java around OkHttp, no `android.*` refs). AndroidKeychain's encrypt/decrypt round trip and AndroidLogging's log-level routing path can't run on the aarch64 sandbox (Robolectric pulls Conscrypt which has no aarch64-linux native, and `androidx.test:monitor` is AAR-only) — those paths need a device or an x86_64 host with Robolectric. diff --git a/libs/jzswag/jzswag-android/build.gradle b/libs/jzswag/jzswag-android/build.gradle new file mode 100644 index 00000000..413d31e5 --- /dev/null +++ b/libs/jzswag/jzswag-android/build.gradle @@ -0,0 +1,121 @@ +// Android port of the zswag client. +// +// IMPORTANT: this module uses the plain `java-library` plugin instead of +// `com.android.library`. Why? +// +// The Android Gradle Plugin invokes `aapt2` even for resource-free library +// modules, and Google currently only ships x86_64 Linux aapt2 binaries. +// On aarch64 Linux build hosts the AGP build fails with +// "AAPT2 daemon startup failed" on `verifyReleaseResources` / +// `processReleaseUnitTestResources`. There is no community aarch64 build +// of aapt2 either. +// +// Output of this module is therefore a JAR, not an AAR. The library code +// references `android.*` (from the `android-all` stub on the compileOnly +// classpath) and `androidx.security:security-crypto` is intentionally not +// used here — AAR dependencies need the AGP. AndroidKeychain therefore +// uses the raw Android Keystore APIs + AES manually rather than +// EncryptedSharedPreferences. +// +// On an x86_64 build host the module can be flipped back to +// `com.android.library` to produce a proper AAR; no source changes are +// required. +// +// TESTING NOTE: AndroidHttpClient is a pure-Java class (only references +// OkHttp + java.net + javax.net.ssl), so it has full unit-test coverage via +// MockWebServer on plain JUnit + Mockito. AndroidKeychain, AndroidLogging, +// and the Context-taking OAClient constructors touch `android.*` APIs +// and need either Robolectric (which fails on aarch64 due to Conscrypt +// lacking an aarch64-linux native) or an Android instrumentation test +// running on a real device. Those are documented as gaps and tracked for +// CI on x86_64 hosts. + +plugins { + id 'java-library' + id 'maven-publish' + id 'jacoco' +} + +jacoco { + toolVersion = '0.8.11' +} + +description = 'zswag Java Android Client - OkHttp + Android Keystore. Pulls in jzswag-shared for the platform-agnostic core.' + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + exceptionFormat "full" + } + finalizedBy jacocoTestReport +} + +// slf4j-android references android.util.Log at class-load time. On a plain +// JVM (where unit tests run) that class doesn't exist; if slf4j-android +// happens to win the binding-discovery race we'd get NoClassDefFoundError. +// Force logback-classic to be the only binding on the test classpath. +configurations { + testRuntimeClasspath { + exclude group: 'uk.uuid.slf4j', module: 'slf4j-android' + } +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + } +} + +dependencies { + // Shared core (transitively pulls in jzswag-api, zserio-runtime, SnakeYAML, Gson, slf4j-api) + api project(':libs:jzswag:jzswag-shared') + + // OkHttp — Android-friendly HTTP client (replaces JDK 11 java.net.http on JVM) + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + + // SLF4J binding for android.util.Log on real Android devices. Marked + // runtimeOnly so it doesn't appear on the test classpath (where the + // android.util.Log class isn't available — tests use logback-classic). + runtimeOnly 'uk.uuid.slf4j:slf4j-android:2.0.9-0' + + // android.* compile-time stubs from Robolectric's prebuilt android.jar. + // At runtime, the consuming Android app provides the real android framework. + compileOnly 'org.robolectric:android-all:14-robolectric-10818077' + // Same stub on the test classpath (compile + runtime) for tests that mock + // Context / SharedPreferences. Mockito needs the class loadable at runtime; + // the stub's method bodies throw "Stub!" — fine since Mockito intercepts calls. + testImplementation 'org.robolectric:android-all:14-robolectric-10818077' + + // Annotations + compileOnly 'org.jetbrains:annotations:24.1.0' + + // --- Test stack --- + // Plain JUnit 5 for the parts that don't need android.* runtime + // (AndroidHttpClient — pure-Java around OkHttp). + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.1' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.1' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.1' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.1' + testImplementation 'org.mockito:mockito-core:5.8.0' + testImplementation 'org.mockito:mockito-junit-jupiter:5.8.0' + testImplementation 'org.assertj:assertj-core:3.24.2' + testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' + testRuntimeOnly 'ch.qos.logback:logback-classic:1.4.14' +} + +publishing { + publications { + maven(MavenPublication) { + from components.java + artifactId = 'jzswag-android' + } + } +} diff --git a/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/AndroidHttpClient.java b/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/AndroidHttpClient.java new file mode 100644 index 00000000..200fe67c --- /dev/null +++ b/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/AndroidHttpClient.java @@ -0,0 +1,332 @@ +package io.github.ndsev.zswag.android; + +import io.github.ndsev.zswag.api.HttpConfig; +import io.github.ndsev.zswag.api.HttpException; +import io.github.ndsev.zswag.api.HttpRequest; +import io.github.ndsev.zswag.api.HttpResponse; +import io.github.ndsev.zswag.api.HttpSettings; +import io.github.ndsev.zswag.api.IHttpClient; +import io.github.ndsev.zswag.api.IKeychain; +import io.github.ndsev.zswag.shared.HttpSettingsLoader; +import okhttp3.Authenticator; +import okhttp3.Call; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.Route; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.nio.charset.StandardCharsets; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.StringJoiner; +import java.util.TreeSet; +import java.util.concurrent.TimeUnit; + +/** + * Android {@link IHttpClient} on top of OkHttp 4. Mirrors {@code JvmHttpClient}'s + * behaviour exactly so a request configured the same way produces the same + * wire-level traffic on either platform: + * + *
    + *
  • persistent {@link HttpSettings} (scope-matched against the URL) merged + * with the per-call adhoc {@link HttpConfig};
  • + *
  • per-request headers from the OpenAPI dispatch layer suppress duplicate + * merged-config entries (case-insensitive) so OkHttp doesn't emit double + * Authorization / Cookie headers;
  • + *
  • basic-auth resolved from cleartext password or {@link IKeychain};
  • + *
  • per-URL proxy config builds a one-shot OkHttpClient (matches + * {@code JvmHttpClient}'s "rare path" approach);
  • + *
  • {@code HTTP_SSL_STRICT} env var + {@link HttpConfig#isSslStrict()} + * drive a TrustEverythingManager when relaxed mode is required;
  • + *
  • {@code HTTP_TIMEOUT} env var sets the connect / read / write timeout + * (default 60 s, matching the C++/JVM clients).
  • + *
+ */ +public class AndroidHttpClient implements IHttpClient { + private static final Logger logger = LoggerFactory.getLogger(AndroidHttpClient.class); + + private static final int DEFAULT_TIMEOUT_SECONDS = 60; + private static final MediaType OCTET_STREAM = MediaType.parse("application/octet-stream"); + + private final HttpSettingsLoader.HotReloader settingsReloader; + private final IKeychain keychain; + private final OkHttpClient strictClient; + private final OkHttpClient permissiveClient; + /** Cache of proxied clients keyed on host:port|strict|permissive — see comment on + * the proxy branch in {@link #execute}. */ + private final java.util.concurrent.ConcurrentMap proxyClientCache = + new java.util.concurrent.ConcurrentHashMap<>(); + + /** + * Loads persistent settings from {@code HTTP_SETTINGS_FILE} (auto-reloads on mtime change, + * matching C++ behaviour) and uses an in-memory IKeychain stub. + */ + public AndroidHttpClient() { + this(HttpSettingsLoader.HotReloader.fromEnvironment(), (s, u) -> { + throw new IllegalStateException( + "AndroidHttpClient was created without an IKeychain; basic-auth keychain lookup is not available. " + + "Pass an AndroidKeychain to the constructor."); + }); + } + + public AndroidHttpClient(@NotNull HttpSettings persistentSettings) { + this(persistentSettings, (s, u) -> { + throw new IllegalStateException( + "AndroidHttpClient was created without an IKeychain; basic-auth keychain lookup is not available. " + + "Pass an AndroidKeychain to the constructor."); + }); + } + + public AndroidHttpClient(@NotNull HttpSettings persistentSettings, @NotNull IKeychain keychain) { + // Caller-supplied snapshot: no source file -> no hot-reload. + this(HttpSettingsLoader.HotReloader.of(null, persistentSettings), keychain); + } + + AndroidHttpClient(@NotNull HttpSettingsLoader.HotReloader reloader, @NotNull IKeychain keychain) { + AndroidLogging.init(); + this.settingsReloader = reloader; + this.keychain = keychain; + Duration timeout = readTimeoutFromEnv(); + this.strictClient = buildOkHttpClient(timeout, true); + this.permissiveClient = buildOkHttpClient(timeout, false); + } + + /** Returns the current persistent settings, re-reading the source file if its mtime changed. */ + @Override + @NotNull + public HttpSettings getPersistentSettings() { + return settingsReloader.current(); + } + + @NotNull + private static Duration readTimeoutFromEnv() { + String envTimeout = System.getenv("HTTP_TIMEOUT"); + if (envTimeout != null && !envTimeout.isEmpty()) { + try { + return Duration.ofSeconds(Integer.parseInt(envTimeout)); + } catch (NumberFormatException e) { + logger.warn("Invalid HTTP_TIMEOUT value '{}', using default {}s", envTimeout, DEFAULT_TIMEOUT_SECONDS); + } + } + return Duration.ofSeconds(DEFAULT_TIMEOUT_SECONDS); + } + + private static boolean envSslStrict() { + // Match C++ httpcl::HttpLibHttpClient (libs/httpcl/src/http-client.cpp:57-58): + // any non-empty value enables strict; unset or empty disables. Aligned with + // JvmHttpClient and the Python client (pyzswagcl) for cross-client parity. + String env = System.getenv("HTTP_SSL_STRICT"); + return env != null && !env.isEmpty(); + } + + @NotNull + private static OkHttpClient buildOkHttpClient(@NotNull Duration timeout, boolean sslStrict) { + OkHttpClient.Builder b = new OkHttpClient.Builder() + .connectTimeout(timeout.getSeconds(), TimeUnit.SECONDS) + .readTimeout(timeout.getSeconds(), TimeUnit.SECONDS) + .writeTimeout(timeout.getSeconds(), TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true); + if (!sslStrict) { + installPermissiveSsl(b); + } + return b.build(); + } + + private static void installPermissiveSsl(@NotNull OkHttpClient.Builder b) { + try { + TrustEverythingManager tm = new TrustEverythingManager(); + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(null, new TrustManager[]{tm}, new java.security.SecureRandom()); + b.sslSocketFactory(ctx.getSocketFactory(), tm); + b.hostnameVerifier((host, session) -> true); + } catch (NoSuchAlgorithmException | KeyManagementException e) { + logger.warn("Failed to install permissive SSLContext: {}", e.getMessage()); + } + } + + @Override + @NotNull + public HttpResponse execute(@NotNull HttpRequest request, @NotNull HttpConfig adhoc) throws HttpException { + HttpConfig effective = settingsReloader.current().forUrl(request.getUrl()).mergedWith(adhoc); + + boolean sslStrict = envSslStrict() && effective.isSslStrict(); + OkHttpClient client = sslStrict ? strictClient : permissiveClient; + + // Cache proxied OkHttpClients per (host:port, sslStrict) so concurrent requests + // share the connection pool / dispatcher threads. OkHttp explicitly recommends one + // client per process; rebuilding per-request loses keep-alive and adds visible + // battery / memory cost on Android. + if (effective.getProxy().isPresent()) { + HttpConfig.Proxy proxy = effective.getProxy().get(); + String cacheKey = proxy.host + ":" + proxy.port + "|" + (sslStrict ? "strict" : "permissive"); + Duration callTimeoutForProxy = effective.getTimeout(); + client = proxyClientCache.computeIfAbsent(cacheKey, + k -> buildClientWithProxy(callTimeoutForProxy, sslStrict, proxy)); + } + + // Honour the merged HttpConfig's per-request timeout. JvmHttpClient applies this + // via HttpRequest.Builder#timeout; on OkHttp we derive a client from the pool so + // the connection cache is shared but callTimeout reflects the per-call value. + // Only override when the caller explicitly set a timeout — otherwise the base + // client's HTTP_TIMEOUT-derived value already applies (matches JvmHttpClient). + Duration explicitTimeout = effective.getTimeoutOrNull(); + if (explicitTimeout != null) { + client = client.newBuilder() + .callTimeout(explicitTimeout.getSeconds(), TimeUnit.SECONDS) + .build(); + } + + String url = applyQueryParams(request.getUrl(), effective.getQuery()); + logger.debug("Executing {} request to {}", request.getMethod(), url); + + Request.Builder rb = new Request.Builder().url(url); + + // Per-request headers (case-insensitive) win over merged config to avoid duplicates. + Set perRequestHeaderNames = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + for (Map.Entry h : request.getHeaders().entrySet()) { + rb.addHeader(h.getKey(), h.getValue()); + perRequestHeaderNames.add(h.getKey()); + } + for (Map.Entry> h : effective.getHeaders().entrySet()) { + if (perRequestHeaderNames.contains(h.getKey())) continue; + for (String v : h.getValue()) { + rb.addHeader(h.getKey(), v); + } + } + + // Cookies → single Cookie header (skip if a Cookie header was already set per-request) + if (!effective.getCookies().isEmpty() && !perRequestHeaderNames.contains("Cookie")) { + StringJoiner cookieJoiner = new StringJoiner("; "); + for (Map.Entry e : effective.getCookies().entrySet()) { + cookieJoiner.add(e.getKey() + "=" + e.getValue()); + } + rb.addHeader("Cookie", cookieJoiner.toString()); + } + + // Basic auth — only when Authorization isn't already set. + if (effective.getAuth().isPresent() + && !perRequestHeaderNames.contains("Authorization") + && !containsHeaderIgnoreCase(effective.getHeaders(), "Authorization")) { + HttpConfig.BasicAuthentication auth = effective.getAuth().get(); + String password = !auth.password.isEmpty() + ? auth.password + : keychain.load(auth.keychain, auth.user); + String credentials = auth.user + ":" + password; + String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + rb.addHeader("Authorization", "Basic " + encoded); + } + + // HTTP method + body. + String method = request.getMethod().toUpperCase(); + byte[] bodyBytes = request.getBody(); + switch (method) { + case "GET": + rb.get(); + break; + case "POST": + rb.post(bodyBytes != null ? RequestBody.create(bodyBytes, OCTET_STREAM) : RequestBody.create(new byte[0], null)); + break; + case "PUT": + rb.put(bodyBytes != null ? RequestBody.create(bodyBytes, OCTET_STREAM) : RequestBody.create(new byte[0], null)); + break; + case "DELETE": + rb.delete(bodyBytes != null ? RequestBody.create(bodyBytes, OCTET_STREAM) : null); + break; + default: + throw new HttpException("Unsupported HTTP method: " + request.getMethod()); + } + + Call call = client.newCall(rb.build()); + try (Response response = call.execute()) { + int code = response.code(); + byte[] respBody = response.body() != null ? response.body().bytes() : null; + // Return the first value per header name (OkHttp's response.header(name) + // returns the *last* value, which would diverge from JvmHttpClient's + // behaviour). Iterate explicitly. + Map headers = new LinkedHashMap<>(); + for (String name : response.headers().names()) { + List values = response.headers().values(name); + if (!values.isEmpty()) { + headers.put(name, values.get(0)); + } + } + logger.debug("Received response with status code: {}", code); + return new HttpResponse(code, response.message(), headers, respBody); + } catch (IOException e) { + logger.error("HTTP request failed: {}", e.getMessage(), e); + throw new HttpException("HTTP request failed: " + e.getMessage(), e); + } + } + + private OkHttpClient buildClientWithProxy(@NotNull Duration timeout, boolean sslStrict, @NotNull HttpConfig.Proxy proxy) { + OkHttpClient.Builder b = new OkHttpClient.Builder() + .connectTimeout(timeout.getSeconds(), TimeUnit.SECONDS) + .readTimeout(timeout.getSeconds(), TimeUnit.SECONDS) + .writeTimeout(timeout.getSeconds(), TimeUnit.SECONDS) + .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxy.host, proxy.port))); + if (!sslStrict) installPermissiveSsl(b); + if (!proxy.user.isEmpty()) { + String password = !proxy.password.isEmpty() ? proxy.password : keychain.load(proxy.keychain, proxy.user); + String creds = "Basic " + Base64.getEncoder() + .encodeToString((proxy.user + ":" + password).getBytes(StandardCharsets.UTF_8)); + b.proxyAuthenticator(new Authenticator() { + @Override + @Nullable + public Request authenticate(@Nullable Route route, @NotNull Response response) { + return response.request().newBuilder().header("Proxy-Authorization", creds).build(); + } + }); + } + return b.build(); + } + + private static boolean containsHeaderIgnoreCase(@NotNull Map> headers, @NotNull String name) { + for (String key : headers.keySet()) { + if (name.equalsIgnoreCase(key)) return true; + } + return false; + } + + @NotNull + private static String applyQueryParams(@NotNull String baseUrl, @NotNull Map> query) { + if (query.isEmpty()) return baseUrl; + StringBuilder sb = new StringBuilder(baseUrl); + boolean hasQuery = baseUrl.indexOf('?') >= 0; + for (Map.Entry> e : query.entrySet()) { + for (String v : e.getValue()) { + sb.append(hasQuery ? '&' : '?'); + hasQuery = true; + sb.append(java.net.URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8)); + sb.append('='); + sb.append(java.net.URLEncoder.encode(v, StandardCharsets.UTF_8)); + } + } + return sb.toString(); + } + + private static final class TrustEverythingManager implements X509TrustManager { + @Override public void checkClientTrusted(X509Certificate[] chain, String authType) {} + @Override public void checkServerTrusted(X509Certificate[] chain, String authType) {} + @Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } + } +} diff --git a/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/AndroidKeychain.java b/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/AndroidKeychain.java new file mode 100644 index 00000000..bd56da5d --- /dev/null +++ b/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/AndroidKeychain.java @@ -0,0 +1,168 @@ +package io.github.ndsev.zswag.android; + +import android.content.Context; +import android.content.SharedPreferences; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; +import io.github.ndsev.zswag.api.IKeychain; +import io.github.ndsev.zswag.api.KeychainException; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.util.Arrays; +import java.util.Base64; + +/** + * Android keychain integration using the platform Keystore. Mirrors the + * {@link IKeychain} contract that the JVM {@code Keychain} class implements, + * so {@code OAuth2Handler} and {@code AndroidHttpClient} consume both + * interchangeably via dependency injection. + * + *

Storage strategy: + *

    + *
  • A symmetric AES-256-GCM key is generated in the platform Keystore on + * first use, aliased {@code io.github.ndsev.zswag.keychain.master}. + * The key never leaves the secure hardware (TEE / StrongBox where + * available); only a {@link Cipher} handle does.
  • + *
  • Per-credential entries (one per {@code service|user} pair) are + * encrypted with that key and stored in a private + * {@link SharedPreferences} file + * ({@code io.github.ndsev.zswag.keychain}). The on-disk blob is + * {@code base64(iv_len:byte | iv | ciphertext_with_gcm_tag)}.
  • + *
+ * + *

Why not {@code androidx.security:security-crypto}? That library is + * distributed as an AAR which the {@code java-library}-based build of this + * module cannot consume (see this module's build.gradle for the aapt2-on-arm + * trade-off). Doing the AES/GCM dance manually keeps us inside Java APIs + * that work both at compile time (against the Robolectric android.jar) and + * at runtime (on a real device). + * + *

Storage of new secrets is a programmatic operation + * ({@link #store(String, String, String)}); zswag itself only ever + * reads via {@link IKeychain#load} so writes are typically issued + * out-of-band by the host app. + */ +public final class AndroidKeychain implements IKeychain { + private static final Logger logger = LoggerFactory.getLogger(AndroidKeychain.class); + + /** Matches the JVM keychain package id so credentials stored on a JVM laptop and synced to a device line up. */ + static final String KEYSTORE_TYPE = "AndroidKeyStore"; + static final String KEY_ALIAS = "io.github.ndsev.zswag.keychain.master"; + static final String PREFS_NAME = "io.github.ndsev.zswag.keychain"; + private static final int GCM_TAG_BITS = 128; + + private final Context appContext; + + public AndroidKeychain(@NotNull Context context) { + this.appContext = context.getApplicationContext(); + } + + @Override + @NotNull + public String load(@NotNull String service, @NotNull String user) { + if (service.isEmpty()) { + throw new KeychainException("keychain: service identifier must not be empty"); + } + SharedPreferences prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + String key = entryKey(service, user); + String encoded = prefs.getString(key, null); + if (encoded == null) { + throw new KeychainException("keychain: no entry for service='" + service + "' user='" + user + "'"); + } + try { + return decrypt(encoded); + } catch (Exception e) { + throw new KeychainException("keychain: failed to decrypt entry for '" + key + "': " + e.getMessage(), e); + } + } + + /** + * Stores or overwrites a credential under {@code (service, user)}. Apps + * typically call this once at first-run during their auth onboarding; + * zswag itself never writes. + */ + public void store(@NotNull String service, @NotNull String user, @NotNull String secret) { + if (service.isEmpty()) { + throw new KeychainException("keychain: service identifier must not be empty"); + } + try { + String encrypted = encrypt(secret); + appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(entryKey(service, user), encrypted) + .apply(); + logger.debug("Stored keychain entry for service='{}' user='{}'", service, user); + } catch (Exception e) { + throw new KeychainException("keychain: failed to encrypt entry: " + e.getMessage(), e); + } + } + + /** Removes the credential under {@code (service, user)} if present. */ + public void delete(@NotNull String service, @NotNull String user) { + appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .remove(entryKey(service, user)) + .apply(); + } + + @NotNull + private static String entryKey(@NotNull String service, @NotNull String user) { + return service + "|" + user; + } + + @NotNull + private SecretKey getOrCreateMasterKey() throws Exception { + KeyStore ks = KeyStore.getInstance(KEYSTORE_TYPE); + ks.load(null); + if (ks.containsAlias(KEY_ALIAS)) { + KeyStore.Entry entry = ks.getEntry(KEY_ALIAS, null); + if (entry instanceof KeyStore.SecretKeyEntry) { + return ((KeyStore.SecretKeyEntry) entry).getSecretKey(); + } + throw new KeychainException("keychain: unexpected entry type for alias " + KEY_ALIAS); + } + KeyGenerator kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_TYPE); + kg.init(new KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build()); + return kg.generateKey(); + } + + @NotNull + private String encrypt(@NotNull String plaintext) throws Exception { + SecretKey key = getOrCreateMasterKey(); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, key); + byte[] iv = cipher.getIV(); + byte[] ct = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + ByteBuffer buf = ByteBuffer.allocate(1 + iv.length + ct.length); + buf.put((byte) iv.length).put(iv).put(ct); + return Base64.getEncoder().encodeToString(buf.array()); + } + + @NotNull + private String decrypt(@NotNull String encoded) throws Exception { + byte[] packed = Base64.getDecoder().decode(encoded); + int ivLen = packed[0] & 0xff; + byte[] iv = Arrays.copyOfRange(packed, 1, 1 + ivLen); + byte[] ct = Arrays.copyOfRange(packed, 1 + ivLen, packed.length); + SecretKey key = getOrCreateMasterKey(); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); + return new String(cipher.doFinal(ct), StandardCharsets.UTF_8); + } + +} diff --git a/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/AndroidLogging.java b/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/AndroidLogging.java new file mode 100644 index 00000000..b1c5c407 --- /dev/null +++ b/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/AndroidLogging.java @@ -0,0 +1,37 @@ +package io.github.ndsev.zswag.android; + +import android.util.Log; + +/** + * Android equivalent of the JVM's {@code JzswagLogging}. On Android the SLF4J + * binding ({@code uk.uuid:slf4j-android}) routes through {@link Log}, whose + * tag-level filtering is set by the platform (logcat / {@code setprop + * log.tag. }) rather than by the application. + * + *

Therefore there is no programmatic root-level change to perform: this + * class exists so app code can call {@link #init()} symmetrically with the + * JVM port, but the call is a near-noop. If {@code HTTP_LOG_LEVEL} is set in + * the process environment, we surface it to logcat once at debug level so + * the developer can confirm the value the JVM modules would have used. + */ +public final class AndroidLogging { + private static volatile boolean initialised = false; + private static final Object LOCK = new Object(); + private static final String TAG = "jzswag"; + + private AndroidLogging() {} + + public static void init() { + if (initialised) return; + synchronized (LOCK) { + if (initialised) return; + String level = System.getenv("HTTP_LOG_LEVEL"); + if (level != null && !level.isEmpty()) { + Log.d(TAG, "HTTP_LOG_LEVEL=" + level + " observed in environment. " + + "On Android, log filtering is controlled by logcat tag levels " + + "(setprop log.tag." + TAG + " " + level.toUpperCase() + ")"); + } + initialised = true; + } + } +} diff --git a/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/OAClient.java b/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/OAClient.java new file mode 100644 index 00000000..29f912bc --- /dev/null +++ b/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/OAClient.java @@ -0,0 +1,128 @@ +package io.github.ndsev.zswag.android; + +import android.content.Context; +import io.github.ndsev.zswag.api.HttpConfig; +import io.github.ndsev.zswag.api.HttpException; +import io.github.ndsev.zswag.api.HttpSettings; +import io.github.ndsev.zswag.api.IKeychain; +import io.github.ndsev.zswag.shared.HttpSettingsLoader; +import io.github.ndsev.zswag.shared.OpenApiClient; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import zserio.runtime.ZserioError; +import zserio.runtime.io.Writer; +import zserio.runtime.service.ServiceClientInterface; +import zserio.runtime.service.ServiceData; + +import java.io.IOException; + +/** + * Android counterpart of the JVM {@code OAClient}: implements zserio's + * {@link ServiceClientInterface} so any zserio-Java-generated {@code XClient} + * accepts an instance as its transport. + * + *

The only public-API difference from the JVM port is the {@link Context} + * parameter on the convenience constructors — needed so {@link AndroidKeychain} + * can reach {@link android.content.SharedPreferences} for credential storage. + * + *

Usage: + *

{@code
+ * OAClient transport = new OAClient(context, "https://api.example.com/openapi.json");
+ * Calculator.CalculatorClient calc = new Calculator.CalculatorClient(transport);
+ * Double result = calc.powerMethod(new BaseAndExponent(...));
+ * }
+ */ +public final class OAClient implements ServiceClientInterface { + private static final Logger logger = LoggerFactory.getLogger(OAClient.class); + + private final OpenApiClient delegate; + + /** + * Creates a client that uses persistent settings from {@code HTTP_SETTINGS_FILE}. + * Subsequent mtime changes to the settings file are picked up on the next request + * (matches the C++/JVM behaviour). Use the + * {@link #OAClient(Context, String, HttpSettings, HttpConfig)} form instead if you want + * to pin a specific snapshot. + */ + public OAClient(@NotNull Context context, @NotNull String openApiSpecUrl) throws IOException { + this(context, openApiSpecUrl, HttpConfig.empty(), 0); + } + + /** + * Env-driven constructor with an explicit {@code serverIndex}. Persistent + * settings come from {@code HTTP_SETTINGS_FILE} via a {@link HttpSettingsLoader.HotReloader} + * so file changes are picked up automatically. + */ + public OAClient(@NotNull Context context, @NotNull String openApiSpecUrl, + @NotNull HttpConfig adhoc, int serverIndex) throws IOException { + AndroidLogging.init(); + IKeychain keychain = new AndroidKeychain(context); + // Package-private ctor: env-driven HotReloader so the source path is preserved. + AndroidHttpClient http = new AndroidHttpClient(HttpSettingsLoader.HotReloader.fromEnvironment(), keychain); + this.delegate = new OpenApiClient(openApiSpecUrl, http, adhoc, keychain, serverIndex); + } + + /** + * Creates a client with explicit persistent settings (typically loaded via + * {@link HttpSettingsLoader}) and no adhoc config. + */ + public OAClient(@NotNull Context context, @NotNull String openApiSpecUrl, + @NotNull HttpSettings persistent) throws IOException { + this(context, openApiSpecUrl, persistent, HttpConfig.empty()); + } + + /** + * Creates a client with explicit persistent settings AND a per-instance + * adhoc {@link HttpConfig}. + */ + public OAClient(@NotNull Context context, @NotNull String openApiSpecUrl, + @NotNull HttpSettings persistent, @NotNull HttpConfig adhoc) throws IOException { + this(context, openApiSpecUrl, persistent, adhoc, 0); + } + + /** + * Creates a client targeting a specific entry of the spec's {@code servers[]} + * array. Mirrors C++ {@code OAClient(..., uint32_t serverIndex)} and Python + * {@code OAClient(..., server_index=N)} — see issue #113. + * + * @param serverIndex index into the parsed {@code servers[]} array (default 0). + * {@link IOException} is thrown during construction if the + * index is out of bounds. + */ + public OAClient(@NotNull Context context, @NotNull String openApiSpecUrl, + @NotNull HttpSettings persistent, @NotNull HttpConfig adhoc, + int serverIndex) throws IOException { + AndroidLogging.init(); + IKeychain keychain = new AndroidKeychain(context); + AndroidHttpClient http = new AndroidHttpClient(persistent, keychain); + this.delegate = new OpenApiClient(openApiSpecUrl, http, adhoc, keychain, serverIndex); + } + + /** Lower-level constructor — for tests / advanced use. */ + public OAClient(@NotNull OpenApiClient delegate) { + this.delegate = delegate; + } + + /** Exposes the underlying OpenAPI client (read-only) for introspection. */ + @NotNull + public OpenApiClient getOpenApiClient() { + return delegate; + } + + @Override + public byte[] callMethod(java.lang.String methodName, + ServiceData requestData, + @Nullable java.lang.Object zserioContext) throws ZserioError { + Writer typed = requestData.getZserioObject(); + if (typed == null) { + throw new ZserioError("OAClient.callMethod: requestData.getZserioObject() returned null"); + } + try { + return delegate.callMethod(methodName, typed); + } catch (HttpException e) { + throw new ZserioError("OAClient: " + methodName + " failed: " + e.getMessage(), e); + } + } +} diff --git a/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/AndroidHttpClientTest.java b/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/AndroidHttpClientTest.java new file mode 100644 index 00000000..089057e5 --- /dev/null +++ b/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/AndroidHttpClientTest.java @@ -0,0 +1,270 @@ +package io.github.ndsev.zswag.android; + +import io.github.ndsev.zswag.api.HttpConfig; +import io.github.ndsev.zswag.api.HttpException; +import io.github.ndsev.zswag.api.HttpRequest; +import io.github.ndsev.zswag.api.HttpResponse; +import io.github.ndsev.zswag.api.HttpSettings; +import io.github.ndsev.zswag.api.IKeychain; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link AndroidHttpClient}. AndroidHttpClient is a pure-Java + * class (uses only OkHttp + java.net + javax.net.ssl, no android.* refs) + * so we test it on plain JUnit + MockWebServer rather than Robolectric. + * Exercises method dispatch, header / cookie / query / basic-auth merging, + * per-request precedence, and the persistent-settings scope match. Mirrors + * {@code JvmHttpClientTest}. + */ +public class AndroidHttpClientTest { + + private static final IKeychain THROWING_KC = (s, u) -> { + throw new IllegalStateException("Keychain not used in this test"); + }; + + private MockWebServer server; + + @BeforeEach + public void start() throws IOException { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + public void stop() throws IOException { + server.shutdown(); + } + + private AndroidHttpClient newClient() { + return new AndroidHttpClient(HttpSettings.empty(), THROWING_KC); + } + + @Test + public void getRequestSendsRequestAndReturnsResponse() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("hello")); + HttpRequest req = HttpRequest.builder() + .method("GET") + .url(server.url("/path").toString()) + .build(); + HttpResponse resp = newClient().execute(req, HttpConfig.empty()); + assertThat(resp.getStatusCode()).isEqualTo(200); + assertThat(new String(resp.getBody())).isEqualTo("hello"); + RecordedRequest recorded = server.takeRequest(); + assertThat(recorded.getMethod()).isEqualTo("GET"); + assertThat(recorded.getPath()).isEqualTo("/path"); + } + + @Test + public void postWithBodySendsBytes() throws Exception { + server.enqueue(new MockResponse().setResponseCode(201)); + byte[] body = "PAYLOAD".getBytes(); + HttpRequest req = HttpRequest.builder().method("POST").url(server.url("/p").toString()).body(body).build(); + HttpResponse resp = newClient().execute(req, HttpConfig.empty()); + assertThat(resp.getStatusCode()).isEqualTo(201); + RecordedRequest recorded = server.takeRequest(); + assertThat(recorded.getMethod()).isEqualTo("POST"); + assertThat(recorded.getBody().readUtf8()).isEqualTo("PAYLOAD"); + } + + @Test + public void postWithoutBodySendsEmpty() throws Exception { + server.enqueue(new MockResponse().setResponseCode(204)); + HttpRequest req = HttpRequest.builder().method("POST").url(server.url("/p").toString()).build(); + HttpResponse resp = newClient().execute(req, HttpConfig.empty()); + assertThat(resp.getStatusCode()).isEqualTo(204); + } + + @Test + public void putRequestSupported() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("PUT").url(server.url("/p").toString()) + .body("body".getBytes()).build(); + HttpResponse resp = newClient().execute(req, HttpConfig.empty()); + assertThat(resp.getStatusCode()).isEqualTo(200); + assertThat(server.takeRequest().getMethod()).isEqualTo("PUT"); + } + + @Test + public void deleteRequestSupportedWithoutBody() throws Exception { + server.enqueue(new MockResponse().setResponseCode(204)); + HttpRequest req = HttpRequest.builder().method("DELETE").url(server.url("/p").toString()).build(); + HttpResponse resp = newClient().execute(req, HttpConfig.empty()); + assertThat(resp.getStatusCode()).isEqualTo(204); + assertThat(server.takeRequest().getMethod()).isEqualTo("DELETE"); + } + + @Test + public void deleteRequestSupportedWithBody() throws Exception { + server.enqueue(new MockResponse().setResponseCode(204)); + HttpRequest req = HttpRequest.builder().method("DELETE").url(server.url("/p").toString()) + .body("payload".getBytes()).build(); + newClient().execute(req, HttpConfig.empty()); + RecordedRequest recorded = server.takeRequest(); + assertThat(recorded.getMethod()).isEqualTo("DELETE"); + assertThat(recorded.getBody().readUtf8()).isEqualTo("payload"); + } + + @Test + public void unsupportedHttpMethodThrows() { + HttpRequest req = HttpRequest.builder().method("PATCH").url(server.url("/x").toString()).build(); + assertThatThrownBy(() -> newClient().execute(req, HttpConfig.empty())) + .isInstanceOf(HttpException.class) + .hasMessageContaining("Unsupported HTTP method"); + } + + @Test + public void perRequestHeadersTakePrecedenceOverAdhocHeaders() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()) + .header("Authorization", "Bearer per-request").build(); + HttpConfig adhoc = HttpConfig.builder().bearerToken("from-adhoc").build(); + newClient().execute(req, adhoc); + RecordedRequest recorded = server.takeRequest(); + assertThat(recorded.getHeaders().values("Authorization")).containsExactly("Bearer per-request"); + } + + @Test + public void cookiesFromConfigAreSent() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build(); + HttpConfig adhoc = HttpConfig.builder().cookie("a", "1").cookie("b", "2").build(); + newClient().execute(req, adhoc); + RecordedRequest recorded = server.takeRequest(); + assertThat(recorded.getHeader("Cookie")).contains("a=1").contains("b=2"); + } + + @Test + public void perRequestCookieHeaderSuppressesConfigCookies() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()) + .header("Cookie", "explicit=yes").build(); + HttpConfig adhoc = HttpConfig.builder().cookie("a", "1").build(); + newClient().execute(req, adhoc); + assertThat(server.takeRequest().getHeader("Cookie")).isEqualTo("explicit=yes"); + } + + @Test + public void basicAuthFromConfigInjectsAuthorizationHeader() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build(); + HttpConfig adhoc = HttpConfig.builder().basicAuth("alice", "secret").build(); + newClient().execute(req, adhoc); + // base64("alice:secret") = "YWxpY2U6c2VjcmV0" + assertThat(server.takeRequest().getHeader("Authorization")).isEqualTo("Basic YWxpY2U6c2VjcmV0"); + } + + @Test + public void basicAuthSuppressedWhenAuthorizationAlreadyOnRequest() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()) + .header("Authorization", "Bearer prebaked").build(); + HttpConfig adhoc = HttpConfig.builder().basicAuth("alice", "secret").build(); + newClient().execute(req, adhoc); + assertThat(server.takeRequest().getHeaders().values("Authorization")).containsExactly("Bearer prebaked"); + } + + @Test + public void basicAuthSuppressedWhenAuthorizationInConfigHeaders() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build(); + HttpConfig adhoc = HttpConfig.builder() + .header("authorization", "Bearer x") + .basicAuth("alice", "secret") + .build(); + newClient().execute(req, adhoc); + assertThat(server.takeRequest().getHeader("Authorization")).contains("Bearer x"); + } + + @Test + public void adhocHeadersFromConfigAreSent() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build(); + HttpConfig adhoc = HttpConfig.builder() + .addHeader("X-Multi", "v1") + .addHeader("X-Multi", "v2") + .build(); + newClient().execute(req, adhoc); + assertThat(server.takeRequest().getHeaders().values("X-Multi")).containsExactly("v1", "v2"); + } + + @Test + public void queryParametersAreAppendedToUrl() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build(); + HttpConfig adhoc = HttpConfig.builder() + .addQuery("a", "1") + .addQuery("a", "2") + .addQuery("b", "x y") + .build(); + newClient().execute(req, adhoc); + String path = server.takeRequest().getPath(); + assertThat(path).contains("a=1").contains("a=2").contains("b=x+y"); + } + + @Test + public void queryParamsAppendedWithExistingQueryString() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p?fixed=yes").toString()).build(); + HttpConfig adhoc = HttpConfig.builder().query("extra", "1").build(); + newClient().execute(req, adhoc); + String path = server.takeRequest().getPath(); + assertThat(path).contains("fixed=yes").contains("extra=1"); + } + + @Test + public void persistentSettingsAreScopeMergedAndAvailableForGetter() { + HttpConfig wildcard = HttpConfig.builder() + .scope("*", HttpSettings.compileScope("*")) + .header("X-Default", "global") + .build(); + HttpSettings persistent = new HttpSettings(Collections.singletonList(wildcard)); + AndroidHttpClient client = new AndroidHttpClient(persistent, THROWING_KC); + assertThat(client.getPersistentSettings()).isSameAs(persistent); + } + + @Test + public void persistentScopeMatchesAndAddsHeaders() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + String url = server.url("/p").toString(); + HttpConfig wildcard = HttpConfig.builder() + .scope("*", HttpSettings.compileScope("*")) + .header("X-Default", "yes") + .build(); + AndroidHttpClient client = new AndroidHttpClient( + new HttpSettings(Collections.singletonList(wildcard)), THROWING_KC); + HttpRequest req = HttpRequest.builder().method("GET").url(url).build(); + client.execute(req, HttpConfig.empty()); + assertThat(server.takeRequest().getHeader("X-Default")).isEqualTo("yes"); + } + + @Test + public void connectionFailureSurfacesAsHttpException() { + HttpRequest req = HttpRequest.builder().method("GET").url("http://127.0.0.1:1/x").build(); + assertThatThrownBy(() -> newClient().execute(req, HttpConfig.empty())) + .isInstanceOf(HttpException.class); + } + + @Test + public void responseHeadersAreReturnedAsFirstValue() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .addHeader("X-Foo", "first") + .addHeader("X-Foo", "second")); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build(); + HttpResponse resp = newClient().execute(req, HttpConfig.empty()); + // OkHttp normalises header casing differently than the JDK; accept either form. + String value = resp.getHeaders().getOrDefault("X-Foo", + resp.getHeaders().getOrDefault("x-foo", null)); + assertThat(value).isEqualTo("first"); + } +} diff --git a/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/AndroidKeychainTest.java b/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/AndroidKeychainTest.java new file mode 100644 index 00000000..c430b9a7 --- /dev/null +++ b/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/AndroidKeychainTest.java @@ -0,0 +1,79 @@ +package io.github.ndsev.zswag.android; + +import android.content.Context; +import android.content.SharedPreferences; +import io.github.ndsev.zswag.api.KeychainException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Plain-JUnit + Mockito tests for {@link AndroidKeychain}. Only the input + * validation + missing-entry paths are exercised here — those don't touch + * the platform Keystore. The encrypt/decrypt round trip and the key + * generation path require Robolectric or an Android device, which the + * sandbox cannot run (Conscrypt has no aarch64 Linux native). + */ +class AndroidKeychainTest { + + @Test + void emptyServiceLoadThrows() { + Context ctx = mock(Context.class); + when(ctx.getApplicationContext()).thenReturn(ctx); + AndroidKeychain kc = new AndroidKeychain(ctx); + assertThatThrownBy(() -> kc.load("", "user")) + .isInstanceOf(KeychainException.class) + .hasMessageContaining("service identifier"); + } + + @Test + void emptyServiceStoreThrows() { + Context ctx = mock(Context.class); + when(ctx.getApplicationContext()).thenReturn(ctx); + AndroidKeychain kc = new AndroidKeychain(ctx); + assertThatThrownBy(() -> kc.store("", "user", "secret")) + .isInstanceOf(KeychainException.class); + } + + @Test + void loadAbsentEntryThrows() { + Context ctx = mock(Context.class); + when(ctx.getApplicationContext()).thenReturn(ctx); + SharedPreferences prefs = mock(SharedPreferences.class); + when(ctx.getSharedPreferences(eq("io.github.ndsev.zswag.keychain"), anyInt())).thenReturn(prefs); + when(prefs.getString(eq("svc.does-not-exist|user.does-not-exist"), eq(null))).thenReturn(null); + AndroidKeychain kc = new AndroidKeychain(ctx); + assertThatThrownBy(() -> kc.load("svc.does-not-exist", "user.does-not-exist")) + .isInstanceOf(KeychainException.class) + .hasMessageContaining("no entry"); + } + + @Test + void deleteCallsSharedPreferencesEditor() { + Context ctx = mock(Context.class); + when(ctx.getApplicationContext()).thenReturn(ctx); + SharedPreferences prefs = mock(SharedPreferences.class); + SharedPreferences.Editor editor = mock(SharedPreferences.Editor.class); + when(prefs.edit()).thenReturn(editor); + when(editor.remove(org.mockito.ArgumentMatchers.anyString())).thenReturn(editor); + when(ctx.getSharedPreferences(eq("io.github.ndsev.zswag.keychain"), anyInt())).thenReturn(prefs); + new AndroidKeychain(ctx).delete("svc", "user"); + // verifyEditing.remove was called with the joined key + org.mockito.Mockito.verify(editor).remove("svc|user"); + org.mockito.Mockito.verify(editor).apply(); + } + + @Test + void exceptionConstructorsPreserveMessageAndCause() { + KeychainException simple = new KeychainException("just msg"); + assertThat(simple).hasMessage("just msg"); + Throwable cause = new RuntimeException("inner"); + KeychainException withCause = new KeychainException("outer", cause); + assertThat(withCause).hasCause(cause).hasMessage("outer"); + } +} diff --git a/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/AndroidLoggingTest.java b/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/AndroidLoggingTest.java new file mode 100644 index 00000000..aa117d9b --- /dev/null +++ b/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/AndroidLoggingTest.java @@ -0,0 +1,36 @@ +package io.github.ndsev.zswag.android; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; + +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Plain-JUnit smoke tests for {@link AndroidLogging}. Only the env-var-unset + * path is exercised here — that path doesn't hit android.util.Log so it works + * on plain JVM. Tests that exercise the env-var-set branch (which routes + * through android.util.Log) need a device or x86_64 host with Robolectric. + */ +class AndroidLoggingTest { + + private void resetInitialised() throws Exception { + Field f = AndroidLogging.class.getDeclaredField("initialised"); + f.setAccessible(true); + f.set(null, false); + } + + @Test + void initIsIdempotent() { + AndroidLogging.init(); + AndroidLogging.init(); + } + + @Test + void initWithoutEnvVarDoesNotThrow() throws Exception { + // HTTP_LOG_LEVEL is not set in the JUnit env, so init() takes the + // null-level branch (no Log.d call) — safe to run on plain JVM. + resetInitialised(); + assertThatCode(AndroidLogging::init).doesNotThrowAnyException(); + } +} diff --git a/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/OAClientTest.java b/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/OAClientTest.java new file mode 100644 index 00000000..baa86ba8 --- /dev/null +++ b/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/OAClientTest.java @@ -0,0 +1,74 @@ +package io.github.ndsev.zswag.android; + +import io.github.ndsev.zswag.api.HttpException; +import io.github.ndsev.zswag.shared.OpenApiClient; +import org.junit.jupiter.api.Test; +import zserio.runtime.ZserioError; +import zserio.runtime.io.Writer; +import zserio.runtime.service.ServiceData; + +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.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link OAClient} that don't require an Android Context + * (uses the lower-level OpenApiClient-delegate constructor). The + * convenience constructors that take a Context are exercised by + * instrumentation tests on a real device, which are out of this PR's scope. + */ +public class OAClientTest { + + @Test + public void getOpenApiClientReturnsUnderlyingDelegate() { + OpenApiClient delegate = mock(OpenApiClient.class); + OAClient zsw = new OAClient(delegate); + assertThat(zsw.getOpenApiClient()).isSameAs(delegate); + } + + @Test + @SuppressWarnings("unchecked") + public void callMethodDelegatesToOpenApiClient() throws Exception { + OpenApiClient delegate = mock(OpenApiClient.class); + when(delegate.callMethod(any(), any())).thenReturn("response".getBytes()); + OAClient zsw = new OAClient(delegate); + + Writer fakeWriter = mock(Writer.class); + ServiceData data = mock(ServiceData.class); + when(data.getZserioObject()).thenReturn(fakeWriter); + + byte[] result = zsw.callMethod("powerMethod", data, null); + assertThat(new String(result)).isEqualTo("response"); + verify(delegate).callMethod("powerMethod", fakeWriter); + } + + @Test + @SuppressWarnings("unchecked") + public void callMethodThrowsZserioErrorWhenZserioObjectMissing() { + OpenApiClient delegate = mock(OpenApiClient.class); + OAClient zsw = new OAClient(delegate); + ServiceData data = mock(ServiceData.class); + when(data.getZserioObject()).thenReturn(null); + assertThatThrownBy(() -> zsw.callMethod("m", data, null)) + .isInstanceOf(ZserioError.class) + .hasMessageContaining("getZserioObject() returned null"); + } + + @Test + @SuppressWarnings("unchecked") + public void callMethodWrapsHttpExceptionAsZserioError() throws Exception { + OpenApiClient delegate = mock(OpenApiClient.class); + when(delegate.callMethod(any(), any())).thenThrow(new HttpException("upstream-failed")); + OAClient zsw = new OAClient(delegate); + Writer fakeWriter = mock(Writer.class); + ServiceData data = mock(ServiceData.class); + when(data.getZserioObject()).thenReturn(fakeWriter); + assertThatThrownBy(() -> zsw.callMethod("powerMethod", data, null)) + .isInstanceOf(ZserioError.class) + .hasMessageContaining("powerMethod failed") + .hasMessageContaining("upstream-failed"); + } +} diff --git a/libs/jzswag/jzswag-api/README.md b/libs/jzswag/jzswag-api/README.md new file mode 100644 index 00000000..aa21e840 --- /dev/null +++ b/libs/jzswag/jzswag-api/README.md @@ -0,0 +1,24 @@ +# jzswag-api + +Platform-agnostic types and interfaces shared by every other Java module (`jzswag-shared`, `jzswag-jvm`, `jzswag-android`). + +## Contents + +- **`HttpConfig`** — per-request adhoc HTTP configuration (headers, query, cookies, basic-auth, proxy, OAuth2, API key). Mirrors C++ `httpcl::Config` and Python `HTTPConfig`. Immutable; build via `HttpConfig.builder()`. +- **`HttpSettings`** — multi-scope persistent settings registry (URL pattern → `HttpConfig`). Mirrors C++ `httpcl::Settings`. Loaded from `HTTP_SETTINGS_FILE` by `HttpSettingsLoader` in `jzswag-shared`. +- **`OpenAPIParameter`**, **`ParameterLocation`**, **`ParameterStyle`**, **`ParameterFormat`** — model types for OpenAPI 3.0 parameter encoding, including the zswag-specific `x-zserio-request-part` extension. +- **`SecurityScheme`**, **`SecuritySchemeType`**, **`SecurityRequirement`** — model types for the OpenAPI security flow, preserving OR-of-AND alternatives. +- **`IHttpClient`** — HTTP transport interface; impls apply persistent + adhoc config per request and expose `getPersistentSettings()` so the dispatch core can compute the effective config without downcasting. +- **`IKeychain`** — credential-store interface; impls live in the platform modules (`Keychain` on JVM, `AndroidKeychain` on Android) and are injected into `OAuth2Handler` and the platform HTTP clients. +- **`HttpRequest`**, **`HttpResponse`**, **`HttpException`** — request/response value types and the standard exception type for non-200 responses, connection failures, and timeouts. + +## Dependencies + +- Java 11+ +- zserio-runtime 2.16.1+ + +No third-party dependencies (the YAML loader for `HttpSettings` lives in `jzswag-shared` to keep this module dep-free). + +## Usage + +This module is a peer dependency of the platform implementations; you don't depend on it directly. See [`docs/java.md`](../../docs/java.md) for client usage examples. diff --git a/libs/jzswag/jzswag-api/build.gradle b/libs/jzswag/jzswag-api/build.gradle new file mode 100644 index 00000000..da832d3f --- /dev/null +++ b/libs/jzswag/jzswag-api/build.gradle @@ -0,0 +1,57 @@ +plugins { + id 'java-library' + id 'maven-publish' + id 'jacoco' +} + +jacoco { + toolVersion = '0.8.11' +} + +description = 'zswag Java API - Shared interfaces for Desktop and Android implementations' + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +dependencies { + // zserio runtime for service integration + api "io.github.ndsev:zserio-runtime:${rootProject.ext.zserio_version}" + + // Annotations + compileOnly 'org.jetbrains:annotations:24.1.0' + + // Kotlin standard library (optional - for Kotlin extensions if enabled) + // implementation "org.jetbrains.kotlin:kotlin-stdlib:${rootProject.ext.kotlin_version}" +} + +// Test dependencies +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.1' + testImplementation 'org.mockito:mockito-core:5.8.0' + testImplementation 'org.assertj:assertj-core:3.24.2' +} + +test { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + } +} + +publishing { + publications { + maven(MavenPublication) { + from components.java + artifactId = 'jzswag-api' + } + } +} diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpConfig.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpConfig.java new file mode 100644 index 00000000..a6af25e3 --- /dev/null +++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpConfig.java @@ -0,0 +1,465 @@ +package io.github.ndsev.zswag.api; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * Per-request HTTP configuration. Mirrors C++ {@code httpcl::Config} and Python + * {@code zswag.HTTPConfig}: extra headers, query parameters, cookies, optional + * basic-auth, proxy, OAuth2, and API key. + * + *

Instances are immutable. Use {@link Builder} to construct, {@link #toBuilder()} + * to derive a modified copy, and {@link #mergedWith(HttpConfig)} to combine two + * configs (the {@code other} config wins on scalar fields; multi-valued fields are + * unioned). + * + *

When held inside an {@link HttpSettings} multi-scope registry, the optional + * {@code scope} / {@code urlPattern} fields select which request URLs the config + * applies to. + */ +public final class HttpConfig { + private final Map> headers; + private final Map> query; + private final Map cookies; + @Nullable private final Duration timeout; + @Nullable private final Boolean sslStrict; + private final BasicAuthentication auth; + private final Proxy proxy; + private final OAuth2 oauth2; + private final String apiKey; + private final String scope; + private final Pattern urlPattern; + + private HttpConfig(Builder builder) { + this.headers = unmodifiableDeepCopy(builder.headers); + this.query = unmodifiableDeepCopy(builder.query); + this.cookies = Collections.unmodifiableMap(new LinkedHashMap<>(builder.cookies)); + this.timeout = builder.timeout; + this.sslStrict = builder.sslStrict; + this.auth = builder.auth; + this.proxy = builder.proxy; + this.oauth2 = builder.oauth2; + this.apiKey = builder.apiKey; + this.scope = builder.scope; + this.urlPattern = builder.urlPattern; + } + + private static Map> unmodifiableDeepCopy(Map> source) { + Map> copy = new LinkedHashMap<>(); + for (Map.Entry> entry : source.entrySet()) { + copy.put(entry.getKey(), Collections.unmodifiableList(new ArrayList<>(entry.getValue()))); + } + return Collections.unmodifiableMap(copy); + } + + @NotNull public Map> getHeaders() { return headers; } + @NotNull public Map> getQuery() { return query; } + @NotNull public Map getCookies() { return cookies; } + @NotNull public Duration getTimeout() { return timeout != null ? timeout : defaultTimeout(); } + + /** + * Returns the raw timeout field — {@code null} means "no opinion" (the effective + * value is determined by the transport's env-derived default). Used by transports + * (e.g. {@code JvmHttpClient}) to distinguish "caller explicitly set 60s" from + * "caller didn't touch it" so {@code HTTP_TIMEOUT} can override the latter. + */ + @Nullable public Duration getTimeoutOrNull() { return timeout; } + /** + * Per-request SSL strictness override. Defaults to {@code true} meaning + * "no opinion" — the effective SSL behavior is determined by the + * {@code HTTP_SSL_STRICT} environment variable (matching C++/Python). An + * explicit {@code false} on this config forces permissive mode regardless + * of env (no C++ equivalent — Java-only extension). + */ + public boolean isSslStrict() { return sslStrict == null || sslStrict; } + @NotNull public Optional getAuth() { return Optional.ofNullable(auth); } + @NotNull public Optional getProxy() { return Optional.ofNullable(proxy); } + @NotNull public Optional getOAuth2() { return Optional.ofNullable(oauth2); } + @NotNull public Optional getApiKey() { return Optional.ofNullable(apiKey); } + @NotNull public Optional getScope() { return Optional.ofNullable(scope); } + @NotNull public Optional getUrlPattern() { return Optional.ofNullable(urlPattern); } + + /** + * Returns the first header value for the given name, or empty if absent. + */ + @NotNull + public Optional getHeader(@NotNull String name) { + List values = headers.get(name); + return (values == null || values.isEmpty()) ? Optional.empty() : Optional.of(values.get(0)); + } + + /** + * Returns a new {@code HttpConfig} merged with {@code other}. Mirrors C++ + * {@code Config::operator|=}: cookies, headers, query are unioned (other's + * entries appended). Auth, proxy, apiKey, oauth2 from {@code other} replace + * this config's values when present (oauth2 sub-fields merge field-by-field). + */ + @NotNull + public HttpConfig mergedWith(@NotNull HttpConfig other) { + Builder b = toBuilder(); + for (Map.Entry> e : other.headers.entrySet()) { + for (String value : e.getValue()) b.addHeader(e.getKey(), value); + } + for (Map.Entry> e : other.query.entrySet()) { + for (String value : e.getValue()) b.addQuery(e.getKey(), value); + } + for (Map.Entry e : other.cookies.entrySet()) { + b.cookie(e.getKey(), e.getValue()); + } + if (other.auth != null) b.auth(other.auth); + if (other.proxy != null) b.proxy(other.proxy); + if (other.apiKey != null) b.apiKey(other.apiKey); + if (other.oauth2 != null) { + b.oauth2(other.oauth2.mergedOnto(this.oauth2)); + } + if (other.timeout != null) b.timeout(other.timeout); + if (other.sslStrict != null) b.sslStrict(other.sslStrict); + return b.build(); + } + + static Duration defaultTimeout() { + return Duration.ofSeconds(60); + } + + @NotNull public Builder toBuilder() { return new Builder(this); } + @NotNull public static Builder builder() { return new Builder(); } + + /** Empty config — useful as a starting point for merging. */ + @NotNull + public static HttpConfig empty() { + return builder().build(); + } + + /** + * Returns a redacted summary of this config suitable for logging. + * Passwords, secrets, API keys are masked. + */ + @NotNull + public String toSafeString() { + StringBuilder sb = new StringBuilder(); + if (auth != null) { + sb.append(" - Basic auth: user=").append(auth.user); + if (!auth.password.isEmpty()) sb.append(", password=****"); + if (!auth.keychain.isEmpty()) sb.append(", keychain=").append(auth.keychain); + sb.append("\n"); + } + if (oauth2 != null) { + sb.append(" - OAuth2: clientId=").append(oauth2.clientId); + if (!oauth2.clientSecret.isEmpty()) sb.append(", clientSecret=****"); + if (!oauth2.clientSecretKeychain.isEmpty()) sb.append(", clientSecretKeychain=").append(oauth2.clientSecretKeychain); + if (!oauth2.tokenUrlOverride.isEmpty()) sb.append(", tokenUrl=").append(oauth2.tokenUrlOverride); + if (!oauth2.audience.isEmpty()) sb.append(", audience=").append(oauth2.audience); + sb.append("\n"); + } + if (proxy != null) { + sb.append(" - Proxy: ").append(proxy.host).append(":").append(proxy.port); + if (!proxy.user.isEmpty()) sb.append(", user=").append(proxy.user).append(", password=****"); + sb.append("\n"); + } + if (apiKey != null) sb.append(" - API key: ****\n"); + if (!cookies.isEmpty()) sb.append(" - Cookies: ").append(cookies.keySet()).append("\n"); + if (!headers.isEmpty()) { + sb.append(" - Headers: "); + for (Map.Entry> entry : headers.entrySet()) { + String k = entry.getKey(); + String redacted = (k.equalsIgnoreCase("Authorization") || k.toLowerCase().contains("token") || k.toLowerCase().contains("secret")) + ? "****" : String.join(",", entry.getValue()); + sb.append(k).append("=").append(redacted).append(" "); + } + sb.append("\n"); + } + if (!query.isEmpty()) sb.append(" - Query keys: ").append(query.keySet()).append("\n"); + return sb.toString(); + } + + public static final class BasicAuthentication { + @NotNull public final String user; + @NotNull public final String password; + @NotNull public final String keychain; + + public BasicAuthentication(@NotNull String user, @NotNull String password, @NotNull String keychain) { + this.user = Objects.requireNonNull(user); + this.password = Objects.requireNonNull(password); + this.keychain = Objects.requireNonNull(keychain); + } + + public static BasicAuthentication ofPassword(String user, String password) { + return new BasicAuthentication(user, password, ""); + } + + public static BasicAuthentication ofKeychain(String user, String keychainService) { + return new BasicAuthentication(user, "", keychainService); + } + } + + public static final class Proxy { + @NotNull public final String host; + public final int port; + @NotNull public final String user; + @NotNull public final String password; + @NotNull public final String keychain; + + public Proxy(@NotNull String host, int port, @NotNull String user, @NotNull String password, @NotNull String keychain) { + this.host = Objects.requireNonNull(host); + this.port = port; + this.user = Objects.requireNonNull(user); + this.password = Objects.requireNonNull(password); + this.keychain = Objects.requireNonNull(keychain); + } + } + + /** + * OAuth2 client-credentials flow configuration. Mirrors C++ {@code Config::OAuth2}. + */ + public static final class OAuth2 { + public enum TokenEndpointAuthMethod { + /** RFC 6749 Section 2.3.1: HTTP Basic with client_id/client_secret in Authorization header. */ + RFC6749_CLIENT_SECRET_BASIC, + /** RFC 5849: OAuth 1.0 HMAC-SHA256 signature on the token request. */ + RFC5849_OAUTH1_SIGNATURE + } + + // Explicit-set flags for non-string fields, used by mergedOnto to know + // whether `this` actually configured the field or just carries the default. + static final int FLAG_USE_FOR_SPEC_FETCH = 1 << 0; + static final int FLAG_TOKEN_ENDPOINT_AUTH_METHOD = 1 << 1; + static final int FLAG_NONCE_LENGTH = 1 << 2; + + @NotNull public final String clientId; + @NotNull public final String clientSecret; + @NotNull public final String clientSecretKeychain; + @NotNull public final String tokenUrlOverride; + @NotNull public final String refreshUrlOverride; + @NotNull public final String audience; + @NotNull public final List scopesOverride; + public final boolean useForSpecFetch; + @NotNull public final TokenEndpointAuthMethod tokenEndpointAuthMethod; + public final int nonceLength; + private final int explicitFlags; + + public OAuth2( + @NotNull String clientId, + @NotNull String clientSecret, + @NotNull String clientSecretKeychain, + @NotNull String tokenUrlOverride, + @NotNull String refreshUrlOverride, + @NotNull String audience, + @NotNull List scopesOverride, + boolean useForSpecFetch, + @NotNull TokenEndpointAuthMethod tokenEndpointAuthMethod, + int nonceLength) { + // Public constructor: caller passed concrete values for everything, + // so all non-string fields are treated as explicitly set. + this(clientId, clientSecret, clientSecretKeychain, tokenUrlOverride, + refreshUrlOverride, audience, scopesOverride, + useForSpecFetch, tokenEndpointAuthMethod, nonceLength, + FLAG_USE_FOR_SPEC_FETCH | FLAG_TOKEN_ENDPOINT_AUTH_METHOD | FLAG_NONCE_LENGTH); + } + + private OAuth2( + @NotNull String clientId, + @NotNull String clientSecret, + @NotNull String clientSecretKeychain, + @NotNull String tokenUrlOverride, + @NotNull String refreshUrlOverride, + @NotNull String audience, + @NotNull List scopesOverride, + boolean useForSpecFetch, + @NotNull TokenEndpointAuthMethod tokenEndpointAuthMethod, + int nonceLength, + int explicitFlags) { + this.clientId = Objects.requireNonNull(clientId); + this.clientSecret = Objects.requireNonNull(clientSecret); + this.clientSecretKeychain = Objects.requireNonNull(clientSecretKeychain); + this.tokenUrlOverride = Objects.requireNonNull(tokenUrlOverride); + this.refreshUrlOverride = Objects.requireNonNull(refreshUrlOverride); + this.audience = Objects.requireNonNull(audience); + this.scopesOverride = Collections.unmodifiableList(new ArrayList<>(scopesOverride)); + this.useForSpecFetch = useForSpecFetch; + this.tokenEndpointAuthMethod = Objects.requireNonNull(tokenEndpointAuthMethod); + this.nonceLength = nonceLength; + this.explicitFlags = explicitFlags; + } + + @NotNull + OAuth2 mergedOnto(@Nullable OAuth2 base) { + if (base == null) return this; + boolean newUseForSpecFetch = (explicitFlags & FLAG_USE_FOR_SPEC_FETCH) != 0 + ? useForSpecFetch : base.useForSpecFetch; + TokenEndpointAuthMethod newTokenAuthMethod = (explicitFlags & FLAG_TOKEN_ENDPOINT_AUTH_METHOD) != 0 + ? tokenEndpointAuthMethod : base.tokenEndpointAuthMethod; + int newNonceLength = (explicitFlags & FLAG_NONCE_LENGTH) != 0 + ? nonceLength : base.nonceLength; + // Union the flags so further merges still see the explicit-set state from either side. + int mergedFlags = explicitFlags | base.explicitFlags; + return new OAuth2( + !clientId.isEmpty() ? clientId : base.clientId, + !clientSecret.isEmpty() ? clientSecret : base.clientSecret, + !clientSecretKeychain.isEmpty() ? clientSecretKeychain : base.clientSecretKeychain, + !tokenUrlOverride.isEmpty() ? tokenUrlOverride : base.tokenUrlOverride, + !refreshUrlOverride.isEmpty() ? refreshUrlOverride : base.refreshUrlOverride, + !audience.isEmpty() ? audience : base.audience, + !scopesOverride.isEmpty() ? scopesOverride : base.scopesOverride, + newUseForSpecFetch, + newTokenAuthMethod, + newNonceLength, + mergedFlags); + } + + public static Builder builder() { return new Builder(); } + + public static final class Builder { + private String clientId = ""; + private String clientSecret = ""; + private String clientSecretKeychain = ""; + private String tokenUrlOverride = ""; + private String refreshUrlOverride = ""; + private String audience = ""; + private List scopesOverride = new ArrayList<>(); + private boolean useForSpecFetch = true; + private TokenEndpointAuthMethod tokenEndpointAuthMethod = TokenEndpointAuthMethod.RFC6749_CLIENT_SECRET_BASIC; + private int nonceLength = 16; + private int explicitFlags = 0; + + public Builder clientId(String v) { this.clientId = v == null ? "" : v; return this; } + public Builder clientSecret(String v) { this.clientSecret = v == null ? "" : v; return this; } + public Builder clientSecretKeychain(String v) { this.clientSecretKeychain = v == null ? "" : v; return this; } + public Builder tokenUrl(String v) { this.tokenUrlOverride = v == null ? "" : v; return this; } + public Builder refreshUrl(String v) { this.refreshUrlOverride = v == null ? "" : v; return this; } + public Builder audience(String v) { this.audience = v == null ? "" : v; return this; } + public Builder scopes(List v) { this.scopesOverride = v == null ? new ArrayList<>() : new ArrayList<>(v); return this; } + public Builder useForSpecFetch(boolean v) { + this.useForSpecFetch = v; + this.explicitFlags |= FLAG_USE_FOR_SPEC_FETCH; + return this; + } + public Builder tokenEndpointAuthMethod(TokenEndpointAuthMethod v) { + this.tokenEndpointAuthMethod = v; + this.explicitFlags |= FLAG_TOKEN_ENDPOINT_AUTH_METHOD; + return this; + } + public Builder nonceLength(int v) { + if (v < 8 || v > 64) { + throw new IllegalArgumentException("tokenEndpointAuth.nonceLength must be between 8 and 64"); + } + this.nonceLength = v; + this.explicitFlags |= FLAG_NONCE_LENGTH; + return this; + } + public OAuth2 build() { + return new OAuth2(clientId, clientSecret, clientSecretKeychain, tokenUrlOverride, + refreshUrlOverride, audience, scopesOverride, useForSpecFetch, + tokenEndpointAuthMethod, nonceLength, explicitFlags); + } + } + } + + public static final class Builder { + private final Map> headers = new LinkedHashMap<>(); + private final Map> query = new LinkedHashMap<>(); + private final Map cookies = new LinkedHashMap<>(); + @Nullable private Duration timeout; + @Nullable private Boolean sslStrict; + private BasicAuthentication auth; + private Proxy proxy; + private OAuth2 oauth2; + private String apiKey; + private String scope; + private Pattern urlPattern; + + Builder() {} + + Builder(HttpConfig config) { + for (Map.Entry> e : config.headers.entrySet()) { + this.headers.put(e.getKey(), new ArrayList<>(e.getValue())); + } + for (Map.Entry> e : config.query.entrySet()) { + this.query.put(e.getKey(), new ArrayList<>(e.getValue())); + } + this.cookies.putAll(config.cookies); + this.timeout = config.timeout; + this.sslStrict = config.sslStrict; + this.auth = config.auth; + this.proxy = config.proxy; + this.oauth2 = config.oauth2; + this.apiKey = config.apiKey; + this.scope = config.scope; + this.urlPattern = config.urlPattern; + } + + @NotNull public Builder header(@NotNull String name, @NotNull String value) { + this.headers.computeIfAbsent(name, k -> new ArrayList<>()).clear(); + this.headers.get(name).add(value); + return this; + } + @NotNull public Builder addHeader(@NotNull String name, @NotNull String value) { + this.headers.computeIfAbsent(name, k -> new ArrayList<>()).add(value); + return this; + } + @NotNull public Builder headers(@NotNull Map entries) { + for (Map.Entry e : entries.entrySet()) header(e.getKey(), e.getValue()); + return this; + } + + @NotNull public Builder query(@NotNull String name, @NotNull String value) { + this.query.computeIfAbsent(name, k -> new ArrayList<>()).clear(); + this.query.get(name).add(value); + return this; + } + @NotNull public Builder addQuery(@NotNull String name, @NotNull String value) { + this.query.computeIfAbsent(name, k -> new ArrayList<>()).add(value); + return this; + } + + @NotNull public Builder cookie(@NotNull String name, @NotNull String value) { + this.cookies.put(name, value); + return this; + } + @NotNull public Builder cookies(@NotNull Map entries) { + this.cookies.putAll(entries); + return this; + } + + @NotNull public Builder timeout(@NotNull Duration timeout) { this.timeout = timeout; return this; } + @NotNull public Builder sslStrict(boolean sslStrict) { this.sslStrict = sslStrict; return this; } + /** Clears the explicit-set state of timeout, restoring the inherited default behaviour. */ + @NotNull public Builder unsetTimeout() { this.timeout = null; return this; } + /** Clears the explicit-set state of sslStrict, restoring "no opinion" — the + * effective behaviour is then determined by {@code HTTP_SSL_STRICT}. */ + @NotNull public Builder unsetSslStrict() { this.sslStrict = null; return this; } + + @NotNull public Builder auth(@Nullable BasicAuthentication auth) { this.auth = auth; return this; } + @NotNull public Builder basicAuth(@NotNull String user, @NotNull String password) { + this.auth = BasicAuthentication.ofPassword(user, password); + return this; + } + + @NotNull public Builder proxy(@Nullable Proxy proxy) { this.proxy = proxy; return this; } + + @NotNull public Builder oauth2(@Nullable OAuth2 oauth2) { this.oauth2 = oauth2; return this; } + + @NotNull public Builder apiKey(@Nullable String apiKey) { this.apiKey = apiKey; return this; } + + @NotNull public Builder bearerToken(@NotNull String token) { + return header("Authorization", "Bearer " + token); + } + + @NotNull public Builder scope(@Nullable String scope, @Nullable Pattern urlPattern) { + this.scope = scope; + this.urlPattern = urlPattern; + return this; + } + + @NotNull public HttpConfig build() { return new HttpConfig(this); } + } +} diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpException.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpException.java new file mode 100644 index 00000000..76cc7bf0 --- /dev/null +++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpException.java @@ -0,0 +1,41 @@ +package io.github.ndsev.zswag.api; + +import org.jetbrains.annotations.Nullable; + +import java.util.Arrays; + +/** + * Exception thrown when HTTP communication fails. + */ +public class HttpException extends Exception { + private final Integer statusCode; + private final byte[] responseBody; + + public HttpException(@Nullable String message) { + super(message); + this.statusCode = null; + this.responseBody = null; + } + + public HttpException(@Nullable String message, @Nullable Throwable cause) { + super(message, cause); + this.statusCode = null; + this.responseBody = null; + } + + public HttpException(@Nullable String message, int statusCode, @Nullable byte[] responseBody) { + super(message); + this.statusCode = statusCode; + this.responseBody = responseBody != null ? Arrays.copyOf(responseBody, responseBody.length) : null; + } + + @Nullable + public Integer getStatusCode() { + return statusCode; + } + + @Nullable + public byte[] getResponseBody() { + return responseBody != null ? Arrays.copyOf(responseBody, responseBody.length) : null; + } +} diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpRequest.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpRequest.java new file mode 100644 index 00000000..0e52046e --- /dev/null +++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpRequest.java @@ -0,0 +1,117 @@ +package io.github.ndsev.zswag.api; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Represents an HTTP request to be sent to a server. + */ +public class HttpRequest { + private final String method; + private final String url; + private final Map headers; + private final byte[] body; + + private HttpRequest(String method, String url, Map headers, byte[] body) { + this.method = method; + this.url = url; + this.headers = headers != null ? Collections.unmodifiableMap(new HashMap<>(headers)) : Collections.emptyMap(); + this.body = body != null ? Arrays.copyOf(body, body.length) : null; + } + + /** + * @return HTTP method (GET, POST, PUT, DELETE, etc.) + */ + @NotNull + public String getMethod() { + return method; + } + + /** + * @return Complete URL including scheme, host, path, and query string + */ + @NotNull + public String getUrl() { + return url; + } + + /** + * @return HTTP headers as unmodifiable map + */ + @NotNull + public Map getHeaders() { + return headers; + } + + /** + * @return Request body as defensive copy (may be null for GET/DELETE) + */ + @Nullable + public byte[] getBody() { + return body != null ? Arrays.copyOf(body, body.length) : null; + } + + /** + * Creates a new builder for constructing HttpRequest instances. + */ + @NotNull + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for HttpRequest instances. + */ + public static class Builder { + private String method; + private String url; + private Map headers = new HashMap<>(); + private byte[] body; + + private Builder() { + } + + @NotNull + public Builder method(@NotNull String method) { + this.method = method; + return this; + } + + @NotNull + public Builder url(@NotNull String url) { + this.url = url; + return this; + } + + @NotNull + public Builder header(@NotNull String name, @NotNull String value) { + this.headers.put(name, value); + return this; + } + + @NotNull + public Builder headers(@NotNull Map headers) { + this.headers.putAll(headers); + return this; + } + + @NotNull + public Builder body(@Nullable byte[] body) { + this.body = body; + return this; + } + + @NotNull + public HttpRequest build() { + if (method == null || url == null) { + throw new IllegalStateException("Method and URL are required"); + } + return new HttpRequest(method, url, headers, body); + } + } +} diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpResponse.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpResponse.java new file mode 100644 index 00000000..4f1ed9e8 --- /dev/null +++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpResponse.java @@ -0,0 +1,65 @@ +package io.github.ndsev.zswag.api; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Represents an HTTP response received from a server. + */ +public class HttpResponse { + private final int statusCode; + private final String statusMessage; + private final Map headers; + private final byte[] body; + + public HttpResponse(int statusCode, @Nullable String statusMessage, + @Nullable Map headers, @Nullable byte[] body) { + this.statusCode = statusCode; + this.statusMessage = statusMessage; + this.headers = headers != null ? Collections.unmodifiableMap(new HashMap<>(headers)) : Collections.emptyMap(); + this.body = body != null ? Arrays.copyOf(body, body.length) : null; + } + + /** + * @return HTTP status code (e.g., 200, 404, 500) + */ + public int getStatusCode() { + return statusCode; + } + + /** + * @return HTTP status message (e.g., "OK", "Not Found") + */ + @Nullable + public String getStatusMessage() { + return statusMessage; + } + + /** + * @return Response headers as unmodifiable map + */ + @NotNull + public Map getHeaders() { + return headers; + } + + /** + * @return Response body as defensive copy (may be null) + */ + @Nullable + public byte[] getBody() { + return body != null ? Arrays.copyOf(body, body.length) : null; + } + + /** + * @return true if status code is in the 2xx range + */ + public boolean isSuccessful() { + return statusCode >= 200 && statusCode < 300; + } +} diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpSettings.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpSettings.java new file mode 100644 index 00000000..9c923e22 --- /dev/null +++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpSettings.java @@ -0,0 +1,91 @@ +package io.github.ndsev.zswag.api; + +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * Multi-scope HTTP settings registry. Mirrors C++ {@code httpcl::Settings}: an + * ordered list of {@link HttpConfig} entries, each with an optional URL scope + * (glob-like pattern compiled to regex). For a given request URL, all matching + * entries are merged into a single effective {@link HttpConfig}. + * + *

Loading from {@code HTTP_SETTINGS_FILE} is performed by + * {@code HttpSettingsLoader} in jzswag-shared (which keeps this module free of + * a YAML dependency). + */ +public final class HttpSettings { + private final List entries; + + public HttpSettings(@NotNull List entries) { + this.entries = Collections.unmodifiableList(new ArrayList<>(entries)); + } + + /** Empty settings — useful as a default when {@code HTTP_SETTINGS_FILE} is unset. */ + @NotNull + public static HttpSettings empty() { + return new HttpSettings(Collections.emptyList()); + } + + @NotNull + public List getEntries() { + return entries; + } + + /** + * Returns the merged {@link HttpConfig} for all entries whose + * {@code urlPattern} matches the given URL. Iterates in declaration order; + * each match is merged onto the accumulated result via + * {@link HttpConfig#mergedWith(HttpConfig)}. + * + *

Mirrors C++ {@code Settings::operator[](url)}. + */ + @NotNull + public HttpConfig forUrl(@NotNull String url) { + HttpConfig result = HttpConfig.empty(); + for (HttpConfig entry : entries) { + Optional pattern = entry.getUrlPattern(); + if (!pattern.isPresent() || pattern.get().matcher(url).matches()) { + result = result.mergedWith(entry); + } + } + return result; + } + + /** + * Converts a glob-like scope pattern (with {@code *} as wildcard) into a + * compiled regex, escaping all other regex metacharacters. Mirrors C++ + * {@code convertToRegex} in {@code http-settings.cpp}. + */ + @NotNull + public static Pattern compileScope(@NotNull String scope) { + StringBuilder sb = new StringBuilder("^"); + for (int i = 0; i < scope.length(); i++) { + char c = scope.charAt(i); + switch (c) { + case '*': + sb.append(".*"); + break; + case '.': + sb.append("\\."); + break; + case '\\': + sb.append("\\\\"); + break; + case '^': case '$': case '|': case '(': case ')': + case '[': case ']': case '{': case '}': case '?': + case '+': case '-': case '!': + sb.append('\\').append(c); + break; + default: + sb.append(c); + } + } + sb.append(".*$"); + return Pattern.compile(sb.toString()); + } +} diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/IHttpClient.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/IHttpClient.java new file mode 100644 index 00000000..e8b918a4 --- /dev/null +++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/IHttpClient.java @@ -0,0 +1,38 @@ +package io.github.ndsev.zswag.api; + +import org.jetbrains.annotations.NotNull; + +/** + * Platform-agnostic HTTP client interface. Implementations are responsible for + * applying both their persistent {@link HttpSettings} (scope-matched against the + * request URL) and the per-call {@code adhoc} {@link HttpConfig} to the request + * before dispatch. Mirrors the C++ {@code httpcl::IHttpClient} contract. + */ +public interface IHttpClient { + /** + * Executes an HTTP request and returns the response. The {@code adhoc} config + * is merged on top of the implementation's persistent settings (scope-matched + * against {@link HttpRequest#getUrl()}). + * + * @param request The HTTP request to execute + * @param adhoc Per-call configuration (use {@link HttpConfig#empty()} for none) + * @return The HTTP response + * @throws HttpException if the request fails + */ + @NotNull + HttpResponse execute(@NotNull HttpRequest request, @NotNull HttpConfig adhoc) throws HttpException; + + /** + * Returns the persistent settings registry this client applies on every + * request. Exposed so that higher layers (e.g. the OpenAPI dispatch core) + * can compute the effective {@link HttpConfig} for a URL without having to + * downcast to a platform-specific implementation. + * + *

Default returns {@link HttpSettings#empty()} so simple lambda-based + * implementations (e.g. test stubs) don't need to override. + */ + @NotNull + default HttpSettings getPersistentSettings() { + return HttpSettings.empty(); + } +} diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/IKeychain.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/IKeychain.java new file mode 100644 index 00000000..5ca34d1d --- /dev/null +++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/IKeychain.java @@ -0,0 +1,26 @@ +package io.github.ndsev.zswag.api; + +import org.jetbrains.annotations.NotNull; + +/** + * Platform-agnostic keychain abstraction. Loads a stored password for + * {@code (service, user)} from the host's secure credential store. + * + *

Implementations live in the platform modules: {@code jzswag-jvm} shells + * out to {@code secret-tool} (Linux) / {@code security} (macOS); {@code + * jzswag-android} uses the Android Keystore (AES-256-GCM master key in the + * platform secure enclave) to encrypt entries stored in a private + * {@code SharedPreferences} file. + * + *

Implementations should throw an unchecked exception if the platform tool + * is missing or the entry doesn't exist — preferable to silently sending an + * empty password. + */ +public interface IKeychain { + /** + * Loads a stored password for {@code (service, user)}. Throws if the + * platform store is unreachable or the entry doesn't exist. + */ + @NotNull + String load(@NotNull String service, @NotNull String user); +} diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/IOpenApiClient.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/IOpenApiClient.java new file mode 100644 index 00000000..4ced0776 --- /dev/null +++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/IOpenApiClient.java @@ -0,0 +1,43 @@ +package io.github.ndsev.zswag.api; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; + +/** + * Interface for OpenAPI-compliant clients. + * Provides methods for calling OpenAPI endpoints with automatic parameter encoding + * and authentication handling. + */ +public interface IOpenApiClient { + /** + * Calls an OpenAPI method with the given parameters. + * + * @param methodPath The OpenAPI method path (e.g., "/users/{id}") + * @param parameters Map of parameter names to values + * @param requestBody Optional request body (zserio binary or null) + * @return The response body as byte array + * @throws HttpException if the call fails + */ + @Nullable + byte[] callMethod(@NotNull String methodPath, + @NotNull Map parameters, + @Nullable byte[] requestBody) throws HttpException; + + /** + * Gets the underlying HTTP client. + * + * @return The HTTP client + */ + @NotNull + IHttpClient getHttpClient(); + + /** + * Gets the OpenAPI specification URL or file path. + * + * @return The OpenAPI spec location + */ + @NotNull + String getOpenAPISpecLocation(); +} diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/KeychainException.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/KeychainException.java new file mode 100644 index 00000000..726c8258 --- /dev/null +++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/KeychainException.java @@ -0,0 +1,19 @@ +package io.github.ndsev.zswag.api; + +/** + * Thrown by {@link IKeychain} implementations when a stored secret cannot be + * loaded — the platform tool is missing, the entry doesn't exist, the user + * cancelled an unlock prompt, etc. Lives in the platform-agnostic API module + * so cross-platform consumers can catch a single stable type rather than the + * platform-specific {@code Keychain.KeychainException} or + * {@code AndroidKeychain.KeychainException}. + */ +public class KeychainException extends RuntimeException { + public KeychainException(String message) { + super(message); + } + + public KeychainException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/OpenAPIParameter.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/OpenAPIParameter.java new file mode 100644 index 00000000..f23f01a6 --- /dev/null +++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/OpenAPIParameter.java @@ -0,0 +1,98 @@ +package io.github.ndsev.zswag.api; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Optional; + +/** + * One OpenAPI operation parameter, enriched with the zswag-specific + * {@code x-zserio-request-part} extension that maps the parameter to a + * field path in the zserio request object. + */ +public class OpenAPIParameter { + /** Sentinel: when {@code requestPart == "*"}, the whole serialized request object goes here. */ + public static final String REQUEST_PART_WHOLE = "*"; + + private final String name; + private final ParameterLocation location; + private final ParameterStyle style; + private final ParameterFormat format; + private final boolean required; + private final boolean explode; + private final String requestPart; // null if no x-zserio-request-part on this parameter + + private OpenAPIParameter(Builder builder) { + this.name = builder.name; + this.location = builder.location; + this.style = builder.style; + this.format = builder.format != null ? builder.format : ParameterFormat.STRING; + this.required = builder.required; + this.explode = builder.explode; + this.requestPart = builder.requestPart; + } + + @NotNull public String getName() { return name; } + @NotNull public ParameterLocation getLocation() { return location; } + @NotNull public ParameterStyle getStyle() { return style; } + @NotNull public ParameterFormat getFormat() { return format; } + public boolean isRequired() { return required; } + public boolean isExplode() { return explode; } + + /** + * The {@code x-zserio-request-part} value: a dotted path into the zserio + * request struct (e.g. {@code "base.value"}), or {@code "*"} for the whole + * object as a binary blob, or empty if the parameter is not zswag-bound. + */ + @NotNull + public Optional getRequestPart() { + return Optional.ofNullable(requestPart); + } + + public boolean isWholeRequest() { + return REQUEST_PART_WHOLE.equals(requestPart); + } + + @NotNull + public static Builder builder(@NotNull String name, @NotNull ParameterLocation location) { + return new Builder(name, location); + } + + public static class Builder { + private final String name; + private final ParameterLocation location; + private ParameterStyle style; + private ParameterFormat format; + private boolean required; + private boolean explode; + private String requestPart; + + private Builder(String name, ParameterLocation location) { + this.name = name; + this.location = location; + this.style = getDefaultStyle(location); + this.explode = (location == ParameterLocation.QUERY || location == ParameterLocation.COOKIE); + } + + private static ParameterStyle getDefaultStyle(ParameterLocation location) { + switch (location) { + case PATH: + case HEADER: + return ParameterStyle.SIMPLE; + case QUERY: + case COOKIE: + return ParameterStyle.FORM; + default: + return ParameterStyle.SIMPLE; + } + } + + @NotNull public Builder style(@NotNull ParameterStyle style) { this.style = style; return this; } + @NotNull public Builder format(@NotNull ParameterFormat format) { this.format = format; return this; } + @NotNull public Builder required(boolean required) { this.required = required; return this; } + @NotNull public Builder explode(boolean explode) { this.explode = explode; return this; } + @NotNull public Builder requestPart(@Nullable String requestPart) { this.requestPart = requestPart; return this; } + + @NotNull public OpenAPIParameter build() { return new OpenAPIParameter(this); } + } +} diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/ParameterFormat.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/ParameterFormat.java new file mode 100644 index 00000000..ffb70d6d --- /dev/null +++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/ParameterFormat.java @@ -0,0 +1,31 @@ +package io.github.ndsev.zswag.api; + +/** + * Parameter value encoding format for zserio types. + */ +public enum ParameterFormat { + /** + * String representation (default) + */ + STRING, + + /** + * Hexadecimal encoding (0x prefix) + */ + HEX, + + /** + * Standard Base64 encoding (RFC 4648) + */ + BASE64, + + /** + * Base64 URL-safe encoding (RFC 4648 Section 5) + */ + BASE64URL, + + /** + * Raw binary data + */ + BINARY +} diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/ParameterLocation.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/ParameterLocation.java new file mode 100644 index 00000000..4ba43a26 --- /dev/null +++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/ParameterLocation.java @@ -0,0 +1,27 @@ +package io.github.ndsev.zswag.api; + +/** + * Specifies where a parameter appears in the HTTP request. + * Corresponds to OpenAPI parameter 'in' field. + */ +public enum ParameterLocation { + /** + * Parameter is part of the URL path (e.g., /users/{id}) + */ + PATH, + + /** + * Parameter is in the query string (e.g., ?page=1&limit=10) + */ + QUERY, + + /** + * Parameter is in HTTP headers + */ + HEADER, + + /** + * Parameter is in cookies + */ + COOKIE +} diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/ParameterStyle.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/ParameterStyle.java new file mode 100644 index 00000000..f0b49ecc --- /dev/null +++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/ParameterStyle.java @@ -0,0 +1,49 @@ +package io.github.ndsev.zswag.api; + +/** + * OpenAPI parameter serialization styles. + * Defines how parameter values are serialized in HTTP requests. + */ +public enum ParameterStyle { + /** + * Simple style (default for path and header parameters) + * Example: /users/5 or X-Header: 3,4,5 + */ + SIMPLE, + + /** + * Label style (for path parameters) + * Example: /users/.5 + */ + LABEL, + + /** + * Matrix style (for path parameters) + * Example: /users/;id=5 + */ + MATRIX, + + /** + * Form style (default for query and cookie parameters) + * Example: ?id=3&id=4&id=5 + */ + FORM, + + /** + * Space-delimited arrays + * Example: ?ids=3%204%205 + */ + SPACE_DELIMITED, + + /** + * Pipe-delimited arrays + * Example: ?ids=3|4|5 + */ + PIPE_DELIMITED, + + /** + * Deep object style (for nested objects) + * Example: ?color[R]=100&color[G]=200 + */ + DEEP_OBJECT +} diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/SecurityRequirement.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/SecurityRequirement.java new file mode 100644 index 00000000..5c2a0e4d --- /dev/null +++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/SecurityRequirement.java @@ -0,0 +1,38 @@ +package io.github.ndsev.zswag.api; + +import org.jetbrains.annotations.NotNull; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * One alternative inside an OpenAPI {@code security:} list. The keys are + * security-scheme names that ALL must be satisfied (AND); the outer list of + * alternatives expresses the OR. + * + *

Mirrors C++ {@code SecurityRequirement} (a single alternative); see + * {@code SecurityAlternatives} which is a {@code List}. + */ +public final class SecurityRequirement { + private final Map> required; + + public SecurityRequirement(@NotNull Map> required) { + Map> copy = new LinkedHashMap<>(); + for (Map.Entry> e : required.entrySet()) { + copy.put(e.getKey(), Collections.unmodifiableList(new java.util.ArrayList<>(e.getValue()))); + } + this.required = Collections.unmodifiableMap(copy); + } + + /** + * Map from security-scheme name to required OAuth2 scopes (empty list for + * non-OAuth2 schemes). All entries must be satisfied for this alternative + * to be considered fulfilled. + */ + @NotNull + public Map> getSchemes() { + return required; + } +} diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/SecurityScheme.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/SecurityScheme.java new file mode 100644 index 00000000..086179f8 --- /dev/null +++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/SecurityScheme.java @@ -0,0 +1,96 @@ +package io.github.ndsev.zswag.api; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * OpenAPI 3.0 security scheme. For HTTP, holds the scheme name (basic/bearer); + * for API key, holds {@code in} location and parameter name; for OAuth2, + * holds the {@code clientCredentials} flow's tokenUrl, refreshUrl, and the + * map of available scopes. + * + *

Only the {@code clientCredentials} OAuth2 flow is supported; the parser + * rejects schemes that declare other flows. + */ +public class SecurityScheme { + private final String name; + private final SecuritySchemeType type; + private final String scheme; + private final ParameterLocation apiKeyLocation; + private final String apiKeyName; + private final String tokenUrl; + private final String refreshUrl; + private final Map oauth2Scopes; + + private SecurityScheme(Builder builder) { + this.name = builder.name; + this.type = builder.type; + this.scheme = builder.scheme; + this.apiKeyLocation = builder.apiKeyLocation; + this.apiKeyName = builder.apiKeyName; + this.tokenUrl = builder.tokenUrl; + this.refreshUrl = builder.refreshUrl; + this.oauth2Scopes = Collections.unmodifiableMap(new LinkedHashMap<>(builder.oauth2Scopes)); + } + + @NotNull public String getName() { return name; } + @NotNull public SecuritySchemeType getType() { return type; } + @Nullable public String getScheme() { return scheme; } + @Nullable public ParameterLocation getApiKeyLocation() { return apiKeyLocation; } + @Nullable public String getApiKeyName() { return apiKeyName; } + + /** OAuth2 token endpoint URL declared in the spec, if {@link SecuritySchemeType#OAUTH2}. */ + @NotNull public Optional getTokenUrl() { return Optional.ofNullable(emptyToNull(tokenUrl)); } + + /** OAuth2 refresh URL declared in the spec, if any. */ + @NotNull public Optional getRefreshUrl() { return Optional.ofNullable(emptyToNull(refreshUrl)); } + + /** Scope name → human description, as declared in the OAuth2 {@code clientCredentials} flow. */ + @NotNull public Map getOAuth2Scopes() { return oauth2Scopes; } + + private static String emptyToNull(String s) { return (s == null || s.isEmpty()) ? null : s; } + + @NotNull + public static Builder builder(@NotNull String name, @NotNull SecuritySchemeType type) { + return new Builder(name, type); + } + + public static class Builder { + private final String name; + private final SecuritySchemeType type; + private String scheme; + private ParameterLocation apiKeyLocation; + private String apiKeyName; + private String tokenUrl; + private String refreshUrl; + private Map oauth2Scopes = new LinkedHashMap<>(); + + private Builder(String name, SecuritySchemeType type) { + this.name = name; + this.type = type; + } + + @NotNull public Builder scheme(@NotNull String scheme) { this.scheme = scheme; return this; } + @NotNull public Builder apiKeyLocation(@NotNull ParameterLocation location) { this.apiKeyLocation = location; return this; } + @NotNull public Builder apiKeyName(@NotNull String name) { this.apiKeyName = name; return this; } + @NotNull public Builder tokenUrl(@Nullable String tokenUrl) { this.tokenUrl = tokenUrl; return this; } + @NotNull public Builder refreshUrl(@Nullable String refreshUrl) { this.refreshUrl = refreshUrl; return this; } + @NotNull public Builder oauth2Scopes(@NotNull Map scopes) { + this.oauth2Scopes = new LinkedHashMap<>(scopes); + return this; + } + @NotNull public Builder addOAuth2Scope(@NotNull String name, @NotNull String description) { + this.oauth2Scopes.put(name, description); + return this; + } + + @NotNull public SecurityScheme build() { return new SecurityScheme(this); } + } +} diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/SecuritySchemeType.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/SecuritySchemeType.java new file mode 100644 index 00000000..4e0e2e12 --- /dev/null +++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/SecuritySchemeType.java @@ -0,0 +1,26 @@ +package io.github.ndsev.zswag.api; + +/** + * OpenAPI security scheme types. + */ +public enum SecuritySchemeType { + /** + * HTTP authentication schemes (Basic, Bearer, etc.) + */ + HTTP, + + /** + * API key in query, header, or cookie + */ + API_KEY, + + /** + * OAuth2 flows + */ + OAUTH2, + + /** + * OpenID Connect Discovery + */ + OPEN_ID_CONNECT +} diff --git a/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/HttpConfigTest.java b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/HttpConfigTest.java new file mode 100644 index 00000000..a7ee39e1 --- /dev/null +++ b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/HttpConfigTest.java @@ -0,0 +1,319 @@ +package io.github.ndsev.zswag.api; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HttpConfigTest { + + @Test + void emptyConfigHasDefaults() { + HttpConfig c = HttpConfig.empty(); + assertThat(c.getHeaders()).isEmpty(); + assertThat(c.getQuery()).isEmpty(); + assertThat(c.getCookies()).isEmpty(); + assertThat(c.getAuth()).isEmpty(); + assertThat(c.getProxy()).isEmpty(); + assertThat(c.getOAuth2()).isEmpty(); + assertThat(c.getApiKey()).isEmpty(); + assertThat(c.getScope()).isEmpty(); + assertThat(c.getUrlPattern()).isEmpty(); + assertThat(c.isSslStrict()).isTrue(); + assertThat(c.getTimeout()).isEqualTo(Duration.ofSeconds(60)); + } + + @Test + void builderCollectsHeadersQueriesCookies() { + HttpConfig c = HttpConfig.builder() + .header("X-A", "v1") + .addHeader("X-A", "v2") + .query("q", "1") + .addQuery("q", "2") + .cookie("session", "abc") + .build(); + assertThat(c.getHeaders().get("X-A")).containsExactly("v1", "v2"); + assertThat(c.getQuery().get("q")).containsExactly("1", "2"); + assertThat(c.getCookies()).containsEntry("session", "abc"); + } + + @Test + void headerReplacesPreviousValueAddHeaderAccumulates() { + HttpConfig c = HttpConfig.builder() + .header("X", "first") + .header("X", "second") // header() should clear and replace + .build(); + assertThat(c.getHeaders().get("X")).containsExactly("second"); + } + + @Test + void queryReplacesPreviousValueAddQueryAccumulates() { + HttpConfig c = HttpConfig.builder() + .query("k", "first") + .query("k", "second") // query() should clear and replace + .build(); + assertThat(c.getQuery().get("k")).containsExactly("second"); + } + + @Test + void getHeaderReturnsFirstValue() { + HttpConfig c = HttpConfig.builder().addHeader("X", "v1").addHeader("X", "v2").build(); + assertThat(c.getHeader("X")).contains("v1"); + assertThat(c.getHeader("Y")).isEmpty(); + } + + @Test + void headersBulkBuilderAcceptsMap() { + Map bulk = new LinkedHashMap<>(); + bulk.put("A", "1"); + bulk.put("B", "2"); + HttpConfig c = HttpConfig.builder().headers(bulk).build(); + assertThat(c.getHeaders().get("A")).containsExactly("1"); + assertThat(c.getHeaders().get("B")).containsExactly("2"); + } + + @Test + void cookiesBulkBuilderAcceptsMap() { + Map bulk = new LinkedHashMap<>(); + bulk.put("a", "1"); + bulk.put("b", "2"); + HttpConfig c = HttpConfig.builder().cookies(bulk).build(); + assertThat(c.getCookies()).containsEntry("a", "1").containsEntry("b", "2"); + } + + @Test + void bearerTokenSetsAuthorizationHeader() { + HttpConfig c = HttpConfig.builder().bearerToken("xyz").build(); + assertThat(c.getHeader("Authorization")).contains("Bearer xyz"); + } + + @Test + void basicAuthFactoryFormsKeychainOrPassword() { + HttpConfig.BasicAuthentication pwd = HttpConfig.BasicAuthentication.ofPassword("u", "p"); + assertThat(pwd.user).isEqualTo("u"); + assertThat(pwd.password).isEqualTo("p"); + assertThat(pwd.keychain).isEmpty(); + HttpConfig.BasicAuthentication kc = HttpConfig.BasicAuthentication.ofKeychain("u2", "svc"); + assertThat(kc.user).isEqualTo("u2"); + assertThat(kc.password).isEmpty(); + assertThat(kc.keychain).isEqualTo("svc"); + } + + @Test + void proxyConstructorStoresAllFields() { + HttpConfig.Proxy p = new HttpConfig.Proxy("127.0.0.1", 3128, "u", "pw", "kc"); + assertThat(p.host).isEqualTo("127.0.0.1"); + assertThat(p.port).isEqualTo(3128); + assertThat(p.user).isEqualTo("u"); + assertThat(p.password).isEqualTo("pw"); + assertThat(p.keychain).isEqualTo("kc"); + } + + @Test + void unsetTimeoutRestoresDefaultTimeout() { + HttpConfig base = HttpConfig.builder().timeout(Duration.ofSeconds(7)).build(); + assertThat(base.getTimeout()).isEqualTo(Duration.ofSeconds(7)); + HttpConfig restored = base.toBuilder().unsetTimeout().build(); + assertThat(restored.getTimeout()).isEqualTo(Duration.ofSeconds(60)); + } + + @Test + void unsetSslStrictRestoresDefault() { + HttpConfig c = HttpConfig.builder().sslStrict(false).build(); + assertThat(c.isSslStrict()).isFalse(); + HttpConfig restored = c.toBuilder().unsetSslStrict().build(); + assertThat(restored.isSslStrict()).isTrue(); + } + + @Test + void scopeSetterStoresScopeAndUrlPattern() { + Pattern p = Pattern.compile(".*"); + HttpConfig c = HttpConfig.builder().scope("globalish", p).build(); + assertThat(c.getScope()).contains("globalish"); + assertThat(c.getUrlPattern()).contains(p); + } + + @Test + void mergedWithUnionsAndOverrides() { + HttpConfig a = HttpConfig.builder() + .header("X-A", "1") + .query("q", "v1") + .cookie("c1", "x") + .basicAuth("alice", "p1") + .apiKey("apk-A") + .build(); + HttpConfig b = HttpConfig.builder() + .header("X-B", "2") + .query("q", "v2") + .cookie("c1", "y") // overwrite c1 + .basicAuth("bob", "p2") + .apiKey("apk-B") + .build(); + HttpConfig m = a.mergedWith(b); + assertThat(m.getHeaders()).containsKey("X-A").containsKey("X-B"); + assertThat(m.getQuery().get("q")).containsExactly("v1", "v2"); + assertThat(m.getCookies()).containsEntry("c1", "y"); + assertThat(m.getAuth().get().user).isEqualTo("bob"); + assertThat(m.getApiKey()).contains("apk-B"); + } + + @Test + void mergedWithProxyOverridesOnlyWhenSet() { + HttpConfig.Proxy proxy = new HttpConfig.Proxy("p", 8080, "", "", ""); + HttpConfig a = HttpConfig.builder().proxy(proxy).build(); + HttpConfig b = HttpConfig.builder().header("X", "y").build(); + assertThat(a.mergedWith(b).getProxy()).contains(proxy); + HttpConfig.Proxy proxy2 = new HttpConfig.Proxy("p2", 9090, "", "", ""); + HttpConfig c = HttpConfig.builder().proxy(proxy2).build(); + assertThat(a.mergedWith(c).getProxy().get().host).isEqualTo("p2"); + } + + @Test + void toBuilderRoundtripPreservesEverything() { + HttpConfig original = HttpConfig.builder() + .header("H", "h") + .query("q", "v") + .cookie("c", "x") + .timeout(Duration.ofSeconds(5)) + .sslStrict(false) + .basicAuth("u", "p") + .apiKey("k") + .scope("s", Pattern.compile(".*")) + .build(); + HttpConfig copy = original.toBuilder().build(); + assertThat(copy.getHeaders()).isEqualTo(original.getHeaders()); + assertThat(copy.getQuery()).isEqualTo(original.getQuery()); + assertThat(copy.getCookies()).isEqualTo(original.getCookies()); + assertThat(copy.getTimeout()).isEqualTo(original.getTimeout()); + assertThat(copy.isSslStrict()).isEqualTo(original.isSslStrict()); + assertThat(copy.getAuth().get().user).isEqualTo("u"); + assertThat(copy.getApiKey()).contains("k"); + assertThat(copy.getScope()).contains("s"); + } + + @Test + void headersAndQueryReturnedMapsAreImmutable() { + HttpConfig c = HttpConfig.builder().header("a", "1").query("b", "2").build(); + assertThatThrownBy(() -> c.getHeaders().put("x", Collections.singletonList("y"))) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> c.getQuery().put("x", Collections.singletonList("y"))) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> c.getCookies().put("x", "y")) + .isInstanceOf(UnsupportedOperationException.class); + // The list within is also immutable + assertThatThrownBy(() -> c.getHeaders().get("a").add("more")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void toSafeStringRedactsSensitiveFields() { + HttpConfig c = HttpConfig.builder() + .basicAuth("alice", "very-secret") + .header("Authorization", "Bearer xyz") + .header("X-Api-Token", "sensitive") + .header("X-Plain", "ok") + .cookie("session", "v") + .query("filter", "x") + .apiKey("k") + .proxy(new HttpConfig.Proxy("h", 1, "u", "pw", "")) + .oauth2(HttpConfig.OAuth2.builder() + .clientId("cid") + .clientSecret("csec") + .audience("aud") + .build()) + .build(); + String s = c.toSafeString(); + assertThat(s).contains("alice"); + assertThat(s).doesNotContain("very-secret"); + assertThat(s).doesNotContain("Bearer xyz"); + assertThat(s).doesNotContain("sensitive"); + assertThat(s).contains("X-Plain=ok"); + assertThat(s).contains("session"); + assertThat(s).contains("filter"); + assertThat(s).contains("API key: ****"); + assertThat(s).contains("cid"); + assertThat(s).doesNotContain("csec"); + assertThat(s).contains("aud"); + } + + @Test + void oauth2BuilderRejectsNonceLengthOutOfRange() { + assertThatThrownBy(() -> HttpConfig.OAuth2.builder().nonceLength(7)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> HttpConfig.OAuth2.builder().nonceLength(65)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void oauth2BuilderAcceptsValidNonceLengthBoundaries() { + HttpConfig.OAuth2 lo = HttpConfig.OAuth2.builder().nonceLength(8).build(); + HttpConfig.OAuth2 hi = HttpConfig.OAuth2.builder().nonceLength(64).build(); + assertThat(lo.nonceLength).isEqualTo(8); + assertThat(hi.nonceLength).isEqualTo(64); + } + + @Test + void oauth2BuilderHandlesNullStrings() { + HttpConfig.OAuth2 o = HttpConfig.OAuth2.builder() + .clientId(null) + .clientSecret(null) + .clientSecretKeychain(null) + .tokenUrl(null) + .refreshUrl(null) + .audience(null) + .scopes(null) + .build(); + assertThat(o.clientId).isEmpty(); + assertThat(o.clientSecret).isEmpty(); + assertThat(o.clientSecretKeychain).isEmpty(); + assertThat(o.tokenUrlOverride).isEmpty(); + assertThat(o.refreshUrlOverride).isEmpty(); + assertThat(o.audience).isEmpty(); + assertThat(o.scopesOverride).isEmpty(); + } + + @Test + void oauth2PublicConstructorTreatsAllFieldsAsExplicit() { + HttpConfig.OAuth2 base = HttpConfig.OAuth2.builder() + .clientId("base") + .nonceLength(32) + .useForSpecFetch(false) + .tokenEndpointAuthMethod(HttpConfig.OAuth2.TokenEndpointAuthMethod.RFC5849_OAUTH1_SIGNATURE) + .build(); + HttpConfig.OAuth2 override = new HttpConfig.OAuth2( + "override", "", "", "", "", "", + Arrays.asList("a"), true, + HttpConfig.OAuth2.TokenEndpointAuthMethod.RFC6749_CLIENT_SECRET_BASIC, + 40); + // Override is built via the public constructor → all flags explicit; merging onto base should win. + HttpConfig merged = HttpConfig.builder().oauth2(base).build() + .mergedWith(HttpConfig.builder().oauth2(override).build()); + HttpConfig.OAuth2 o = merged.getOAuth2().get(); + assertThat(o.clientId).isEqualTo("override"); + assertThat(o.nonceLength).isEqualTo(40); + assertThat(o.useForSpecFetch).isTrue(); + assertThat(o.tokenEndpointAuthMethod) + .isEqualTo(HttpConfig.OAuth2.TokenEndpointAuthMethod.RFC6749_CLIENT_SECRET_BASIC); + } + + @Test + void oauth2MergedOntoNullBaseReturnsThis() { + HttpConfig.OAuth2 only = HttpConfig.OAuth2.builder().clientId("solo").build(); + HttpConfig merged = HttpConfig.builder().build() + .mergedWith(HttpConfig.builder().oauth2(only).build()); + assertThat(merged.getOAuth2().get().clientId).isEqualTo("solo"); + } + + @Test + void httpConfigBuilderAuthSetterAcceptsNull() { + HttpConfig c = HttpConfig.builder().basicAuth("u", "p").auth(null).build(); + assertThat(c.getAuth()).isEmpty(); + } +} diff --git a/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/HttpRequestResponseTest.java b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/HttpRequestResponseTest.java new file mode 100644 index 00000000..34d0bbe1 --- /dev/null +++ b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/HttpRequestResponseTest.java @@ -0,0 +1,131 @@ +package io.github.ndsev.zswag.api; + +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HttpRequestResponseTest { + + @Test + void requestBuilderSetsAllFields() { + HttpRequest r = HttpRequest.builder() + .method("GET") + .url("https://example.com/x") + .header("X", "y") + .build(); + assertThat(r.getMethod()).isEqualTo("GET"); + assertThat(r.getUrl()).isEqualTo("https://example.com/x"); + assertThat(r.getHeaders()).containsEntry("X", "y"); + assertThat(r.getBody()).isNull(); + } + + @Test + void requestBodyIsDefensivelyCopied() { + byte[] orig = new byte[]{1, 2, 3}; + HttpRequest r = HttpRequest.builder().method("POST").url("u").body(orig).build(); + // Mutating the original must not affect the request body + orig[0] = 99; + assertThat(r.getBody()).containsExactly(1, 2, 3); + // The returned body is also a defensive copy + byte[] returned = r.getBody(); + returned[0] = 88; + assertThat(r.getBody()).containsExactly(1, 2, 3); + } + + @Test + void requestBuilderHeadersBulkAddsAll() { + Map bulk = new LinkedHashMap<>(); + bulk.put("A", "1"); + bulk.put("B", "2"); + HttpRequest r = HttpRequest.builder().method("GET").url("u").headers(bulk).build(); + assertThat(r.getHeaders()).containsEntry("A", "1").containsEntry("B", "2"); + } + + @Test + void requestBuilderRequiresMethodAndUrl() { + assertThatThrownBy(() -> HttpRequest.builder().method("GET").build()) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> HttpRequest.builder().url("u").build()) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void requestHeadersAreImmutable() { + HttpRequest r = HttpRequest.builder().method("GET").url("u").header("a", "b").build(); + assertThatThrownBy(() -> r.getHeaders().put("c", "d")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void responseStatusCodeAndIsSuccessful() { + HttpResponse ok = new HttpResponse(200, "OK", null, null); + HttpResponse created = new HttpResponse(201, null, null, null); + HttpResponse redirect = new HttpResponse(301, null, null, null); + HttpResponse notFound = new HttpResponse(404, "Not Found", null, null); + HttpResponse serverErr = new HttpResponse(500, null, null, null); + assertThat(ok.isSuccessful()).isTrue(); + assertThat(created.isSuccessful()).isTrue(); + assertThat(redirect.isSuccessful()).isFalse(); + assertThat(notFound.isSuccessful()).isFalse(); + assertThat(serverErr.isSuccessful()).isFalse(); + assertThat(ok.getStatusMessage()).isEqualTo("OK"); + assertThat(notFound.getStatusCode()).isEqualTo(404); + } + + @Test + void responseBodyIsDefensivelyCopied() { + byte[] orig = new byte[]{9, 8, 7}; + HttpResponse r = new HttpResponse(200, null, null, orig); + orig[0] = 0; + assertThat(r.getBody()).containsExactly(9, 8, 7); + byte[] read = r.getBody(); + read[0] = 0; + assertThat(r.getBody()).containsExactly(9, 8, 7); + } + + @Test + void responseHeadersAreImmutable() { + Map headers = new LinkedHashMap<>(); + headers.put("X", "y"); + HttpResponse r = new HttpResponse(200, null, headers, null); + assertThat(r.getHeaders()).containsEntry("X", "y"); + assertThatThrownBy(() -> r.getHeaders().put("c", "d")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void responseHandlesNullBodyAndHeaders() { + HttpResponse r = new HttpResponse(204, null, null, null); + assertThat(r.getBody()).isNull(); + assertThat(r.getHeaders()).isEmpty(); + assertThat(r.getStatusMessage()).isNull(); + } + + @Test + void httpExceptionConstructors() { + HttpException simple = new HttpException("oops"); + assertThat(simple).hasMessage("oops"); + assertThat(simple.getStatusCode()).isNull(); + assertThat(simple.getResponseBody()).isNull(); + + Throwable cause = new RuntimeException("root"); + HttpException withCause = new HttpException("err", cause); + assertThat(withCause.getCause()).isSameAs(cause); + + byte[] body = new byte[]{1, 2}; + HttpException withStatus = new HttpException("bad", 500, body); + assertThat(withStatus.getStatusCode()).isEqualTo(500); + body[0] = 99; + assertThat(withStatus.getResponseBody()).containsExactly(1, 2); + } + + @Test + void httpExceptionWithNullResponseBodyIsNull() { + HttpException e = new HttpException("x", 400, null); + assertThat(e.getResponseBody()).isNull(); + } +} diff --git a/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/HttpSettingsTest.java b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/HttpSettingsTest.java new file mode 100644 index 00000000..6bc82320 --- /dev/null +++ b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/HttpSettingsTest.java @@ -0,0 +1,79 @@ +package io.github.ndsev.zswag.api; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HttpSettingsTest { + + @Test + void emptyHasNoEntries() { + HttpSettings s = HttpSettings.empty(); + assertThat(s.getEntries()).isEmpty(); + assertThat(s.forUrl("https://anywhere/")).isNotNull(); + } + + @Test + void entriesAreImmutable() { + HttpSettings s = HttpSettings.empty(); + assertThatThrownBy(() -> s.getEntries().add(HttpConfig.empty())) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void compileScopeWildcardMatchesEverything() { + Pattern p = HttpSettings.compileScope("*"); + assertThat(p.matcher("anything").matches()).isTrue(); + assertThat(p.matcher("").matches()).isTrue(); + } + + @Test + void compileScopeEscapesDotsAndMetachars() { + // Dots are literal, parens/brackets/braces/?/+/-/!/^/$/| are escaped + Pattern p = HttpSettings.compileScope("a.b+c?[]{}|()-!^$"); + assertThat(p.matcher("a.b+c?[]{}|()-!^$").matches()).isTrue(); + assertThat(p.matcher("aXb+c?[]{}|()-!^$").matches()).isFalse(); + } + + @Test + void compileScopeEscapesBackslash() { + Pattern p = HttpSettings.compileScope("a\\b"); + assertThat(p.matcher("a\\b").matches()).isTrue(); + } + + @Test + void compileScopeMatchesGlobs() { + Pattern p = HttpSettings.compileScope("https://*.foo.com/*"); + assertThat(p.matcher("https://api.foo.com/data").matches()).isTrue(); + assertThat(p.matcher("https://foo.com/").matches()).isFalse(); + assertThat(p.matcher("http://api.foo.com/").matches()).isFalse(); + } + + @Test + void forUrlMergesAllMatchingScopes() { + HttpConfig wildcard = HttpConfig.builder() + .scope("*", HttpSettings.compileScope("*")) + .header("X-Generic", "global") + .build(); + HttpConfig fooSpecific = HttpConfig.builder() + .scope("https://*.foo.com/*", HttpSettings.compileScope("https://*.foo.com/*")) + .header("X-Foo", "yes") + .build(); + HttpSettings s = new HttpSettings(Arrays.asList(wildcard, fooSpecific)); + HttpConfig forFoo = s.forUrl("https://api.foo.com/x"); + assertThat(forFoo.getHeaders()).containsKey("X-Generic").containsKey("X-Foo"); + HttpConfig forOther = s.forUrl("https://bar.com/y"); + assertThat(forOther.getHeaders()).containsKey("X-Generic").doesNotContainKey("X-Foo"); + } + + @Test + void forUrlAppliesEntryWithoutPattern() { + HttpConfig anyEntry = HttpConfig.builder().header("X", "y").build(); + HttpSettings s = new HttpSettings(Arrays.asList(anyEntry)); + assertThat(s.forUrl("https://anywhere/").getHeader("X")).contains("y"); + } +} diff --git a/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/OpenAPIParameterTest.java b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/OpenAPIParameterTest.java new file mode 100644 index 00000000..b52f9b2a --- /dev/null +++ b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/OpenAPIParameterTest.java @@ -0,0 +1,77 @@ +package io.github.ndsev.zswag.api; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class OpenAPIParameterTest { + + @Test + void defaultStyleForPathIsSimple() { + OpenAPIParameter p = OpenAPIParameter.builder("id", ParameterLocation.PATH).build(); + assertThat(p.getStyle()).isEqualTo(ParameterStyle.SIMPLE); + assertThat(p.isExplode()).isFalse(); + } + + @Test + void defaultStyleForHeaderIsSimple() { + OpenAPIParameter p = OpenAPIParameter.builder("X", ParameterLocation.HEADER).build(); + assertThat(p.getStyle()).isEqualTo(ParameterStyle.SIMPLE); + assertThat(p.isExplode()).isFalse(); + } + + @Test + void defaultStyleForQueryIsFormExploded() { + OpenAPIParameter p = OpenAPIParameter.builder("q", ParameterLocation.QUERY).build(); + assertThat(p.getStyle()).isEqualTo(ParameterStyle.FORM); + assertThat(p.isExplode()).isTrue(); + } + + @Test + void defaultStyleForCookieIsFormExploded() { + OpenAPIParameter p = OpenAPIParameter.builder("c", ParameterLocation.COOKIE).build(); + assertThat(p.getStyle()).isEqualTo(ParameterStyle.FORM); + assertThat(p.isExplode()).isTrue(); + } + + @Test + void formatDefaultsToString() { + OpenAPIParameter p = OpenAPIParameter.builder("x", ParameterLocation.QUERY).build(); + assertThat(p.getFormat()).isEqualTo(ParameterFormat.STRING); + } + + @Test + void buildersStoreOverrides() { + OpenAPIParameter p = OpenAPIParameter.builder("x", ParameterLocation.QUERY) + .style(ParameterStyle.PIPE_DELIMITED) + .format(ParameterFormat.HEX) + .required(true) + .explode(false) + .requestPart("base.field") + .build(); + assertThat(p.getName()).isEqualTo("x"); + assertThat(p.getLocation()).isEqualTo(ParameterLocation.QUERY); + assertThat(p.getStyle()).isEqualTo(ParameterStyle.PIPE_DELIMITED); + assertThat(p.getFormat()).isEqualTo(ParameterFormat.HEX); + assertThat(p.isRequired()).isTrue(); + assertThat(p.isExplode()).isFalse(); + assertThat(p.getRequestPart()).contains("base.field"); + assertThat(p.isWholeRequest()).isFalse(); + } + + @Test + void wholeRequestSentinelDetected() { + OpenAPIParameter p = OpenAPIParameter.builder("body", ParameterLocation.QUERY) + .requestPart(OpenAPIParameter.REQUEST_PART_WHOLE) + .build(); + assertThat(p.isWholeRequest()).isTrue(); + assertThat(OpenAPIParameter.REQUEST_PART_WHOLE).isEqualTo("*"); + } + + @Test + void requestPartAbsentWhenNotSet() { + OpenAPIParameter p = OpenAPIParameter.builder("x", ParameterLocation.QUERY).build(); + assertThat(p.getRequestPart()).isEmpty(); + assertThat(p.isWholeRequest()).isFalse(); + } +} diff --git a/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/SecuritySchemeAndRequirementTest.java b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/SecuritySchemeAndRequirementTest.java new file mode 100644 index 00000000..7db43e73 --- /dev/null +++ b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/SecuritySchemeAndRequirementTest.java @@ -0,0 +1,114 @@ +package io.github.ndsev.zswag.api; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SecuritySchemeAndRequirementTest { + + @Test + void httpSchemeBuilderCarriesScheme() { + SecurityScheme s = SecurityScheme.builder("BasicHttp", SecuritySchemeType.HTTP) + .scheme("basic") + .build(); + assertThat(s.getName()).isEqualTo("BasicHttp"); + assertThat(s.getType()).isEqualTo(SecuritySchemeType.HTTP); + assertThat(s.getScheme()).isEqualTo("basic"); + assertThat(s.getApiKeyLocation()).isNull(); + assertThat(s.getApiKeyName()).isNull(); + assertThat(s.getTokenUrl()).isEmpty(); + assertThat(s.getRefreshUrl()).isEmpty(); + assertThat(s.getOAuth2Scopes()).isEmpty(); + } + + @Test + void apiKeySchemeStoresLocationAndName() { + SecurityScheme s = SecurityScheme.builder("AK", SecuritySchemeType.API_KEY) + .apiKeyLocation(ParameterLocation.HEADER) + .apiKeyName("X-Api-Key") + .build(); + assertThat(s.getApiKeyLocation()).isEqualTo(ParameterLocation.HEADER); + assertThat(s.getApiKeyName()).isEqualTo("X-Api-Key"); + } + + @Test + void oauth2BuilderAcceptsTokenUrlAndScopes() { + Map scopes = new LinkedHashMap<>(); + scopes.put("read", "Read access"); + scopes.put("write", "Write access"); + SecurityScheme s = SecurityScheme.builder("OA2", SecuritySchemeType.OAUTH2) + .tokenUrl("https://auth/token") + .refreshUrl("https://auth/refresh") + .oauth2Scopes(scopes) + .build(); + assertThat(s.getTokenUrl()).contains("https://auth/token"); + assertThat(s.getRefreshUrl()).contains("https://auth/refresh"); + assertThat(s.getOAuth2Scopes()).containsEntry("read", "Read access").containsEntry("write", "Write access"); + } + + @Test + void oauth2EmptyTokenUrlIsTreatedAsAbsent() { + SecurityScheme s = SecurityScheme.builder("OA2", SecuritySchemeType.OAUTH2) + .tokenUrl("") + .refreshUrl(null) + .build(); + assertThat(s.getTokenUrl()).isEmpty(); + assertThat(s.getRefreshUrl()).isEmpty(); + } + + @Test + void addOAuth2ScopeAccumulates() { + SecurityScheme s = SecurityScheme.builder("OA2", SecuritySchemeType.OAUTH2) + .tokenUrl("u") + .addOAuth2Scope("a", "alpha") + .addOAuth2Scope("b", "beta") + .build(); + assertThat(s.getOAuth2Scopes()).containsOnlyKeys("a", "b"); + } + + @Test + void oauth2ScopesMapIsImmutable() { + SecurityScheme s = SecurityScheme.builder("OA2", SecuritySchemeType.OAUTH2) + .tokenUrl("u").addOAuth2Scope("a", "alpha").build(); + assertThatThrownBy(() -> s.getOAuth2Scopes().put("x", "y")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void securityRequirementCopiesAndIsImmutable() { + Map> raw = new LinkedHashMap<>(); + raw.put("oauth2", Arrays.asList("scope1", "scope2")); + raw.put("apikey", Collections.emptyList()); + SecurityRequirement req = new SecurityRequirement(raw); + // Mutating the source map after construction must not affect the requirement + raw.put("evil", Collections.singletonList("x")); + assertThat(req.getSchemes()).containsOnlyKeys("oauth2", "apikey"); + // The returned map and lists are immutable + assertThatThrownBy(() -> req.getSchemes().put("x", Collections.emptyList())) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> req.getSchemes().get("oauth2").add("more")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void enumValuesAccessible() { + // Cheap exercising of enum value lists + assertThat(SecuritySchemeType.values()).contains( + SecuritySchemeType.HTTP, SecuritySchemeType.API_KEY, + SecuritySchemeType.OAUTH2, SecuritySchemeType.OPEN_ID_CONNECT); + assertThat(SecuritySchemeType.valueOf("OAUTH2")).isEqualTo(SecuritySchemeType.OAUTH2); + assertThat(ParameterStyle.values()).contains( + ParameterStyle.SIMPLE, ParameterStyle.LABEL, ParameterStyle.MATRIX, + ParameterStyle.FORM, ParameterStyle.SPACE_DELIMITED, + ParameterStyle.PIPE_DELIMITED, ParameterStyle.DEEP_OBJECT); + assertThat(ParameterFormat.valueOf("HEX")).isEqualTo(ParameterFormat.HEX); + assertThat(ParameterLocation.valueOf("PATH")).isEqualTo(ParameterLocation.PATH); + } +} diff --git a/libs/jzswag/jzswag-jvm/README.md b/libs/jzswag/jzswag-jvm/README.md new file mode 100644 index 00000000..0e94a79c --- /dev/null +++ b/libs/jzswag/jzswag-jvm/README.md @@ -0,0 +1,39 @@ +# jzswag-jvm + +JVM port of the zswag OpenAPI client. Built on the JDK 11 `HttpClient`; no JNI. Runs anywhere a standard JVM does — server, desktop, lambda, CLI, IDE plugin. Pulls in `jzswag-shared` for the platform-agnostic core (OpenAPI dispatch, parameter encoding, OAuth2 flow, YAML loader); only the HTTP transport, keychain, and logging are JVM-specific. + +## Role in the project + +- Implements zserio's `zserio.runtime.service.ServiceClientInterface` via `OAClient`, so a zserio-Java-generated `XClient` accepts an instance as its transport — the same idiom as Python's `services.MyService.Client(OAClient(url))` and C++'s `MyService::Client(openApiClient)`. +- Performs full request decomposition driven by the OpenAPI spec's `x-zserio-request-part` extension (logic in `jzswag-shared`). +- Handles all authentication schemes: HTTP Basic, HTTP Bearer, API key (header/query/cookie), OAuth2 client credentials with both `rfc6749-client-secret-basic` and `rfc5849-oauth1-signature` token-endpoint methods. +- Loads the same `HTTP_SETTINGS_FILE` YAML format as the C++ and Python clients, with URL-scoped persistent settings. +- Integrates with the platform keychain (Linux `secret-tool`, macOS `security`) for credential storage. + +## Documentation + +See [`docs/java.md`](../../docs/java.md) for the canonical Java client guide — usage idioms, configuration model, OAuth2 wiring, troubleshooting, and the running integration test. + +For the OpenAPI feature support matrix (Java vs C++ vs Python), see [the interop tables in README.md](../../README.md#openapi-options-interoperability). + +## JVM-specific contents + +- `OAClient` — public entry point; implements `ServiceClientInterface`. Constructs a `JvmHttpClient` + `Keychain` and delegates to the shared `OpenApiClient`. +- `JvmHttpClient` — JDK 11 `HttpClient` wrapper; merges persistent + adhoc config per request; applies SSL/proxy/basic-auth/cookies. +- `Keychain` — `IKeychain` impl that shells out to platform tools: Linux `secret-tool`, macOS `security`. Windows lookup is **not yet implemented** for the Java client (C++/Python clients support it via the C `keychain` library); attempting to load a `keychain:` reference on Windows throws `KeychainException` — see [`docs/java.md`](../../docs/java.md) for the workaround. +- `JzswagLogging` — wires `HTTP_LOG_LEVEL` + `HTTP_LOG_FILE` + `HTTP_LOG_FILE_MAXSIZE` env vars to the logback root logger via reflection. + +(All the cross-platform pieces — `OpenApiClient`, `OpenAPIParser`, `ParameterEncoder`, `ZserioReflection`, `OAuth2Handler`, `OAuth1Signature`, `HttpSettingsLoader` — live in `jzswag-shared`.) + +## Dependencies + +- `jzswag-shared` (transitively pulls `jzswag-api`, zserio-runtime, SnakeYAML, Gson, slf4j-api). +- Logback 1.4.14 (runtime SLF4J binding). + +## Testing + +```bash +./gradlew :libs:jzswag:jzswag-jvm:test +``` + +Line coverage ≥60%. Unit tests cover header / cookie / query / basic-auth merging via OkHttp's `MockWebServer`, the `Keychain` OS-detection branches, and the `JzswagLogging` init paths. Integration testing happens in `libs/jzswag/jzswag-test/`. diff --git a/libs/jzswag/jzswag-jvm/build.gradle b/libs/jzswag/jzswag-jvm/build.gradle new file mode 100644 index 00000000..4a544fdc --- /dev/null +++ b/libs/jzswag/jzswag-jvm/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'java-library' + id 'maven-publish' + id 'jacoco' +} + +jacoco { + toolVersion = '0.8.11' +} + +description = 'zswag Java JVM Client - Pure Java implementation using Java 11 HttpClient' + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + exceptionFormat "full" + } + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + } +} + +dependencies { + // Shared core (transitively pulls in jzswag-api, zserio-runtime, SnakeYAML, Gson, slf4j-api) + api project(':libs:jzswag:jzswag-shared') + + // Logging binding — Logback root for HTTP_LOG_LEVEL plumbing + runtimeOnly 'ch.qos.logback:logback-classic:1.4.14' + + // Annotations + compileOnly 'org.jetbrains:annotations:24.1.0' + + // Testing + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.1' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.1' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.1' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.1' + testImplementation 'org.mockito:mockito-core:5.8.0' + testImplementation 'org.mockito:mockito-junit-jupiter:5.8.0' + testImplementation 'org.assertj:assertj-core:3.24.2' + testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' +} + +publishing { + publications { + maven(MavenPublication) { + from components.java + artifactId = 'jzswag-jvm' + } + } +} diff --git a/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/JvmHttpClient.java b/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/JvmHttpClient.java new file mode 100644 index 00000000..9e56630a --- /dev/null +++ b/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/JvmHttpClient.java @@ -0,0 +1,387 @@ +package io.github.ndsev.zswag.jvm; + +import io.github.ndsev.zswag.api.*; +import io.github.ndsev.zswag.shared.HttpSettingsLoader; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.zip.GZIPInputStream; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.StringJoiner; +import java.util.TreeSet; + +/** + * JVM {@link IHttpClient} on top of the JDK 11 {@link HttpClient}. + * + *

On every request the client merges its persistent {@link HttpSettings} + * (URL-scope-matched) with the adhoc {@link HttpConfig} passed by the caller, + * matching the C++ {@code HttpLibHttpClient} flow. Headers, cookies, query + * parameters, basic-auth and proxy from the merged config are applied to the + * underlying request. + */ +public class JvmHttpClient implements IHttpClient { + private static final Logger logger = LoggerFactory.getLogger(JvmHttpClient.class); + + private static final int DEFAULT_TIMEOUT_SECONDS = 60; + + private final HttpSettingsLoader.HotReloader settingsReloader; + private final IKeychain keychain; + private final HttpClient strictClient; + private final HttpClient permissiveClient; + /** + * The env-derived default timeout, captured at construction. Applied to per-request + * dispatches when the merged {@link HttpConfig} did not explicitly set a timeout — + * matching C++ where the same {@code HTTP_TIMEOUT} value drives both connect and + * per-request behaviour. + */ + private final Duration defaultRequestTimeout; + private final java.util.concurrent.ConcurrentMap proxyClientCache = + new java.util.concurrent.ConcurrentHashMap<>(); + + /** + * Creates a client that loads persistent settings from {@code HTTP_SETTINGS_FILE} + * and applies {@code HTTP_TIMEOUT} / {@code HTTP_SSL_STRICT} env vars. Subsequent + * mtime changes to {@code HTTP_SETTINGS_FILE} are picked up automatically — matches + * the C++ {@code Settings::operator[]} hot-reload behaviour for credential rotation. + */ + public JvmHttpClient() { + this(HttpSettingsLoader.HotReloader.fromEnvironment(), new Keychain()); + } + + public JvmHttpClient(@NotNull HttpSettings persistentSettings) { + this(persistentSettings, new Keychain()); + } + + public JvmHttpClient(@NotNull HttpSettings persistentSettings, @NotNull IKeychain keychain) { + // Caller-supplied settings: no associated source file, so no hot-reload. + this(HttpSettingsLoader.HotReloader.of(null, persistentSettings), keychain); + } + + JvmHttpClient(@NotNull HttpSettingsLoader.HotReloader reloader, @NotNull IKeychain keychain) { + JzswagLogging.init(); + this.settingsReloader = reloader; + this.keychain = keychain; + Duration timeout = readTimeoutFromEnv(); + this.defaultRequestTimeout = timeout; + this.strictClient = buildJdkClient(timeout, true); + this.permissiveClient = buildJdkClient(timeout, false); + } + + /** For tests: explicit timeout override. */ + JvmHttpClient(@NotNull HttpSettings persistentSettings, @NotNull Duration timeout) { + this.settingsReloader = HttpSettingsLoader.HotReloader.of(null, persistentSettings); + this.keychain = new Keychain(); + this.defaultRequestTimeout = timeout; + this.strictClient = buildJdkClient(timeout, true); + this.permissiveClient = buildJdkClient(timeout, false); + } + + /** Returns the current persistent settings, re-reading the source file if its mtime changed. */ + @Override + @NotNull + public HttpSettings getPersistentSettings() { + return settingsReloader.current(); + } + + @NotNull + private static Duration readTimeoutFromEnv() { + String envTimeout = System.getenv("HTTP_TIMEOUT"); + if (envTimeout != null && !envTimeout.isEmpty()) { + try { + int seconds = Integer.parseInt(envTimeout); + return Duration.ofSeconds(seconds); + } catch (NumberFormatException e) { + logger.warn("Invalid HTTP_TIMEOUT value '{}', using default {}s", envTimeout, DEFAULT_TIMEOUT_SECONDS); + } + } + return Duration.ofSeconds(DEFAULT_TIMEOUT_SECONDS); + } + + private static boolean envSslStrict() { + // Match C++ httpcl::HttpLibHttpClient (libs/httpcl/src/http-client.cpp:57-58): + // any non-empty value enables strict; unset or empty disables. The Python + // client inherits this via pyzswagcl. Keep the semantics aligned across all + // three clients so a shared http-settings + env-var setup behaves identically. + // Surprising consequence: HTTP_SSL_STRICT=0 enables strict (any non-empty does). + String env = System.getenv("HTTP_SSL_STRICT"); + return env != null && !env.isEmpty(); + } + + private static HttpClient buildJdkClient(@NotNull Duration connectTimeout, boolean sslStrict) { + HttpClient.Builder b = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(connectTimeout); + if (!sslStrict) { + try { + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(null, new TrustManager[]{new TrustEverythingManager()}, new java.security.SecureRandom()); + b.sslContext(ctx); + } catch (NoSuchAlgorithmException | KeyManagementException e) { + logger.warn("Failed to install permissive SSLContext: {}", e.getMessage()); + } + } + return b.build(); + } + + @Override + @NotNull + public io.github.ndsev.zswag.api.HttpResponse execute(@NotNull io.github.ndsev.zswag.api.HttpRequest request, + @NotNull HttpConfig adhoc) throws HttpException { + // Merge: persistent (scope-matched) | adhoc — matches C++ Settings[uri] |= httpConfig_. + // settingsReloader.current() re-reads HTTP_SETTINGS_FILE if its mtime advanced since + // the last call, so credential rotation in long-running clients is picked up + // transparently (matches C++ Settings::operator[]). + HttpConfig effective = settingsReloader.current().forUrl(request.getUrl()).mergedWith(adhoc); + + // Effective SSL strictness: request.adhoc has the final say if it ever sets sslStrict=false, + // otherwise honor env. (Persistent settings file does not carry sslStrict in C++ either.) + boolean sslStrict = envSslStrict() && effective.isSslStrict(); + HttpClient jdk = sslStrict ? strictClient : permissiveClient; + + // JDK HttpClient takes proxy on the builder, not per-call. Cache per proxy + sslStrict + // tuple so concurrent requests through the same proxy share a connection pool / + // executor instead of each spawning a fresh HttpClient (which the previous version did). + if (effective.getProxy().isPresent()) { + HttpConfig.Proxy proxy = effective.getProxy().get(); + String cacheKey = proxy.host + ":" + proxy.port + "|" + (sslStrict ? "strict" : "permissive"); + Duration ctimeout = jdk.connectTimeout().orElse(Duration.ofSeconds(DEFAULT_TIMEOUT_SECONDS)); + jdk = proxyClientCache.computeIfAbsent(cacheKey, + k -> buildClientWithProxy(ctimeout, sslStrict, proxy)); + } + + try { + String url = applyQueryParams(request.getUrl(), effective.getQuery()); + logger.debug("Executing {} request to {}", request.getMethod(), url); + + // Per-request timeout: prefer an explicit caller value; otherwise fall back to the + // env-derived default (HTTP_TIMEOUT) captured at construction. Matches C++ where + // a single HTTP_TIMEOUT value drives both connect and per-request behaviour. + Duration explicitTimeout = effective.getTimeoutOrNull(); + Duration requestTimeout = explicitTimeout != null ? explicitTimeout : defaultRequestTimeout; + HttpRequest.Builder rb = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(requestTimeout); + + // Per-request headers from the OpenAPI dispatch layer take precedence: any + // header set here (e.g., OAuth2 Bearer minted by applySecurity) suppresses + // the same header from the merged persistent + adhoc layer below. This + // prevents the JDK HttpRequest.Builder.header() append-semantics from + // emitting duplicate Authorization (or other single-valued) headers when + // both layers configure them. + Set perRequestHeaderNames = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + for (Map.Entry h : request.getHeaders().entrySet()) { + rb.header(h.getKey(), h.getValue()); + perRequestHeaderNames.add(h.getKey()); + } + // Persistent + adhoc headers (multi-valued); skip names already supplied above. + for (Map.Entry> h : effective.getHeaders().entrySet()) { + if (perRequestHeaderNames.contains(h.getKey())) continue; + for (String v : h.getValue()) { + rb.header(h.getKey(), v); + } + } + + // Cookies → single Cookie header (skip if a Cookie header was already set per-request) + if (!effective.getCookies().isEmpty() && !perRequestHeaderNames.contains("Cookie")) { + StringJoiner cookieJoiner = new StringJoiner("; "); + for (Map.Entry e : effective.getCookies().entrySet()) { + cookieJoiner.add(e.getKey() + "=" + e.getValue()); + } + rb.header("Cookie", cookieJoiner.toString()); + } + + // Basic auth — only set if Authorization isn't already provided (e.g., bearer + // from per-request OAuth2 minting, or static Authorization in effective.headers) + if (effective.getAuth().isPresent() + && !perRequestHeaderNames.contains("Authorization") + && !containsHeaderIgnoreCase(effective.getHeaders(), "Authorization")) { + HttpConfig.BasicAuthentication auth = effective.getAuth().get(); + String password = !auth.password.isEmpty() + ? auth.password + : keychain.load(auth.keychain, auth.user); + String credentials = auth.user + ":" + password; + String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + rb.header("Authorization", "Basic " + encoded); + } + + // HTTP method + body + switch (request.getMethod().toUpperCase()) { + case "GET": + rb.GET(); + break; + case "POST": + rb.POST(request.getBody() != null + ? HttpRequest.BodyPublishers.ofByteArray(request.getBody()) + : HttpRequest.BodyPublishers.noBody()); + break; + case "PUT": + rb.PUT(request.getBody() != null + ? HttpRequest.BodyPublishers.ofByteArray(request.getBody()) + : HttpRequest.BodyPublishers.noBody()); + break; + case "DELETE": + if (request.getBody() != null) { + rb.method("DELETE", HttpRequest.BodyPublishers.ofByteArray(request.getBody())); + } else { + rb.DELETE(); + } + break; + default: + throw new HttpException("Unsupported HTTP method: " + request.getMethod()); + } + + HttpResponse response = jdk.send(rb.build(), HttpResponse.BodyHandlers.ofByteArray()); + logger.debug("Received response with status code: {}", response.statusCode()); + + // JDK HttpClient does NOT auto-decompress gzip responses (cpp-httplib and OkHttp do). + // If the server returns Content-Encoding: gzip we have to decompress here ourselves; + // otherwise the caller sees garbled bytes. Match the C++/Android behaviour transparently. + byte[] body = response.body(); + String contentEncoding = response.headers().firstValue("Content-Encoding").orElse(null); + boolean decompressed = false; + if (body != null && contentEncoding != null + && "gzip".equalsIgnoreCase(contentEncoding.trim())) { + try { + body = decompressGzip(body); + decompressed = true; + } catch (IOException e) { + logger.warn("Failed to decompress gzip response from {}: {}", url, e.getMessage()); + // Fall through with the original (compressed) bytes — caller will see the + // raw body and can decide. + } + } + + // After successful decompression, the original Content-Encoding/Length no longer + // describe the returned body. Strip them so downstream callers inspecting headers + // don't get a stale view (and so they don't try to decompress a second time). + Map respHeaders = convertHeaders(response.headers().map()); + if (decompressed) { + respHeaders.remove("Content-Encoding"); + respHeaders.remove("content-encoding"); + respHeaders.remove("Content-Length"); + respHeaders.remove("content-length"); + } + return new io.github.ndsev.zswag.api.HttpResponse( + response.statusCode(), + null, + respHeaders, + body); + + } catch (IOException e) { + logger.error("HTTP request failed: {}", e.getMessage(), e); + throw new HttpException("HTTP request failed: " + e.getMessage(), e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("HTTP request interrupted: {}", e.getMessage(), e); + throw new HttpException("HTTP request interrupted: " + e.getMessage(), e); + } + } + + private HttpClient buildClientWithProxy(@NotNull Duration timeout, boolean sslStrict, @NotNull HttpConfig.Proxy proxy) { + HttpClient.Builder b = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(timeout) + .proxy(ProxySelector.of(new InetSocketAddress(proxy.host, proxy.port))); + if (!sslStrict) { + try { + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(null, new TrustManager[]{new TrustEverythingManager()}, new java.security.SecureRandom()); + b.sslContext(ctx); + } catch (NoSuchAlgorithmException | KeyManagementException e) { + logger.warn("Failed to install permissive SSLContext: {}", e.getMessage()); + } + } + if (!proxy.user.isEmpty()) { + String password = !proxy.password.isEmpty() ? proxy.password : keychain.load(proxy.keychain, proxy.user); + b.authenticator(new java.net.Authenticator() { + @Override + protected java.net.PasswordAuthentication getPasswordAuthentication() { + return new java.net.PasswordAuthentication(proxy.user, password.toCharArray()); + } + }); + } + return b.build(); + } + + /** + * Decompresses a gzip-encoded byte buffer. Used to transparently handle + * Content-Encoding: gzip responses, since the JDK HttpClient (unlike cpp-httplib + * and OkHttp) does not auto-decompress. + */ + @NotNull + private static byte[] decompressGzip(@NotNull byte[] gzipped) throws IOException { + try (GZIPInputStream gz = new GZIPInputStream(new ByteArrayInputStream(gzipped)); + ByteArrayOutputStream out = new ByteArrayOutputStream(gzipped.length * 2)) { + byte[] buf = new byte[8192]; + int n; + while ((n = gz.read(buf)) > 0) { + out.write(buf, 0, n); + } + return out.toByteArray(); + } + } + + private static boolean containsHeaderIgnoreCase(@NotNull Map> headers, @NotNull String name) { + for (String key : headers.keySet()) { + if (name.equalsIgnoreCase(key)) return true; + } + return false; + } + + @NotNull + private static String applyQueryParams(@NotNull String baseUrl, @NotNull Map> query) { + if (query.isEmpty()) return baseUrl; + StringBuilder sb = new StringBuilder(baseUrl); + boolean hasQuery = baseUrl.indexOf('?') >= 0; + for (Map.Entry> e : query.entrySet()) { + for (String v : e.getValue()) { + sb.append(hasQuery ? '&' : '?'); + hasQuery = true; + sb.append(java.net.URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8)); + sb.append('='); + sb.append(java.net.URLEncoder.encode(v, StandardCharsets.UTF_8)); + } + } + return sb.toString(); + } + + @NotNull + private static Map convertHeaders(@NotNull Map> headersMap) { + Map result = new java.util.LinkedHashMap<>(); + for (Map.Entry> e : headersMap.entrySet()) { + if (!e.getValue().isEmpty()) { + result.put(e.getKey(), e.getValue().get(0)); + } + } + return result; + } + + private static final class TrustEverythingManager implements X509TrustManager { + @Override public void checkClientTrusted(X509Certificate[] chain, String authType) {} + @Override public void checkServerTrusted(X509Certificate[] chain, String authType) {} + @Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } + } +} diff --git a/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/JzswagLogging.java b/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/JzswagLogging.java new file mode 100644 index 00000000..c0ccbe73 --- /dev/null +++ b/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/JzswagLogging.java @@ -0,0 +1,166 @@ +package io.github.ndsev.zswag.jvm; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Method; +import java.util.Locale; + +/** + * Wires up zswag's logging-related environment variables to the SLF4J/logback + * root logger so the JVM client produces the same diagnostics as the C++ client. + * + *

    + *
  • {@code HTTP_LOG_LEVEL} — sets the root logger level (debug, trace, …).
  • + *
  • {@code HTTP_LOG_FILE} — adds a {@code RollingFileAppender} writing to this + * path. C++ uses three rotation indices ({@code FILE}, {@code FILE-1}, + * {@code FILE-2}); we mirror that.
  • + *
  • {@code HTTP_LOG_FILE_MAXSIZE} — rotation size threshold in bytes + * (default 1 GB, matching C++ {@code log.cpp}).
  • + *
+ * + *

Safe to call from anywhere; idempotent. Has no effect if logback is not + * the active SLF4J binding (e.g. on Android with a different logger). + */ +public final class JzswagLogging { + private static volatile boolean initialised = false; + private static final Object LOCK = new Object(); + private static final long DEFAULT_MAX_FILE_SIZE = 1024L * 1024L * 1024L; // 1 GB, matches C++ + + private JzswagLogging() {} + + public static void init() { + if (initialised) return; + synchronized (LOCK) { + if (initialised) return; + String level = System.getenv("HTTP_LOG_LEVEL"); + if (level != null && !level.isEmpty()) { + if (!setLogbackRootLevel(level)) { + System.err.println("[jzswag] HTTP_LOG_LEVEL=" + level + + " but the SLF4J binding is not logback; ignoring."); + } + } + String logFile = System.getenv("HTTP_LOG_FILE"); + if (logFile != null && !logFile.isEmpty()) { + long maxSize = parseMaxSize(System.getenv("HTTP_LOG_FILE_MAXSIZE")); + if (!attachLogbackFileAppender(logFile, maxSize)) { + System.err.println("[jzswag] HTTP_LOG_FILE=" + logFile + + " but the SLF4J binding is not logback; file logging ignored."); + } + } + initialised = true; + } + } + + private static long parseMaxSize(String env) { + if (env == null || env.isEmpty()) return DEFAULT_MAX_FILE_SIZE; + try { + return Long.parseLong(env.trim()); + } catch (NumberFormatException e) { + System.err.println("[jzswag] Invalid HTTP_LOG_FILE_MAXSIZE='" + env + + "', using default " + DEFAULT_MAX_FILE_SIZE + " bytes."); + return DEFAULT_MAX_FILE_SIZE; + } + } + + private static boolean setLogbackRootLevel(String levelName) { + try { + org.slf4j.ILoggerFactory factory = LoggerFactory.getILoggerFactory(); + if (!"ch.qos.logback.classic.LoggerContext".equals(factory.getClass().getName())) { + return false; + } + Logger root = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); + Class levelClass = Class.forName("ch.qos.logback.classic.Level"); + Method toLevel = levelClass.getMethod("toLevel", String.class); + Object level = toLevel.invoke(null, levelName.toUpperCase(Locale.ROOT)); + Class logbackLogger = Class.forName("ch.qos.logback.classic.Logger"); + Method setLevel = logbackLogger.getMethod("setLevel", levelClass); + setLevel.invoke(root, level); + return true; + } catch (ReflectiveOperationException | RuntimeException e) { + return false; + } + } + + /** + * Builds a {@code RollingFileAppender} with a {@code FixedWindowRollingPolicy} + * (3-file window: FILE, FILE-1, FILE-2) and a {@code SizeBasedTriggeringPolicy}. + * Mirrors the C++ {@code log.cpp} setup. All wiring is done reflectively so this + * class doesn't compile-time-depend on logback (the api/shared modules don't either). + */ + private static boolean attachLogbackFileAppender(String logFile, long maxFileSizeBytes) { + try { + org.slf4j.ILoggerFactory factory = LoggerFactory.getILoggerFactory(); + if (!"ch.qos.logback.classic.LoggerContext".equals(factory.getClass().getName())) { + return false; + } + // Pattern matches the typical logback default — match cpp's log line layout + // enough that grep across language logs is feasible. + String pattern = "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"; + + // PatternLayoutEncoder + Class peClass = Class.forName("ch.qos.logback.classic.encoder.PatternLayoutEncoder"); + Object encoder = peClass.getDeclaredConstructor().newInstance(); + peClass.getMethod("setContext", Class.forName("ch.qos.logback.core.Context")) + .invoke(encoder, factory); + peClass.getMethod("setPattern", String.class).invoke(encoder, pattern); + peClass.getMethod("start").invoke(encoder); + + // FixedWindowRollingPolicy — 3-file window FILE / FILE-1 / FILE-2 + Class rpClass = Class.forName("ch.qos.logback.core.rolling.FixedWindowRollingPolicy"); + Object rollingPolicy = rpClass.getDeclaredConstructor().newInstance(); + rpClass.getMethod("setContext", Class.forName("ch.qos.logback.core.Context")) + .invoke(rollingPolicy, factory); + rpClass.getMethod("setFileNamePattern", String.class) + .invoke(rollingPolicy, logFile + "-%i"); + rpClass.getMethod("setMinIndex", int.class).invoke(rollingPolicy, 1); + rpClass.getMethod("setMaxIndex", int.class).invoke(rollingPolicy, 2); + + // SizeBasedTriggeringPolicy + Class tpClass = Class.forName("ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy"); + Object triggeringPolicy = tpClass.getDeclaredConstructor().newInstance(); + tpClass.getMethod("setContext", Class.forName("ch.qos.logback.core.Context")) + .invoke(triggeringPolicy, factory); + // FileSize.valueOf accepts strings like "1GB"; using a raw byte count via toString. + Class fileSizeClass = Class.forName("ch.qos.logback.core.util.FileSize"); + Method fileSizeValueOf = fileSizeClass.getMethod("valueOf", String.class); + Object fileSize = fileSizeValueOf.invoke(null, maxFileSizeBytes + ""); + tpClass.getMethod("setMaxFileSize", fileSizeClass).invoke(triggeringPolicy, fileSize); + tpClass.getMethod("start").invoke(triggeringPolicy); + + // RollingFileAppender + Class rfaClass = Class.forName("ch.qos.logback.core.rolling.RollingFileAppender"); + Object appender = rfaClass.getDeclaredConstructor().newInstance(); + rfaClass.getMethod("setContext", Class.forName("ch.qos.logback.core.Context")) + .invoke(appender, factory); + rfaClass.getMethod("setName", String.class).invoke(appender, "jzswag-http-log-file"); + rfaClass.getMethod("setFile", String.class).invoke(appender, logFile); + rfaClass.getMethod("setEncoder", Class.forName("ch.qos.logback.core.encoder.Encoder")) + .invoke(appender, encoder); + // Hook the rolling/triggering policies onto the appender + each other. + rfaClass.getMethod("setRollingPolicy", + Class.forName("ch.qos.logback.core.rolling.RollingPolicy")) + .invoke(appender, rollingPolicy); + rfaClass.getMethod("setTriggeringPolicy", + Class.forName("ch.qos.logback.core.rolling.TriggeringPolicy")) + .invoke(appender, triggeringPolicy); + // setParent on rollingPolicy needs the appender — order matters. + rpClass.getMethod("setParent", + Class.forName("ch.qos.logback.core.FileAppender")) + .invoke(rollingPolicy, appender); + rpClass.getMethod("start").invoke(rollingPolicy); + rfaClass.getMethod("start").invoke(appender); + + // Attach to root logger. + Logger root = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); + Class logbackLogger = Class.forName("ch.qos.logback.classic.Logger"); + Method addAppender = logbackLogger.getMethod("addAppender", + Class.forName("ch.qos.logback.core.Appender")); + addAppender.invoke(root, appender); + return true; + } catch (ReflectiveOperationException | RuntimeException e) { + System.err.println("[jzswag] Failed to install HTTP_LOG_FILE appender: " + e.getMessage()); + return false; + } + } +} diff --git a/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/Keychain.java b/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/Keychain.java new file mode 100644 index 00000000..ee515a27 --- /dev/null +++ b/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/Keychain.java @@ -0,0 +1,148 @@ +package io.github.ndsev.zswag.jvm; + +import io.github.ndsev.zswag.api.IKeychain; +import io.github.ndsev.zswag.api.KeychainException; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** + * JVM keychain integration: load credentials from the OS-native credential + * store. Mirrors C++ {@code httpcl::secret} (which wraps the {@code keychain} + * library). + * + *

Implementation strategy: shells out to the platform-native keychain CLI + * (no JNI). Linux: {@code secret-tool}; macOS: {@code security}; Windows: + * not yet implemented. + * + *

If the platform tool is unavailable or returns no entry, callers see a + * {@link KeychainException} — preferable to silently sending an empty password. + */ +public final class Keychain implements IKeychain { + private static final Logger logger = LoggerFactory.getLogger(Keychain.class); + + /** Matches C++ {@code KEYCHAIN_PACKAGE} so secrets stored by C++ are visible to Java. */ + static final String PACKAGE = "lib.openapi.zserio.client"; + + private static final long TIMEOUT_SECONDS = 60; + + public Keychain() {} + + @Override + @NotNull + public String load(@NotNull String service, @NotNull String user) { + if (service.isEmpty()) { + throw new KeychainException("keychain: service identifier must not be empty"); + } + logger.debug("Loading secret (service={}, user={}) ...", service, user); + Os os = detectOs(); + try { + switch (os) { + case LINUX: + return loadLinux(service, user); + case MACOS: + return loadMacOs(service, user); + case WINDOWS: + return loadWindows(service, user); + default: + throw new KeychainException("keychain: unsupported platform " + System.getProperty("os.name")); + } + } catch (InterruptedException e) { + // Real interruption — restore the interrupt flag so callers can react. + Thread.currentThread().interrupt(); + throw new KeychainException("keychain: interrupted while loading secret: " + e.getMessage(), e); + } catch (IOException e) { + // I/O failure — do NOT touch the interrupt flag. + throw new KeychainException("keychain: failed to load secret: " + e.getMessage(), e); + } + } + + private static String loadLinux(String service, String user) throws IOException, InterruptedException { + ProcessBuilder pb = new ProcessBuilder("secret-tool", "lookup", + "package", PACKAGE, + "service", service, + "user", user); + return runReadStdout(pb, "secret-tool"); + } + + private static String loadMacOs(String service, String user) throws IOException, InterruptedException { + ProcessBuilder pb = new ProcessBuilder("security", "find-generic-password", + "-s", service, + "-a", user, + "-w"); + return runReadStdout(pb, "security").trim(); + } + + /** + * Windows credential manager support is not yet implemented for the Java JVM client. + *

+ * The C++ httpcl library wraps the C-language {@code keychain} library which handles + * the Windows Data Protection API (DPAPI) under the hood; Python (via pyzswagcl) + * inherits that. A Java equivalent would either shell out to {@code cmdkey}/ + * {@code vaultcmd} or call DPAPI through JNA — both are non-trivial and have been + * scheduled for a separate follow-up. + *

+ * Workaround for Windows users today: put cleartext credentials in + * {@code http-settings.yaml} via {@code password:} (instead of {@code keychain:}), + * or pass them adhoc through {@code HttpConfig.basicAuth(user, password)}. + */ + private static String loadWindows(String service, String user) { + throw new KeychainException( + "keychain: Windows credential manager lookup is not yet implemented in the Java JVM client. " + + "Workaround: use a cleartext 'password:' entry in http-settings.yaml, or " + + "configure credentials adhoc via HttpConfig.basicAuth(user, password). " + + "See README.md → Keychain integration for details. " + + "(The C++ and Python clients DO support Windows credential manager.)"); + } + + private static String runReadStdout(@NotNull ProcessBuilder pb, @NotNull String tool) throws IOException, InterruptedException { + pb.redirectErrorStream(false); + Process p; + try { + p = pb.start(); + } catch (IOException e) { + throw new KeychainException("keychain: '" + tool + "' is not installed or not on PATH", e); + } + if (!p.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + p.destroyForcibly(); + throw new KeychainException("keychain: '" + tool + "' timed out after " + TIMEOUT_SECONDS + "s"); + } + StringBuilder out = new StringBuilder(); + try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = r.readLine()) != null) out.append(line).append('\n'); + } + if (p.exitValue() != 0) { + String stderr; + try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getErrorStream(), StandardCharsets.UTF_8))) { + StringBuilder e = new StringBuilder(); + String line; + while ((line = r.readLine()) != null) e.append(line).append('\n'); + stderr = e.toString().trim(); + } + throw new KeychainException("keychain: '" + tool + "' exited " + p.exitValue() + + (stderr.isEmpty() ? "" : ": " + stderr)); + } + String s = out.toString(); + if (s.endsWith("\n")) s = s.substring(0, s.length() - 1); + return s; + } + + private enum Os { LINUX, MACOS, WINDOWS, UNKNOWN } + + private static Os detectOs() { + String name = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + if (name.contains("linux")) return Os.LINUX; + if (name.contains("mac")) return Os.MACOS; + if (name.contains("win")) return Os.WINDOWS; + return Os.UNKNOWN; + } + +} diff --git a/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/OAClient.java b/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/OAClient.java new file mode 100644 index 00000000..434c25ab --- /dev/null +++ b/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/OAClient.java @@ -0,0 +1,130 @@ +package io.github.ndsev.zswag.jvm; + +import io.github.ndsev.zswag.api.HttpConfig; +import io.github.ndsev.zswag.api.HttpException; +import io.github.ndsev.zswag.api.HttpSettings; +import io.github.ndsev.zswag.api.IKeychain; +import io.github.ndsev.zswag.shared.HttpSettingsLoader; +import io.github.ndsev.zswag.shared.OpenApiClient; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import zserio.runtime.ZserioError; +import zserio.runtime.io.Writer; +import zserio.runtime.service.ServiceClientInterface; +import zserio.runtime.service.ServiceData; + +import java.io.IOException; + +/** + * JVM Java port of Python's {@code services.MyService.Client(OAClient(url))} + * idiom. Implements zserio's {@link ServiceClientInterface} so that any + * zserio-Java-generated {@code XClient} class accepts an instance of this + * class as its transport. + * + *

Usage: + *

{@code
+ * OAClient transport = new OAClient("http://api.example.com/openapi.json");
+ * Calculator.CalculatorClient calc = new Calculator.CalculatorClient(transport);
+ * Double result = calc.powerMethod(new BaseAndExponent(...));
+ * }
+ * + *

Internally delegates to {@link OpenApiClient}, which performs + * {@code x-zserio-request-part} request decomposition. + */ +public final class OAClient implements ServiceClientInterface { + private static final Logger logger = LoggerFactory.getLogger(OAClient.class); + + private final OpenApiClient delegate; + + /** + * Creates a client that uses persistent settings from {@code HTTP_SETTINGS_FILE}. + * Subsequent mtime changes to the settings file are picked up on the next request + * (matches C++ {@code Settings::operator[]} hot-reload). Use the + * {@link #OAClient(String, HttpSettings, HttpConfig)} form instead if you want + * to pin a specific snapshot. + */ + public OAClient(@NotNull String openApiSpecUrl) throws IOException { + this(openApiSpecUrl, HttpConfig.empty(), 0); + } + + /** + * Env-driven constructor with an explicit {@code serverIndex}. Persistent + * settings come from {@code HTTP_SETTINGS_FILE} via a {@link HttpSettingsLoader.HotReloader} + * so file changes are picked up automatically. + */ + public OAClient(@NotNull String openApiSpecUrl, @NotNull HttpConfig adhoc, int serverIndex) + throws IOException { + IKeychain keychain = new Keychain(); + // Package-private ctor: env-driven HotReloader so the source path is preserved + // and mtime advances trigger an automatic reload on the next request. + JvmHttpClient http = new JvmHttpClient(HttpSettingsLoader.HotReloader.fromEnvironment(), keychain); + this.delegate = new OpenApiClient(openApiSpecUrl, http, adhoc, keychain, serverIndex); + } + + /** + * Creates a client with explicit persistent settings (typically loaded via + * {@link HttpSettingsLoader}) and no adhoc config. + */ + public OAClient(@NotNull String openApiSpecUrl, @NotNull HttpSettings persistent) throws IOException { + this(openApiSpecUrl, persistent, HttpConfig.empty()); + } + + /** + * Creates a client with explicit persistent settings AND a per-instance + * adhoc {@link HttpConfig}. Mirrors the C++/Python pattern of passing + * {@code httpcl::Config}/{@code HTTPConfig} into {@code OAClient}. + */ + public OAClient(@NotNull String openApiSpecUrl, @NotNull HttpSettings persistent, @NotNull HttpConfig adhoc) + throws IOException { + this(openApiSpecUrl, persistent, adhoc, 0); + } + + /** + * Creates a client targeting a specific entry of the spec's + * {@code servers[]} array. Mirrors C++ {@code OAClient(..., uint32_t serverIndex)} + * and Python {@code OAClient(..., server_index=N)} — see issue #113. + * + * @param serverIndex index into the parsed {@code servers[]} array (default 0). + * {@link IOException} is thrown during construction if the + * index is out of bounds. + */ + public OAClient(@NotNull String openApiSpecUrl, @NotNull HttpSettings persistent, @NotNull HttpConfig adhoc, + int serverIndex) throws IOException { + IKeychain keychain = new Keychain(); + JvmHttpClient http = new JvmHttpClient(persistent, keychain); + this.delegate = new OpenApiClient(openApiSpecUrl, http, adhoc, keychain, serverIndex); + } + + /** Lower-level constructor — for tests / advanced use. */ + public OAClient(@NotNull OpenApiClient delegate) { + this.delegate = delegate; + } + + /** Exposes the underlying OpenAPI client (read-only) for introspection. */ + @NotNull + public OpenApiClient getOpenApiClient() { + return delegate; + } + + /** + * Implementation of zserio's {@link ServiceClientInterface}: decomposes the + * typed request, dispatches the HTTP call, returns response bytes. + */ + @Override + public byte[] callMethod(java.lang.String methodName, + ServiceData requestData, + @Nullable java.lang.Object context) throws ZserioError { + Writer typed = requestData.getZserioObject(); + if (typed == null) { + throw new ZserioError("OAClient.callMethod: requestData.getZserioObject() returned null"); + } + try { + return delegate.callMethod(methodName, typed); + } catch (HttpException e) { + ZserioError err = new ZserioError("OAClient: " + methodName + " failed: " + e.getMessage(), e); + throw err; + } + } +} diff --git a/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/HttpConfigAndSettingsTest.java b/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/HttpConfigAndSettingsTest.java new file mode 100644 index 00000000..955ebbe3 --- /dev/null +++ b/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/HttpConfigAndSettingsTest.java @@ -0,0 +1,184 @@ +package io.github.ndsev.zswag.jvm; + +import io.github.ndsev.zswag.api.HttpConfig; +import io.github.ndsev.zswag.api.HttpSettings; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Arrays; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +class HttpConfigAndSettingsTest { + + @Test + void mergedWithUnionsHeadersAndQuery() { + HttpConfig a = HttpConfig.builder() + .header("X-A", "1") + .query("q", "v1") + .build(); + HttpConfig b = HttpConfig.builder() + .header("X-B", "2") + .query("q", "v2") + .build(); + HttpConfig merged = a.mergedWith(b); + assertThat(merged.getHeaders()).containsKey("X-A").containsKey("X-B"); + // Multi-valued union: q has both v1 and v2. + assertThat(merged.getQuery().get("q")).containsExactly("v1", "v2"); + } + + @Test + void mergedWithOverwritesAuthAndProxy() { + HttpConfig a = HttpConfig.builder().basicAuth("alice", "p1").build(); + HttpConfig b = HttpConfig.builder().basicAuth("bob", "p2").build(); + HttpConfig merged = a.mergedWith(b); + assertThat(merged.getAuth().get().user).isEqualTo("bob"); + assertThat(merged.getAuth().get().password).isEqualTo("p2"); + } + + @Test + void mergedWithKeepsBaseAuthIfOtherHasNone() { + HttpConfig a = HttpConfig.builder().basicAuth("alice", "p1").build(); + HttpConfig b = HttpConfig.builder().header("X-Y", "z").build(); + HttpConfig merged = a.mergedWith(b); + assertThat(merged.getAuth().get().user).isEqualTo("alice"); + } + + @Test + void oauth2SubFieldsMergedFieldByField() { + HttpConfig a = HttpConfig.builder() + .oauth2(HttpConfig.OAuth2.builder().clientId("base").audience("aud-1").build()) + .build(); + HttpConfig b = HttpConfig.builder() + .oauth2(HttpConfig.OAuth2.builder().clientId("override").build()) + .build(); + HttpConfig merged = a.mergedWith(b); + HttpConfig.OAuth2 oauth = merged.getOAuth2().get(); + assertThat(oauth.clientId).isEqualTo("override"); + assertThat(oauth.audience).isEqualTo("aud-1"); // preserved from base since b had none + } + + @Test + void compileScopeMatchesGlobs() { + Pattern p = HttpSettings.compileScope("https://*.foo.com/*"); + assertThat(p.matcher("https://api.foo.com/data").matches()).isTrue(); + // The literal dot before foo is required: "foo.com" alone does NOT match "*.foo.com". + assertThat(p.matcher("https://foo.com/").matches()).isFalse(); + assertThat(p.matcher("http://api.foo.com/").matches()).isFalse(); // protocol mismatch + assertThat(p.matcher("https://bar.example.com/").matches()).isFalse(); + } + + @Test + void compileScopeEscapesRegexMetachars() { + Pattern p = HttpSettings.compileScope("a.b+c"); + assertThat(p.matcher("a.b+c").matches()).isTrue(); + assertThat(p.matcher("aXbXc").matches()).isFalse(); + } + + @Test + void compileScopeWildcardMatchesAll() { + Pattern p = HttpSettings.compileScope("*"); + assertThat(p.matcher("anything").matches()).isTrue(); + assertThat(p.matcher("").matches()).isTrue(); + } + + @Test + void forUrlMergesAllMatchingScopes() { + HttpConfig wildcard = HttpConfig.builder() + .scope("*", HttpSettings.compileScope("*")) + .header("X-Generic", "global") + .build(); + HttpConfig fooSpecific = HttpConfig.builder() + .scope("https://*.foo.com/*", HttpSettings.compileScope("https://*.foo.com/*")) + .header("X-Foo", "yes") + .build(); + HttpSettings s = new HttpSettings(Arrays.asList(wildcard, fooSpecific)); + + HttpConfig forFoo = s.forUrl("https://api.foo.com/x"); + assertThat(forFoo.getHeaders()).containsKey("X-Generic").containsKey("X-Foo"); + + HttpConfig forOther = s.forUrl("https://bar.com/y"); + assertThat(forOther.getHeaders()).containsKey("X-Generic").doesNotContainKey("X-Foo"); + } + + @Test + void emptySettingsForUrlReturnsEmptyConfig() { + HttpConfig c = HttpSettings.empty().forUrl("https://anywhere/"); + assertThat(c.getHeaders()).isEmpty(); + assertThat(c.getAuth()).isNotPresent(); + } + + @Test + void mergedWithPreservesBaseSslStrictFalseWhenOtherUntouched() { + // Regression: previously `mergedWith` overrode sslStrict only when other.sslStrict==false, + // which couldn't distinguish "explicitly true" from "default". A wildcard scope that disables + // strict SSL in dev should not be poisoned by a later merge that didn't touch sslStrict. + HttpConfig base = HttpConfig.builder().sslStrict(false).build(); + HttpConfig other = HttpConfig.builder().header("X", "y").build(); + assertThat(base.mergedWith(other).isSslStrict()).isFalse(); + } + + @Test + void mergedWithLetsOtherReEnableSslStrict() { + // Regression: previously the merge could only ever turn sslStrict OFF (the !other.sslStrict + // branch was one-way), so a config explicitly setting sslStrict(true) couldn't restore strictness. + HttpConfig base = HttpConfig.builder().sslStrict(false).build(); + HttpConfig other = HttpConfig.builder().sslStrict(true).build(); + assertThat(base.mergedWith(other).isSslStrict()).isTrue(); + } + + @Test + void mergedWithPreservesBaseTimeoutWhenOtherUntouched() { + // Regression: previously `mergedWith` compared other.timeout to defaultTimeout() and + // overrode only on inequality, which (a) loses an explicit "set to default" and (b) loses + // a non-default base when the merging-in side never touched timeout. + HttpConfig base = HttpConfig.builder().timeout(Duration.ofSeconds(5)).build(); + HttpConfig other = HttpConfig.builder().header("X", "y").build(); + assertThat(base.mergedWith(other).getTimeout()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void mergedWithLetsOtherOverrideTimeout() { + HttpConfig base = HttpConfig.builder().timeout(Duration.ofSeconds(5)).build(); + HttpConfig other = HttpConfig.builder().timeout(Duration.ofSeconds(20)).build(); + assertThat(base.mergedWith(other).getTimeout()).isEqualTo(Duration.ofSeconds(20)); + } + + @Test + void oauth2MergedOntoPreservesBaseTokenEndpointAuthMethodWhenThisDidNotSetIt() { + // Regression: previously `OAuth2.mergedOnto` always took useForSpecFetch / + // tokenEndpointAuthMethod / nonceLength from `this`, so any merge with an OAuth2 built + // without those setters would silently overwrite a non-default base value. + HttpConfig.OAuth2 base = HttpConfig.OAuth2.builder() + .clientId("base") + .tokenEndpointAuthMethod(HttpConfig.OAuth2.TokenEndpointAuthMethod.RFC5849_OAUTH1_SIGNATURE) + .nonceLength(32) + .useForSpecFetch(false) + .build(); + HttpConfig.OAuth2 override = HttpConfig.OAuth2.builder().clientId("override").build(); + HttpConfig merged = HttpConfig.builder().oauth2(base).build() + .mergedWith(HttpConfig.builder().oauth2(override).build()); + HttpConfig.OAuth2 oauth = merged.getOAuth2().get(); + assertThat(oauth.tokenEndpointAuthMethod) + .isEqualTo(HttpConfig.OAuth2.TokenEndpointAuthMethod.RFC5849_OAUTH1_SIGNATURE); + assertThat(oauth.nonceLength).isEqualTo(32); + assertThat(oauth.useForSpecFetch).isFalse(); + assertThat(oauth.clientId).isEqualTo("override"); + } + + @Test + void oauth2MergedOntoLetsThisOverrideExplicitlySetFields() { + HttpConfig.OAuth2 base = HttpConfig.OAuth2.builder() + .clientId("base") + .nonceLength(32) + .build(); + HttpConfig.OAuth2 override = HttpConfig.OAuth2.builder() + .clientId("override") + .nonceLength(48) + .build(); + HttpConfig merged = HttpConfig.builder().oauth2(base).build() + .mergedWith(HttpConfig.builder().oauth2(override).build()); + assertThat(merged.getOAuth2().get().nonceLength).isEqualTo(48); + } +} diff --git a/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/JvmHttpClientTest.java b/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/JvmHttpClientTest.java new file mode 100644 index 00000000..23a70dde --- /dev/null +++ b/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/JvmHttpClientTest.java @@ -0,0 +1,314 @@ +package io.github.ndsev.zswag.jvm; + +import io.github.ndsev.zswag.api.HttpConfig; +import io.github.ndsev.zswag.api.HttpException; +import io.github.ndsev.zswag.api.HttpRequest; +import io.github.ndsev.zswag.api.HttpResponse; +import io.github.ndsev.zswag.api.HttpSettings; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.zip.GZIPOutputStream; +import okio.Buffer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JvmHttpClientTest { + + private MockWebServer server; + + @BeforeEach + void start() throws IOException { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void stop() throws IOException { + server.shutdown(); + } + + private JvmHttpClient newClient() { + return new JvmHttpClient(HttpSettings.empty(), Duration.ofSeconds(5)); + } + + @Test + void getRequestSendsRequestAndReturnsResponse() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("hello")); + HttpRequest req = HttpRequest.builder() + .method("GET") + .url(server.url("/path").toString()) + .build(); + HttpResponse resp = newClient().execute(req, HttpConfig.empty()); + assertThat(resp.getStatusCode()).isEqualTo(200); + assertThat(new String(resp.getBody())).isEqualTo("hello"); + RecordedRequest recorded = server.takeRequest(); + assertThat(recorded.getMethod()).isEqualTo("GET"); + assertThat(recorded.getPath()).isEqualTo("/path"); + } + + @Test + void postWithBodySendsBytes() throws Exception { + server.enqueue(new MockResponse().setResponseCode(201)); + byte[] body = "PAYLOAD".getBytes(); + HttpRequest req = HttpRequest.builder().method("POST").url(server.url("/p").toString()).body(body).build(); + HttpResponse resp = newClient().execute(req, HttpConfig.empty()); + assertThat(resp.getStatusCode()).isEqualTo(201); + RecordedRequest recorded = server.takeRequest(); + assertThat(recorded.getMethod()).isEqualTo("POST"); + assertThat(recorded.getBody().readUtf8()).isEqualTo("PAYLOAD"); + } + + @Test + void postWithoutBodySendsEmpty() throws Exception { + server.enqueue(new MockResponse().setResponseCode(204)); + HttpRequest req = HttpRequest.builder().method("POST").url(server.url("/p").toString()).build(); + HttpResponse resp = newClient().execute(req, HttpConfig.empty()); + assertThat(resp.getStatusCode()).isEqualTo(204); + } + + @Test + void putRequestSupported() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("PUT").url(server.url("/p").toString()) + .body("body".getBytes()).build(); + HttpResponse resp = newClient().execute(req, HttpConfig.empty()); + assertThat(resp.getStatusCode()).isEqualTo(200); + RecordedRequest recorded = server.takeRequest(); + assertThat(recorded.getMethod()).isEqualTo("PUT"); + } + + @Test + void putWithoutBodyHasEmptyBody() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("PUT").url(server.url("/p").toString()).build(); + HttpResponse resp = newClient().execute(req, HttpConfig.empty()); + assertThat(resp.getStatusCode()).isEqualTo(200); + } + + @Test + void deleteRequestSupportedWithoutBody() throws Exception { + server.enqueue(new MockResponse().setResponseCode(204)); + HttpRequest req = HttpRequest.builder().method("DELETE").url(server.url("/p").toString()).build(); + HttpResponse resp = newClient().execute(req, HttpConfig.empty()); + assertThat(resp.getStatusCode()).isEqualTo(204); + assertThat(server.takeRequest().getMethod()).isEqualTo("DELETE"); + } + + @Test + void deleteRequestSupportedWithBody() throws Exception { + server.enqueue(new MockResponse().setResponseCode(204)); + HttpRequest req = HttpRequest.builder().method("DELETE").url(server.url("/p").toString()) + .body("payload".getBytes()).build(); + HttpResponse resp = newClient().execute(req, HttpConfig.empty()); + assertThat(resp.getStatusCode()).isEqualTo(204); + RecordedRequest recorded = server.takeRequest(); + assertThat(recorded.getMethod()).isEqualTo("DELETE"); + assertThat(recorded.getBody().readUtf8()).isEqualTo("payload"); + } + + @Test + void unsupportedHttpMethodThrows() { + HttpRequest req = HttpRequest.builder().method("PATCH").url(server.url("/x").toString()).build(); + assertThatThrownBy(() -> newClient().execute(req, HttpConfig.empty())) + .isInstanceOf(HttpException.class) + .hasMessageContaining("Unsupported HTTP method"); + } + + @Test + void perRequestHeadersTakePrecedenceOverAdhocHeaders() throws Exception { + // Per-request Authorization should suppress an adhoc-config Authorization (avoiding + // duplicate single-valued headers from JDK HttpRequest.Builder.header() append-semantics). + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()) + .header("Authorization", "Bearer per-request").build(); + HttpConfig adhoc = HttpConfig.builder().bearerToken("from-adhoc").build(); + newClient().execute(req, adhoc); + RecordedRequest recorded = server.takeRequest(); + assertThat(recorded.getHeaders().values("Authorization")).containsExactly("Bearer per-request"); + } + + @Test + void cookiesFromConfigAreSent() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build(); + HttpConfig adhoc = HttpConfig.builder().cookie("a", "1").cookie("b", "2").build(); + newClient().execute(req, adhoc); + RecordedRequest recorded = server.takeRequest(); + assertThat(recorded.getHeader("Cookie")).contains("a=1").contains("b=2"); + } + + @Test + void perRequestCookieHeaderSuppressesConfigCookies() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()) + .header("Cookie", "explicit=yes").build(); + HttpConfig adhoc = HttpConfig.builder().cookie("a", "1").build(); + newClient().execute(req, adhoc); + RecordedRequest recorded = server.takeRequest(); + assertThat(recorded.getHeader("Cookie")).isEqualTo("explicit=yes"); + } + + @Test + void basicAuthFromConfigInjectsAuthorizationHeader() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build(); + HttpConfig adhoc = HttpConfig.builder().basicAuth("alice", "secret").build(); + newClient().execute(req, adhoc); + RecordedRequest recorded = server.takeRequest(); + // base64("alice:secret") = "YWxpY2U6c2VjcmV0" + assertThat(recorded.getHeader("Authorization")).isEqualTo("Basic YWxpY2U6c2VjcmV0"); + } + + @Test + void basicAuthSuppressedWhenAuthorizationAlreadyOnRequest() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()) + .header("Authorization", "Bearer prebaked").build(); + HttpConfig adhoc = HttpConfig.builder().basicAuth("alice", "secret").build(); + newClient().execute(req, adhoc); + RecordedRequest recorded = server.takeRequest(); + // Per-request header wins; Basic from config not added + assertThat(recorded.getHeaders().values("Authorization")).containsExactly("Bearer prebaked"); + } + + @Test + void basicAuthSuppressedWhenAuthorizationInConfigHeaders() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build(); + HttpConfig adhoc = HttpConfig.builder() + .header("authorization", "Bearer x") // case-insensitive check + .basicAuth("alice", "secret") + .build(); + newClient().execute(req, adhoc); + RecordedRequest recorded = server.takeRequest(); + assertThat(recorded.getHeader("Authorization")).contains("Bearer x"); + } + + @Test + void adhocHeadersFromConfigAreSent() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build(); + HttpConfig adhoc = HttpConfig.builder() + .addHeader("X-Multi", "v1") + .addHeader("X-Multi", "v2") + .build(); + newClient().execute(req, adhoc); + RecordedRequest recorded = server.takeRequest(); + assertThat(recorded.getHeaders().values("X-Multi")).containsExactly("v1", "v2"); + } + + @Test + void queryParametersAreAppendedToUrl() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build(); + HttpConfig adhoc = HttpConfig.builder() + .addQuery("a", "1") + .addQuery("a", "2") + .addQuery("b", "x y") + .build(); + newClient().execute(req, adhoc); + RecordedRequest recorded = server.takeRequest(); + assertThat(recorded.getPath()).contains("a=1").contains("a=2").contains("b=x+y"); + } + + @Test + void queryParamsAppendedWithExistingQueryString() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p?fixed=yes").toString()).build(); + HttpConfig adhoc = HttpConfig.builder().query("extra", "1").build(); + newClient().execute(req, adhoc); + RecordedRequest recorded = server.takeRequest(); + assertThat(recorded.getPath()).contains("fixed=yes").contains("extra=1"); + } + + @Test + void persistentSettingsAreScopeMergedAndAvailableForGetter() { + HttpConfig wildcard = HttpConfig.builder() + .scope("*", HttpSettings.compileScope("*")) + .header("X-Default", "global") + .build(); + HttpSettings persistent = new HttpSettings(Collections.singletonList(wildcard)); + JvmHttpClient client = new JvmHttpClient(persistent, Duration.ofSeconds(5)); + assertThat(client.getPersistentSettings()).isSameAs(persistent); + } + + @Test + void persistentScopeMatchesAndAddsHeaders() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + String url = server.url("/p").toString(); + HttpConfig wildcard = HttpConfig.builder() + .scope("*", HttpSettings.compileScope("*")) + .header("X-Default", "yes") + .build(); + JvmHttpClient client = new JvmHttpClient( + new HttpSettings(Collections.singletonList(wildcard)), Duration.ofSeconds(5)); + HttpRequest req = HttpRequest.builder().method("GET").url(url).build(); + client.execute(req, HttpConfig.empty()); + assertThat(server.takeRequest().getHeader("X-Default")).isEqualTo("yes"); + } + + @Test + void connectionFailureSurfacesAsHttpException() { + // Pick an unused port (server.shutdown later not needed) + HttpRequest req = HttpRequest.builder().method("GET").url("http://127.0.0.1:1/x").build(); + assertThatThrownBy(() -> newClient().execute(req, HttpConfig.empty())) + .isInstanceOf(HttpException.class); + } + + @Test + void defaultConstructorReadsEnvButYieldsValidClient() { + // Stripped-down construction: just ensure the no-arg constructor doesn't throw. + JvmHttpClient c = new JvmHttpClient(); + assertThat(c.getPersistentSettings()).isNotNull(); + } + + @Test + void gzipResponseIsAutoDecompressed() throws Exception { + // JDK HttpClient does NOT auto-decompress gzip (unlike cpp-httplib and OkHttp); + // JvmHttpClient compensates by inspecting Content-Encoding and decoding the body. + // Without this, the calling zserio deserialization would see garbled bytes. + String payload = "{\"answer\":42}"; + ByteArrayOutputStream raw = new ByteArrayOutputStream(); + try (GZIPOutputStream gz = new GZIPOutputStream(raw)) { + gz.write(payload.getBytes(StandardCharsets.UTF_8)); + } + server.enqueue(new MockResponse() + .setResponseCode(200) + .addHeader("Content-Encoding", "gzip") + .setBody(new Buffer().write(raw.toByteArray()))); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build(); + HttpResponse resp = newClient().execute(req, HttpConfig.empty()); + assertThat(new String(resp.getBody(), StandardCharsets.UTF_8)).isEqualTo(payload); + // After decompression the returned headers must NOT advertise gzip any more — + // they describe the body the caller actually sees. + assertThat(resp.getHeaders()) + .doesNotContainKey("Content-Encoding") + .doesNotContainKey("content-encoding") + .doesNotContainKey("Content-Length") + .doesNotContainKey("content-length"); + } + + @Test + void responseHeadersAreReturnedAsFirstValue() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .addHeader("X-Foo", "first") + .addHeader("X-Foo", "second")); + HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build(); + HttpResponse resp = newClient().execute(req, HttpConfig.empty()); + // JDK HttpClient lowercases header names in HttpHeaders.map(); accept either casing + String value = resp.getHeaders().getOrDefault("X-Foo", + resp.getHeaders().getOrDefault("x-foo", null)); + assertThat(value).isEqualTo("first"); + } +} diff --git a/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/JzswagLoggingTest.java b/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/JzswagLoggingTest.java new file mode 100644 index 00000000..08c51c89 --- /dev/null +++ b/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/JzswagLoggingTest.java @@ -0,0 +1,36 @@ +package io.github.ndsev.zswag.jvm; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; + +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Smoke tests for {@link JzswagLogging}. The full HTTP_LOG_LEVEL → logback + * root-logger plumbing isn't testable in pure JUnit — env vars can't reliably + * be set at runtime. We verify that {@code init()} is idempotent and doesn't + * throw on the env-var-unset branch (the typical CI path). + */ +class JzswagLoggingTest { + + private void resetInitialised() throws Exception { + Field f = JzswagLogging.class.getDeclaredField("initialised"); + f.setAccessible(true); + f.set(null, false); + } + + @Test + void initIsIdempotent() { + assertThatCode(() -> { + JzswagLogging.init(); + JzswagLogging.init(); + }).doesNotThrowAnyException(); + } + + @Test + void initWithoutEnvVarDoesNotThrow() throws Exception { + resetInitialised(); + assertThatCode(JzswagLogging::init).doesNotThrowAnyException(); + } +} diff --git a/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/KeychainTest.java b/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/KeychainTest.java new file mode 100644 index 00000000..3d88b7de --- /dev/null +++ b/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/KeychainTest.java @@ -0,0 +1,83 @@ +package io.github.ndsev.zswag.jvm; + +import io.github.ndsev.zswag.api.KeychainException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Isolated; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +// @Isolated guards against parallel test execution: this class mutates the +// global os.name system property which other tests might read concurrently. +@Isolated +class KeychainTest { + + private String savedOsName; + + @BeforeEach + void saveOsName() { + savedOsName = System.getProperty("os.name"); + } + + @AfterEach + void restoreOsName() { + if (savedOsName != null) System.setProperty("os.name", savedOsName); + } + + @Test + void emptyServiceThrows() { + assertThatThrownBy(() -> new Keychain().load("", "user")) + .isInstanceOf(KeychainException.class) + .hasMessageContaining("service identifier"); + } + + @Test + void unknownPlatformThrowsUnsupported() { + System.setProperty("os.name", "PalmOS"); + assertThatThrownBy(() -> new Keychain().load("svc", "user")) + .isInstanceOf(KeychainException.class) + .hasMessageContaining("unsupported platform"); + } + + @Test + void windowsThrowsNotImplemented() { + System.setProperty("os.name", "Windows 10"); + assertThatThrownBy(() -> new Keychain().load("svc", "user")) + .isInstanceOf(KeychainException.class) + .hasMessageContaining("Windows credential manager"); + } + + @Test + void linuxThrowsWhenSecretToolMissing() { + // On the CI runner secret-tool is not installed, so this exercises the + // "ProcessBuilder.start IOException → 'not installed or not on PATH'" branch. + // If a developer happens to have secret-tool installed locally, the test asserts a + // generic KeychainException — either way, we exercise loadLinux(). + System.setProperty("os.name", "Linux"); + assertThatThrownBy(() -> new Keychain().load("zswag.test.does-not-exist", "no.such.user")) + .isInstanceOf(KeychainException.class); + } + + @Test + void macOsThrowsWhenSecurityToolMissingOrEntryAbsent() { + // 'security' is macOS-only and unlikely on Linux CI; this exercises the IOException path + // ("not installed or not on PATH") on non-mac runners. + System.setProperty("os.name", "Mac OS X"); + assertThatThrownBy(() -> new Keychain().load("zswag.test.does-not-exist", "no.such.user")) + .isInstanceOf(KeychainException.class); + } + + @Test + void keychainExceptionMessageAndCausePreserved() { + KeychainException simple = new KeychainException("just msg"); + assertThatThrownBy(() -> { throw simple; }) + .isInstanceOf(KeychainException.class) + .hasMessage("just msg"); + Throwable cause = new RuntimeException("inner"); + KeychainException withCause = new KeychainException("outer", cause); + assertThatThrownBy(() -> { throw withCause; }) + .isInstanceOf(KeychainException.class) + .hasCause(cause); + } +} diff --git a/libs/jzswag/jzswag-shared/README.md b/libs/jzswag/jzswag-shared/README.md new file mode 100644 index 00000000..e4038e3e --- /dev/null +++ b/libs/jzswag/jzswag-shared/README.md @@ -0,0 +1,33 @@ +# jzswag-shared + +Platform-agnostic core of the zswag Java client. Sits between `jzswag-api` (interfaces only) and the platform-specific `jzswag-jvm` / `jzswag-android` modules. Contains every line of code that does not depend on a particular HTTP transport, OS keychain, or logging backend. + +## Contents + +- **`OpenApiClient`** — the request-decomposition + dispatch core. Reads `x-zserio-request-part` from the parsed spec, encodes parameters via `ParameterEncoder`, applies security via `applySecurity()`, and hands the final `HttpRequest` off to the injected `IHttpClient`. +- **`OpenAPIParser`** — SnakeYAML-based OpenAPI 3.0 parser, with full support for the zswag extensions (`x-zserio-request-part`, `application/x-zserio-object`, OAuth2 `clientCredentials` flow). Rejects PATCH operations and non-`clientCredentials` OAuth2 flows up front. +- **`ParameterEncoder`** — per-location encoding (`encodeForPath`, `encodeForQuery`, `encodeForHeader`, `encodeForCookie`) covering `simple`/`label`/`matrix`/`form` × `explode` × `string`/`byte`/`base64`/`base64url`/`hex`/`binary`. +- **`OAuth2Handler`** — client-credentials flow with cached, refresh-token-aware token minting. Supports both `rfc6749-client-secret-basic` and `rfc5849-oauth1-signature` token-endpoint authentication. Takes an `IKeychain` so it can resolve a `clientSecretKeychain` reference on either platform. +- **`OAuth1Signature`** — RFC 5849 HMAC-SHA256 signature builder used by the `rfc5849-oauth1-signature` token-endpoint auth method. +- **`HttpSettingsLoader`** — YAML loader for the multi-scope settings file (`HTTP_SETTINGS_FILE`). Schema documented in [`docs/http-settings.md`](../../docs/http-settings.md), shared with the C++ and Python clients. +- **`ZserioReflection`** — POJO getter reflection that resolves dotted `x-zserio-request-part` paths against zserio-Java-generated request structs. + +## Dependencies + +- `jzswag-api` (peer module, transitive `api` exposure). +- zserio-runtime ≥ 2.16.1. +- SnakeYAML 2.2 (YAML parsing). +- Gson 2.10.1 (OAuth2 token-response JSON). +- SLF4J 2.0.9 API (binding chosen by the consuming platform module). + +## Usage + +This module is a peer dependency of the platform implementations; you don't depend on it directly. Add either `jzswag-jvm` or `jzswag-android` and you'll get this module transitively. + +## Testing + +```bash +./gradlew :libs:jzswag:jzswag-shared:test +``` + +Coverage is ≥60% line on the suite. Unit tests cover the YAML loader, multi-scope merging, parameter encoding, OAuth1 signature conformance, OAuth2 flow edge cases, and zserio reflection. diff --git a/libs/jzswag/jzswag-shared/build.gradle b/libs/jzswag/jzswag-shared/build.gradle new file mode 100644 index 00000000..2334e07a --- /dev/null +++ b/libs/jzswag/jzswag-shared/build.gradle @@ -0,0 +1,72 @@ +plugins { + id 'java-library' + id 'maven-publish' + id 'jacoco' +} + +jacoco { + toolVersion = '0.8.11' +} + +description = 'zswag Java Shared - Platform-agnostic core (OpenAPI dispatch, parsing, OAuth2). Used by jzswag-jvm and jzswag-android.' + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + exceptionFormat "full" + } + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + } +} + +dependencies { + api project(':libs:jzswag:jzswag-api') + + // zserio runtime + implementation "io.github.ndsev:zserio-runtime:${rootProject.ext.zserio_version}" + + // YAML parsing (OpenAPI specs + HTTP_SETTINGS_FILE) + implementation 'org.yaml:snakeyaml:2.2' + + // JSON parsing (OAuth2 token responses) + implementation 'com.google.code.gson:gson:2.10.1' + + // Logging API only — platform modules pick the binding (logback-classic on JVM, + // slf4j-android on Android). Exposed transitively so consumers can use loggers too. + api 'org.slf4j:slf4j-api:2.0.9' + + // Annotations + compileOnly 'org.jetbrains:annotations:24.1.0' + + // Testing + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.1' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.1' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.1' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.1' + testRuntimeOnly 'ch.qos.logback:logback-classic:1.4.14' + testImplementation 'org.mockito:mockito-core:5.8.0' + testImplementation 'org.mockito:mockito-junit-jupiter:5.8.0' + testImplementation 'org.assertj:assertj-core:3.24.2' +} + +publishing { + publications { + maven(MavenPublication) { + from components.java + artifactId = 'jzswag-shared' + } + } +} diff --git a/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/HttpSettingsLoader.java b/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/HttpSettingsLoader.java new file mode 100644 index 00000000..c8e47819 --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/HttpSettingsLoader.java @@ -0,0 +1,460 @@ +package io.github.ndsev.zswag.shared; + +import io.github.ndsev.zswag.api.HttpConfig; +import io.github.ndsev.zswag.api.HttpSettings; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Loads {@link HttpSettings} from a YAML file matching the C++/Python schema + * documented under "HTTP Settings File Format" in README.md. + * + *

Top-level shape: + *

{@code
+ * http-settings:
+ *   - scope:            # or url: 
+ *     basic-auth: { user, password|keychain }
+ *     proxy: { host, port, user?, password|keychain? }
+ *     cookies: { ... }
+ *     headers: { ... }
+ *     query: { ... }
+ *     api-key: 
+ *     oauth2:
+ *       clientId, clientSecret|clientSecretKeychain,
+ *       tokenUrl?, refreshUrl?, audience?, scope?, useForSpecFetch?,
+ *       tokenEndpointAuth: { method, nonceLength? }
+ * }
+ * + *

Legacy schema (top-level entries treated as a single un-scoped config) is + * also accepted, matching C++ {@code http-settings.cpp:466-469}. + */ +public final class HttpSettingsLoader { + private static final Logger logger = LoggerFactory.getLogger(HttpSettingsLoader.class); + + public static final String ENV_SETTINGS_FILE = "HTTP_SETTINGS_FILE"; + + private HttpSettingsLoader() {} + + /** + * Tracks the source path that {@link #loadFromEnvironment} most recently resolved, + * so {@link HotReloader} can rebuild a fresh {@link HttpSettings} when the file + * changes on disk. Per-thread? No — the env var is process-wide and reading it + * twice in close succession is fine. Lazy holder keeps things thread-safe. + */ + @org.jetbrains.annotations.Nullable + public static Path environmentSourcePath() { + String path = System.getenv(ENV_SETTINGS_FILE); + if (path == null || path.isEmpty()) return null; + Path file = Paths.get(path); + return Files.isRegularFile(file) ? file : null; + } + + /** + * Tracks an {@link HttpSettings} object that gets re-read from disk when the + * source file's last-modified timestamp advances. Mirrors C++ + * {@code httpcl::Settings::operator[]} (http-settings.cpp:520-543) which checks + * mtime per call and re-parses on change — supports credential rotation in + * long-running clients. + * + *

Thread-safe via double-checked locking on the {@code current} reference. + * Failed reloads log a warning and keep the previous snapshot rather than + * dropping to empty (better than losing all credentials mid-flight). + */ + public static final class HotReloader { + @org.jetbrains.annotations.Nullable + private final Path source; + private final java.util.concurrent.atomic.AtomicReference current; + private volatile long lastMtimeMillis; + + private HotReloader(@org.jetbrains.annotations.Nullable Path source, @NotNull HttpSettings initial) { + this.source = source; + this.current = new java.util.concurrent.atomic.AtomicReference<>(initial); + this.lastMtimeMillis = readMtimeOrZero(); + } + + /** Builds a reloader wired to {@code HTTP_SETTINGS_FILE} (or a no-op one if unset). */ + @NotNull + public static HotReloader fromEnvironment() { + Path src = environmentSourcePath(); + return new HotReloader(src, loadFromEnvironment()); + } + + /** Builds a reloader against an explicit path (or a no-op one if {@code source} null). */ + @NotNull + public static HotReloader of(@org.jetbrains.annotations.Nullable Path source, @NotNull HttpSettings initial) { + return new HotReloader(source, initial); + } + + /** + * Returns the current settings, reloading from disk if the source file's mtime + * has advanced since last call. Calling this once per request is cheap (single + * {@code stat}), comparable to the C++ implementation. + */ + @NotNull + public HttpSettings current() { + if (source == null) return current.get(); + long mtime = readMtimeOrZero(); + if (mtime > lastMtimeMillis) { + synchronized (this) { + if (mtime > lastMtimeMillis) { + try { + HttpSettings reloaded = loadFromFile(source); + current.set(reloaded); + lastMtimeMillis = mtime; + logger.debug("Reloaded HTTP_SETTINGS_FILE from '{}' (mtime advanced).", source); + } catch (IOException | RuntimeException e) { + // SnakeYAML throws ParserException (RuntimeException) on malformed YAML; + // IOException on disk failures. Either way: keep the old snapshot + // rather than dropping to empty during an in-flight rotation. + logger.warn("Failed to reload HTTP_SETTINGS_FILE '{}': {}. " + + "Keeping previous snapshot.", source, e.getMessage()); + // Bump lastMtimeMillis so we don't try to reload the same broken + // file every request. + lastMtimeMillis = mtime; + } + } + } + } + return current.get(); + } + + private long readMtimeOrZero() { + if (source == null) return 0L; + try { + return Files.getLastModifiedTime(source).toMillis(); + } catch (IOException e) { + return 0L; + } + } + } + + /** + * Loads settings from {@code HTTP_SETTINGS_FILE} if set; returns empty + * settings otherwise. Empty/unset env var, or non-existent path, yield + * empty settings (logged at debug level), matching C++ semantics. + */ + @NotNull + public static HttpSettings loadFromEnvironment() { + String path = System.getenv(ENV_SETTINGS_FILE); + if (path == null || path.isEmpty()) { + logger.debug("HTTP_SETTINGS_FILE environment variable is empty."); + return HttpSettings.empty(); + } + Path file = Paths.get(path); + if (!Files.isRegularFile(file)) { + logger.debug("The HTTP_SETTINGS_FILE path '{}' is not a file.", path); + return HttpSettings.empty(); + } + try { + return loadFromFile(file); + } catch (IOException e) { + logger.error("Failed to read http-settings from '{}': {}", path, e.getMessage()); + return HttpSettings.empty(); + } + } + + @NotNull + public static HttpSettings loadFromFile(@NotNull Path file) throws IOException { + try (InputStream input = Files.newInputStream(file)) { + LoaderOptions options = new LoaderOptions(); + options.setAllowDuplicateKeys(false); + Yaml yaml = new Yaml(new SafeConstructor(options)); + Object root = yaml.load(input); + return parseRoot(root); + } + } + + @NotNull + @SuppressWarnings("unchecked") + static HttpSettings parseRoot(@Nullable Object root) { + if (root == null) { + return HttpSettings.empty(); + } + List> entries; + if (root instanceof Map) { + Map map = (Map) root; + Object node = map.get("http-settings"); + if (node == null) { + logger.debug("No 'http-settings' section found in YAML."); + return HttpSettings.empty(); + } + if (!(node instanceof List)) { + throw new IllegalArgumentException("'http-settings' must be a list"); + } + entries = (List>) node; + } else if (root instanceof List) { + entries = (List>) root; + } else { + throw new IllegalArgumentException( + "Top-level YAML must be a map with 'http-settings' key, or a list"); + } + + List configs = new ArrayList<>(); + for (Map entry : entries) { + configs.add(parseEntry(entry)); + } + return new HttpSettings(configs); + } + + @SuppressWarnings("unchecked") + private static HttpConfig parseEntry(@NotNull Map entry) { + HttpConfig.Builder b = HttpConfig.builder(); + + if (entry.containsKey("url")) { + String url = String.valueOf(entry.get("url")); + b.scope(null, java.util.regex.Pattern.compile(url)); + } else { + String scope = entry.containsKey("scope") ? String.valueOf(entry.get("scope")) : "*"; + b.scope(scope, HttpSettings.compileScope(scope)); + } + + Object cookies = entry.get("cookies"); + if (cookies instanceof Map) { + for (Map.Entry e : ((Map) cookies).entrySet()) { + b.cookie(String.valueOf(e.getKey()), String.valueOf(e.getValue())); + } + } + + Object headers = entry.get("headers"); + if (headers instanceof Map) { + for (Map.Entry e : ((Map) headers).entrySet()) { + b.addHeader(String.valueOf(e.getKey()), String.valueOf(e.getValue())); + } + } + + Object query = entry.get("query"); + if (query instanceof Map) { + for (Map.Entry e : ((Map) query).entrySet()) { + b.addQuery(String.valueOf(e.getKey()), String.valueOf(e.getValue())); + } + } + + Object basicAuth = entry.get("basic-auth"); + if (basicAuth instanceof Map) { + Map ba = (Map) basicAuth; + String user = optString(ba, "user"); + if (user == null) { + throw new IllegalArgumentException("basic-auth requires 'user'"); + } + String password = optString(ba, "password"); + String keychain = optString(ba, "keychain"); + if (password == null && keychain == null) { + throw new IllegalArgumentException("basic-auth requires either 'password' or 'keychain'"); + } + b.auth(new HttpConfig.BasicAuthentication( + user, + password != null ? password : "", + keychain != null ? keychain : "")); + } + + Object proxy = entry.get("proxy"); + if (proxy instanceof Map) { + Map p = (Map) proxy; + String host = optString(p, "host"); + Integer port = optInt(p, "port"); + if (host == null || port == null) { + throw new IllegalArgumentException("proxy requires 'host' and 'port'"); + } + String user = optString(p, "user"); + String password = optString(p, "password"); + String keychain = optString(p, "keychain"); + if (user != null && password == null && keychain == null) { + throw new IllegalArgumentException("proxy with 'user' requires 'password' or 'keychain'"); + } + b.proxy(new HttpConfig.Proxy( + host, port, + user != null ? user : "", + password != null ? password : "", + keychain != null ? keychain : "")); + } + + Object apiKey = entry.get("api-key"); + if (apiKey instanceof String) { + b.apiKey((String) apiKey); + } + + Object oauth2 = entry.get("oauth2"); + if (oauth2 instanceof Map) { + b.oauth2(parseOAuth2((Map) oauth2)); + } + + return b.build(); + } + + private static HttpConfig.OAuth2 parseOAuth2(@NotNull Map node) { + HttpConfig.OAuth2.Builder b = HttpConfig.OAuth2.builder() + .clientId(optString(node, "clientId")) + .clientSecret(optString(node, "clientSecret")) + .clientSecretKeychain(optString(node, "clientSecretKeychain")) + .tokenUrl(optString(node, "tokenUrl")) + .refreshUrl(optString(node, "refreshUrl")) + .audience(optString(node, "audience")); + + Object scope = node.get("scope"); + if (scope instanceof List) { + List scopes = new ArrayList<>(); + for (Object s : (List) scope) scopes.add(String.valueOf(s)); + b.scopes(scopes); + } + + Object useForSpecFetch = node.get("useForSpecFetch"); + if (useForSpecFetch instanceof Boolean) { + b.useForSpecFetch((Boolean) useForSpecFetch); + } + + Object tea = node.get("tokenEndpointAuth"); + if (tea instanceof Map) { + @SuppressWarnings("unchecked") + Map teaMap = (Map) tea; + String method = optString(teaMap, "method"); + if (method != null) { + switch (method) { + case "rfc6749-client-secret-basic": + b.tokenEndpointAuthMethod(HttpConfig.OAuth2.TokenEndpointAuthMethod.RFC6749_CLIENT_SECRET_BASIC); + break; + case "rfc5849-oauth1-signature": + b.tokenEndpointAuthMethod(HttpConfig.OAuth2.TokenEndpointAuthMethod.RFC5849_OAUTH1_SIGNATURE); + break; + default: + throw new IllegalArgumentException("Unknown tokenEndpointAuth method: " + method); + } + } + Integer nonceLength = optInt(teaMap, "nonceLength"); + if (nonceLength != null) { + b.nonceLength(nonceLength); + } + } + + return b.build(); + } + + // ------------------------------------------------------------------------ + // Write-back: serialize HttpSettings back to YAML. + // + // Mirrors C++ Settings::store (http-settings.cpp:484). Useful for tooling + // that updates credentials programmatically and re-writes the settings file. + // The HotReloader on the active HTTP client will pick the change up + // automatically on the next request. + // ------------------------------------------------------------------------ + + /** + * Writes a {@link HttpSettings} snapshot to a YAML file in the same schema this + * loader reads. Secrets are written verbatim; the caller is responsible for + * choosing whether to embed cleartext passwords or keychain references when + * building the {@link HttpConfig} entries. + * + * @param destination path to write to (will be created or overwritten) + * @param settings snapshot to serialize + * @throws IOException on filesystem failure + */ + public static void writeToFile(@NotNull Path destination, @NotNull HttpSettings settings) throws IOException { + try (BufferedWriter writer = Files.newBufferedWriter(destination)) { + org.yaml.snakeyaml.DumperOptions opts = new org.yaml.snakeyaml.DumperOptions(); + opts.setDefaultFlowStyle(org.yaml.snakeyaml.DumperOptions.FlowStyle.BLOCK); + opts.setIndent(2); + opts.setPrettyFlow(true); + new Yaml(opts).dump(settingsToYamlTree(settings), writer); + } + } + + /** Convert HttpSettings → POJO tree (Maps/Lists/Strings) for SnakeYAML's dump(). */ + @NotNull + private static Map settingsToYamlTree(@NotNull HttpSettings settings) { + List> entries = new ArrayList<>(); + for (HttpConfig config : settings.getEntries()) { + entries.add(configToYamlTree(config)); + } + Map root = new LinkedHashMap<>(); + root.put("http-settings", entries); + return root; + } + + @NotNull + private static Map configToYamlTree(@NotNull HttpConfig config) { + Map e = new LinkedHashMap<>(); + // Scope: prefer the original scope glob, fall back to urlPattern if only that's set. + config.getScope().ifPresent(s -> e.put("scope", s)); + if (!config.getScope().isPresent() && config.getUrlPattern().isPresent()) { + e.put("url", config.getUrlPattern().get().pattern()); + } + config.getAuth().ifPresent(auth -> { + Map a = new LinkedHashMap<>(); + a.put("user", auth.user); + if (!auth.password.isEmpty()) a.put("password", auth.password); + if (!auth.keychain.isEmpty()) a.put("keychain", auth.keychain); + e.put("basic-auth", a); + }); + config.getProxy().ifPresent(p -> { + Map proxy = new LinkedHashMap<>(); + proxy.put("host", p.host); + proxy.put("port", p.port); + if (!p.user.isEmpty()) proxy.put("user", p.user); + if (!p.password.isEmpty()) proxy.put("password", p.password); + if (!p.keychain.isEmpty()) proxy.put("keychain", p.keychain); + e.put("proxy", proxy); + }); + if (!config.getCookies().isEmpty()) e.put("cookies", new LinkedHashMap<>(config.getCookies())); + if (!config.getHeaders().isEmpty()) { + // Flatten single-value headers; preserve list form for multi-valued. + Map headers = new LinkedHashMap<>(); + for (Map.Entry> h : config.getHeaders().entrySet()) { + headers.put(h.getKey(), h.getValue().size() == 1 ? h.getValue().get(0) : new ArrayList<>(h.getValue())); + } + e.put("headers", headers); + } + if (!config.getQuery().isEmpty()) { + Map query = new LinkedHashMap<>(); + for (Map.Entry> q : config.getQuery().entrySet()) { + query.put(q.getKey(), q.getValue().size() == 1 ? q.getValue().get(0) : new ArrayList<>(q.getValue())); + } + e.put("query", query); + } + config.getApiKey().ifPresent(k -> e.put("api-key", k)); + config.getOAuth2().ifPresent(o -> { + Map oauth = new LinkedHashMap<>(); + if (!o.clientId.isEmpty()) oauth.put("clientId", o.clientId); + if (!o.clientSecret.isEmpty()) oauth.put("clientSecret", o.clientSecret); + if (!o.clientSecretKeychain.isEmpty()) oauth.put("clientSecretKeychain", o.clientSecretKeychain); + if (!o.tokenUrlOverride.isEmpty()) oauth.put("tokenUrl", o.tokenUrlOverride); + if (!o.refreshUrlOverride.isEmpty()) oauth.put("refreshUrl", o.refreshUrlOverride); + if (!o.audience.isEmpty()) oauth.put("audience", o.audience); + if (!o.scopesOverride.isEmpty()) oauth.put("scope", new ArrayList<>(o.scopesOverride)); + e.put("oauth2", oauth); + }); + return e; + } + + @Nullable + private static String optString(@NotNull Map map, @NotNull String key) { + Object v = map.get(key); + return v == null ? null : String.valueOf(v); + } + + @Nullable + private static Integer optInt(@NotNull Map map, @NotNull String key) { + Object v = map.get(key); + if (v == null) return null; + if (v instanceof Number) return ((Number) v).intValue(); + try { + return Integer.parseInt(String.valueOf(v)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("'" + key + "' must be an integer, got: " + v); + } + } +} diff --git a/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/OAuth1Signature.java b/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/OAuth1Signature.java new file mode 100644 index 00000000..b4010986 --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/OAuth1Signature.java @@ -0,0 +1,149 @@ +package io.github.ndsev.zswag.shared; + +import org.jetbrains.annotations.NotNull; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.InvalidKeyException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * OAuth 1.0 (RFC 5849) signature utilities — HMAC-SHA256 only. Java port of + * C++ {@code httpcl::oauth1::*}. Used for the + * {@code rfc5849-oauth1-signature} variant of OAuth2 token-endpoint + * authentication. + */ +public final class OAuth1Signature { + private static final SecureRandom RANDOM = new SecureRandom(); + private static final char[] ALPHANUM = + ("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz").toCharArray(); + + private OAuth1Signature() {} + + /** Cryptographically secure alphanumeric nonce of the given length (8..64). */ + @NotNull + public static String generateNonce(int length) { + if (length < 8 || length > 64) { + throw new IllegalArgumentException("Nonce length must be between 8 and 64"); + } + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + sb.append(ALPHANUM[RANDOM.nextInt(ALPHANUM.length)]); + } + return sb.toString(); + } + + /** Seconds-since-epoch as decimal string. */ + @NotNull + public static String generateTimestamp() { + return Long.toString(System.currentTimeMillis() / 1000L); + } + + /** + * RFC 3986 percent-encoding: keep unreserved characters (A-Z, a-z, 0-9, -, ., _, ~); + * percent-encode everything else as upper-case hex. + */ + @NotNull + public static String percentEncode(@NotNull String input) { + byte[] bytes = input.getBytes(StandardCharsets.UTF_8); + StringBuilder sb = new StringBuilder(bytes.length * 3); + for (byte b : bytes) { + int u = b & 0xFF; + if ((u >= 'A' && u <= 'Z') + || (u >= 'a' && u <= 'z') + || (u >= '0' && u <= '9') + || u == '-' || u == '.' || u == '_' || u == '~') { + sb.append((char) u); + } else { + sb.append('%'); + sb.append(Character.toUpperCase(Character.forDigit((u >> 4) & 0xF, 16))); + sb.append(Character.toUpperCase(Character.forDigit(u & 0xF, 16))); + } + } + return sb.toString(); + } + + /** + * Builds the signature base string per RFC 5849 Section 3.4.1: + * {@code METHOD&percent(URL)&percent(sorted-percent-encoded-params)}. + */ + @NotNull + static String buildSignatureBaseString(@NotNull String httpMethod, @NotNull String url, + @NotNull Map params) { + List encodedPairs = new ArrayList<>(params.size()); + for (Map.Entry e : params.entrySet()) { + encodedPairs.add(percentEncode(e.getKey()) + "=" + percentEncode(e.getValue())); + } + Collections.sort(encodedPairs); + StringBuilder paramString = new StringBuilder(); + for (int i = 0; i < encodedPairs.size(); i++) { + if (i > 0) paramString.append('&'); + paramString.append(encodedPairs.get(i)); + } + return httpMethod.toUpperCase(Locale.ROOT) + "&" + percentEncode(url) + "&" + percentEncode(paramString.toString()); + } + + /** + * Computes HMAC-SHA256 signature for a token request. + * Signing key is {@code percent(consumer_secret)&percent(token_secret)}; for the + * client-credentials flow {@code token_secret} is empty. + */ + @NotNull + public static String computeSignature(@NotNull String httpMethod, @NotNull String url, + @NotNull Map params, + @NotNull String consumerSecret, @NotNull String tokenSecret) { + String base = buildSignatureBaseString(httpMethod, url, params); + String key = percentEncode(consumerSecret) + "&" + percentEncode(tokenSecret); + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] hmac = mac.doFinal(base.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(hmac); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new IllegalStateException("HMAC-SHA256 unavailable: " + e.getMessage(), e); + } + } + + /** + * Builds the {@code Authorization: OAuth ...} header for a signed token + * request, including all five {@code oauth_*} parameters and the computed + * signature. Body parameters are included in signature computation but are + * NOT echoed in the header. + */ + @NotNull + public static String buildAuthorizationHeader( + @NotNull String httpMethod, @NotNull String url, + @NotNull String consumerKey, @NotNull String consumerSecret, + @NotNull Map bodyParams, int nonceLength) { + String timestamp = generateTimestamp(); + String nonce = generateNonce(nonceLength); + + Map allParams = new LinkedHashMap<>(); + allParams.put("oauth_consumer_key", consumerKey); + allParams.put("oauth_signature_method", "HMAC-SHA256"); + allParams.put("oauth_timestamp", timestamp); + allParams.put("oauth_nonce", nonce); + allParams.put("oauth_version", "1.0"); + allParams.putAll(bodyParams); + + String signature = computeSignature(httpMethod, url, allParams, consumerSecret, ""); + + StringBuilder h = new StringBuilder("OAuth "); + h.append("oauth_consumer_key=\"").append(percentEncode(consumerKey)).append("\", "); + h.append("oauth_signature_method=\"HMAC-SHA256\", "); + h.append("oauth_timestamp=\"").append(timestamp).append("\", "); + h.append("oauth_nonce=\"").append(percentEncode(nonce)).append("\", "); + h.append("oauth_version=\"1.0\", "); + h.append("oauth_signature=\"").append(percentEncode(signature)).append("\""); + return h.toString(); + } +} diff --git a/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/OAuth2Handler.java b/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/OAuth2Handler.java new file mode 100644 index 00000000..cd1b3d7a --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/OAuth2Handler.java @@ -0,0 +1,302 @@ +package io.github.ndsev.zswag.shared; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import io.github.ndsev.zswag.api.HttpConfig; +import io.github.ndsev.zswag.api.HttpException; +import io.github.ndsev.zswag.api.HttpRequest; +import io.github.ndsev.zswag.api.HttpResponse; +import io.github.ndsev.zswag.api.IHttpClient; +import io.github.ndsev.zswag.api.IKeychain; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; + +/** + * OAuth 2.0 client-credentials flow handler with full zswag parity: + *

    + *
  • Multi-instance token cache keyed by {@code (tokenUrl, clientId, audience, scopeKey)} + * so multiple OAuth2 schemes don't collide.
  • + *
  • Refresh-token reuse on expiry; falls back to fresh mint if the refresh fails.
  • + *
  • {@code rfc6749-client-secret-basic} (default, HTTP Basic) and + * {@code rfc5849-oauth1-signature} (HMAC-SHA256) token-endpoint + * authentication methods.
  • + *
  • Optional {@code audience} parameter on the token request.
  • + *
  • Public client support: when no client secret is configured, the client_id + * is sent in the token request body instead.
  • + *
  • Override precedence: settings.tokenUrl/refreshUrl/scopes win over spec values.
  • + *
+ * + *

Mirrors C++ {@code OAuth2ClientCredentialsHandler::satisfy} + + * {@code requestToken} in {@code openapi-oauth.cpp}. + */ +public final class OAuth2Handler { + private static final Logger logger = LoggerFactory.getLogger(OAuth2Handler.class); + + private static final String GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"; + private static final String GRANT_TYPE_REFRESH_TOKEN = "refresh_token"; + + /** Process-wide token cache. Per-handler caches were tested and rejected: in the C++ reference + * the handler is shared across calls to the same OAClient, and tokens are keyed by + * (tokenUrl, clientId, audience, scope) so multiple schemes don't collide. */ + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + /** Striped lock pool to serialise mint/refresh attempts. A fixed pool bounds memory + * regardless of how many distinct {@link TokenKey}s flow through the process; two + * unrelated keys may occasionally share a stripe (false sharing), which only blocks + * unrelated mints — an acceptable trade-off for the leak-free behaviour. */ + private static final int LOCK_STRIPES = 32; + private static final ReentrantLock[] STRIPED_LOCKS = new ReentrantLock[LOCK_STRIPES]; + static { + for (int i = 0; i < LOCK_STRIPES; i++) STRIPED_LOCKS[i] = new ReentrantLock(); + } + + private static ReentrantLock lockFor(@NotNull TokenKey key) { + return STRIPED_LOCKS[(key.hashCode() & 0x7fffffff) % LOCK_STRIPES]; + } + + private final IHttpClient httpClient; + private final IKeychain keychain; + private final Gson gson = new Gson(); + + public OAuth2Handler(@NotNull IHttpClient httpClient, @NotNull IKeychain keychain) { + this.httpClient = httpClient; + this.keychain = keychain; + } + + /** + * Returns a valid bearer token for the given OAuth2 config + resolved + * tokenUrl/refreshUrl/scopes (already merged from settings vs spec by the + * caller). Uses the process-wide cache; mints or refreshes as needed. + * + * @throws HttpException if the token endpoint returns non-2xx or the + * response is malformed. + */ + @NotNull + public String getAccessToken(@NotNull HttpConfig.OAuth2 oauth, @NotNull String tokenUrl, + @NotNull String refreshUrl, @NotNull List scopes) throws HttpException { + String scopeKey = String.join(":", scopes); + TokenKey key = new TokenKey(tokenUrl, oauth.clientId, oauth.audience, scopeKey); + + // Fast path: cached and valid. + MintedToken cached = CACHE.get(key); + if (cached != null && System.nanoTime() < cached.expiresAtNanos) { + logger.debug("[OAuth2] Using cached token (still valid)"); + return cached.accessToken; + } + + ReentrantLock lock = lockFor(key); + lock.lock(); + try { + // Recheck after acquiring lock. + cached = CACHE.get(key); + if (cached != null && System.nanoTime() < cached.expiresAtNanos) { + return cached.accessToken; + } + + // Try refresh first if we have a refresh token. + if (cached != null && !cached.refreshToken.isEmpty()) { + logger.debug("[OAuth2] Cached token expired, attempting refresh at {}...", refreshUrl); + try { + MintedToken refreshed = requestToken(oauth, refreshUrl, GRANT_TYPE_REFRESH_TOKEN, + scopes, cached.refreshToken); + CACHE.put(key, refreshed); + logger.debug("[OAuth2] Refresh successful"); + return refreshed.accessToken; + } catch (HttpException e) { + logger.debug("[OAuth2] Refresh failed: {}; falling back to mint", e.getMessage()); + } + } + + // Mint fresh. + logger.debug("[OAuth2] Minting new token at {}", tokenUrl); + MintedToken minted = requestToken(oauth, tokenUrl, GRANT_TYPE_CLIENT_CREDENTIALS, scopes, ""); + CACHE.put(key, minted); + return minted.accessToken; + } finally { + lock.unlock(); + } + } + + /** + * Performs a single token mint or refresh. {@code refreshToken} is empty + * for client_credentials grant; non-empty for refresh_token grant. + */ + @NotNull + private MintedToken requestToken(@NotNull HttpConfig.OAuth2 oauth, @NotNull String url, + @NotNull String grantType, @NotNull List scopes, + @NotNull String refreshToken) throws HttpException { + // Build form body. + StringBuilder body = new StringBuilder("grant_type=").append(grantType); + if (GRANT_TYPE_CLIENT_CREDENTIALS.equals(grantType)) { + if (!scopes.isEmpty()) { + body.append("&scope=").append(ParameterEncoder.urlEncode(String.join(" ", scopes))); + } + if (!oauth.audience.isEmpty()) { + body.append("&audience=").append(ParameterEncoder.urlEncode(oauth.audience)); + } + } else if (GRANT_TYPE_REFRESH_TOKEN.equals(grantType)) { + body.append("&refresh_token=").append(ParameterEncoder.urlEncode(refreshToken)); + } + + // Resolve client secret (cleartext or keychain). + String secret = oauth.clientSecret; + if (secret.isEmpty() && !oauth.clientSecretKeychain.isEmpty()) { + secret = keychain.load(oauth.clientSecretKeychain, oauth.clientId); + } + + // Public client (no secret): send client_id in the body. + if (secret.isEmpty()) { + body.append("&client_id=").append(ParameterEncoder.urlEncode(oauth.clientId)); + } + + // Build the HTTP request with the appropriate Authorization scheme. + HttpRequest.Builder rb = HttpRequest.builder() + .method("POST") + .url(url) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body.toString().getBytes(StandardCharsets.UTF_8)); + + if (!secret.isEmpty()) { + switch (oauth.tokenEndpointAuthMethod) { + case RFC5849_OAUTH1_SIGNATURE: { + Map bodyParams = parseBodyParams(body.toString()); + String authHeader = OAuth1Signature.buildAuthorizationHeader( + "POST", url, oauth.clientId, secret, bodyParams, oauth.nonceLength); + rb.header("Authorization", authHeader); + logger.debug("[OAuth2] Token endpoint auth method: rfc5849-oauth1-signature (HMAC-SHA256)"); + break; + } + case RFC6749_CLIENT_SECRET_BASIC: + default: { + String creds = oauth.clientId + ":" + secret; + String b64 = java.util.Base64.getEncoder().encodeToString(creds.getBytes(StandardCharsets.UTF_8)); + rb.header("Authorization", "Basic " + b64); + logger.debug("[OAuth2] Token endpoint auth method: rfc6749-client-secret-basic (HTTP Basic)"); + break; + } + } + } + + logger.debug("[OAuth2] Requesting token: grant_type={}, url={}", grantType, url); + + HttpResponse response = httpClient.execute(rb.build(), HttpConfig.empty()); + if (response.getStatusCode() < 200 || response.getStatusCode() >= 300) { + String err = response.getBody() != null + ? new String(response.getBody(), StandardCharsets.UTF_8) + : "(empty)"; + throw new HttpException("OAuth2 token endpoint returned non-2xx (" + response.getStatusCode() + + ") for grant_type=" + grantType + ": " + err, + response.getStatusCode(), response.getBody()); + } + + byte[] bodyBytes = response.getBody(); + if (bodyBytes == null || bodyBytes.length == 0) { + throw new HttpException("OAuth2 token endpoint returned 2xx with empty body for grant_type=" + + grantType, response.getStatusCode(), bodyBytes); + } + String responseBody = new String(bodyBytes, StandardCharsets.UTF_8); + JsonObject json = gson.fromJson(responseBody, JsonObject.class); + + if (json == null || !json.has("access_token")) { + throw new HttpException("OAuth2: access_token missing in response for grant_type=" + grantType); + } + + MintedToken minted = new MintedToken(); + minted.accessToken = json.get("access_token").getAsString(); + int expiresIn = json.has("expires_in") ? json.get("expires_in").getAsInt() : 3600; + // 30-second jiggle to match C++; clamp the floor at 1s so a short-lived test + // token (expires_in < 30) doesn't go straight into the past and trigger an + // infinite re-mint loop. + long effectiveLifetime = Math.max(expiresIn - 30, 1); + // Use monotonic clock (matches C++ openapi-oauth.cpp:56 which uses std::chrono::steady_clock): + // wall-clock jumps from NTP slews or manual time changes must not retroactively expire + // valid tokens or extend the lifetime of an expired one. + minted.expiresAtNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(effectiveLifetime); + if (json.has("refresh_token")) { + minted.refreshToken = json.get("refresh_token").getAsString(); + } else if (GRANT_TYPE_REFRESH_TOKEN.equals(grantType) && !refreshToken.isEmpty()) { + // Server didn't reissue; keep the old refresh token. + minted.refreshToken = refreshToken; + } + logger.debug("[OAuth2] Token minted (expires in {}s)", expiresIn); + return minted; + } + + @NotNull + static Map parseBodyParams(@NotNull String body) { + Map out = new LinkedHashMap<>(); + if (body.isEmpty()) return out; + for (String pair : body.split("&")) { + int eq = pair.indexOf('='); + if (eq < 0) continue; + String k = pair.substring(0, eq); + String v; + try { + v = URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + v = pair.substring(eq + 1); + } + out.put(k, v); + } + return out; + } + + /** + * Clears the cached token for the given key — call when a 401 is received + * to force a re-mint on the next request. + */ + public static void clearToken(@NotNull String tokenUrl, @NotNull String clientId, + @NotNull String audience, @NotNull List scopes) { + CACHE.remove(new TokenKey(tokenUrl, clientId, audience, String.join(":", scopes))); + } + + /** Test hook: clears the entire process-wide cache. */ + static void clearAllCachedTokens() { + CACHE.clear(); + } + + private static final class TokenKey { + final String tokenUrl; + final String clientId; + final String audience; + final String scopeKey; + + TokenKey(String tokenUrl, String clientId, String audience, String scopeKey) { + this.tokenUrl = tokenUrl; + this.clientId = clientId; + this.audience = audience; + this.scopeKey = scopeKey; + } + + @Override public boolean equals(Object o) { + if (!(o instanceof TokenKey)) return false; + TokenKey k = (TokenKey) o; + return Objects.equals(tokenUrl, k.tokenUrl) + && Objects.equals(clientId, k.clientId) + && Objects.equals(audience, k.audience) + && Objects.equals(scopeKey, k.scopeKey); + } + + @Override public int hashCode() { + return Objects.hash(tokenUrl, clientId, audience, scopeKey); + } + } + + private static final class MintedToken { + String accessToken = ""; + String refreshToken = ""; + /** Monotonic-clock deadline. Compare via {@code System.nanoTime() < expiresAtNanos}. */ + long expiresAtNanos = 0L; + } +} diff --git a/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/OpenAPIParser.java b/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/OpenAPIParser.java new file mode 100644 index 00000000..c72a5f6b --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/OpenAPIParser.java @@ -0,0 +1,530 @@ +package io.github.ndsev.zswag.shared; + +import io.github.ndsev.zswag.api.*; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.*; + +/** + * Parser for OpenAPI 3.0 specifications, with full support for the zswag + * extensions ({@code x-zserio-request-part}, {@code application/x-zserio-object} + * request bodies, OAuth2 {@code clientCredentials} flow). + * + *

Mirrors the C++ {@code openapi-parser.cpp} dispatch model: + *

    + *
  • Only HTTP methods GET/POST/PUT/DELETE are recognised; PATCH operations + * are ignored (see README "OpenAPI Options Interoperability" — patch + * cannot be realised over the zserio transport interface).
  • + *
  • Only the OAuth2 {@code clientCredentials} flow is accepted; other + * flows ({@code authorizationCode}, {@code implicit}, {@code password}) + * cause the scheme to be rejected with {@link IllegalArgumentException}.
  • + *
  • Top-level {@code security:} is loaded as the default, applied to any + * operation that does not declare its own {@code security}.
  • + *
  • Per-operation {@code security} preserves the OR-of-AND structure as + * a list of {@link SecurityRequirement} alternatives. Empty list + * ({@code security: []}) means "explicitly no auth required".
  • + *
+ */ +public class OpenAPIParser { + private static final Logger logger = LoggerFactory.getLogger(OpenAPIParser.class); + + private final Map spec; + private final Map methods = new LinkedHashMap<>(); + private final Map securitySchemes = new LinkedHashMap<>(); + private final List servers = new ArrayList<>(); + + /** Top-level (default) security requirement; null = no global default. */ + @Nullable + private List defaultSecurity; + + /** + * Parses a spec from a URL/path. For HTTPS URLs, the caller may need to + * supply OAuth2 tokens via the {@link #fetch(String, java.util.function.Consumer)} + * helper; this constructor does not authenticate the spec fetch. + */ + public OpenAPIParser(@NotNull String specLocation) throws IOException { + this(loadSpec(specLocation, h -> {})); + } + + /** + * Parses a spec where the caller has already added auth headers (e.g. + * an OAuth2 bearer token for {@code useForSpecFetch}) via + * {@code headerInjector}. + * + *

Note: this constructor uses a raw {@code URLConnection} for HTTP(S) + * spec URLs and therefore does NOT honour {@code HTTP_SSL_STRICT}, proxy + * settings, {@code HTTP_TIMEOUT}, basic-auth, or persistent headers/cookies/query + * from {@code http-settings.yaml}. Prefer + * {@link #OpenAPIParser(String, IHttpClient, HttpConfig, java.util.Map)} for + * full parity with the C++ spec-fetch path. + */ + public OpenAPIParser(@NotNull String specLocation, + @NotNull java.util.function.Consumer headerInjector) throws IOException { + this(loadSpec(specLocation, headerInjector)); + } + + /** + * Parses a spec fetched via the configured {@link IHttpClient}, so the spec-fetch + * request respects {@code HTTP_SSL_STRICT}, proxy, basic-auth, {@code HTTP_TIMEOUT}, + * and any persistent {@code headers:}/{@code cookies:}/{@code query:} from + * {@code http-settings.yaml} — matching the C++ {@code fetchOpenAPIConfig} flow. + * + * @param specLocation HTTP(S) URL of the spec, or a local file path + * @param httpClient transport used when the location is HTTP(S); ignored for files + * @param adhoc per-call HTTP config (e.g. pre-minted OAuth2 Bearer header + * — pass {@link HttpConfig#empty()} if no extra config is needed) + * @param extraHeaders additional headers to add on top of the merged config + * (typically empty; reserved for special-casing the OAuth2 + * {@code useForSpecFetch} token injection) + */ + public OpenAPIParser(@NotNull String specLocation, + @NotNull IHttpClient httpClient, + @NotNull HttpConfig adhoc, + @NotNull java.util.Map extraHeaders) throws IOException { + this(loadSpecViaHttpClient(specLocation, httpClient, adhoc, extraHeaders)); + } + + private OpenAPIParser(@NotNull Map spec) { + this.spec = spec; + parseSpec(); + } + + @NotNull + @SuppressWarnings("unchecked") + private static Map loadSpec(@NotNull String location, + @NotNull java.util.function.Consumer headerInjector) throws IOException { + logger.info("Loading OpenAPI spec from: {}", location); + InputStream input; + if (location.startsWith("http://") || location.startsWith("https://")) { + URLConnection conn = new URL(location).openConnection(); + headerInjector.accept(conn); + input = conn.getInputStream(); + } else { + input = Files.newInputStream(Paths.get(location)); + } + try (input) { + return parseYaml(input); + } + } + + /** + * Fetches the spec through the supplied {@link IHttpClient} so SSL/proxy/timeout/ + * persistent-settings all apply (matches C++ {@code fetchOpenAPIConfig}). Falls + * back to the local-file path when the location isn't an HTTP URL. + */ + @NotNull + @SuppressWarnings("unchecked") + private static Map loadSpecViaHttpClient(@NotNull String location, + @NotNull IHttpClient httpClient, + @NotNull HttpConfig adhoc, + @NotNull java.util.Map extraHeaders) + throws IOException { + logger.info("Loading OpenAPI spec from: {} (via {})", location, httpClient.getClass().getSimpleName()); + if (location.startsWith("http://") || location.startsWith("https://")) { + // Build a GET request; the IHttpClient layer applies persistent settings + adhoc + // + env vars (HTTP_SSL_STRICT, HTTP_TIMEOUT) and handles proxy/basic-auth. + HttpRequest.Builder rb = HttpRequest.builder().method("GET").url(location); + for (java.util.Map.Entry h : extraHeaders.entrySet()) { + rb.header(h.getKey(), h.getValue()); + } + HttpResponse response; + try { + response = httpClient.execute(rb.build(), adhoc); + } catch (HttpException e) { + throw new IOException("Spec fetch failed for '" + location + "': " + e.getMessage(), e); + } + if (response.getStatusCode() < 200 || response.getStatusCode() >= 300) { + throw new IOException("Spec fetch failed for '" + location + "': HTTP " + + response.getStatusCode()); + } + byte[] body = response.getBody(); + if (body == null || body.length == 0) { + throw new IOException("Spec fetch returned an empty body for '" + location + "'"); + } + try (java.io.ByteArrayInputStream stream = new java.io.ByteArrayInputStream(body)) { + return parseYaml(stream); + } + } + // Non-HTTP location: read directly from the filesystem. + try (InputStream input = Files.newInputStream(Paths.get(location))) { + return parseYaml(input); + } + } + + @NotNull + @SuppressWarnings("unchecked") + private static Map parseYaml(@NotNull InputStream input) throws IOException { + LoaderOptions options = new LoaderOptions(); + options.setAllowDuplicateKeys(false); + Yaml yaml = new Yaml(new SafeConstructor(options)); + Map loaded = yaml.load(input); + if (loaded == null) { + throw new IOException("Failed to load OpenAPI spec - empty or invalid YAML"); + } + return loaded; + } + + @SuppressWarnings("unchecked") + private void parseSpec() { + // servers + List> serversList = (List>) spec.get("servers"); + if (serversList != null) { + for (Map server : serversList) { + String url = (String) server.get("url"); + if (url != null) { + servers.add(url); + logger.debug("Found server: {}", url); + } + } + } + + // securitySchemes + Map components = (Map) spec.get("components"); + if (components != null) { + Map securitySchemesMap = (Map) components.get("securitySchemes"); + if (securitySchemesMap != null) { + parseSecuritySchemes(securitySchemesMap); + } + } + + // root-level (default) security + Object rootSec = spec.get("security"); + if (rootSec instanceof List) { + this.defaultSecurity = parseSecurityList((List>) rootSec); + logger.debug("Parsed root-level default security: {} alternatives", defaultSecurity.size()); + } + + // paths + Map paths = (Map) spec.get("paths"); + if (paths != null) { + parsePaths(paths); + } + } + + @SuppressWarnings("unchecked") + private void parseSecuritySchemes(@NotNull Map schemesMap) { + for (Map.Entry entry : schemesMap.entrySet()) { + String name = entry.getKey(); + Map schemeData = (Map) entry.getValue(); + String typeStr = (String) schemeData.get("type"); + SecuritySchemeType type = parseSecuritySchemeType(typeStr); + + SecurityScheme.Builder builder = SecurityScheme.builder(name, type); + + switch (type) { + case HTTP: + builder.scheme((String) schemeData.get("scheme")); + break; + case API_KEY: + builder.apiKeyLocation(parseParameterLocation((String) schemeData.get("in"))); + builder.apiKeyName((String) schemeData.get("name")); + break; + case OAUTH2: + parseOAuth2Flows(name, (Map) schemeData.get("flows"), builder); + break; + case OPEN_ID_CONNECT: + // Match C++ openapi-parser.cpp: reject at parse time so a Java client and a + // C++ client either both load or both refuse the same spec. A spec containing + // an openIdConnect scheme that isn't actually used at any operation will be + // rejected here too — same trade-off as C++. + throw new IllegalArgumentException( + "Security scheme '" + name + "' uses openIdConnect, which is not supported " + + "by zswag clients (use 'http' bearer or 'oauth2' clientCredentials instead)"); + } + + SecurityScheme scheme = builder.build(); + securitySchemes.put(name, scheme); + logger.debug("Parsed security scheme: {} ({})", name, type); + } + } + + @SuppressWarnings("unchecked") + private void parseOAuth2Flows(@NotNull String schemeName, @Nullable Map flows, + @NotNull SecurityScheme.Builder builder) { + if (flows == null) { + throw new IllegalArgumentException("OAuth2 scheme '" + schemeName + "' is missing the 'flows' object"); + } + Map clientCredentials = (Map) flows.get("clientCredentials"); + if (clientCredentials == null) { + // Match C++ openapi-parser.cpp:381 — only clientCredentials is supported. + throw new IllegalArgumentException( + "OAuth2 scheme '" + schemeName + "': only the 'clientCredentials' flow is supported by zswag" + + " (got flows: " + flows.keySet() + ")"); + } + builder.tokenUrl((String) clientCredentials.get("tokenUrl")); + builder.refreshUrl((String) clientCredentials.get("refreshUrl")); + Object scopes = clientCredentials.get("scopes"); + if (scopes instanceof Map) { + for (Map.Entry e : ((Map) scopes).entrySet()) { + builder.addOAuth2Scope(String.valueOf(e.getKey()), String.valueOf(e.getValue())); + } + } + } + + @SuppressWarnings("unchecked") + private void parsePaths(@NotNull Map paths) { + for (Map.Entry pathEntry : paths.entrySet()) { + String pathTemplate = pathEntry.getKey(); + Map pathItem = (Map) pathEntry.getValue(); + // PATCH is intentionally absent — see class javadoc. + for (String httpMethod : Arrays.asList("get", "post", "put", "delete")) { + Map operation = (Map) pathItem.get(httpMethod); + if (operation != null) { + parseOperation(pathTemplate, httpMethod.toUpperCase(Locale.ROOT), operation); + } + } + // Warn if patch is declared so users know it'll be silently ignored. + if (pathItem.get("patch") != null) { + logger.warn("Path '{}' declares a PATCH operation which zswag does not support; it will be ignored.", pathTemplate); + } + } + } + + @SuppressWarnings("unchecked") + private void parseOperation(@NotNull String pathTemplate, @NotNull String httpMethod, + @NotNull Map operation) { + String operationId = (String) operation.get("operationId"); + if (operationId == null) { + operationId = httpMethod + pathTemplate.replaceAll("[^a-zA-Z0-9]", "_"); + } + + MethodInfo methodInfo = new MethodInfo(operationId, pathTemplate, httpMethod); + + // parameters + List> parameters = (List>) operation.get("parameters"); + if (parameters != null) { + for (Map param : parameters) { + methodInfo.addParameter(parseParameter(param, pathTemplate)); + } + } + + // requestBody (body parameter is implicit when application/x-zserio-object content type is declared) + Map requestBody = (Map) operation.get("requestBody"); + if (requestBody != null) { + Map content = (Map) requestBody.get("content"); + if (content != null && content.containsKey("application/x-zserio-object")) { + methodInfo.bodyRequestObject = true; + } else if (content != null && !content.isEmpty()) { + logger.warn("Operation {} {} has a requestBody with media types {} — only application/x-zserio-object is consumed by zswag", + httpMethod, pathTemplate, content.keySet()); + } + } + + // security: per-op overrides global + Object opSec = operation.get("security"); + if (opSec instanceof List) { + methodInfo.security = parseSecurityList((List>) opSec); + } else { + methodInfo.security = null; // inherit default + } + + methods.put(operationId, methodInfo); + logger.debug("Parsed operation: {} {} ({})", httpMethod, pathTemplate, operationId); + } + + @SuppressWarnings("unchecked") + @NotNull + private static List parseSecurityList(@NotNull List> raw) { + List alternatives = new ArrayList<>(); + for (Map alt : raw) { + Map> required = new LinkedHashMap<>(); + for (Map.Entry e : alt.entrySet()) { + List scopes = new ArrayList<>(); + if (e.getValue() instanceof List) { + for (Object s : (List) e.getValue()) { + scopes.add(String.valueOf(s)); + } + } + required.put(e.getKey(), scopes); + } + alternatives.add(new SecurityRequirement(required)); + } + return alternatives; + } + + @SuppressWarnings("unchecked") + @NotNull + private OpenAPIParameter parseParameter(@NotNull Map paramData, @NotNull String pathTemplate) { + String name = (String) paramData.get("name"); + ParameterLocation location = parseParameterLocation((String) paramData.get("in")); + + OpenAPIParameter.Builder builder = OpenAPIParameter.builder(name, location); + + Boolean required = (Boolean) paramData.get("required"); + if (required != null) builder.required(required); + + String style = (String) paramData.get("style"); + if (style != null) { + ParameterStyle ps = parseParameterStyle(style); + validateStyleLocation(name, location, ps, pathTemplate); + builder.style(ps); + } + + Boolean explode = (Boolean) paramData.get("explode"); + if (explode != null) builder.explode(explode); + + Map schema = (Map) paramData.get("schema"); + if (schema != null) { + String format = (String) schema.get("format"); + if (format != null) builder.format(parseParameterFormat(format)); + } + + // The zswag extension that drives request decomposition. + Object xrp = paramData.get("x-zserio-request-part"); + if (xrp != null) { + builder.requestPart(String.valueOf(xrp)); + } + + return builder.build(); + } + + private static void validateStyleLocation(@NotNull String paramName, @NotNull ParameterLocation loc, + @NotNull ParameterStyle style, @NotNull String pathTemplate) { + // Mirrors C++ openapi-parser.cpp:191-209 + switch (style) { + case MATRIX: + case LABEL: + if (loc != ParameterLocation.PATH) { + throw new IllegalArgumentException( + "Parameter '" + paramName + "' on " + pathTemplate + + ": style '" + style + "' is only valid for path parameters"); + } + break; + case FORM: + if (loc != ParameterLocation.QUERY && loc != ParameterLocation.COOKIE) { + throw new IllegalArgumentException( + "Parameter '" + paramName + "' on " + pathTemplate + + ": style 'form' is only valid for query or cookie parameters"); + } + break; + case SIMPLE: + if (loc == ParameterLocation.QUERY || loc == ParameterLocation.COOKIE) { + throw new IllegalArgumentException( + "Parameter '" + paramName + "' on " + pathTemplate + + ": style 'simple' is not valid for query or cookie parameters"); + } + break; + default: + break; + } + } + + @NotNull + private SecuritySchemeType parseSecuritySchemeType(@Nullable String type) { + if (type == null) return SecuritySchemeType.HTTP; + switch (type.toLowerCase(Locale.ROOT)) { + case "http": return SecuritySchemeType.HTTP; + case "apikey": return SecuritySchemeType.API_KEY; + case "oauth2": return SecuritySchemeType.OAUTH2; + case "openidconnect": return SecuritySchemeType.OPEN_ID_CONNECT; + default: + throw new IllegalArgumentException("Unknown security scheme type: " + type); + } + } + + @NotNull + private ParameterLocation parseParameterLocation(@Nullable String location) { + if (location == null) return ParameterLocation.QUERY; + switch (location.toLowerCase(Locale.ROOT)) { + case "path": return ParameterLocation.PATH; + case "query": return ParameterLocation.QUERY; + case "header": return ParameterLocation.HEADER; + case "cookie": return ParameterLocation.COOKIE; + default: + throw new IllegalArgumentException("Unknown parameter location: " + location); + } + } + + @NotNull + private ParameterStyle parseParameterStyle(@Nullable String style) { + if (style == null) return ParameterStyle.SIMPLE; + switch (style.toLowerCase(Locale.ROOT)) { + case "simple": return ParameterStyle.SIMPLE; + case "label": return ParameterStyle.LABEL; + case "matrix": return ParameterStyle.MATRIX; + case "form": return ParameterStyle.FORM; + case "spacedelimited": return ParameterStyle.SPACE_DELIMITED; + case "pipedelimited": return ParameterStyle.PIPE_DELIMITED; + case "deepobject": return ParameterStyle.DEEP_OBJECT; + default: + throw new IllegalArgumentException("Unknown parameter style: " + style); + } + } + + @NotNull + private ParameterFormat parseParameterFormat(@Nullable String format) { + if (format == null) return ParameterFormat.STRING; + switch (format.toLowerCase(Locale.ROOT)) { + case "hex": return ParameterFormat.HEX; + case "byte": // Alias for base64 per OpenAPI spec + case "base64": return ParameterFormat.BASE64; + case "base64url": return ParameterFormat.BASE64URL; + case "binary": return ParameterFormat.BINARY; + case "string": return ParameterFormat.STRING; + default: + logger.debug("Unknown parameter format '{}', defaulting to STRING", format); + return ParameterFormat.STRING; + } + } + + @NotNull public List getServers() { return Collections.unmodifiableList(servers); } + @NotNull public Map getSecuritySchemes() { return Collections.unmodifiableMap(securitySchemes); } + @Nullable public MethodInfo getMethod(@NotNull String operationId) { return methods.get(operationId); } + @NotNull public Map getMethods() { return Collections.unmodifiableMap(methods); } + + /** Top-level default security requirement (or empty if no root-level security). */ + @NotNull public Optional> getDefaultSecurity() { + return Optional.ofNullable(defaultSecurity == null ? null : Collections.unmodifiableList(defaultSecurity)); + } + + /** One OpenAPI operation. */ + public static class MethodInfo { + private final String operationId; + private final String pathTemplate; + private final String httpMethod; + private final List parameters = new ArrayList<>(); + boolean bodyRequestObject; + @Nullable List security; // null = inherit global default + + public MethodInfo(@NotNull String operationId, @NotNull String pathTemplate, @NotNull String httpMethod) { + this.operationId = operationId; + this.pathTemplate = pathTemplate; + this.httpMethod = httpMethod; + } + + public void addParameter(@NotNull OpenAPIParameter parameter) { parameters.add(parameter); } + + @NotNull public String getOperationId() { return operationId; } + @NotNull public String getPathTemplate() { return pathTemplate; } + @NotNull public String getHttpMethod() { return httpMethod; } + @NotNull public List getParameters() { return Collections.unmodifiableList(parameters); } + + /** True if the operation declares an {@code application/x-zserio-object} request body. */ + public boolean hasZserioBody() { return bodyRequestObject; } + + /** + * Per-operation security as an OR-of-AND list of alternatives, or empty + * if the operation should fall back to the global default security. + * An empty list (operation explicitly declares {@code security: []}) + * means "no auth required". + */ + @NotNull + public Optional> getSecurity() { + return Optional.ofNullable(security == null ? null : Collections.unmodifiableList(security)); + } + } +} diff --git a/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/OpenApiClient.java b/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/OpenApiClient.java new file mode 100644 index 00000000..67d5515d --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/OpenApiClient.java @@ -0,0 +1,520 @@ +package io.github.ndsev.zswag.shared; + +import io.github.ndsev.zswag.api.*; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import zserio.runtime.io.Writer; + +import java.io.IOException; +import java.util.*; +import java.util.function.Function; + +/** + * The Java port of the C++ {@code zswagcl::OpenApiClient} / Python + * {@code zswag.OAClient}: dispatches OpenAPI calls described by a spec, with + * full {@code x-zserio-request-part} request-decomposition logic. + * + *

Two entry points: + *

    + *
  • {@link #callMethod(String, Object)} — the recommended typed API. + * Takes a zserio request object; uses POJO reflection (via + * {@link ZserioReflection}) to resolve {@code x-zserio-request-part} + * paths and encode each parameter into the request URL, headers, + * cookies, or query. The whole serialized request is sent as the body + * when the operation declares an {@code application/x-zserio-object} + * request body.
  • + *
  • {@link #callMethod(String, Map, byte[])} — low-level entry point + * where the caller has already decomposed the request into a parameter + * map and/or pre-serialized body bytes. Useful for non-zserio OpenAPI + * endpoints or for testing.
  • + *
+ */ +public class OpenApiClient implements IOpenApiClient { + private static final Logger logger = LoggerFactory.getLogger(OpenApiClient.class); + + /** zswag MIME type for both request bodies and response Accept header. */ + public static final String ZSERIO_OBJECT_CONTENT_TYPE = "application/x-zserio-object"; + + private final String specLocation; + private final IHttpClient httpClient; + private final HttpConfig adhoc; + private final IKeychain keychain; + private final OpenAPIParser parser; + private final String baseUrl; + private final int serverIndex; + + public OpenApiClient(@NotNull String specLocation, @NotNull IHttpClient httpClient, + @NotNull IKeychain keychain) throws IOException { + this(specLocation, httpClient, HttpConfig.empty(), keychain, 0); + } + + public OpenApiClient(@NotNull String specLocation, @NotNull IHttpClient httpClient, + @NotNull HttpConfig adhoc, @NotNull IKeychain keychain) throws IOException { + this(specLocation, httpClient, adhoc, keychain, 0); + } + + /** + * @param serverIndex index into the spec's {@code servers[]} array (default 0). + * Matches C++ {@code OAClient(..., uint32_t serverIndex)} and + * Python {@code OAClient(..., server_index=N)}. Out-of-bounds + * values raise an {@link IOException} during construction. + */ + public OpenApiClient(@NotNull String specLocation, @NotNull IHttpClient httpClient, + @NotNull HttpConfig adhoc, @NotNull IKeychain keychain, + int serverIndex) throws IOException { + if (serverIndex < 0) { + throw new IllegalArgumentException( + "serverIndex must be >= 0, got " + serverIndex); + } + this.specLocation = specLocation; + this.httpClient = httpClient; + this.adhoc = adhoc; + this.keychain = keychain; + this.serverIndex = serverIndex; + this.parser = parseSpec(specLocation, httpClient, adhoc, keychain); + // Validate the chosen index against the parsed spec's servers list. + // An empty servers list is treated as [{ "url": "/" }] per OpenAPI 3.0+ §4.7.5, + // so index 0 is always valid even with no declared servers. + int actualServerCount = Math.max(parser.getServers().size(), 1); + if (serverIndex >= actualServerCount) { + throw new IOException(String.format( + "serverIndex %d is out of bounds (spec declares %d server(s))", + serverIndex, actualServerCount)); + } + this.baseUrl = resolveBaseUrl(); + } + + /** + * Parses the OpenAPI spec, optionally pre-acquiring an OAuth2 access token + * and injecting it as an {@code Authorization: Bearer} header on the spec + * fetch request. This is required when the spec endpoint itself sits + * behind OAuth2 (the user opts in via + * {@link HttpConfig.OAuth2#useForSpecFetch}, default {@code true}). + * + *

When {@code useForSpecFetch=true} but the merged config lacks + * {@code tokenUrl} (we have nothing else to fall back on at this point + * — the spec hasn't been parsed yet so its {@code flows.clientCredentials.tokenUrl} + * is unknown), throws {@link IOException} with a descriptive message + * rather than silently fetching unauthenticated and 401-ing. + */ + @NotNull + private static OpenAPIParser parseSpec(@NotNull String specLocation, @NotNull IHttpClient httpClient, + @NotNull HttpConfig adhoc, @NotNull IKeychain keychain) throws IOException { + HttpConfig effective = httpClient.getPersistentSettings().forUrl(specLocation).mergedWith(adhoc); + HttpConfig.OAuth2 oauth = effective.getOAuth2().orElse(null); + boolean isHttpSpec = specLocation.startsWith("http://") || specLocation.startsWith("https://"); + // Build the extra-headers map used for the OAuth2 Bearer injection (useForSpecFetch). + // Empty for the no-OAuth2 / disabled / local-file cases. + java.util.Map extraHeaders = java.util.Collections.emptyMap(); + if (isHttpSpec && oauth != null && oauth.useForSpecFetch) { + if (oauth.tokenUrlOverride.isEmpty()) { + logger.warn("[OAuth2] useForSpecFetch=true but oauth2.tokenUrl is not set in http-settings; " + + "fetching spec '{}' unauthenticated. Set oauth2.tokenUrl, or set useForSpecFetch=false " + + "to suppress this warning if the spec endpoint is publicly readable.", specLocation); + } else { + try { + OAuth2Handler handler = new OAuth2Handler(httpClient, keychain); + String token = handler.getAccessToken( + oauth, oauth.tokenUrlOverride, oauth.tokenUrlOverride, oauth.scopesOverride); + logger.debug("[OAuth2] Pre-fetch token acquired for spec endpoint {}", specLocation); + extraHeaders = java.util.Collections.singletonMap("Authorization", "Bearer " + token); + } catch (HttpException e) { + logger.warn("[OAuth2] Pre-fetch token mint failed for spec '{}': {}. " + + "Continuing without Authorization header.", specLocation, e.getMessage()); + } + } + } + // Route the spec fetch through the configured IHttpClient so HTTP_SSL_STRICT, proxy, + // basic-auth, HTTP_TIMEOUT, and persistent headers/cookies/query all apply — matches + // C++ fetchOpenAPIConfig (openapi-parser.cpp:499). Local-file specs go through the + // filesystem directly (the IHttpClient path is skipped internally). + return new OpenAPIParser(specLocation, httpClient, adhoc, extraHeaders); + } + + @NotNull + private String resolveBaseUrl() { + List servers = parser.getServers(); + String serverUrl = !servers.isEmpty() ? servers.get(serverIndex) : ""; + boolean isRelativeUrl = serverUrl.isEmpty() || serverUrl.startsWith("/"); + + if (isRelativeUrl && specLocation.startsWith("http")) { + try { + java.net.URL url = new java.net.URL(specLocation); + String protocol = url.getProtocol(); + String host = url.getHost(); + int port = url.getPort(); + String basePath = serverUrl.isEmpty() ? "" : serverUrl; + String resolved = (port != -1) + ? protocol + "://" + host + ":" + port + basePath + : protocol + "://" + host + basePath; + logger.info("Resolved relative server URL '{}' to: {}", serverUrl, resolved); + return resolved; + } catch (java.net.MalformedURLException e) { + logger.warn("Failed to parse spec location URL: {}", e.getMessage()); + return serverUrl; + } + } else if (!serverUrl.isEmpty()) { + return serverUrl; + } + logger.warn("No servers defined in OpenAPI spec and cannot infer from spec location"); + return ""; + } + + // ------------------------------------------------------------------------ + // Typed entry point — the canonical "Python/C++ feel" API. + // ------------------------------------------------------------------------ + + /** + * Calls an OpenAPI method with a typed zserio request. The request is + * decomposed into path/query/header/cookie parameters and (if the + * operation declares it) a serialized {@code application/x-zserio-object} + * body, per {@code x-zserio-request-part} on each parameter. + * + * @param methodIdent OpenAPI {@code operationId} (matches zserio method name) + * @param zserioRequest typed zserio request object (must implement {@link Writer} + * if the operation declares a request body) + * @return raw response bytes (caller deserializes via zserio) + */ + @NotNull + public byte[] callMethod(@NotNull String methodIdent, @NotNull Object zserioRequest) throws HttpException { + OpenAPIParser.MethodInfo info = parser.getMethod(methodIdent); + if (info == null) { + throw new HttpException("Method '" + methodIdent + "' is not part of the OpenAPI specification"); + } + + Function resolver = param -> { + String requestPart = param.getRequestPart().orElse(null); + if (requestPart == null) { + // Parameters without x-zserio-request-part are not auto-filled by + // the dispatch; they may be supplied by HttpConfig (e.g. an + // API-key header). Return null to skip. + return null; + } + return ZserioReflection.resolveOrSerialize(zserioRequest, requestPart); + }; + + byte[] body = null; + if (info.hasZserioBody()) { + if (!(zserioRequest instanceof Writer)) { + throw new HttpException("Operation " + methodIdent + " declares a zserio request body, but " + + zserioRequest.getClass().getName() + " does not implement zserio.runtime.io.Writer"); + } + body = ZserioReflection.serialize((Writer) zserioRequest); + } + + return dispatch(info, resolver, body); + } + + // ------------------------------------------------------------------------ + // Map-based entry point — low-level / testing. + // ------------------------------------------------------------------------ + + @Override + @Nullable + public byte[] callMethod(@NotNull String methodPath, @NotNull Map parameters, + @Nullable byte[] requestBody) throws HttpException { + OpenAPIParser.MethodInfo info = parser.getMethod(methodPath); + if (info == null) { + throw new HttpException("Method '" + methodPath + "' is not part of the OpenAPI specification"); + } + Function resolver = param -> parameters.get(param.getName()); + return dispatch(info, resolver, requestBody); + } + + // ------------------------------------------------------------------------ + // Shared dispatch core. + // ------------------------------------------------------------------------ + + @NotNull + private byte[] dispatch(@NotNull OpenAPIParser.MethodInfo info, + @NotNull Function resolver, + @Nullable byte[] body) throws HttpException { + logger.debug("Calling {} {} ({})", info.getHttpMethod(), info.getPathTemplate(), info.getOperationId()); + + String path = info.getPathTemplate(); + List> queryPairs = new ArrayList<>(); + Map opHeaders = new LinkedHashMap<>(); + Map opCookies = new LinkedHashMap<>(); + + for (OpenAPIParameter param : info.getParameters()) { + Object value = resolver.apply(param); + if (value == null) { + if (param.isRequired() && param.getRequestPart().isPresent()) { + throw new HttpException("Required parameter '" + param.getName() + + "' resolved to null via x-zserio-request-part: " + param.getRequestPart().get()); + } + continue; + } + + switch (param.getLocation()) { + case PATH: + // Use RFC 3986 path encoder (NOT URLEncoder which is form-urlencoded). + // The form encoder would mangle matrix-style ';key=value' to '%3Bkey%3Dvalue' + // and label-style '.value' to '%2Evalue', breaking the URL syntax the server + // expects. See ParameterEncoder.pathEncode and RFC 3986 §3.3. + path = path.replace("{" + param.getName() + "}", + ParameterEncoder.pathEncode(ParameterEncoder.encodeForPath(param, value))); + break; + case QUERY: + queryPairs.addAll(ParameterEncoder.encodeForQuery(param, value)); + break; + case HEADER: + opHeaders.put(param.getName(), ParameterEncoder.encodeForHeader(param, value)); + break; + case COOKIE: + opCookies.put(param.getName(), ParameterEncoder.encodeForCookie(param, value)); + break; + } + } + + // Reject unfilled path placeholders rather than emitting them literally. + if (path.matches(".*\\{[^/}]+\\}.*")) { + throw new HttpException("Unfilled path placeholder in '" + path + "' for " + info.getOperationId()); + } + + // Build full URL. + StringBuilder fullUrl = new StringBuilder(baseUrl); + if (!baseUrl.isEmpty() && !baseUrl.endsWith("/") && !path.startsWith("/")) { + fullUrl.append("/"); + } + fullUrl.append(path); + if (!queryPairs.isEmpty()) { + fullUrl.append("?").append(ParameterEncoder.buildQueryString(queryPairs)); + } + + // Operation-level cookies → Cookie header (merged with persistent/adhoc cookies in HttpClient). + if (!opCookies.isEmpty()) { + StringJoiner sj = new StringJoiner("; "); + for (Map.Entry e : opCookies.entrySet()) sj.add(e.getKey() + "=" + e.getValue()); + opHeaders.merge("Cookie", sj.toString(), (existing, incoming) -> existing + "; " + incoming); + } + + // zswag protocol headers. + opHeaders.put("Accept", ZSERIO_OBJECT_CONTENT_TYPE); + if (body != null) { + opHeaders.put("Content-Type", ZSERIO_OBJECT_CONTENT_TYPE); + } + + // Apply security: route api-key to the right location and mint OAuth2 tokens. + // The merged config is needed to know which auth credentials are configured. + HttpConfig effective = mergedConfigFor(fullUrl.toString()); + applySecurity(info, effective, opHeaders, queryPairs); + + // Re-append the (possibly-extended) query string when applySecurity added api-key/query entries. + // Reset URL building since query may have grown. + StringBuilder finalUrl = new StringBuilder(baseUrl); + if (!baseUrl.isEmpty() && !baseUrl.endsWith("/") && !path.startsWith("/")) { + finalUrl.append("/"); + } + finalUrl.append(path); + if (!queryPairs.isEmpty()) { + finalUrl.append("?").append(ParameterEncoder.buildQueryString(queryPairs)); + } + + // Build the HTTP request. + io.github.ndsev.zswag.api.HttpRequest.Builder rb = io.github.ndsev.zswag.api.HttpRequest.builder() + .method(info.getHttpMethod()) + .url(finalUrl.toString()) + .headers(opHeaders); + if (body != null) rb.body(body); + + io.github.ndsev.zswag.api.HttpResponse response = httpClient.execute(rb.build(), adhoc); + + // Strict 200 — matches C++ openapi-client.cpp:200. + if (response.getStatusCode() != 200) { + String contextDesc = "[" + info.getHttpMethod() + " " + fullUrl + "]"; + String errorMsg = contextDesc + " Got HTTP status: " + response.getStatusCode(); + throw new HttpException(errorMsg, response.getStatusCode(), response.getBody()); + } + byte[] respBody = response.getBody(); + return respBody != null ? respBody : new byte[0]; + } + + /** + * Computes the effective {@link HttpConfig} for a given URL: the persistent + * settings exposed by the underlying {@link IHttpClient} (scope-matched + * against the URL) merged with this client's adhoc config. + */ + @NotNull + private HttpConfig mergedConfigFor(@NotNull String url) { + return httpClient.getPersistentSettings().forUrl(url).mergedWith(adhoc); + } + + /** + * Walks the operation's security alternatives and applies each scheme: + *

    + *
  • HTTP basic / bearer: validated by the underlying {@link IHttpClient} + * from the merged config; throws here if neither is configured.
  • + *
  • API-key: routes the merged config's {@link HttpConfig#getApiKey()} + * to header / query / cookie based on the scheme's {@code in}.
  • + *
  • OAuth2: mints (or pulls cached) bearer token via + * {@link OAuth2Handler}, applying spec/settings precedence rules, + * then injects {@code Authorization: Bearer ...} into the request + * headers.
  • + *
+ * + *

Picks the first alternative whose schemes are all present in the + * merged config. Throws if no alternative can be satisfied. + */ + private void applySecurity(@NotNull OpenAPIParser.MethodInfo info, + @NotNull HttpConfig effective, + @NotNull Map opHeaders, + @NotNull List> queryPairs) throws HttpException { + List alternatives = info.getSecurity() + .orElse(parser.getDefaultSecurity().orElse(Collections.emptyList())); + if (alternatives.isEmpty()) return; + + // Pick the first alternative whose schemes can be satisfied. + Map schemes = parser.getSecuritySchemes(); + List failures = new ArrayList<>(); + for (SecurityRequirement alt : alternatives) { + try { + for (Map.Entry> req : alt.getSchemes().entrySet()) { + SecurityScheme scheme = schemes.get(req.getKey()); + if (scheme == null) { + throw new HttpException("Security scheme '" + req.getKey() + "' referenced by operation but not defined in components.securitySchemes"); + } + applySingleScheme(scheme, req.getValue(), effective, opHeaders, queryPairs); + } + return; // all schemes in this alternative satisfied + } catch (HttpException e) { + failures.add(e.getMessage()); + } + } + throw new HttpException("Operation " + info.getOperationId() + " requires security but none of the " + + alternatives.size() + " alternatives could be satisfied: " + failures); + } + + private void applySingleScheme(@NotNull SecurityScheme scheme, @NotNull List requiredScopes, + @NotNull HttpConfig effective, @NotNull Map opHeaders, + @NotNull List> queryPairs) throws HttpException { + switch (scheme.getType()) { + case HTTP: { + String s = scheme.getScheme() == null ? "" : scheme.getScheme().toLowerCase(); + if ("basic".equals(s)) { + // Accept either basic-auth credentials in the merged config OR a + // pre-set Authorization: Basic header (matches C++ HttpBasicHandler::satisfy + // at openapi-security.cpp:22-37). Without this, a user who configures + // their own static Authorization header gets a misleading "no basic-auth + // configured" error. + boolean hasBasicHeader = effective.getHeader("Authorization") + .map(v -> v.toLowerCase().startsWith("basic ")) + .orElse(false); + if (!effective.getAuth().isPresent() && !hasBasicHeader) { + throw new HttpException("HTTP Basic auth required but no basic-auth configured"); + } + } else if ("bearer".equals(s)) { + boolean hasBearer = effective.getHeader("Authorization") + .map(v -> v.startsWith("Bearer ")) + .orElse(false); + if (!hasBearer) { + throw new HttpException("HTTP Bearer auth required but no Authorization: Bearer header configured"); + } + } + break; + } + case API_KEY: { + String keyValue = effective.getApiKey().orElse(null); + if (keyValue == null) { + // The user might have set the key directly via the matching channel. + // Probe for it before declaring failure. + keyValue = lookupConfiguredApiKey(scheme, effective); + } + if (keyValue == null) { + throw new HttpException("API-key auth required by scheme '" + scheme.getName() + + "' but no api-key configured (set via http-settings api-key, or directly via " + + scheme.getApiKeyLocation() + " '" + scheme.getApiKeyName() + "')"); + } + if (effective.getApiKey().isPresent()) { + // Route the configured api-key to the appropriate location. + switch (scheme.getApiKeyLocation()) { + case HEADER: + opHeaders.put(scheme.getApiKeyName(), keyValue); + break; + case QUERY: + queryPairs.add(new java.util.AbstractMap.SimpleImmutableEntry<>(scheme.getApiKeyName(), keyValue)); + break; + case COOKIE: { + String cookieValue = scheme.getApiKeyName() + "=" + keyValue; + opHeaders.merge("Cookie", cookieValue, + (existing, incoming) -> existing + "; " + incoming); + break; + } + default: + break; + } + } + break; + } + case OAUTH2: { + // Resolve OAuth2 config from settings (effective.oauth2) — the spec scopes/tokenUrl + // are fallbacks when settings don't override. + HttpConfig.OAuth2 oauth = effective.getOAuth2().orElse(null); + if (oauth == null) { + throw new HttpException("OAuth2 required by scheme '" + scheme.getName() + + "' but no oauth2 config in HTTP settings"); + } + String tokenUrl = !oauth.tokenUrlOverride.isEmpty() + ? oauth.tokenUrlOverride + : scheme.getTokenUrl().orElse(""); + String refreshUrl = !oauth.refreshUrlOverride.isEmpty() + ? oauth.refreshUrlOverride + : scheme.getRefreshUrl().orElse(tokenUrl); + if (tokenUrl.isEmpty()) { + throw new HttpException("OAuth2 client-credentials: tokenUrl is missing in spec and http-settings"); + } + List scopes = !oauth.scopesOverride.isEmpty() ? oauth.scopesOverride : requiredScopes; + + OAuth2Handler handler = new OAuth2Handler(httpClient, keychain); + String token = handler.getAccessToken(oauth, tokenUrl, refreshUrl, scopes); + opHeaders.put("Authorization", "Bearer " + token); + break; + } + case OPEN_ID_CONNECT: + throw new HttpException("OpenID Connect security scheme '" + scheme.getName() + + "' is not supported by zswag clients"); + } + } + + /** + * Probes the merged config for an API-key value already supplied directly + * via header/query/cookie (matching the scheme's location). Returns the + * value found, or null if none. + */ + @Nullable + private String lookupConfiguredApiKey(@NotNull SecurityScheme scheme, @NotNull HttpConfig effective) { + String name = scheme.getApiKeyName(); + if (name == null || scheme.getApiKeyLocation() == null) return null; + switch (scheme.getApiKeyLocation()) { + case HEADER: + return effective.getHeader(name).orElse(null); + case QUERY: + List queryVals = effective.getQuery().get(name); + return (queryVals != null && !queryVals.isEmpty()) ? queryVals.get(0) : null; + case COOKIE: + return effective.getCookies().get(name); + default: + return null; + } + } + + @Override + @NotNull + public IHttpClient getHttpClient() { + return httpClient; + } + + @Override + @NotNull + public String getOpenAPISpecLocation() { + return specLocation; + } + + /** Exposes the parsed spec for callers that need to introspect operations. */ + @NotNull + public OpenAPIParser getParser() { + return parser; + } +} diff --git a/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/ParameterEncoder.java b/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/ParameterEncoder.java new file mode 100644 index 00000000..4d8c02ec --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/ParameterEncoder.java @@ -0,0 +1,453 @@ +package io.github.ndsev.zswag.shared; + +import io.github.ndsev.zswag.api.*; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.net.URLEncoder; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.StringJoiner; + +/** + * Encodes OpenAPI parameter values for path, query, header, and cookie locations. + * Mirrors the C++ {@code openapi-parameter-helper.cpp} contract: + * + *

    + *
  • Path/header/cookie locations return one styled string per parameter.
  • + *
  • Query parameters return a list of {@code (name, value)} pairs so that + * {@code style: form, explode: true} can yield repeated query keys + * ({@code ?id=1&id=2&id=3}).
  • + *
  • Whole-blob body parameters ({@code x-zserio-request-part: "*"}) are + * served as raw bytes via {@link #encodeWholeBlobBody}.
  • + *
+ */ +public class ParameterEncoder { + + private ParameterEncoder() {} + + // ------------------------------------------------------------------------ + // High-level API: encode for a specific location. + // ------------------------------------------------------------------------ + + /** + * Encodes a parameter for a path placeholder (or label/matrix style on a + * path param). + */ + @NotNull + public static String encodeForPath(@NotNull OpenAPIParameter param, @NotNull Object value) { + List arrayElements = formatArrayElements(value, param.getFormat()); + if (arrayElements != null) { + return applyPathArrayStyle(param.getName(), arrayElements, param.getStyle(), param.isExplode()); + } + if (value instanceof Map) { + return applyPathMapStyle(param.getName(), + (Map) value, param.getStyle(), param.isExplode(), param.getFormat()); + } + String formatted = formatScalarValue(value, param.getFormat()); + return applyPathScalarStyle(param.getName(), formatted, param.getStyle()); + } + + /** + * Encodes a parameter for a header value. Style is always {@code simple} for + * headers per OpenAPI; {@code explode} only matters for arrays. + */ + @NotNull + public static String encodeForHeader(@NotNull OpenAPIParameter param, @NotNull Object value) { + List arrayElements = formatArrayElements(value, param.getFormat()); + if (arrayElements != null) { + // simple style: comma-joined + return String.join(",", arrayElements); + } + if (value instanceof Map) { + // simple style on map: "k1,v1,k2,v2" (no explode in header per OpenAPI). + return String.join(",", flattenMapForJoin((Map) value, param.getFormat())); + } + return formatScalarValue(value, param.getFormat()); + } + + /** + * Encodes a parameter for a cookie. Returns just the cookie value; the + * caller assembles {@code name=value; …} into the {@code Cookie} header. + */ + @NotNull + public static String encodeForCookie(@NotNull OpenAPIParameter param, @NotNull Object value) { + List arrayElements = formatArrayElements(value, param.getFormat()); + if (arrayElements != null) { + // form style, comma-joined when not exploded; explode + cookie isn't well-defined. + return String.join(",", arrayElements); + } + if (value instanceof Map) { + // Cookie carrying a compound/map value: comma-joined "k1,v1,k2,v2" (matches the + // C++ openapi-parameter-helper encodeForCookie of a map). + return String.join(",", flattenMapForJoin((Map) value, param.getFormat())); + } + return formatScalarValue(value, param.getFormat()); + } + + /** + * Encodes a parameter for the query string. Returns a list of + * {@code (name, value)} pairs. For {@code style: form, explode: true} + * arrays this is one pair per element; for {@code explode: false} arrays + * it's a single pair with comma-joined values; scalars are a single pair. + * + *

Map-shaped values (e.g. a zserio compound resolved through a future + * IReflectableView): explode=true emits one pair per map entry + * ({@code ?k1=v1&k2=v2}); explode=false emits a single comma-joined pair + * ({@code ?name=k1,v1,k2,v2}) — matches C++ {@code queryOrHeaderPairs} at + * openapi-parameter-helper.cpp:197-205. + */ + @NotNull + public static List> encodeForQuery(@NotNull OpenAPIParameter param, @NotNull Object value) { + List> result = new ArrayList<>(); + List arrayElements = formatArrayElements(value, param.getFormat()); + if (arrayElements != null) { + if (param.isExplode()) { + for (String v : arrayElements) { + result.add(new AbstractMap.SimpleImmutableEntry<>(param.getName(), v)); + } + } else { + result.add(new AbstractMap.SimpleImmutableEntry<>(param.getName(), String.join(",", arrayElements))); + } + return result; + } + if (value instanceof Map) { + Map map = (Map) value; + if (param.isExplode()) { + // ?k1=v1&k2=v2 — each map entry becomes its own pair. + for (Map.Entry e : map.entrySet()) { + result.add(new AbstractMap.SimpleImmutableEntry<>( + String.valueOf(e.getKey()), + formatScalarValue(e.getValue(), param.getFormat()))); + } + } else { + // ?paramName=k1,v1,k2,v2 — flattened, single-pair form. + result.add(new AbstractMap.SimpleImmutableEntry<>( + param.getName(), + String.join(",", flattenMapForJoin(map, param.getFormat())))); + } + return result; + } + String formatted = formatScalarValue(value, param.getFormat()); + result.add(new AbstractMap.SimpleImmutableEntry<>(param.getName(), formatted)); + return result; + } + + /** Helper: flatten a Map's entries to ["k1","v1","k2","v2", ...] using the given format. */ + @NotNull + private static List flattenMapForJoin(@NotNull Map map, @NotNull ParameterFormat format) { + List flat = new ArrayList<>(map.size() * 2); + for (Map.Entry e : map.entrySet()) { + flat.add(String.valueOf(e.getKey())); + flat.add(formatScalarValue(e.getValue(), format)); + } + return flat; + } + + /** + * Returns the raw bytes of {@code value} for use as an + * {@code application/x-zserio-object} request body. Used when + * {@code x-zserio-request-part: "*"} is in effect. + */ + @NotNull + public static byte[] encodeWholeBlobBody(@NotNull Object value) { + if (value instanceof byte[]) return (byte[]) value; + return toBytes(value); + } + + // ------------------------------------------------------------------------ + // Style application — split by location to mirror C++ helper. + // ------------------------------------------------------------------------ + + @NotNull + private static String applyPathScalarStyle(@NotNull String name, @NotNull String value, + @NotNull ParameterStyle style) { + switch (style) { + case SIMPLE: return value; + case LABEL: return "." + value; + case MATRIX: return ";" + name + "=" + value; + default: return value; + } + } + + /** + * Path-style application for map-shaped values. Matches C++ + * {@code openapi-parameter-helper::pathStr} on a map (openapi-parameter-helper.cpp:140-160). + * + *

Style × explode behaviour: + *

    + *
  • {@code simple} (any explode): {@code k1,v1,k2,v2} or + * {@code k1=v1,k2=v2} when explode (per OpenAPI 3 spec).
  • + *
  • {@code label}: {@code .k1.v1.k2.v2} or {@code .k1=v1.k2=v2} (explode).
  • + *
  • {@code matrix}: {@code ;name=k1,v1,k2,v2} or {@code ;k1=v1;k2=v2} (explode).
  • + *
+ */ + @NotNull + private static String applyPathMapStyle(@NotNull String name, @NotNull Map map, + @NotNull ParameterStyle style, boolean explode, + @NotNull ParameterFormat format) { + if (map.isEmpty()) return ""; + switch (style) { + case SIMPLE: + return joinMapEntries(map, explode ? "=" : ",", ",", format); + case LABEL: + return "." + joinMapEntries(map, explode ? "=" : ",", explode ? "." : ",", format); + case MATRIX: + if (explode) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry e : map.entrySet()) { + sb.append(';').append(e.getKey()).append('=') + .append(formatScalarValue(e.getValue(), format)); + } + return sb.toString(); + } + return ";" + name + "=" + String.join(",", flattenMapForJoin(map, format)); + default: + return String.join(",", flattenMapForJoin(map, format)); + } + } + + /** + * Helper: render a map as a list of {@code keyvalue} pairs joined with + * {@code entrySep}. + */ + @NotNull + private static String joinMapEntries(@NotNull Map map, @NotNull String kvSep, + @NotNull String entrySep, @NotNull ParameterFormat format) { + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (Map.Entry e : map.entrySet()) { + if (!first) sb.append(entrySep); + sb.append(e.getKey()).append(kvSep).append(formatScalarValue(e.getValue(), format)); + first = false; + } + return sb.toString(); + } + + @NotNull + private static String applyPathArrayStyle(@NotNull String name, @NotNull List values, + @NotNull ParameterStyle style, boolean explode) { + if (values.isEmpty()) return ""; + switch (style) { + case SIMPLE: + return String.join(",", values); + case LABEL: + return "." + (explode ? String.join(".", values) : String.join(",", values)); + case MATRIX: + if (explode) { + StringBuilder sb = new StringBuilder(); + for (String v : values) sb.append(';').append(name).append('=').append(v); + return sb.toString(); + } + return ";" + name + "=" + String.join(",", values); + default: + return String.join(",", values); + } + } + + // ------------------------------------------------------------------------ + // Format conversion (scalar & array elements). + // ------------------------------------------------------------------------ + + @Nullable + private static List formatArrayElements(@NotNull Object value, @NotNull ParameterFormat format) { + if (value instanceof Collection) { + List result = new ArrayList<>(); + for (Object element : (Collection) value) result.add(formatScalarValue(element, format)); + return result; + } else if (value instanceof Object[]) { + List result = new ArrayList<>(); + for (Object element : (Object[]) value) result.add(formatScalarValue(element, format)); + return result; + } else if (value instanceof short[]) { + short[] arr = (short[]) value; + List result = new ArrayList<>(arr.length); + for (short v : arr) result.add(formatWithByteWidth(v, 1, format)); + return result; + } else if (value instanceof int[]) { + int[] arr = (int[]) value; + List result = new ArrayList<>(arr.length); + for (int v : arr) result.add(formatWithByteWidth(v, 4, format)); + return result; + } else if (value instanceof long[]) { + long[] arr = (long[]) value; + List result = new ArrayList<>(arr.length); + for (long v : arr) result.add(formatWithByteWidth(v, 8, format)); + return result; + } else if (value instanceof double[]) { + double[] arr = (double[]) value; + List result = new ArrayList<>(arr.length); + for (double v : arr) result.add(formatScalarValue(v, format)); + return result; + } else if (value instanceof float[]) { + float[] arr = (float[]) value; + List result = new ArrayList<>(arr.length); + for (float v : arr) result.add(formatScalarValue(v, format)); + return result; + } else if (value instanceof boolean[]) { + boolean[] arr = (boolean[]) value; + List result = new ArrayList<>(arr.length); + for (boolean v : arr) result.add(formatScalarValue(v, format)); + return result; + } + // byte[] is binary scalar, not array. + return null; + } + + @NotNull + private static String formatWithByteWidth(long value, int byteWidth, @NotNull ParameterFormat format) { + switch (format) { + case STRING: return String.valueOf(value); + case HEX: return toSignedHexString(value); + case BASE64: return Base64.getEncoder().encodeToString(toBytesWithWidth(value, byteWidth)); + case BASE64URL: return Base64.getUrlEncoder().encodeToString(toBytesWithWidth(value, byteWidth)); + case BINARY: return String.valueOf(value); + default: return String.valueOf(value); + } + } + + @NotNull + private static String formatScalarValue(@NotNull Object value, @NotNull ParameterFormat format) { + // Booleans: "0" / "1" (server-side parsing matches C++ behavior). + if (value instanceof Boolean) return ((Boolean) value) ? "1" : "0"; + + switch (format) { + case STRING: return String.valueOf(value); + case HEX: return toHexString(value); + case BASE64: return Base64.getEncoder().encodeToString(toBytes(value)); + case BASE64URL: return Base64.getUrlEncoder().encodeToString(toBytes(value)); + case BINARY: + // Binary == raw bytes interpreted as a string per C++ formatBuffer. For numeric + // types this is rarely meaningful; for byte[]/String it's the identity. + return value instanceof byte[] + ? new String((byte[]) value, StandardCharsets.UTF_8) + : String.valueOf(value); + default: return String.valueOf(value); + } + } + + @NotNull + private static String toSignedHexString(long value) { + return value < 0 ? "-" + Long.toHexString(-value) : Long.toHexString(value); + } + + @NotNull + private static String toHexString(@NotNull Object value) { + if (value instanceof byte[]) { + byte[] bytes = (byte[]) value; + StringBuilder hex = new StringBuilder(); + for (byte b : bytes) hex.append(String.format("%02x", b & 0xFF)); + return hex.toString(); + } else if (value instanceof Number) { + return toSignedHexString(((Number) value).longValue()); + } + return String.valueOf(value); + } + + @NotNull + private static byte[] toBytes(@NotNull Object value) { + if (value instanceof byte[]) return (byte[]) value; + if (value instanceof Byte) return new byte[]{(Byte) value}; + if (value instanceof Short) return ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN).putShort((Short) value).array(); + if (value instanceof Integer) return ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt((Integer) value).array(); + if (value instanceof Long) return ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN).putLong((Long) value).array(); + if (value instanceof Float) return ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putFloat((Float) value).array(); + if (value instanceof Double) return ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN).putDouble((Double) value).array(); + return String.valueOf(value).getBytes(StandardCharsets.UTF_8); + } + + @NotNull + private static byte[] toBytesWithWidth(long value, int byteWidth) { + byte[] bytes = new byte[byteWidth]; + for (int i = 0; i < byteWidth; i++) { + bytes[byteWidth - 1 - i] = (byte) ((value >> (i * 8)) & 0xFF); + } + return bytes; + } + + // ------------------------------------------------------------------------ + // URL building helpers. + // ------------------------------------------------------------------------ + + @NotNull + public static String urlEncode(@NotNull String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + /** + * RFC 3986 path-component encoder. + * + *

Crucially differs from {@link #urlEncode} ({@code application/x-www-form-urlencoded}): + * the path encoder preserves the sub-delims ({@code !$&'()*+,;=}), the unreserved + * pchar bytes ({@code -._~}), and {@code :} and {@code @} — all of which are valid + * in a path segment per RFC 3986 §3.3. This matches the C++ httpcl + * {@code URIComponents::appendPath} behaviour and is required for the OpenAPI path + * styles {@code matrix} (uses {@code ;}, {@code =}) and {@code label} (uses {@code .}). + * + *

Without this distinction, a matrix-styled value like {@code ;id=42} would be + * mangled to {@code %3Bid%3D42}, and the server would not recognise it as matrix syntax. + */ + @NotNull + public static String pathEncode(@NotNull String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + StringBuilder out = new StringBuilder(bytes.length); + for (byte raw : bytes) { + int b = raw & 0xff; + if (isUnreservedPChar(b)) { + out.append((char) b); + } else { + out.append('%'); + out.append(HEX_DIGITS[(b >> 4) & 0xF]); + out.append(HEX_DIGITS[b & 0xF]); + } + } + return out.toString(); + } + + private static final char[] HEX_DIGITS = "0123456789ABCDEF".toCharArray(); + + /** + * True for bytes that are valid as-is in a path segment per RFC 3986 §3.3 (pchar): + * unreserved | sub-delims | ":" | "@". '/' is NOT included — segment separator must + * stay an escape candidate; callers handle the separator themselves. + */ + private static boolean isUnreservedPChar(int b) { + // unreserved: ALPHA / DIGIT / "-" / "." / "_" / "~" + if ((b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9')) return true; + switch (b) { + // unreserved + case '-': case '.': case '_': case '~': + // sub-delims + case '!': case '$': case '&': case '\'': case '(': case ')': + case '*': case '+': case ',': case ';': case '=': + // pchar additions + case ':': case '@': + return true; + default: + return false; + } + } + + /** + * Builds a query string from an ordered list of {@code (name, value)} pairs, + * preserving order and duplicates so that {@code style: form, explode: true} + * yields the expected {@code ?id=1&id=2&id=3}. + */ + @NotNull + public static String buildQueryString(@NotNull List> pairs) { + if (pairs.isEmpty()) return ""; + StringJoiner sj = new StringJoiner("&"); + for (Map.Entry entry : pairs) { + sj.add(urlEncode(entry.getKey()) + "=" + urlEncode(entry.getValue())); + } + return sj.toString(); + } +} diff --git a/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/ZserioReflection.java b/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/ZserioReflection.java new file mode 100644 index 00000000..ec3c5415 --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/ZserioReflection.java @@ -0,0 +1,154 @@ +package io.github.ndsev.zswag.shared; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import zserio.runtime.io.BitStreamWriter; +import zserio.runtime.io.ByteArrayBitStreamWriter; +import zserio.runtime.io.Writer; + +import java.io.IOException; +import java.lang.reflect.Method; + +/** + * Resolves zswag {@code x-zserio-request-part} dotted paths against typed + * zserio request objects via JavaBean-style getter reflection. Mirrors the + * Python/C++ "find by member path" flow ({@code reflectable->find(field)} + * in {@code oaclient.cpp:141}) but uses Java's POJO accessor convention + * because zserio Java codegen does not emit a runtime introspection view. + * + *

Path semantics: + *

    + *
  • {@code "*"} means "the whole request object" — caller serializes it.
  • + *
  • Empty path means "the whole request object" — same as {@code "*"}.
  • + *
  • Dot-separated segments are zserio identifiers (snake_case allowed); + * each is normalized to lowerCamel and looked up via {@code getXxx()}.
  • + *
  • Zserio enum values are unwrapped to their underlying numeric via + * {@code ZserioEnum.getGenericValue()} so they can be encoded via the + * OpenAPI parameter format (string/hex/base64/...).
  • + *
+ * + *

Resolution returns the raw Java value (primitive box, array, byte[], + * String, or another zserio compound). The caller decides how to encode it. + */ +public final class ZserioReflection { + + private ZserioReflection() {} + + /** + * Resolves the given path against {@code root}. + * Returns {@code null} only when an intermediate segment evaluates to {@code null}; + * use {@link #resolveOptional} for caller-controlled handling. + * + * @throws IllegalArgumentException if a path segment doesn't correspond to a getter. + */ + @Nullable + public static Object resolve(@NotNull Object root, @NotNull String dottedPath) { + if (dottedPath.isEmpty() || "*".equals(dottedPath)) { + return root; + } + Object current = root; + for (String segment : dottedPath.split("\\.")) { + if (current == null) { + return null; + } + current = invokeGetter(current, segment); + } + // Unwrap zserio enum to its underlying numeric. + if (current instanceof zserio.runtime.ZserioEnum) { + return ((zserio.runtime.ZserioEnum) current).getGenericValue(); + } + return current; + } + + /** + * Serializes a zserio {@link Writer} (any zserio struct/choice/union/etc.) + * to a byte array using the standard bitstream writer. Mirrors the + * {@code writeAll} step in {@code OAClient::callMethod} for whole-blob bodies. + */ + @NotNull + public static byte[] serialize(@NotNull Writer obj) { + try (ByteArrayBitStreamWriter w = new ByteArrayBitStreamWriter()) { + obj.write(w); + return w.toByteArray(); + } catch (IOException e) { + throw new IllegalStateException("Failed to serialize zserio object: " + e.getMessage(), e); + } + } + + /** + * Convenience: resolves the path and, if the result is a zserio + * {@link Writer}, serializes it to bytes (i.e. nested-compound case at + * the same code path as {@code reflectableToParameterValue}'s + * {@code STRUCT/CHOICE/UNION} branch in {@code oaclient.cpp:97-112}). + */ + @Nullable + public static Object resolveOrSerialize(@NotNull Object root, @NotNull String dottedPath) { + Object resolved = resolve(root, dottedPath); + if (resolved instanceof Writer && !"*".equals(dottedPath) && !dottedPath.isEmpty()) { + return serialize((Writer) resolved); + } + return resolved; + } + + /** + * Calls the JavaBean getter for the given zserio identifier on {@code obj}. + * Tries {@code getX} first, then {@code isX} for booleans. + */ + @NotNull + private static Object invokeGetter(@NotNull Object obj, @NotNull String zserioIdent) { + String getter = toGetterName(zserioIdent, "get"); + Class cls = obj.getClass(); + Method m = findNoArgMethod(cls, getter); + if (m == null) { + String isGetter = toGetterName(zserioIdent, "is"); + m = findNoArgMethod(cls, isGetter); + } + if (m == null) { + throw new IllegalArgumentException( + "No getter for zserio field '" + zserioIdent + "' on " + cls.getSimpleName() + + " (tried " + getter + "() and " + toGetterName(zserioIdent, "is") + "())"); + } + try { + Object result = m.invoke(obj); + if (result == null) { + throw new IllegalStateException( + "Getter " + cls.getSimpleName() + "." + m.getName() + "() returned null while resolving zserio path"); + } + return result; + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "Failed to invoke " + cls.getSimpleName() + "." + m.getName() + "(): " + e.getMessage(), e); + } + } + + @Nullable + private static Method findNoArgMethod(@NotNull Class cls, @NotNull String name) { + try { + return cls.getMethod(name); + } catch (NoSuchMethodException e) { + return null; + } + } + + /** + * Converts a zserio identifier ({@code base}, {@code enum_value}, {@code my_field_2}) to + * its corresponding JavaBean getter name with the given prefix + * ({@code "get"} or {@code "is"}). Snake_case underscores mark word boundaries + * (each next word starts capitalized); other characters pass through. + */ + @NotNull + static String toGetterName(@NotNull String zserioIdent, @NotNull String prefix) { + StringBuilder out = new StringBuilder(prefix); + boolean nextUpper = true; + for (int i = 0; i < zserioIdent.length(); i++) { + char c = zserioIdent.charAt(i); + if (c == '_') { + nextUpper = true; + continue; + } + out.append(nextUpper ? Character.toUpperCase(c) : c); + nextUpper = false; + } + return out.toString(); + } +} diff --git a/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/HotReloaderTest.java b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/HotReloaderTest.java new file mode 100644 index 00000000..9410eaf8 --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/HotReloaderTest.java @@ -0,0 +1,101 @@ +package io.github.ndsev.zswag.shared; + +import io.github.ndsev.zswag.api.HttpSettings; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that {@link HttpSettingsLoader.HotReloader} re-reads the source file when + * its modification time advances — matches C++ {@code Settings::operator[]} behaviour + * for credential-rotation use cases. + */ +class HotReloaderTest { + + @TempDir + Path tmp; + + private static final String SETTINGS_V1 = String.join("\n", + "http-settings:", + " - scope: '*'", + " headers:", + " X-Version: v1" + ); + + private static final String SETTINGS_V2 = String.join("\n", + "http-settings:", + " - scope: '*'", + " headers:", + " X-Version: v2" + ); + + @Test + void initialLoadReturnsSettingsFromFile() throws Exception { + Path file = tmp.resolve("settings.yaml"); + Files.writeString(file, SETTINGS_V1); + HttpSettings initial = HttpSettingsLoader.loadFromFile(file); + HttpSettingsLoader.HotReloader r = HttpSettingsLoader.HotReloader.of(file, initial); + assertThat(r.current().forUrl("https://anything") + .getHeader("X-Version")).contains("v1"); + } + + @Test + void unchangedFileReturnsSameInstance() throws Exception { + Path file = tmp.resolve("settings.yaml"); + Files.writeString(file, SETTINGS_V1); + HttpSettings initial = HttpSettingsLoader.loadFromFile(file); + HttpSettingsLoader.HotReloader r = HttpSettingsLoader.HotReloader.of(file, initial); + HttpSettings first = r.current(); + HttpSettings second = r.current(); + // No reload happened — same instance (identity equality). + assertThat(second).isSameAs(first); + } + + @Test + void advancedMtimeTriggersReload() throws Exception { + Path file = tmp.resolve("settings.yaml"); + Files.writeString(file, SETTINGS_V1); + HttpSettings initial = HttpSettingsLoader.loadFromFile(file); + HttpSettingsLoader.HotReloader r = HttpSettingsLoader.HotReloader.of(file, initial); + assertThat(r.current().forUrl("https://anything") + .getHeader("X-Version")).contains("v1"); + + // Overwrite and bump mtime explicitly (some filesystems coalesce same-second writes). + Files.writeString(file, SETTINGS_V2); + Files.setLastModifiedTime(file, FileTime.fromMillis(System.currentTimeMillis() + 5000)); + + assertThat(r.current().forUrl("https://anything") + .getHeader("X-Version")).contains("v2"); + } + + @Test + void reloadFailureKeepsPreviousSnapshot() throws Exception { + Path file = tmp.resolve("settings.yaml"); + Files.writeString(file, SETTINGS_V1); + HttpSettings initial = HttpSettingsLoader.loadFromFile(file); + HttpSettingsLoader.HotReloader r = HttpSettingsLoader.HotReloader.of(file, initial); + + // Corrupt the file so re-parsing fails; bump mtime to trigger the reload path. + Files.writeString(file, "this: is\nnot: { valid yaml: deliberately broken: oh: no: :::"); + Files.setLastModifiedTime(file, FileTime.fromMillis(System.currentTimeMillis() + 5000)); + + // Should keep the previous v1 snapshot, not drop to empty. + assertThat(r.current().forUrl("https://anything") + .getHeader("X-Version")).contains("v1"); + } + + @Test + void noSourcePathSkipsReloadChecks() throws Exception { + HttpSettings snapshot = HttpSettings.empty(); + HttpSettingsLoader.HotReloader r = HttpSettingsLoader.HotReloader.of(null, snapshot); + assertThat(r.current()).isSameAs(snapshot); + // Repeated calls — same instance, never reloads. + assertThat(r.current()).isSameAs(snapshot); + } +} diff --git a/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/HttpSettingsLoaderFileEnvTest.java b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/HttpSettingsLoaderFileEnvTest.java new file mode 100644 index 00000000..a967b3be --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/HttpSettingsLoaderFileEnvTest.java @@ -0,0 +1,56 @@ +package io.github.ndsev.zswag.shared; + +import io.github.ndsev.zswag.api.HttpSettings; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HttpSettingsLoaderFileEnvTest { + + @Test + void loadFromFileParsesValidYaml(@TempDir Path dir) throws IOException { + Path p = dir.resolve("settings.yaml"); + Files.writeString(p, String.join("\n", + "http-settings:", + " - scope: 'https://*.foo.com/*'", + " headers:", + " X-Trace: trace-1")); + HttpSettings s = HttpSettingsLoader.loadFromFile(p); + assertThat(s.getEntries()).hasSize(1); + assertThat(s.forUrl("https://api.foo.com/x").getHeader("X-Trace")).contains("trace-1"); + } + + @Test + void loadFromFileEmptyYamlYieldsEmpty(@TempDir Path dir) throws IOException { + Path p = dir.resolve("e.yaml"); + Files.writeString(p, ""); + HttpSettings s = HttpSettingsLoader.loadFromFile(p); + assertThat(s.getEntries()).isEmpty(); + } + + @Test + void loadFromEnvironmentReturnsEmptyWhenEnvUnset() { + // Cannot reliably set an env var from within a JVM test. We rely on the default + // (HTTP_SETTINGS_FILE not set in CI) to exercise the unset/empty branch. + HttpSettings s = HttpSettingsLoader.loadFromEnvironment(); + assertThat(s).isNotNull(); + } + + @Test + void parseRootRejectsNonListHttpSettings() { + assertThatThrownBy(() -> HttpSettingsLoader.parseRoot(java.util.Map.of("http-settings", "not-a-list"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void parseRootRejectsScalarRoot() { + assertThatThrownBy(() -> HttpSettingsLoader.parseRoot(42)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/HttpSettingsLoaderTest.java b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/HttpSettingsLoaderTest.java new file mode 100644 index 00000000..edc819c2 --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/HttpSettingsLoaderTest.java @@ -0,0 +1,292 @@ +package io.github.ndsev.zswag.shared; + +import io.github.ndsev.zswag.api.HttpConfig; +import io.github.ndsev.zswag.api.HttpSettings; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Verifies that the YAML schema accepted by HttpSettingsLoader matches the C++/Python + * reference exactly so that the same {@code HTTP_SETTINGS_FILE} can drive all clients. + */ +class HttpSettingsLoaderTest { + + @Test + void emptyRootProducesEmptySettings() { + HttpSettings s = HttpSettingsLoader.parseRoot(null); + assertThat(s.getEntries()).isEmpty(); + } + + @Test + void mapRootWithoutHttpSettingsKeyProducesEmpty() { + Map root = new LinkedHashMap<>(); + root.put("unrelated", 42); + HttpSettings s = HttpSettingsLoader.parseRoot(root); + assertThat(s.getEntries()).isEmpty(); + } + + @Test + void mapRootWithHttpSettingsKeyParsesAllEntries() { + Map entry1 = entry("https://*.foo.com/*", + "basic-auth", entry(null, "user", "alice", "password", "secret")); + Map entry2 = entry("https://api.bar.com/*", + "headers", entry(null, "X-Trace", "abc")); + Map root = new LinkedHashMap<>(); + root.put("http-settings", Arrays.asList(entry1, entry2)); + + HttpSettings s = HttpSettingsLoader.parseRoot(root); + assertThat(s.getEntries()).hasSize(2); + assertThat(s.getEntries().get(0).getAuth()).isPresent(); + assertThat(s.getEntries().get(0).getAuth().get().user).isEqualTo("alice"); + assertThat(s.getEntries().get(0).getAuth().get().password).isEqualTo("secret"); + assertThat(s.getEntries().get(1).getHeaders()).containsKey("X-Trace"); + } + + @Test + void legacyListRootIsAccepted() { + // Matches C++ http-settings.cpp:466-469 backwards-compat path. + Map entry = entry("*", + "headers", entry(null, "X-Old", "v")); + HttpSettings s = HttpSettingsLoader.parseRoot(Arrays.asList(entry)); + assertThat(s.getEntries()).hasSize(1); + assertThat(s.getEntries().get(0).getHeaders()).containsKey("X-Old"); + } + + @Test + void scopeDefaultsToWildcardWhenAbsent() { + Map root = singleEntry(null); + HttpSettings s = HttpSettingsLoader.parseRoot(root); + assertThat(s.getEntries().get(0).getScope()).contains("*"); + } + + @Test + void rawUrlRegexIsPreserved() { + Map entry = entry(null, + "url", "^https://api\\.example\\.com/.*$", + "headers", entry(null, "X-Y", "z")); + HttpSettings s = HttpSettingsLoader.parseRoot(asHttpSettings(entry)); + // url-form entries have no scope (only urlPattern); compileScope wasn't applied. + assertThat(s.getEntries().get(0).getScope()).isNotPresent(); + assertThat(s.getEntries().get(0).getUrlPattern()).isPresent(); + } + + @Test + void basicAuthRequiresUser() { + Map entry = entry("*", + "basic-auth", entry(null, "password", "secret")); + assertThatThrownBy(() -> HttpSettingsLoader.parseRoot(asHttpSettings(entry))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("basic-auth requires 'user'"); + } + + @Test + void basicAuthRequiresPasswordOrKeychain() { + Map entry = entry("*", + "basic-auth", entry(null, "user", "alice")); + assertThatThrownBy(() -> HttpSettingsLoader.parseRoot(asHttpSettings(entry))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("password"); + } + + @Test + void basicAuthAcceptsKeychain() { + Map entry = entry("*", + "basic-auth", entry(null, "user", "alice", "keychain", "my-service")); + HttpSettings s = HttpSettingsLoader.parseRoot(asHttpSettings(entry)); + HttpConfig.BasicAuthentication a = s.getEntries().get(0).getAuth().get(); + assertThat(a.password).isEmpty(); + assertThat(a.keychain).isEqualTo("my-service"); + } + + @Test + void proxyParsedWithCredentials() { + Map entry = entry("*", + "proxy", entry(null, "host", "proxy.local", "port", 3128, + "user", "u", "password", "p")); + HttpSettings s = HttpSettingsLoader.parseRoot(asHttpSettings(entry)); + HttpConfig.Proxy p = s.getEntries().get(0).getProxy().get(); + assertThat(p.host).isEqualTo("proxy.local"); + assertThat(p.port).isEqualTo(3128); + assertThat(p.user).isEqualTo("u"); + assertThat(p.password).isEqualTo("p"); + } + + @Test + void proxyRequiresHostAndPort() { + Map entry = entry("*", + "proxy", entry(null, "port", 3128)); + assertThatThrownBy(() -> HttpSettingsLoader.parseRoot(asHttpSettings(entry))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void apiKeyParsedAsString() { + Map entry = entry("*", "api-key", "my-token"); + HttpSettings s = HttpSettingsLoader.parseRoot(asHttpSettings(entry)); + assertThat(s.getEntries().get(0).getApiKey()).contains("my-token"); + } + + @Test + void oauth2ParsedFully() { + Map oauth2 = new LinkedHashMap<>(); + oauth2.put("clientId", "my-id"); + oauth2.put("clientSecret", "secret"); + oauth2.put("tokenUrl", "https://issuer/oauth/token"); + oauth2.put("audience", "https://api/"); + oauth2.put("scope", Arrays.asList("read", "write")); + oauth2.put("useForSpecFetch", false); + Map tea = new LinkedHashMap<>(); + tea.put("method", "rfc5849-oauth1-signature"); + tea.put("nonceLength", 32); + oauth2.put("tokenEndpointAuth", tea); + + Map entry = entry("https://*.example.com/*", "oauth2", oauth2); + HttpSettings s = HttpSettingsLoader.parseRoot(asHttpSettings(entry)); + + HttpConfig.OAuth2 cfg = s.getEntries().get(0).getOAuth2().get(); + assertThat(cfg.clientId).isEqualTo("my-id"); + assertThat(cfg.clientSecret).isEqualTo("secret"); + assertThat(cfg.tokenUrlOverride).isEqualTo("https://issuer/oauth/token"); + assertThat(cfg.audience).isEqualTo("https://api/"); + assertThat(cfg.scopesOverride).containsExactly("read", "write"); + assertThat(cfg.useForSpecFetch).isFalse(); + assertThat(cfg.tokenEndpointAuthMethod) + .isEqualTo(HttpConfig.OAuth2.TokenEndpointAuthMethod.RFC5849_OAUTH1_SIGNATURE); + assertThat(cfg.nonceLength).isEqualTo(32); + } + + @Test + void oauth2RejectsUnknownAuthMethod() { + Map tea = new LinkedHashMap<>(); + tea.put("method", "unknown-scheme"); + Map oauth2 = new LinkedHashMap<>(); + oauth2.put("clientId", "x"); + oauth2.put("tokenEndpointAuth", tea); + Map entry = entry("*", "oauth2", oauth2); + assertThatThrownBy(() -> HttpSettingsLoader.parseRoot(asHttpSettings(entry))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown tokenEndpointAuth method"); + } + + @Test + void oauth2NonceLengthOutOfRange() { + Map tea = new LinkedHashMap<>(); + tea.put("method", "rfc5849-oauth1-signature"); + tea.put("nonceLength", 4); // below minimum + Map oauth2 = new LinkedHashMap<>(); + oauth2.put("clientId", "x"); + oauth2.put("tokenEndpointAuth", tea); + Map entry = entry("*", "oauth2", oauth2); + assertThatThrownBy(() -> HttpSettingsLoader.parseRoot(asHttpSettings(entry))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("nonceLength must be between 8 and 64"); + } + + @Test + void oauth2DefaultsToBasicAuthAndUseForSpecFetchTrue() { + Map oauth2 = new LinkedHashMap<>(); + oauth2.put("clientId", "x"); + oauth2.put("clientSecret", "y"); + Map entry = entry("*", "oauth2", oauth2); + HttpSettings s = HttpSettingsLoader.parseRoot(asHttpSettings(entry)); + HttpConfig.OAuth2 cfg = s.getEntries().get(0).getOAuth2().get(); + assertThat(cfg.tokenEndpointAuthMethod) + .isEqualTo(HttpConfig.OAuth2.TokenEndpointAuthMethod.RFC6749_CLIENT_SECRET_BASIC); + assertThat(cfg.useForSpecFetch).isTrue(); + assertThat(cfg.nonceLength).isEqualTo(16); + } + + // --- helpers -------------------------------------------------------- + + /** Builds a single-entry http-settings root map, with the given inner entry. */ + private static Map singleEntry(String scope) { + Map e = new LinkedHashMap<>(); + if (scope != null) e.put("scope", scope); + return asHttpSettings(e); + } + + private static Map asHttpSettings(Map entry) { + Map root = new LinkedHashMap<>(); + root.put("http-settings", java.util.Collections.singletonList(entry)); + return root; + } + + /** Builds a single-entry http-settings root map with the given key/value pairs. */ + private static Map entry(String scope, Object... kvs) { + Map map = new LinkedHashMap<>(); + if (scope != null) map.put("scope", scope); + for (int i = 0; i < kvs.length; i += 2) { + map.put((String) kvs[i], kvs[i + 1]); + } + return map; + } + + // ------------------------------------------------------------------------ + // Write-back round-trip tests (HttpSettingsLoader.writeToFile) + // ------------------------------------------------------------------------ + + @Test + void writeToFileEmittedYamlCanBeReloadedToEquivalentSettings(@TempDir Path tmp) throws Exception { + HttpConfig.OAuth2 oauth = HttpConfig.OAuth2.builder() + .clientId("client-A") + .clientSecretKeychain("kc-A") + .tokenUrl("https://idp/token") + .scopes(Arrays.asList("api.read", "api.write")) + .build(); + HttpConfig entry = HttpConfig.builder() + .scope("https://*.api.example.com/*", HttpSettings.compileScope("https://*.api.example.com/*")) + .header("X-Trace", "enabled") + .query("v", "2") + .basicAuth("alice", "secret") + .oauth2(oauth) + .build(); + HttpSettings settings = new HttpSettings(Collections.singletonList(entry)); + + Path out = tmp.resolve("written.yaml"); + HttpSettingsLoader.writeToFile(out, settings); + assertThat(Files.size(out)).isPositive(); + + HttpSettings reloaded = HttpSettingsLoader.loadFromFile(out); + assertThat(reloaded.getEntries()).hasSize(1); + HttpConfig r = reloaded.getEntries().get(0); + assertThat(r.getScope()).contains("https://*.api.example.com/*"); + assertThat(r.getHeader("X-Trace")).contains("enabled"); + assertThat(r.getQuery().get("v")).containsExactly("2"); + assertThat(r.getAuth()).isPresent(); + assertThat(r.getAuth().get().user).isEqualTo("alice"); + assertThat(r.getOAuth2()).isPresent(); + assertThat(r.getOAuth2().get().clientId).isEqualTo("client-A"); + assertThat(r.getOAuth2().get().tokenUrlOverride).isEqualTo("https://idp/token"); + assertThat(r.getOAuth2().get().scopesOverride).containsExactly("api.read", "api.write"); + } + + @Test + void writeToFileOmitsEmptyOptionalFields(@TempDir Path tmp) throws Exception { + // A minimal config with only headers should not emit empty basic-auth / proxy / oauth2 + // blocks. + HttpConfig minimal = HttpConfig.builder() + .scope("*", HttpSettings.compileScope("*")) + .header("X-Foo", "bar") + .build(); + Path out = tmp.resolve("minimal.yaml"); + HttpSettingsLoader.writeToFile(out, new HttpSettings(Collections.singletonList(minimal))); + String written = Files.readString(out); + assertThat(written) + .contains("X-Foo") + .doesNotContain("basic-auth") + .doesNotContain("proxy") + .doesNotContain("oauth2"); + } +} diff --git a/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OAuth1SignatureTest.java b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OAuth1SignatureTest.java new file mode 100644 index 00000000..3d43986e --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OAuth1SignatureTest.java @@ -0,0 +1,110 @@ +package io.github.ndsev.zswag.shared; + +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * RFC 5849 + RFC 3986 conformance tests for the OAuth 1.0 HMAC-SHA256 + * signing helper. Exercises the parts that have to byte-for-byte match the + * C++ {@code httpcl::oauth1::*} implementation so a server validating signed + * token requests accepts the Java client identically. + */ +class OAuth1SignatureTest { + + @Test + void percentEncodeKeepsRFC3986Unreserved() { + // Unreserved per RFC 3986: A-Z a-z 0-9 - . _ ~ + String unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + assertThat(OAuth1Signature.percentEncode(unreserved)).isEqualTo(unreserved); + } + + @Test + void percentEncodeEncodesEverythingElse() { + // Reserved chars MUST be encoded. + assertThat(OAuth1Signature.percentEncode("a b")).isEqualTo("a%20b"); + assertThat(OAuth1Signature.percentEncode("x&y=z")).isEqualTo("x%26y%3Dz"); + assertThat(OAuth1Signature.percentEncode("/")).isEqualTo("%2F"); + assertThat(OAuth1Signature.percentEncode("+")).isEqualTo("%2B"); + } + + @Test + void percentEncodeUsesUpperCaseHex() { + assertThat(OAuth1Signature.percentEncode("ÿ")).isEqualTo("%C3%BF"); + } + + @Test + void generateNonceLengthCheckLowerBound() { + assertThatThrownBy(() -> OAuth1Signature.generateNonce(7)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void generateNonceLengthCheckUpperBound() { + assertThatThrownBy(() -> OAuth1Signature.generateNonce(65)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void generateNonceProducesAlphanumeric() { + String n = OAuth1Signature.generateNonce(32); + assertThat(n).hasSize(32); + assertThat(n).matches("[A-Za-z0-9]+"); + } + + @Test + void signatureBaseStringFollowsRFC5849Format() { + Map params = new LinkedHashMap<>(); + params.put("oauth_consumer_key", "key"); + params.put("oauth_nonce", "n"); + params.put("oauth_signature_method", "HMAC-SHA256"); + params.put("oauth_timestamp", "1"); + params.put("oauth_version", "1.0"); + String base = OAuth1Signature.buildSignatureBaseString("POST", "https://x.example.com/oauth/token", params); + // Sorted, percent-encoded params, joined by &; prefixed by METHOD&percent(URL). + assertThat(base).isEqualTo( + "POST&https%3A%2F%2Fx.example.com%2Foauth%2Ftoken&" + + "oauth_consumer_key%3Dkey%26" + + "oauth_nonce%3Dn%26" + + "oauth_signature_method%3DHMAC-SHA256%26" + + "oauth_timestamp%3D1%26" + + "oauth_version%3D1.0"); + } + + @Test + void computeSignatureIsDeterministicForFixedInputs() { + Map params = new LinkedHashMap<>(); + params.put("oauth_consumer_key", "client"); + params.put("oauth_nonce", "abc12345"); + params.put("oauth_signature_method", "HMAC-SHA256"); + params.put("oauth_timestamp", "1700000000"); + params.put("oauth_version", "1.0"); + params.put("grant_type", "client_credentials"); + + String s1 = OAuth1Signature.computeSignature("POST", "https://issuer/oauth/token", params, "secret", ""); + String s2 = OAuth1Signature.computeSignature("POST", "https://issuer/oauth/token", params, "secret", ""); + assertThat(s1).isEqualTo(s2); + // base64 of HMAC-SHA256 (32 bytes) is 44 chars including padding. + assertThat(s1).hasSize(44); + } + + @Test + void buildAuthorizationHeaderIncludesAllOauthParams() { + Map bodyParams = new LinkedHashMap<>(); + bodyParams.put("grant_type", "client_credentials"); + String h = OAuth1Signature.buildAuthorizationHeader( + "POST", "https://issuer/oauth/token", "client", "secret", bodyParams, 16); + assertThat(h).startsWith("OAuth "); + assertThat(h) + .contains("oauth_consumer_key=\"client\"") + .contains("oauth_signature_method=\"HMAC-SHA256\"") + .contains("oauth_timestamp=\"") + .contains("oauth_nonce=\"") + .contains("oauth_version=\"1.0\"") + .contains("oauth_signature=\""); + } +} diff --git a/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OAuth2HandlerTest.java b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OAuth2HandlerTest.java new file mode 100644 index 00000000..afc51d66 --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OAuth2HandlerTest.java @@ -0,0 +1,197 @@ +package io.github.ndsev.zswag.shared; + +import io.github.ndsev.zswag.api.HttpConfig; +import io.github.ndsev.zswag.api.HttpException; +import io.github.ndsev.zswag.api.HttpRequest; +import io.github.ndsev.zswag.api.HttpResponse; +import io.github.ndsev.zswag.api.IHttpClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Regression tests for {@link OAuth2Handler} pinning behaviours that production + * deployments depend on but the broader test suite did not previously cover. + */ +class OAuth2HandlerTest { + + @BeforeEach + void clearTokenCache() { + OAuth2Handler.clearAllCachedTokens(); + } + + @Test + void requestTokenThrowsDescriptiveErrorOnEmpty2xxBody() { + // Regression: previously `new String(response.getBody(), UTF-8)` NPE'd if a + // misbehaving token endpoint returned 200 with an empty/null body. + IHttpClient stub = (request, adhoc) -> new HttpResponse(200, null, new LinkedHashMap<>(), null); + OAuth2Handler handler = new OAuth2Handler(stub, (s,u) -> "test-keychain-secret"); + HttpConfig.OAuth2 oauth = HttpConfig.OAuth2.builder() + .clientId("cid").clientSecret("csec").build(); + assertThatThrownBy(() -> handler.getAccessToken(oauth, "https://idp.example/token", "https://idp.example/token", Collections.emptyList())) + .isInstanceOf(HttpException.class) + .hasMessageContaining("empty body"); + } + + @Test + void requestTokenThrowsWhenAccessTokenMissingFromResponse() { + IHttpClient stub = (request, adhoc) -> new HttpResponse( + 200, null, new LinkedHashMap<>(), + "{\"token_type\":\"bearer\"}".getBytes()); + OAuth2Handler handler = new OAuth2Handler(stub, (s,u) -> "test-keychain-secret"); + HttpConfig.OAuth2 oauth = HttpConfig.OAuth2.builder() + .clientId("cid").clientSecret("csec").build(); + assertThatThrownBy(() -> handler.getAccessToken(oauth, "https://idp.example/token", "https://idp.example/token", Collections.emptyList())) + .isInstanceOf(HttpException.class) + .hasMessageContaining("access_token"); + } + + @Test + void requestTokenSurfacesNon2xxWithBodyInMessage() { + IHttpClient stub = (request, adhoc) -> new HttpResponse( + 401, null, new LinkedHashMap<>(), + "{\"error\":\"invalid_client\"}".getBytes()); + OAuth2Handler handler = new OAuth2Handler(stub, (s,u) -> "test-keychain-secret"); + HttpConfig.OAuth2 oauth = HttpConfig.OAuth2.builder() + .clientId("cid").clientSecret("csec").build(); + assertThatThrownBy(() -> handler.getAccessToken(oauth, "https://idp.example/token", "https://idp.example/token", Collections.emptyList())) + .isInstanceOf(HttpException.class) + .hasMessageContaining("invalid_client"); + } + + @Test + void cachedTokenReusedOnSecondCall() throws Exception { + // First call mints; second call hits the cache and does not call the IdP again. + AtomicInteger callCount = new AtomicInteger(0); + IHttpClient stub = (request, adhoc) -> { + callCount.incrementAndGet(); + return jsonResponse(200, "{\"access_token\":\"tok-1\",\"expires_in\":3600}"); + }; + OAuth2Handler handler = new OAuth2Handler(stub, (s, u) -> ""); + HttpConfig.OAuth2 oauth = HttpConfig.OAuth2.builder() + .clientId("cid").clientSecret("csec").build(); + + String t1 = handler.getAccessToken(oauth, "https://idp/token", "https://idp/token", Collections.emptyList()); + String t2 = handler.getAccessToken(oauth, "https://idp/token", "https://idp/token", Collections.emptyList()); + + assertThat(t1).isEqualTo("tok-1"); + assertThat(t2).isEqualTo("tok-1"); + assertThat(callCount.get()).as("token endpoint hit only once").isEqualTo(1); + } + + @Test + void distinctTokenKeysDoNotCollideInCache() throws Exception { + // Two clients with different (clientId) keys should each have their own cache entry. + IHttpClient stub = (request, adhoc) -> { + String body = new String(request.getBody(), StandardCharsets.UTF_8); + String tokenForClient = body.contains("&audience=tenant-A") ? "tok-A" : "tok-B"; + return jsonResponse(200, "{\"access_token\":\"" + tokenForClient + "\",\"expires_in\":3600}"); + }; + OAuth2Handler handler = new OAuth2Handler(stub, (s, u) -> ""); + HttpConfig.OAuth2 a = HttpConfig.OAuth2.builder() + .clientId("cid").clientSecret("csec").audience("tenant-A").build(); + HttpConfig.OAuth2 b = HttpConfig.OAuth2.builder() + .clientId("cid").clientSecret("csec").audience("tenant-B").build(); + + String tA = handler.getAccessToken(a, "https://idp/token", "https://idp/token", Collections.emptyList()); + String tB = handler.getAccessToken(b, "https://idp/token", "https://idp/token", Collections.emptyList()); + + assertThat(tA).isEqualTo("tok-A"); + assertThat(tB).isEqualTo("tok-B"); + } + + @Test + void expiredTokenWithRefreshTokenTriggersRefresh() throws Exception { + // Mint a token that has a refresh_token, expire it manually, then call again. + // The handler should issue a refresh_token grant (not a fresh client_credentials mint). + List grantTypes = new java.util.ArrayList<>(); + IHttpClient stub = (request, adhoc) -> { + String body = new String(request.getBody(), StandardCharsets.UTF_8); + String grantType = body.split("&")[0].split("=")[1]; + grantTypes.add(grantType); + String accessToken = "refresh_token".equals(grantType) ? "tok-refreshed" : "tok-1"; + return jsonResponse(200, "{\"access_token\":\"" + accessToken + + "\",\"refresh_token\":\"rtok\",\"expires_in\":3600}"); + }; + OAuth2Handler handler = new OAuth2Handler(stub, (s, u) -> ""); + HttpConfig.OAuth2 oauth = HttpConfig.OAuth2.builder() + .clientId("cid").clientSecret("csec").build(); + + String t1 = handler.getAccessToken(oauth, "https://idp/token", "https://idp/token", Collections.emptyList()); + expireCachedToken("https://idp/token", "cid", "", ""); + String t2 = handler.getAccessToken(oauth, "https://idp/token", "https://idp/token", Collections.emptyList()); + + assertThat(t1).isEqualTo("tok-1"); + assertThat(t2).isEqualTo("tok-refreshed"); + assertThat(grantTypes).containsExactly("client_credentials", "refresh_token"); + } + + @Test + void refreshFailureFallsBackToFreshMint() throws Exception { + // When the refresh_token grant fails, the handler should retry with client_credentials. + List grantTypes = new java.util.ArrayList<>(); + IHttpClient stub = (request, adhoc) -> { + String body = new String(request.getBody(), StandardCharsets.UTF_8); + String grantType = body.split("&")[0].split("=")[1]; + grantTypes.add(grantType); + if ("refresh_token".equals(grantType)) { + // Simulate refresh failure (token revoked / IdP rotated keys). + return jsonResponse(401, "{\"error\":\"invalid_grant\"}"); + } + return jsonResponse(200, "{\"access_token\":\"fresh-tok\",\"refresh_token\":\"rtok\",\"expires_in\":3600}"); + }; + OAuth2Handler handler = new OAuth2Handler(stub, (s, u) -> ""); + HttpConfig.OAuth2 oauth = HttpConfig.OAuth2.builder() + .clientId("cid").clientSecret("csec").build(); + + // First mint succeeds and caches a refresh token. + handler.getAccessToken(oauth, "https://idp/token", "https://idp/token", Collections.emptyList()); + expireCachedToken("https://idp/token", "cid", "", ""); + // Second call: refresh fails → fresh mint succeeds. + String t2 = handler.getAccessToken(oauth, "https://idp/token", "https://idp/token", Collections.emptyList()); + + assertThat(t2).isEqualTo("fresh-tok"); + assertThat(grantTypes).as("ordering: initial mint, failed refresh, fallback mint") + .containsExactly("client_credentials", "refresh_token", "client_credentials"); + } + + // ------------------------------------------------------------------------ + // Test helpers — reach into OAuth2Handler's private cache to manipulate + // expiry without sleeping. The alternative (Thread.sleep) is flaky and slow. + // ------------------------------------------------------------------------ + + private static HttpResponse jsonResponse(int status, String json) { + return new HttpResponse(status, null, new LinkedHashMap<>(), json.getBytes(StandardCharsets.UTF_8)); + } + + @SuppressWarnings("unchecked") + private static void expireCachedToken(String tokenUrl, String clientId, String audience, String scopeKey) + throws Exception { + Field cacheField = OAuth2Handler.class.getDeclaredField("CACHE"); + cacheField.setAccessible(true); + java.util.Map cache = (java.util.Map) cacheField.get(null); + Class tokenKeyClass = Class.forName(OAuth2Handler.class.getName() + "$TokenKey"); + java.lang.reflect.Constructor tkCtor = tokenKeyClass.getDeclaredConstructor( + String.class, String.class, String.class, String.class); + tkCtor.setAccessible(true); + Object key = tkCtor.newInstance(tokenUrl, clientId, audience, scopeKey); + Object minted = cache.get(key); + if (minted == null) { + throw new IllegalStateException("No cached token for key " + key); + } + Field expiresAtNanos = minted.getClass().getDeclaredField("expiresAtNanos"); + expiresAtNanos.setAccessible(true); + // Set a deadline 60s in the past on the monotonic clock. + expiresAtNanos.setLong(minted, System.nanoTime() - java.util.concurrent.TimeUnit.SECONDS.toNanos(60)); + } +} diff --git a/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OpenAPIParserTest.java b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OpenAPIParserTest.java new file mode 100644 index 00000000..f9e921c9 --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OpenAPIParserTest.java @@ -0,0 +1,390 @@ +package io.github.ndsev.zswag.shared; + +import io.github.ndsev.zswag.api.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OpenAPIParserTest { + + private static Path writeSpec(Path dir, String yaml) throws IOException { + Path p = dir.resolve("api.yaml"); + Files.writeString(p, yaml); + return p; + } + + @Test + void parsesFixtureWithServersSchemesAndOperations() throws IOException { + OpenAPIParser parser = new OpenAPIParser( + Path.of("src/test/resources/test-openapi.yaml").toAbsolutePath().toString()); + assertThat(parser.getServers()).contains("https://api.example.com/v1", "https://backup.example.com/v1"); + assertThat(parser.getSecuritySchemes()).containsOnlyKeys( + "BearerAuth", "BasicAuth", "ApiKeyAuth", "QueryKeyAuth", "CookieAuth", "OAuth2Auth"); + + SecurityScheme bearer = parser.getSecuritySchemes().get("BearerAuth"); + assertThat(bearer.getType()).isEqualTo(SecuritySchemeType.HTTP); + assertThat(bearer.getScheme()).isEqualTo("bearer"); + + SecurityScheme apik = parser.getSecuritySchemes().get("ApiKeyAuth"); + assertThat(apik.getType()).isEqualTo(SecuritySchemeType.API_KEY); + assertThat(apik.getApiKeyLocation()).isEqualTo(ParameterLocation.HEADER); + assertThat(apik.getApiKeyName()).isEqualTo("X-API-Key"); + + SecurityScheme oa2 = parser.getSecuritySchemes().get("OAuth2Auth"); + assertThat(oa2.getType()).isEqualTo(SecuritySchemeType.OAUTH2); + assertThat(oa2.getTokenUrl()).contains("https://auth.example.com/token"); + assertThat(oa2.getOAuth2Scopes()).containsOnlyKeys("read", "write"); + } + + @Test + void parsesGlobalDefaultSecurity() throws IOException { + OpenAPIParser parser = new OpenAPIParser( + Path.of("src/test/resources/test-openapi.yaml").toAbsolutePath().toString()); + assertThat(parser.getDefaultSecurity()).isPresent(); + assertThat(parser.getDefaultSecurity().get()).hasSize(1); + assertThat(parser.getDefaultSecurity().get().get(0).getSchemes()).containsOnlyKeys("BearerAuth"); + } + + @Test + void parsesOperationParametersAndPath(@TempDir Path dir) throws IOException { + OpenAPIParser parser = new OpenAPIParser( + Path.of("src/test/resources/test-openapi.yaml").toAbsolutePath().toString()); + OpenAPIParser.MethodInfo getUser = parser.getMethod("getUser"); + assertThat(getUser).isNotNull(); + assertThat(getUser.getHttpMethod()).isEqualTo("GET"); + assertThat(getUser.getPathTemplate()).isEqualTo("/users/{userId}"); + assertThat(getUser.getParameters()).hasSize(2); + OpenAPIParameter userIdParam = getUser.getParameters().get(0); + assertThat(userIdParam.getName()).isEqualTo("userId"); + assertThat(userIdParam.getLocation()).isEqualTo(ParameterLocation.PATH); + assertThat(userIdParam.isRequired()).isTrue(); + assertThat(getUser.hasZserioBody()).isFalse(); + } + + @Test + void operationWithEmptySecurityListMeansExplicitlyNoAuth() throws IOException { + OpenAPIParser parser = new OpenAPIParser( + Path.of("src/test/resources/test-openapi.yaml").toAbsolutePath().toString()); + OpenAPIParser.MethodInfo pub = parser.getMethod("publicEndpoint"); + assertThat(pub.getSecurity()).isPresent(); + assertThat(pub.getSecurity().get()).isEmpty(); + } + + @Test + void emptyYamlThrowsIOException(@TempDir Path dir) throws IOException { + Path p = writeSpec(dir, ""); + assertThatThrownBy(() -> new OpenAPIParser(p.toString())) + .isInstanceOf(IOException.class); + } + + @Test + void duplicateKeysAreRejected(@TempDir Path dir) throws IOException { + Path p = writeSpec(dir, String.join("\n", + "openapi: '3.0.0'", + "info: {title: t, version: '1'}", + "paths:", + " /a: {get: {operationId: a, responses: {'200': {description: ok}}}}", + "paths:", + " /b: {get: {operationId: b, responses: {'200': {description: ok}}}}")); + // SafeConstructor with allowDuplicateKeys=false throws + assertThatThrownBy(() -> new OpenAPIParser(p.toString())) + .isInstanceOfAny(RuntimeException.class, IOException.class); + } + + @Test + void rejectsOAuth2WithUnsupportedFlow(@TempDir Path dir) throws IOException { + Path p = writeSpec(dir, String.join("\n", + "openapi: '3.0.0'", + "info: {title: t, version: '1'}", + "components:", + " securitySchemes:", + " OA2:", + " type: oauth2", + " flows:", + " authorizationCode:", + " authorizationUrl: https://x/authorize", + " tokenUrl: https://x/token", + " scopes: {}", + "paths: {}")); + assertThatThrownBy(() -> new OpenAPIParser(p.toString())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("clientCredentials"); + } + + @Test + void rejectsOAuth2WithMissingFlows(@TempDir Path dir) throws IOException { + Path p = writeSpec(dir, String.join("\n", + "openapi: '3.0.0'", + "info: {title: t, version: '1'}", + "components:", + " securitySchemes:", + " OA2:", + " type: oauth2", + "paths: {}")); + assertThatThrownBy(() -> new OpenAPIParser(p.toString())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("flows"); + } + + @Test + void rejectsUnknownSecuritySchemeType(@TempDir Path dir) throws IOException { + Path p = writeSpec(dir, String.join("\n", + "openapi: '3.0.0'", + "info: {title: t, version: '1'}", + "components:", + " securitySchemes:", + " Bogus:", + " type: lol-not-a-real-type", + "paths: {}")); + assertThatThrownBy(() -> new OpenAPIParser(p.toString())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsUnknownParameterLocation(@TempDir Path dir) throws IOException { + Path p = writeSpec(dir, String.join("\n", + "openapi: '3.0.0'", + "info: {title: t, version: '1'}", + "paths:", + " /x:", + " get:", + " operationId: x", + " parameters:", + " - name: foo", + " in: bogus", + " schema: {type: string}", + " responses: {'200': {description: ok}}")); + assertThatThrownBy(() -> new OpenAPIParser(p.toString())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsUnknownParameterStyle(@TempDir Path dir) throws IOException { + Path p = writeSpec(dir, String.join("\n", + "openapi: '3.0.0'", + "info: {title: t, version: '1'}", + "paths:", + " /x:", + " get:", + " operationId: x", + " parameters:", + " - name: foo", + " in: query", + " style: bogusStyle", + " schema: {type: string}", + " responses: {'200': {description: ok}}")); + assertThatThrownBy(() -> new OpenAPIParser(p.toString())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void invalidStyleForLocationCausesError(@TempDir Path dir) throws IOException { + // matrix style on a query parameter is invalid + Path p = writeSpec(dir, String.join("\n", + "openapi: '3.0.0'", + "info: {title: t, version: '1'}", + "paths:", + " /x:", + " get:", + " operationId: x", + " parameters:", + " - name: foo", + " in: query", + " style: matrix", + " schema: {type: string}", + " responses: {'200': {description: ok}}")); + assertThatThrownBy(() -> new OpenAPIParser(p.toString())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("path parameters"); + } + + @Test + void simpleStyleOnQueryRejected(@TempDir Path dir) throws IOException { + Path p = writeSpec(dir, String.join("\n", + "openapi: '3.0.0'", + "info: {title: t, version: '1'}", + "paths:", + " /x:", + " get:", + " operationId: x", + " parameters:", + " - name: foo", + " in: query", + " style: simple", + " schema: {type: string}", + " responses: {'200': {description: ok}}")); + assertThatThrownBy(() -> new OpenAPIParser(p.toString())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void formStyleOnPathRejected(@TempDir Path dir) throws IOException { + Path p = writeSpec(dir, String.join("\n", + "openapi: '3.0.0'", + "info: {title: t, version: '1'}", + "paths:", + " /x/{y}:", + " get:", + " operationId: x", + " parameters:", + " - name: y", + " in: path", + " style: form", + " required: true", + " schema: {type: string}", + " responses: {'200': {description: ok}}")); + assertThatThrownBy(() -> new OpenAPIParser(p.toString())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void parsesXZserioRequestPartAndFormat(@TempDir Path dir) throws IOException { + Path p = writeSpec(dir, String.join("\n", + "openapi: '3.0.0'", + "info: {title: t, version: '1'}", + "paths:", + " /a/{id}:", + " post:", + " operationId: doIt", + " parameters:", + " - name: id", + " in: path", + " required: true", + " schema: {type: string, format: hex}", + " x-zserio-request-part: base.id", + " - name: blob", + " in: query", + " schema: {type: string, format: byte}", + " x-zserio-request-part: '*'", + " requestBody:", + " content:", + " application/x-zserio-object: {}", + " responses: {'200': {description: ok}}")); + OpenAPIParser parser = new OpenAPIParser(p.toString()); + OpenAPIParser.MethodInfo m = parser.getMethod("doIt"); + assertThat(m.hasZserioBody()).isTrue(); + OpenAPIParameter idParam = m.getParameters().get(0); + assertThat(idParam.getRequestPart()).contains("base.id"); + assertThat(idParam.getFormat()).isEqualTo(ParameterFormat.HEX); + OpenAPIParameter blobParam = m.getParameters().get(1); + // 'byte' is an alias for base64 per OpenAPI + assertThat(blobParam.getFormat()).isEqualTo(ParameterFormat.BASE64); + assertThat(blobParam.isWholeRequest()).isTrue(); + } + + @Test + void unknownFormatFallsBackToString(@TempDir Path dir) throws IOException { + Path p = writeSpec(dir, String.join("\n", + "openapi: '3.0.0'", + "info: {title: t, version: '1'}", + "paths:", + " /a:", + " get:", + " operationId: a", + " parameters:", + " - name: q", + " in: query", + " schema: {type: string, format: weirdcustomfmt}", + " responses: {'200': {description: ok}}")); + OpenAPIParser parser = new OpenAPIParser(p.toString()); + assertThat(parser.getMethod("a").getParameters().get(0).getFormat()) + .isEqualTo(ParameterFormat.STRING); + } + + @Test + void operationsWithoutOperationIdGetSyntheticId(@TempDir Path dir) throws IOException { + Path p = writeSpec(dir, String.join("\n", + "openapi: '3.0.0'", + "info: {title: t, version: '1'}", + "paths:", + " /x:", + " get:", + " responses: {'200': {description: ok}}")); + OpenAPIParser parser = new OpenAPIParser(p.toString()); + // operationId == method + path with non-alphanumeric replaced + assertThat(parser.getMethods()).containsKey("GET_x"); + } + + @Test + void patchOperationIsIgnored(@TempDir Path dir) throws IOException { + Path p = writeSpec(dir, String.join("\n", + "openapi: '3.0.0'", + "info: {title: t, version: '1'}", + "paths:", + " /x:", + " patch:", + " operationId: doPatch", + " responses: {'200': {description: ok}}")); + OpenAPIParser parser = new OpenAPIParser(p.toString()); + assertThat(parser.getMethods()).isEmpty(); + } + + @Test + void requestBodyWithoutZserioObjectIsLoggedNotThrown(@TempDir Path dir) throws IOException { + Path p = writeSpec(dir, String.join("\n", + "openapi: '3.0.0'", + "info: {title: t, version: '1'}", + "paths:", + " /x:", + " post:", + " operationId: a", + " requestBody:", + " content:", + " application/json: {schema: {type: object}}", + " responses: {'200': {description: ok}}")); + OpenAPIParser parser = new OpenAPIParser(p.toString()); + assertThat(parser.getMethod("a").hasZserioBody()).isFalse(); + } + + @Test + void securitySchemeWithoutTypeDefaultsToHttp(@TempDir Path dir) throws IOException { + Path p = writeSpec(dir, String.join("\n", + "openapi: '3.0.0'", + "info: {title: t, version: '1'}", + "components:", + " securitySchemes:", + " OnlyName: {scheme: basic}", + "paths: {}")); + OpenAPIParser parser = new OpenAPIParser(p.toString()); + assertThat(parser.getSecuritySchemes().get("OnlyName").getType()).isEqualTo(SecuritySchemeType.HTTP); + } + + @Test + void openIdConnectSchemeRejectedAtParseTime(@TempDir Path dir) throws IOException { + // Matches C++ openapi-parser.cpp: openIdConnect is rejected up-front so a Java + // client and a C++ client either both load or both refuse the same spec. + Path p = writeSpec(dir, String.join("\n", + "openapi: '3.0.0'", + "info: {title: t, version: '1'}", + "components:", + " securitySchemes:", + " OIDC: {type: openIdConnect, openIdConnectUrl: 'https://x/.well-known'}", + "paths: {}")); + assertThatThrownBy(() -> new OpenAPIParser(p.toString())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("openIdConnect") + .hasMessageContaining("not supported"); + } + + @Test + void getUnknownMethodReturnsNull() throws IOException { + OpenAPIParser parser = new OpenAPIParser( + Path.of("src/test/resources/test-openapi.yaml").toAbsolutePath().toString()); + assertThat(parser.getMethod("doesNotExist")).isNull(); + } + + @Test + void specWithoutPathsParsesCleanly(@TempDir Path dir) throws IOException { + Path p = writeSpec(dir, "openapi: '3.0.0'\ninfo: {title: t, version: '1'}\n"); + OpenAPIParser parser = new OpenAPIParser(p.toString()); + assertThat(parser.getMethods()).isEmpty(); + assertThat(parser.getServers()).isEmpty(); + assertThat(parser.getDefaultSecurity()).isEmpty(); + } +} diff --git a/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OpenApiClientSecurityTest.java b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OpenApiClientSecurityTest.java new file mode 100644 index 00000000..861d8019 --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OpenApiClientSecurityTest.java @@ -0,0 +1,273 @@ +package io.github.ndsev.zswag.shared; + +import io.github.ndsev.zswag.api.HttpConfig; +import io.github.ndsev.zswag.api.HttpException; +import io.github.ndsev.zswag.api.HttpRequest; +import io.github.ndsev.zswag.api.HttpResponse; +import io.github.ndsev.zswag.api.HttpSettings; +import io.github.ndsev.zswag.api.IHttpClient; +import io.github.ndsev.zswag.api.IKeychain; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Integration tests for {@link OpenApiClient#applySecurity} (private — exercised + * via {@code callMethod}). Earlier the dispatch core was reachable in tests + * only via {@code mock(OpenApiClient.class)}; these tests construct a real + * client against a synthetic OpenAPI spec and a stub {@link IHttpClient} that + * captures the outgoing request, then assert what was applied. + * + *

The spec covers the four interesting branches: OAuth2 (mints token + adds + * Bearer header), API-key in header, API-key in query, OR-of-AND alternatives + * fallthrough. + */ +class OpenApiClientSecurityTest { + + @TempDir + Path tmp; + + @BeforeEach + void clearTokenCache() { + OAuth2Handler.clearAllCachedTokens(); + } + + private static final String SPEC = String.join("\n", + "openapi: \"3.0.0\"", + "info: { title: 'Sec', version: '1.0' }", + "servers: [ { url: 'https://api.example.com/v1' } ]", + "paths:", + " /oauth2:", + " get:", + " operationId: oauth2Op", + " responses: { '200': { description: ok } }", + " security: [ { OAuth2Auth: [ read ] } ]", + " /api-key-header:", + " get:", + " operationId: apiKeyHeaderOp", + " responses: { '200': { description: ok } }", + " security: [ { ApiKeyHeader: [] } ]", + " /api-key-query:", + " get:", + " operationId: apiKeyQueryOp", + " responses: { '200': { description: ok } }", + " security: [ { ApiKeyQuery: [] } ]", + " /alternative:", + " get:", + " operationId: alternativeOp", + " responses: { '200': { description: ok } }", + " # Either Bearer (HTTP) OR ApiKeyHeader satisfies the requirement.", + " security:", + " - BearerAuth: []", + " - ApiKeyHeader: []", + "components:", + " securitySchemes:", + " BearerAuth: { type: http, scheme: bearer }", + " ApiKeyHeader: { type: apiKey, in: header, name: X-API-Key }", + " ApiKeyQuery: { type: apiKey, in: query, name: api_key }", + " OAuth2Auth:", + " type: oauth2", + " flows:", + " clientCredentials:", + " tokenUrl: https://idp.example.com/token", + " scopes: { read: 'Read access' }" + ); + + /** + * Captures the most recent outgoing HttpRequest so tests can assert on it. + * Routes the OAuth2 token endpoint to a deterministic JSON response; routes + * everything else to a 200 with empty body. + */ + private static final class CapturingHttpClient implements IHttpClient { + final AtomicReference lastApiCall = new AtomicReference<>(); + final HttpSettings persistent; + + CapturingHttpClient() { this(HttpSettings.empty()); } + CapturingHttpClient(HttpSettings persistent) { this.persistent = persistent; } + + @Override + public HttpSettings getPersistentSettings() { return persistent; } + + @Override + public HttpResponse execute(HttpRequest request, HttpConfig adhoc) { + String url = request.getUrl(); + if (url.contains("/token")) { + String body = "{\"access_token\":\"minted-tok\",\"expires_in\":3600}"; + return new HttpResponse(200, null, new LinkedHashMap<>(), + body.getBytes(StandardCharsets.UTF_8)); + } + lastApiCall.set(request); + return new HttpResponse(200, null, new LinkedHashMap<>(), new byte[0]); + } + } + + private Path writeSpec() throws IOException { + Path p = tmp.resolve("test-spec.yaml"); + Files.writeString(p, SPEC); + return p; + } + + private static IKeychain noKeychain() { + return (s, u) -> { throw new RuntimeException("keychain not expected in this test"); }; + } + + @Test + void oauth2SchemeMintsTokenAndAddsBearerHeader() throws Exception { + Path spec = writeSpec(); + CapturingHttpClient http = new CapturingHttpClient(); + HttpConfig adhoc = HttpConfig.builder() + .oauth2(HttpConfig.OAuth2.builder() + .clientId("cid").clientSecret("csec") + // useForSpecFetch defaults to true but the spec is loaded from a file + // here, so the spec-fetch branch isn't hit (no HTTP). + .build()) + .build(); + OpenApiClient client = new OpenApiClient(spec.toString(), http, adhoc, noKeychain()); + + client.callMethod("oauth2Op", Collections.emptyMap(), null); + + HttpRequest sent = http.lastApiCall.get(); + assertThat(sent).as("dispatch reached HTTP layer").isNotNull(); + assertThat(sent.getHeaders().get("Authorization")).isEqualTo("Bearer minted-tok"); + } + + @Test + void apiKeyInHeaderRoutedToCorrectHeaderName() throws Exception { + Path spec = writeSpec(); + CapturingHttpClient http = new CapturingHttpClient(); + HttpConfig adhoc = HttpConfig.builder().apiKey("secret-key-value").build(); + OpenApiClient client = new OpenApiClient(spec.toString(), http, adhoc, noKeychain()); + + client.callMethod("apiKeyHeaderOp", Collections.emptyMap(), null); + + HttpRequest sent = http.lastApiCall.get(); + assertThat(sent).isNotNull(); + assertThat(sent.getHeaders().get("X-API-Key")).isEqualTo("secret-key-value"); + assertThat(sent.getHeaders()).doesNotContainKey("Authorization"); + } + + @Test + void apiKeyInQueryRoutedToUrl() throws Exception { + Path spec = writeSpec(); + CapturingHttpClient http = new CapturingHttpClient(); + HttpConfig adhoc = HttpConfig.builder().apiKey("query-key-value").build(); + OpenApiClient client = new OpenApiClient(spec.toString(), http, adhoc, noKeychain()); + + client.callMethod("apiKeyQueryOp", Collections.emptyMap(), null); + + HttpRequest sent = http.lastApiCall.get(); + assertThat(sent).isNotNull(); + assertThat(sent.getUrl()).contains("api_key=query-key-value"); + assertThat(sent.getHeaders()).doesNotContainKey("X-API-Key"); + } + + @Test + void alternativesPickFirstSatisfiable_apiKeyWhenNoBearer() throws Exception { + Path spec = writeSpec(); + CapturingHttpClient http = new CapturingHttpClient(); + // Only API-key configured: BearerAuth requires Authorization header which we + // don't provide → applySecurity falls through to the ApiKeyHeader alternative. + HttpConfig adhoc = HttpConfig.builder().apiKey("api-key-fallback").build(); + OpenApiClient client = new OpenApiClient(spec.toString(), http, adhoc, noKeychain()); + + client.callMethod("alternativeOp", Collections.emptyMap(), null); + + HttpRequest sent = http.lastApiCall.get(); + assertThat(sent).isNotNull(); + assertThat(sent.getHeaders().get("X-API-Key")).isEqualTo("api-key-fallback"); + } + + @Test + void alternativesPickFirstSatisfiable_bearerWhenAuthorizationProvided() throws Exception { + Path spec = writeSpec(); + CapturingHttpClient http = new CapturingHttpClient(); + // Caller pre-supplies Authorization → BearerAuth alternative wins. + HttpConfig adhoc = HttpConfig.builder() + .bearerToken("user-supplied-token") + .apiKey("fallback-only-if-bearer-fails") + .build(); + OpenApiClient client = new OpenApiClient(spec.toString(), http, adhoc, noKeychain()); + + client.callMethod("alternativeOp", Collections.emptyMap(), null); + + HttpRequest sent = http.lastApiCall.get(); + assertThat(sent).isNotNull(); + // The Bearer is in effective.headers (merged from adhoc), not opHeaders, so the + // dispatch layer doesn't re-add it; assert it would land via the dispatch path. + // X-API-Key must NOT be present because the Bearer alternative was chosen first. + assertThat(sent.getHeaders()).doesNotContainKey("X-API-Key"); + } + + @Test + void specFetchRoutesThroughConfiguredIHttpClient() throws Exception { + // Verifies that an HTTP(S) spec URL is fetched via the configured IHttpClient + // (so HTTP_SSL_STRICT, proxy, basic-auth, HTTP_TIMEOUT, and persistent headers + // all apply to the spec fetch). Previously OpenAPIParser used raw URLConnection, + // bypassing every one of those — matches C++ fetchOpenAPIConfig now. + Path spec = writeSpec(); + String specContent = java.nio.file.Files.readString(spec); + + // Counting stub that serves the spec body on a specific URL and counts hits. + java.util.concurrent.atomic.AtomicInteger fetchCount = new java.util.concurrent.atomic.AtomicInteger(0); + IHttpClient countingHttp = (request, adhoc) -> { + String url = request.getUrl(); + if (url.endsWith("/openapi.yaml")) { + fetchCount.incrementAndGet(); + return new HttpResponse(200, null, new LinkedHashMap<>(), + specContent.getBytes(StandardCharsets.UTF_8)); + } + // Other requests would go to the spec's path operations; not exercised here. + return new HttpResponse(200, null, new LinkedHashMap<>(), new byte[0]); + }; + + // Use an http:// URL so the IHttpClient branch fires. + OpenApiClient client = new OpenApiClient( + "http://api.example.test/openapi.yaml", countingHttp, HttpConfig.empty(), noKeychain()); + assertThat(client).isNotNull(); + assertThat(fetchCount.get()).as("spec fetch must go through IHttpClient").isEqualTo(1); + } + + @Test + void useForSpecFetchWithoutTokenUrlFallsThroughToUnauthFetch() throws Exception { + // When useForSpecFetch=true but oauth2.tokenUrl is unset, match C++ behaviour + // (openapi-oauth.cpp:283-345): log a warning and continue unauthenticated. + // The downstream OpenAPIParser request will surface the real failure if the spec + // endpoint actually requires auth. + // + // Here we use a local-file spec so OpenAPIParser succeeds, demonstrating that the + // useForSpecFetch path no longer aborts construction when tokenUrl is missing. + Path spec = writeSpec(); + CapturingHttpClient http = new CapturingHttpClient(); + HttpConfig adhoc = HttpConfig.builder() + .oauth2(HttpConfig.OAuth2.builder().clientId("cid").clientSecret("csec").build()) + .build(); + // Should construct without throwing; the file spec is publicly readable. + new OpenApiClient(spec.toString(), http, adhoc, noKeychain()); + } + + @Test + void noConfiguredCredentialsRaisesDescriptiveError() throws Exception { + Path spec = writeSpec(); + CapturingHttpClient http = new CapturingHttpClient(); + // No apiKey, no bearer, no oauth2 — nothing satisfies /alternative. + HttpConfig adhoc = HttpConfig.empty(); + OpenApiClient client = new OpenApiClient(spec.toString(), http, adhoc, noKeychain()); + + assertThatThrownBy(() -> client.callMethod("alternativeOp", Collections.emptyMap(), null)) + .isInstanceOf(HttpException.class) + .hasMessageContaining("none of the"); + } +} diff --git a/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OpenApiClientServerIndexTest.java b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OpenApiClientServerIndexTest.java new file mode 100644 index 00000000..8f023452 --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OpenApiClientServerIndexTest.java @@ -0,0 +1,135 @@ +package io.github.ndsev.zswag.shared; + +import io.github.ndsev.zswag.api.HttpConfig; +import io.github.ndsev.zswag.api.HttpRequest; +import io.github.ndsev.zswag.api.HttpResponse; +import io.github.ndsev.zswag.api.HttpSettings; +import io.github.ndsev.zswag.api.IHttpClient; +import io.github.ndsev.zswag.api.IKeychain; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Multi-server support — matches C++ {@code OAClient(..., uint32_t serverIndex)} + * and Python {@code OAClient(..., server_index=N)} (issue #113). zswag-Java's + * {@link OpenApiClient} accepts a {@code serverIndex} constructor parameter + * that selects which entry of the parsed {@code servers[]} array is used as + * the base URL for dispatch. + */ +class OpenApiClientServerIndexTest { + + @TempDir + Path tmp; + + private static final String SPEC = String.join("\n", + "openapi: \"3.0.0\"", + "info: { title: t, version: '1.0' }", + "servers:", + " - url: 'https://primary.example.com/v1'", + " - url: 'https://secondary.example.com/v2'", + " - url: 'https://tertiary.example.com/v3'", + "paths:", + " /ping:", + " get:", + " operationId: ping", + " responses: { '200': { description: ok } }" + ); + + private static final class CapturingHttpClient implements IHttpClient { + final AtomicReference last = new AtomicReference<>(); + + @Override + public HttpSettings getPersistentSettings() { return HttpSettings.empty(); } + + @Override + public HttpResponse execute(HttpRequest request, HttpConfig adhoc) { + last.set(request); + return new HttpResponse(200, null, new LinkedHashMap<>(), new byte[0]); + } + } + + private static IKeychain noKeychain() { + return (s, u) -> { throw new RuntimeException("keychain not expected"); }; + } + + private Path writeSpec() throws IOException { + Path p = tmp.resolve("openapi.yaml"); + Files.writeString(p, SPEC); + return p; + } + + @Test + void defaultIndexZeroPicksFirstServer() throws Exception { + Path spec = writeSpec(); + CapturingHttpClient http = new CapturingHttpClient(); + // The 4-arg constructor defaults serverIndex to 0. + OpenApiClient client = new OpenApiClient(spec.toString(), http, HttpConfig.empty(), noKeychain()); + client.callMethod("ping", Collections.emptyMap(), null); + assertThat(http.last.get().getUrl()).startsWith("https://primary.example.com/v1/ping"); + } + + @Test + void explicitIndexOnePicksSecondServer() throws Exception { + Path spec = writeSpec(); + CapturingHttpClient http = new CapturingHttpClient(); + OpenApiClient client = new OpenApiClient(spec.toString(), http, HttpConfig.empty(), noKeychain(), 1); + client.callMethod("ping", Collections.emptyMap(), null); + assertThat(http.last.get().getUrl()).startsWith("https://secondary.example.com/v2/ping"); + } + + @Test + void explicitIndexTwoPicksThirdServer() throws Exception { + Path spec = writeSpec(); + CapturingHttpClient http = new CapturingHttpClient(); + OpenApiClient client = new OpenApiClient(spec.toString(), http, HttpConfig.empty(), noKeychain(), 2); + client.callMethod("ping", Collections.emptyMap(), null); + assertThat(http.last.get().getUrl()).startsWith("https://tertiary.example.com/v3/ping"); + } + + @Test + void outOfBoundsIndexThrowsAtConstructionWithClearMessage() throws Exception { + Path spec = writeSpec(); + CapturingHttpClient http = new CapturingHttpClient(); + assertThatThrownBy(() -> + new OpenApiClient(spec.toString(), http, HttpConfig.empty(), noKeychain(), 5)) + .isInstanceOf(IOException.class) + .hasMessageContaining("serverIndex 5 is out of bounds") + .hasMessageContaining("3 server(s)"); + } + + @Test + void negativeIndexThrowsIllegalArgumentException() throws Exception { + Path spec = writeSpec(); + CapturingHttpClient http = new CapturingHttpClient(); + assertThatThrownBy(() -> + new OpenApiClient(spec.toString(), http, HttpConfig.empty(), noKeychain(), -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("serverIndex must be >= 0"); + } + + @Test + void indexZeroValidEvenWhenServersArrayIsEmpty() throws Exception { + // An empty servers array implies [{ "url": "/" }] per OpenAPI 3.0+ §4.7.5. + // Index 0 should still be acceptable in that case. + Path p = tmp.resolve("openapi.yaml"); + Files.writeString(p, String.join("\n", + "openapi: \"3.0.0\"", + "info: { title: t, version: '1.0' }", + "servers: []", + "paths: { /ping: { get: { operationId: ping, responses: { '200': { description: ok } } } } }" + )); + CapturingHttpClient http = new CapturingHttpClient(); + // Should NOT throw — index 0 is valid even with no declared servers. + new OpenApiClient(p.toString(), http, HttpConfig.empty(), noKeychain(), 0); + } +} diff --git a/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/ParameterEncoderTest.java b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/ParameterEncoderTest.java new file mode 100644 index 00000000..d2afbaf6 --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/ParameterEncoderTest.java @@ -0,0 +1,239 @@ +package io.github.ndsev.zswag.shared; + +import io.github.ndsev.zswag.api.OpenAPIParameter; +import io.github.ndsev.zswag.api.ParameterFormat; +import io.github.ndsev.zswag.api.ParameterLocation; +import io.github.ndsev.zswag.api.ParameterStyle; +import org.junit.jupiter.api.Test; + +import java.util.AbstractMap; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that ParameterEncoder produces the same byte/string sequences as + * the C++ {@code openapi-parameter-helper.cpp}, especially for the styles + + * formats combinations the calc API exercises. + */ +class ParameterEncoderTest { + + @Test + void scalarPathSimple() { + OpenAPIParameter p = OpenAPIParameter.builder("base", ParameterLocation.PATH) + .style(ParameterStyle.SIMPLE).build(); + assertThat(ParameterEncoder.encodeForPath(p, 2)).isEqualTo("2"); + } + + @Test + void scalarPathLabel() { + OpenAPIParameter p = OpenAPIParameter.builder("base", ParameterLocation.PATH) + .style(ParameterStyle.LABEL).build(); + assertThat(ParameterEncoder.encodeForPath(p, "x")).isEqualTo(".x"); + } + + @Test + void scalarPathMatrix() { + OpenAPIParameter p = OpenAPIParameter.builder("base", ParameterLocation.PATH) + .style(ParameterStyle.MATRIX).build(); + assertThat(ParameterEncoder.encodeForPath(p, 7)).isEqualTo(";base=7"); + } + + @Test + void simpleArrayPathCommaJoined() { + OpenAPIParameter p = OpenAPIParameter.builder("values", ParameterLocation.PATH) + .style(ParameterStyle.SIMPLE).format(ParameterFormat.STRING).build(); + assertThat(ParameterEncoder.encodeForPath(p, new int[]{1, 2, 3})).isEqualTo("1,2,3"); + } + + @Test + void labelArrayWithExplodeUsesDotSeparator() { + OpenAPIParameter p = OpenAPIParameter.builder("values", ParameterLocation.PATH) + .style(ParameterStyle.LABEL).explode(true).format(ParameterFormat.STRING).build(); + assertThat(ParameterEncoder.encodeForPath(p, new int[]{1, 2, 3})).isEqualTo(".1.2.3"); + } + + @Test + void matrixArrayWithExplodeRepeatsName() { + OpenAPIParameter p = OpenAPIParameter.builder("values", ParameterLocation.PATH) + .style(ParameterStyle.MATRIX).explode(true).format(ParameterFormat.STRING).build(); + assertThat(ParameterEncoder.encodeForPath(p, new int[]{1, 2})).isEqualTo(";values=1;values=2"); + } + + @Test + void formArrayExplodeFalseProducesSinglePair() { + OpenAPIParameter p = OpenAPIParameter.builder("values", ParameterLocation.QUERY) + .style(ParameterStyle.FORM).explode(false).format(ParameterFormat.STRING).build(); + List> pairs = ParameterEncoder.encodeForQuery(p, new int[]{1, 2, 3}); + assertThat(pairs).hasSize(1); + assertThat(pairs.get(0).getKey()).isEqualTo("values"); + assertThat(pairs.get(0).getValue()).isEqualTo("1,2,3"); + } + + @Test + void formArrayExplodeTrueProducesOnePairPerElement() { + OpenAPIParameter p = OpenAPIParameter.builder("values", ParameterLocation.QUERY) + .style(ParameterStyle.FORM).explode(true).format(ParameterFormat.STRING).build(); + List> pairs = ParameterEncoder.encodeForQuery(p, Arrays.asList("a", "b", "c")); + assertThat(pairs).extracting(Map.Entry::getKey).containsExactly("values", "values", "values"); + assertThat(pairs).extracting(Map.Entry::getValue).containsExactly("a", "b", "c"); + } + + @Test + void hexFormatSignedNegativesUseDashPrefix() { + OpenAPIParameter p = OpenAPIParameter.builder("v", ParameterLocation.QUERY) + .format(ParameterFormat.HEX).build(); + List> pairs = ParameterEncoder.encodeForQuery(p, -200); + assertThat(pairs.get(0).getValue()).isEqualTo("-c8"); + } + + @Test + void base64FormatUsesByteWidthForIntArray() { + // int[] elements are 4 bytes each → AAAAAQ== for value 1. + OpenAPIParameter p = OpenAPIParameter.builder("v", ParameterLocation.PATH) + .style(ParameterStyle.SIMPLE).format(ParameterFormat.BASE64).build(); + String encoded = ParameterEncoder.encodeForPath(p, new int[]{1, 2}); + assertThat(encoded).isEqualTo("AAAAAQ==,AAAAAg=="); + } + + @Test + void base64UrlFormatForByteArrayUsesPaddedUrlSafe() { + // short[] elements are 1 byte (zserio uint8 stored as short). + OpenAPIParameter p = OpenAPIParameter.builder("v", ParameterLocation.PATH) + .style(ParameterStyle.SIMPLE).format(ParameterFormat.BASE64URL).build(); + String encoded = ParameterEncoder.encodeForPath(p, new short[]{8, 16, 32, 64}); + // Base64URL of single bytes 8/16/32/64. + assertThat(encoded).isEqualTo("CA==,EA==,IA==,QA=="); + } + + @Test + void base64FormatScalarStringEncodesUtf8Bytes() { + OpenAPIParameter p = OpenAPIParameter.builder("v", ParameterLocation.QUERY) + .format(ParameterFormat.BASE64).build(); + List> pairs = ParameterEncoder.encodeForQuery(p, "foo"); + assertThat(pairs.get(0).getValue()).isEqualTo("Zm9v"); + } + + @Test + void booleanScalarFormattedAsZeroOrOne() { + OpenAPIParameter p = OpenAPIParameter.builder("v", ParameterLocation.QUERY) + .format(ParameterFormat.STRING).build(); + assertThat(ParameterEncoder.encodeForQuery(p, true).get(0).getValue()).isEqualTo("1"); + assertThat(ParameterEncoder.encodeForQuery(p, false).get(0).getValue()).isEqualTo("0"); + } + + @Test + void buildQueryStringPreservesOrderAndDuplicates() { + List> pairs = Arrays.asList( + new java.util.AbstractMap.SimpleImmutableEntry<>("id", "1"), + new java.util.AbstractMap.SimpleImmutableEntry<>("id", "2"), + new java.util.AbstractMap.SimpleImmutableEntry<>("name", "x y") + ); + // 'x y' must be url-encoded. + assertThat(ParameterEncoder.buildQueryString(pairs)).isEqualTo("id=1&id=2&name=x+y"); + } + + @Test + void pathEncodePreservesMatrixAndLabelDelimiters() { + // Critical for path styles 'matrix' (uses ';' and '=') and 'label' (uses '.'). + // urlEncode would mangle these to %3B/%3D/%2E and break server-side parsing. + assertThat(ParameterEncoder.pathEncode(";id=42")).isEqualTo(";id=42"); + assertThat(ParameterEncoder.pathEncode(".42")).isEqualTo(".42"); + assertThat(ParameterEncoder.pathEncode(";a=1;b=2")).isEqualTo(";a=1;b=2"); + assertThat(ParameterEncoder.pathEncode(".1.2.3")).isEqualTo(".1.2.3"); + } + + @Test + void pathEncodeKeepsUnreservedAndSubDelimsVerbatim() { + // RFC 3986 §3.3 pchar: unreserved / pct-encoded / sub-delims / ":" / "@" + String pchar = "abcXYZ0189-._~!$&'()*+,;=:@"; + assertThat(ParameterEncoder.pathEncode(pchar)).isEqualTo(pchar); + } + + @Test + void pathEncodeEscapesReservedAndSeparatorChars() { + // Reserved gen-delims plus the segment separator MUST be encoded inside a value. + assertThat(ParameterEncoder.pathEncode("a/b")).isEqualTo("a%2Fb"); + assertThat(ParameterEncoder.pathEncode("a?b")).isEqualTo("a%3Fb"); + assertThat(ParameterEncoder.pathEncode("a#b")).isEqualTo("a%23b"); + assertThat(ParameterEncoder.pathEncode("a b")).isEqualTo("a%20b"); // space -> %20, not '+' + } + + @Test + void pathEncodeIsUtf8Aware() { + // Multi-byte UTF-8 must be percent-encoded byte by byte. + assertThat(ParameterEncoder.pathEncode("é")).isEqualTo("%C3%A9"); + assertThat(ParameterEncoder.pathEncode("日")).isEqualTo("%E6%97%A5"); + } + + // ------------------------------------------------------------------------ + // Map-shaped parameter encoding (matches C++ openapi-parameter-helper). + // Currently no caller produces a Map (ZserioReflection only emits scalars and + // arrays) but the encoder is ready for a future IReflectableView equivalent. + // ------------------------------------------------------------------------ + + @Test + void mapValueQueryFormExplodeEmitsOnePairPerEntry() { + OpenAPIParameter p = OpenAPIParameter.builder("color", ParameterLocation.QUERY) + .style(ParameterStyle.FORM).explode(true) + .format(ParameterFormat.STRING).build(); + Map map = new LinkedHashMap<>(); + map.put("R", 100); + map.put("G", 200); + map.put("B", 150); + List> pairs = ParameterEncoder.encodeForQuery(p, map); + assertThat(pairs).containsExactly( + new AbstractMap.SimpleImmutableEntry<>("R", "100"), + new AbstractMap.SimpleImmutableEntry<>("G", "200"), + new AbstractMap.SimpleImmutableEntry<>("B", "150")); + } + + @Test + void mapValueQueryFormNoExplodeProducesSinglePair() { + OpenAPIParameter p = OpenAPIParameter.builder("color", ParameterLocation.QUERY) + .style(ParameterStyle.FORM).explode(false) + .format(ParameterFormat.STRING).build(); + Map map = new LinkedHashMap<>(); + map.put("R", "ff"); + map.put("G", "00"); + List> pairs = ParameterEncoder.encodeForQuery(p, map); + assertThat(pairs).containsExactly( + new AbstractMap.SimpleImmutableEntry<>("color", "R,ff,G,00")); + } + + @Test + void mapValuePathMatrixExplodeEmitsSemicolonPerEntry() { + OpenAPIParameter p = OpenAPIParameter.builder("color", ParameterLocation.PATH) + .style(ParameterStyle.MATRIX).explode(true) + .format(ParameterFormat.STRING).build(); + Map map = new LinkedHashMap<>(); + map.put("R", 1); + map.put("G", 2); + assertThat(ParameterEncoder.encodeForPath(p, map)).isEqualTo(";R=1;G=2"); + } + + @Test + void mapValuePathLabelExplodeUsesDotKvSep() { + OpenAPIParameter p = OpenAPIParameter.builder("color", ParameterLocation.PATH) + .style(ParameterStyle.LABEL).explode(true) + .format(ParameterFormat.STRING).build(); + Map map = new LinkedHashMap<>(); + map.put("R", 1); + map.put("G", 2); + assertThat(ParameterEncoder.encodeForPath(p, map)).isEqualTo(".R=1.G=2"); + } + + @Test + void mapValueHeaderSimpleStyleIsCommaJoined() { + OpenAPIParameter p = OpenAPIParameter.builder("X-Color", ParameterLocation.HEADER) + .style(ParameterStyle.SIMPLE) + .format(ParameterFormat.STRING).build(); + Map map = new LinkedHashMap<>(); + map.put("R", 1); + map.put("G", 2); + assertThat(ParameterEncoder.encodeForHeader(p, map)).isEqualTo("R,1,G,2"); + } +} diff --git a/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/ZserioReflectionTest.java b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/ZserioReflectionTest.java new file mode 100644 index 00000000..aa3de71b --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/ZserioReflectionTest.java @@ -0,0 +1,103 @@ +package io.github.ndsev.zswag.shared; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Verifies that POJO getter reflection correctly resolves x-zserio-request-part + * dotted paths, normalising snake_case to lowerCamel and unwrapping zserio + * enum types via ZserioEnum.getGenericValue(). + */ +class ZserioReflectionTest { + + @Test + void wholeRequestSentinelReturnsRoot() { + Outer obj = new Outer(); + assertThat(ZserioReflection.resolve(obj, "*")).isSameAs(obj); + assertThat(ZserioReflection.resolve(obj, "")).isSameAs(obj); + } + + @Test + void singleSegmentResolvesGetter() { + Outer obj = new Outer(); + obj.value = 42; + assertThat(ZserioReflection.resolve(obj, "value")).isEqualTo(42); + } + + @Test + void dottedPathDescendsIntoNestedStructs() { + Outer obj = new Outer(); + obj.inner = new Inner(); + obj.inner.label = "hello"; + assertThat(ZserioReflection.resolve(obj, "inner.label")).isEqualTo("hello"); + } + + @Test + void snakeCaseSegmentIsNormalisedToLowerCamel() { + Outer obj = new Outer(); + obj.enumValue = 7; // getter is getEnumValue() + assertThat(ZserioReflection.resolve(obj, "enum_value")).isEqualTo(7); + } + + @Test + void zserioEnumIsUnwrappedToGenericValue() { + Outer obj = new Outer(); + obj.fakeEnum = FakeZserioEnum.SECOND; + Object resolved = ZserioReflection.resolve(obj, "fakeEnum"); + assertThat(resolved).isInstanceOf(Number.class); + assertThat(((Number) resolved).intValue()).isEqualTo(1); + } + + @Test + void missingGetterThrowsDescriptiveError() { + Outer obj = new Outer(); + assertThatThrownBy(() -> ZserioReflection.resolve(obj, "nonExistentField")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("nonExistentField"); + } + + @Test + void getterNameNormalisationConvertsSnakeCase() { + assertThat(ZserioReflection.toGetterName("base", "get")).isEqualTo("getBase"); + assertThat(ZserioReflection.toGetterName("enum_value", "get")).isEqualTo("getEnumValue"); + assertThat(ZserioReflection.toGetterName("my_field_2", "get")).isEqualTo("getMyField2"); + assertThat(ZserioReflection.toGetterName("alreadyCamel", "get")).isEqualTo("getAlreadyCamel"); + assertThat(ZserioReflection.toGetterName("flag", "is")).isEqualTo("isFlag"); + } + + // -- Test fixtures matching zserio Java codegen conventions --------- + + public static class Outer { + private int value; + private Inner inner; + private int enumValue; + private FakeZserioEnum fakeEnum; + + public int getValue() { return value; } + public Inner getInner() { return inner; } + public int getEnumValue() { return enumValue; } + public FakeZserioEnum getFakeEnum() { return fakeEnum; } + } + + public static class Inner { + private String label; + public String getLabel() { return label; } + } + + /** Mimics zserio-Java's generated enum: implements ZserioEnum with getValue/getGenericValue. */ + public enum FakeZserioEnum implements zserio.runtime.ZserioEnum, zserio.runtime.io.Writer, zserio.runtime.SizeOf { + FIRST(0), SECOND(1), THIRD(2); + + private final int value; + FakeZserioEnum(int v) { this.value = v; } + public int getValue() { return value; } + @Override public Number getGenericValue() { return value; } + @Override public int bitSizeOf() { return 32; } + @Override public int bitSizeOf(long bitPosition) { return 32; } + @Override public long initializeOffsets() { return 32; } + @Override public long initializeOffsets(long bitPosition) { return bitPosition + 32; } + @Override public void write(zserio.runtime.io.BitStreamWriter out) {} + } +} diff --git a/libs/jzswag/jzswag-shared/src/test/resources/test-openapi.yaml b/libs/jzswag/jzswag-shared/src/test/resources/test-openapi.yaml new file mode 100644 index 00000000..8d3a9922 --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/test/resources/test-openapi.yaml @@ -0,0 +1,96 @@ +openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +servers: + - url: https://api.example.com/v1 + - url: https://backup.example.com/v1 +paths: + /users/{userId}: + get: + operationId: getUser + summary: Get user by ID + parameters: + - name: userId + in: path + required: true + schema: + type: string + format: string + - name: X-Request-ID + in: header + required: false + schema: + type: string + responses: + '200': + description: Success + security: + - BearerAuth: [] + /items: + get: + operationId: listItems + parameters: + - name: ids + in: query + required: false + explode: true + schema: + type: array + format: hex + items: + type: string + responses: + '200': + description: Success + security: + - ApiKeyAuth: [] + post: + operationId: createItem + requestBody: + content: + application/json: + schema: + type: object + responses: + '201': + description: Created + security: + - BasicAuth: [] + /public: + get: + operationId: publicEndpoint + responses: + '200': + description: Success + security: [] +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + BasicAuth: + type: http + scheme: basic + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + QueryKeyAuth: + type: apiKey + in: query + name: api_key + CookieAuth: + type: apiKey + in: cookie + name: session_id + OAuth2Auth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://auth.example.com/token + scopes: + read: Read access + write: Write access +security: + - BearerAuth: [] diff --git a/libs/jzswag/jzswag-test/README.md b/libs/jzswag/jzswag-test/README.md new file mode 100644 index 00000000..e15f0dd4 --- /dev/null +++ b/libs/jzswag/jzswag-test/README.md @@ -0,0 +1,63 @@ +# jzswag-test + +Integration tests for the Java zswag client. Validates the full dispatch flow against the Python Calculator server (`zswag.test.calc`). + +## What's tested + +`CalculatorTestClient` exercises 10 cases covering every parameter style, format, and authentication scheme the Calculator API exposes: + +| # | Operation | Tests | +|---|---|---| +| 1 | `power(BaseAndExponent)` | nested `x-zserio-request-part` (`base.value`, `exponent.value`); path + header parameters; explicit `security: []` (no auth). | +| 2 | `intSum(Integers)` | `style: form, explode: true` query array; hex-encoded ints; HTTP Bearer auth. | +| 3 | `byteSum(Bytes)` | base64url-encoded byte array in path; HTTP Basic auth. | +| 4 | `intMul(Integers)` | base64-encoded int32 array in path; query API-key auth. | +| 5 | `floatMul(Doubles)` | float array in query (`explode: false`); cookie API-key auth. | +| 6 | `bitMul(Bools)` | bool array; header API-key auth; expects `false`. | +| 7 | `bitMul(Bools)` | bool array; header API-key auth; expects `true`. | +| 8 | `identity(Double)` | POST request body as `application/x-zserio-object`; cookie API-key auth. | +| 9 | `concat(Strings)` | base64-encoded string array; HTTP Bearer auth. | +| 10 | `name(EnumWrapper)` | enum unwrap to numeric via `ZserioEnum.getGenericValue()`; global default `HeaderAuth` security. | + +The test client is structured as the **canonical Java port idiom**: each test constructs a `OAClient` (which implements `zserio.runtime.service.ServiceClientInterface`), wraps it in the zserio-generated `Calculator.CalculatorClient`, and invokes the typed method directly. There is no manual request decomposition — every parameter is resolved via `x-zserio-request-part`. + +## Running the test + +### Prerequisites + +```bash +python3 -m venv .venv && source .venv/bin/activate +pip install zswag # the test depends on the Python server as the counterparty +``` + +### Automated harness + +```bash +./libs/jzswag/jzswag-test/test-java-client.bash +``` + +The script builds the Java test client, starts the Python Calculator server on port 5555, runs `CalculatorTestClient`, and stops the server on exit. + +### Manual + +```bash +# In one terminal: +python3 -m zswag.test.calc server localhost:5555 + +# In another: +./gradlew :libs:jzswag:jzswag-test:run --args="localhost:5555" +``` + +## Why this test matters + +The earlier "test passing" claim from before the parity work was misleading: the test harness was hand-decomposing each request into the parameter map the OpenAPI spec required, then calling `oaClient.callMethod(path, params, preSerializedBytes)`. The Java client itself never read `x-zserio-request-part`. After the parity rewrite the test now goes through the actual zswag flow, so a green run validates that the Java client genuinely matches the Python/C++ behaviour end-to-end. + +## Build notes + +The build downloads the zserio Java compiler and generates Java classes from `libs/zswag/test/calc/calculator.zs` on every `compileJava`. A post-codegen sed step in `build.gradle` patches the generated `Calculator.java` to qualify `String` as `java.lang.String` where needed (the calc service has a zserio struct named `String` that shadows `java.lang.String` inside the `calculator` package — a zserio-Java codegen quirk specific to services with that struct name). + +## See also + +- [`docs/java.md`](../../docs/java.md) — canonical Java client guide. +- [`libs/zswag/test/calc/api.yaml`](../../libs/zswag/test/calc/api.yaml) — the OpenAPI spec the test exercises (good reference for `x-zserio-request-part` usage). +- [`CalculatorTestClient.java`](src/main/java/com/ndsev/zswag/test/CalculatorTestClient.java) — the test source. diff --git a/libs/jzswag/jzswag-test/build.gradle b/libs/jzswag/jzswag-test/build.gradle new file mode 100644 index 00000000..da820eb4 --- /dev/null +++ b/libs/jzswag/jzswag-test/build.gradle @@ -0,0 +1,116 @@ +plugins { + id 'application' +} + +description = 'zswag Java integration tests using Calculator service' + +application { + mainClass = 'io.github.ndsev.zswag.test.CalculatorTestClient' +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +dependencies { + // Java client + implementation project(':libs:jzswag:jzswag-jvm') + + // zserio runtime + implementation "io.github.ndsev:zserio-runtime:${rootProject.ext.zserio_version}" + + // Logging + implementation 'org.slf4j:slf4j-api:2.0.9' + runtimeOnly 'ch.qos.logback:logback-classic:1.4.14' + + // Testing + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1' + testImplementation 'org.assertj:assertj-core:3.24.2' +} + +// Define paths +def zserioSourceRoot = file("${projectDir}/../../zswag/test/calc") +def zserioInputFile = file("${zserioSourceRoot}/calculator.zs") +def zserioOutputDir = file("${projectDir}/src/main/java") + +// Task to download zserio compiler +task downloadZserio { + description = 'Download zserio compiler' + doLast { + configurations.detachedConfiguration( + dependencies.create("io.github.ndsev:zserio:${rootProject.ext.zserio_version}") + ).resolve() + } +} + +// Task to generate Java classes from zserio +task generateZserio(type: JavaExec) { + description = 'Generate Java classes from calculator.zs' + group = 'build' + + dependsOn downloadZserio + + classpath = configurations.detachedConfiguration( + dependencies.create("io.github.ndsev:zserio:${rootProject.ext.zserio_version}") + ) + + mainClass = 'zserio.tools.ZserioTool' + + args = [ + '-java', zserioOutputDir.absolutePath, + '-withoutSourcesAmalgamation', + '-src', zserioSourceRoot.absolutePath, + 'calculator.zs' + ] + + inputs.file(zserioInputFile) + outputs.dir(zserioOutputDir) + + doFirst { + zserioOutputDir.mkdirs() + logger.lifecycle("Generating Java classes from ${zserioInputFile.name}") + } + + // Workaround: zserio-Java codegen emits unqualified `String` for service + // method-name constants and method dispatch. Inside a package that also + // declares a zserio struct named `String` (calculator.String), the bare + // `String` resolves to the package-local type and breaks compilation. + // Patch Calculator.java to qualify those occurrences as java.lang.String. + doLast { + def calc = file("${zserioOutputDir}/calculator/Calculator.java") + if (calc.exists()) { + def text = calc.text + text = text.replaceAll('public static final String ([a-zA-Z0-9_]+_METHOD_NAME)', + 'public static final java.lang.String $1') + text = text.replaceAll('public final String callMethod\\(', + 'public final java.lang.String callMethod(') + // Method() inner-class signature uses String for methodName too. + text = text.replaceAll('public zserio\\.runtime\\.service\\.ServiceData<\\? extends zserio\\.runtime\\.io\\.Writer> invoke\\(\\s*byte\\[\\] requestData, java\\.lang\\.Object context\\)', + '$0') + calc.text = text + } + } +} + +// Generate zserio classes before compiling +compileJava.dependsOn generateZserio + +// Calculator.java is now compiled — the test uses zserio-generated CalculatorClient +// (it implements the same shape as Python's services.MyService.Client) through +// OAClient, our ServiceClientInterface adapter. + +// Clean generated sources +clean { + delete file("${projectDir}/src/main/java/calculator") +} + +tasks.named('run') { + // Pass command line args + if (project.hasProperty('appArgs')) { + args project.property('appArgs').split('\\s+').toList() + } + + // Enable console input + standardInput = System.in +} diff --git a/libs/jzswag/jzswag-test/src/main/java/io/github/ndsev/zswag/test/CalculatorTestClient.java b/libs/jzswag/jzswag-test/src/main/java/io/github/ndsev/zswag/test/CalculatorTestClient.java new file mode 100644 index 00000000..6dc627a7 --- /dev/null +++ b/libs/jzswag/jzswag-test/src/main/java/io/github/ndsev/zswag/test/CalculatorTestClient.java @@ -0,0 +1,211 @@ +package io.github.ndsev.zswag.test; + +import calculator.BaseAndExponent; +import calculator.Bool; +import calculator.Bools; +import calculator.Bytes; +import calculator.Calculator; +import calculator.Double; +import calculator.Doubles; +import calculator.Enum; +import calculator.EnumWrapper; +import calculator.I32; +import calculator.Integers; +import calculator.Strings; +// NOTE: calculator.String shadows java.lang.String — qualify java strings as java.lang.String. +import io.github.ndsev.zswag.api.HttpConfig; +import io.github.ndsev.zswag.api.HttpSettings; +import io.github.ndsev.zswag.jvm.JvmHttpClient; +import io.github.ndsev.zswag.jvm.OAClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Integration test for the Java zswag client against the Python Calculator + * server. Mirrors the Python {@code libs/zswag/test/calc/client.py} flow. + * + *

This is the canonical "Java port" usage: each test constructs a + * {@link OAClient}, wraps it in the zserio-generated + * {@link Calculator.CalculatorClient}, and invokes the typed method directly. + * No manual request decomposition — every parameter is resolved from the + * zserio request object via {@code x-zserio-request-part}. + */ +public class CalculatorTestClient { + private static final Logger logger = LoggerFactory.getLogger(CalculatorTestClient.class); + + private final java.lang.String host; + private final int port; + private int testCounter = 0; + private int failedTests = 0; + + public CalculatorTestClient(java.lang.String host, int port) { + this.host = host; + this.port = port; + } + + public static void main(java.lang.String[] args) { + if (args.length == 0) { + System.err.println("Usage: CalculatorTestClient "); + System.err.println("Example: CalculatorTestClient localhost:5555"); + System.exit(1); + } + java.lang.String[] hostPort = args[0].split(":"); + java.lang.String host = hostPort[0]; + int port = hostPort.length > 1 ? Integer.parseInt(hostPort[1]) : 5000; + + CalculatorTestClient client = new CalculatorTestClient(host, port); + System.exit(client.runAllTests()); + } + + public int runAllTests() { + java.lang.String serverUrl = java.lang.String.format("http://%s:%d/openapi.json", host, port); + System.out.printf("[java-test-client] Connecting to %s%n", serverUrl); + System.out.flush(); + + // Test 1: power() — security: [], base in path, exponent in X-Ponent header. + runTest("Pass fields in path and header (no auth)", () -> { + BaseAndExponent request = new BaseAndExponent(); + request.setBase(new I32(2)); + request.setExponent(new I32(3)); + request.setUnused1(0); + request.setUnused2(""); + request.setUnused3(0.0f); + request.setUnused5(new boolean[0]); + + Calculator.CalculatorClient calc = newCalcClient(serverUrl, HttpConfig.empty()); + Double response = calc.powerMethod(request); + assertDoubleEquals(8.0, response.getValue(), "power(2, 3) should equal 8"); + }); + + // Test 2: intSum() — Bearer auth, hex-encoded array in query, explode: true. + runTest("Pass hex-encoded array in query (Bearer auth)", () -> { + Integers request = new Integers(new int[]{100, -200, 400}); + HttpConfig adhoc = HttpConfig.builder() + .header("Authorization", "Bearer 123") + .build(); + Calculator.CalculatorClient calc = newCalcClient(serverUrl, adhoc); + Double response = calc.intSumMethod(request); + assertDoubleEquals(300.0, response.getValue(), "intSum([100, -200, 400]) should equal 300"); + }); + + // Test 3: byteSum() — Basic auth, base64url-encoded byte array in path. + runTest("Pass base64url-encoded byte array in path (Basic auth)", () -> { + Bytes request = new Bytes(new short[]{8, 16, 32, 64}); + HttpConfig adhoc = HttpConfig.builder().basicAuth("u", "pw").build(); + Calculator.CalculatorClient calc = newCalcClient(serverUrl, adhoc); + Double response = calc.byteSumMethod(request); + assertDoubleEquals(120.0, response.getValue(), "byteSum([8, 16, 32, 64]) should equal 120"); + }); + + // Test 4: intMul() — Query API-key auth, base64-encoded array in path. + runTest("Pass base64-encoded long array in path (Query API-key)", () -> { + Integers request = new Integers(new int[]{1, 2, 3, 4}); + HttpConfig adhoc = HttpConfig.builder().query("api-key", "42").build(); + Calculator.CalculatorClient calc = newCalcClient(serverUrl, adhoc); + Double response = calc.intMulMethod(request); + assertDoubleEquals(24.0, response.getValue(), "intMul([1, 2, 3, 4]) should equal 24"); + }); + + // Test 5: floatMul() — Cookie auth, float array in query, explode: false. + runTest("Pass float array in query (Cookie auth)", () -> { + Doubles request = new Doubles(new double[]{34.5, 2.0}); + HttpConfig adhoc = HttpConfig.builder().cookie("api-cookie", "42").build(); + Calculator.CalculatorClient calc = newCalcClient(serverUrl, adhoc); + Double response = calc.floatMulMethod(request); + assertDoubleEquals(69.0, response.getValue(), "floatMul([34.5, 2.0]) should equal 69"); + }); + + // Test 6: bitMul() — Header API-key, bool array (false expected). + runTest("Pass bool array in query (Header API-key, expect false)", () -> { + Bools request = new Bools(new boolean[]{true, false}); + HttpConfig adhoc = HttpConfig.builder().header("X-Generic-Token", "42").build(); + Calculator.CalculatorClient calc = newCalcClient(serverUrl, adhoc); + Bool response = calc.bitMulMethod(request); + assertEquals(false, response.getValue(), "bitMul([true, false]) should equal false"); + }); + + // Test 7: bitMul() — Header API-key, bool array (true expected). + runTest("Pass bool array in query (Header API-key, expect true)", () -> { + Bools request = new Bools(new boolean[]{true, true}); + HttpConfig adhoc = HttpConfig.builder().header("X-Generic-Token", "42").build(); + Calculator.CalculatorClient calc = newCalcClient(serverUrl, adhoc); + Bool response = calc.bitMulMethod(request); + assertEquals(true, response.getValue(), "bitMul([true, true]) should equal true"); + }); + + // Test 8: identity() — Cookie auth, request as application/x-zserio-object body. + runTest("Pass request as blob in body (Cookie auth)", () -> { + Double request = new Double(1.0); + HttpConfig adhoc = HttpConfig.builder().cookie("api-cookie", "42").build(); + Calculator.CalculatorClient calc = newCalcClient(serverUrl, adhoc); + Double response = calc.identityMethod(request); + assertDoubleEquals(1.0, response.getValue(), "identity(1.0) should equal 1.0"); + }); + + // Test 9: concat() — Bearer auth, base64-encoded string array. + runTest("Pass base64-encoded strings (Bearer auth)", () -> { + Strings request = new Strings(new java.lang.String[]{"foo", "bar"}); + HttpConfig adhoc = HttpConfig.builder().header("Authorization", "Bearer 123").build(); + Calculator.CalculatorClient calc = newCalcClient(serverUrl, adhoc); + calculator.String response = calc.concatMethod(request); + assertEquals("foobar", response.getValue(), "concat(['foo', 'bar']) should equal 'foobar'"); + }); + + // Test 10: name() — global default Header auth, enum value as path scalar. + runTest("Pass enum (global default Header auth)", () -> { + EnumWrapper request = new EnumWrapper(Enum.TEST_ENUM_0); + HttpConfig adhoc = HttpConfig.builder().header("X-Generic-Token", "42").build(); + Calculator.CalculatorClient calc = newCalcClient(serverUrl, adhoc); + calculator.String response = calc.nameMethod(request); + assertEquals("TEST_ENUM_0", response.getValue(), "name(TEST_ENUM_0) should equal 'TEST_ENUM_0'"); + }); + + System.out.println(); + if (failedTests > 0) { + System.out.printf("[java-test-client] Done, %d test(s) failed!%n", failedTests); + return 1; + } + System.out.println("[java-test-client] All tests succeeded!"); + return 0; + } + + private Calculator.CalculatorClient newCalcClient(java.lang.String openApiUrl, HttpConfig adhoc) throws Exception { + // No persistent settings file in this test; adhoc carries the auth/headers per call. + OAClient transport = new OAClient(openApiUrl, HttpSettings.empty(), adhoc); + return new Calculator.CalculatorClient(transport); + } + + private void runTest(java.lang.String description, TestCase testCase) { + testCounter++; + try { + System.out.printf("[java-test-client] Test#%d: %s%n", testCounter, description); + System.out.flush(); + testCase.run(); + System.out.printf("[java-test-client] -> Success.%n"); + System.out.flush(); + } catch (Exception e) { + failedTests++; + System.out.printf("[java-test-client] -> ERROR: %s%n", + e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()); + logger.error("Test failed", e); + System.out.flush(); + } + } + + private void assertDoubleEquals(double expected, double actual, java.lang.String message) { + if (Math.abs(expected - actual) > 0.0001) { + throw new AssertionError(java.lang.String.format("%s: expected %.4f but got %.4f", message, expected, actual)); + } + } + + private void assertEquals(Object expected, Object actual, java.lang.String message) { + if (!expected.equals(actual)) { + throw new AssertionError(java.lang.String.format("%s: expected '%s' but got '%s'", message, expected, actual)); + } + } + + @FunctionalInterface + interface TestCase { + void run() throws Exception; + } +} diff --git a/libs/jzswag/jzswag-test/test-java-client.bash b/libs/jzswag/jzswag-test/test-java-client.bash new file mode 100755 index 00000000..b4dd145e --- /dev/null +++ b/libs/jzswag/jzswag-test/test-java-client.bash @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# Integration test script for Java zswag client +# Tests the Java client against the Python Calculator server +# + +set -e + +# Get script directory +my_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +project_root="$my_dir/../../.." + +# Configuration +TEST_HOST="localhost" +TEST_PORT="5555" +SERVER_START_TIMEOUT=10 + +echo "=========================================" +echo "Java zswag Client Integration Test" +echo "=========================================" +echo "" + +# Check if Python zswag module is available +if ! python3 -c "import zswag.test.calc" 2>/dev/null; then + echo "ERROR: Python zswag module not found!" + echo "" + echo "Please install it first:" + echo " pip install -r requirements.txt" + echo " pip install build/bin/wheel/*.whl" + echo "" + exit 1 +fi + +# Build the Java test client +echo "→ [1/4] Building Java test client..." +cd "$project_root" +./gradlew :libs:jzswag:jzswag-test:build --quiet || { + echo "ERROR: Failed to build Java test client" + exit 1 +} +echo " ✓ Build successful" +echo "" + +# Start Python server in background +echo "→ [2/4] Starting Python Calculator server on $TEST_HOST:$TEST_PORT..." +python3 -m zswag.test.calc server "$TEST_HOST:$TEST_PORT" & +SERVER_PID=$! + +# Ensure server is killed on exit +trap "echo ''; echo '→ [4/4] Stopping server (PID $SERVER_PID)...'; kill $SERVER_PID 2>/dev/null; wait $SERVER_PID 2>/dev/null; echo ' ✓ Server stopped'; echo ''" EXIT + +# Wait for server to start +echo " Waiting for server to start..." +for i in $(seq 1 $SERVER_START_TIMEOUT); do + if curl -s "http://$TEST_HOST:$TEST_PORT/openapi.json" > /dev/null 2>&1; then + echo " ✓ Server ready (took ${i}s)" + break + fi + if [ $i -eq $SERVER_START_TIMEOUT ]; then + echo "ERROR: Server failed to start within ${SERVER_START_TIMEOUT}s" + exit 1 + fi + sleep 1 +done +echo "" + +# Run Java test client +echo "→ [3/4] Running Java test client..." +echo "=========================================" +./gradlew :libs:jzswag:jzswag-test:run --quiet --args="$TEST_HOST:$TEST_PORT" +TEST_EXIT_CODE=$? +echo "=========================================" +echo "" + +# Check results +if [ $TEST_EXIT_CODE -eq 0 ]; then + echo "✅ All integration tests PASSED!" + echo "" + exit 0 +else + echo "❌ Integration tests FAILED with exit code $TEST_EXIT_CODE" + echo "" + exit $TEST_EXIT_CODE +fi diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..a5563af8 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,16 @@ +// Foojay resolver lets Gradle auto-download the toolchain JDK declared in +// build.gradle (Temurin 17). Without it, contributors need a matching JDK +// already installed locally, defeating the point of toolchains. +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} + +rootProject.name = 'zswag' + +// Java modules — grouped under libs/jzswag/ so the libs/ root stays focused +// on the C++/Python siblings (httpcl, zswagcl, zswag, pyzswagcl). +include 'libs:jzswag:jzswag-api' +include 'libs:jzswag:jzswag-shared' +include 'libs:jzswag:jzswag-jvm' +include 'libs:jzswag:jzswag-android' +include 'libs:jzswag:jzswag-test'