diff --git a/.github/workflows/service-noise-generation-service.yml b/.github/workflows/service-noise-generation-service.yml new file mode 100644 index 0000000..511e660 --- /dev/null +++ b/.github/workflows/service-noise-generation-service.yml @@ -0,0 +1,131 @@ +name: noise-generation-service + +on: + push: + branches: ["master"] + paths: + - "services/noise-generation-service/**" + pull_request: + branches: ["master"] + paths: + - "services/noise-generation-service/**" + +defaults: + run: + working-directory: services/noise-generation-service + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - name: Cache dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: services/noise-generation-service + - name: Formatting check + run: cargo fmt --check + - name: Clippy linting + run: cargo clippy --all-targets --all-features -- -D warnings + + test: + name: Test + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v4 + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + - name: Cache dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: services/noise-generation-service + - name: Run tests + run: | + # Start server in background for integration tests + cargo run -- server & + SERVER_PID=$! + + # Wait for server to be ready + for i in $(seq 1 30); do + if curl -s http://localhost:8000/v1/algorithms > /dev/null 2>&1; then + echo "Server is ready" + break + fi + echo "Waiting for server... ($i/30)" + sleep 1 + done + + # Run all tests + cargo test || EXIT_CODE=$? + + # Stop the server + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true + + exit ${EXIT_CODE:-0} + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: services/noise-generation-service/coverage/lcov.info + flags: noise-generation-service + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + if: always() + + generate-openapi: + name: Generate OpenAPI Spec + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + - name: Cache dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: services/noise-generation-service + - name: Generate OpenAPI spec + run: cargo run -- openapi --output noise-generation-service.openapi.json + - name: Upload OpenAPI spec + uses: actions/upload-artifact@v4 + with: + name: openapi-spec-noise-generation-service + path: services/noise-generation-service/noise-generation-service.openapi.json + + build: + name: Build & Push Docker Image + runs-on: ubuntu-latest + needs: generate-openapi + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/noise-generation-service + tags: | + type=sha,prefix=sha- + type=raw,value=latest + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: services/noise-generation-service + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/README.md b/README.md index c94884c..7a53300 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![jsonpath-mapper-service CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-jsonpath-mapper-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-jsonpath-mapper-service.yml) [![sql-assessment-service CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-sql-assessment-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-sql-assessment-service.yml) [![fermentaladin-service CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-fermentaladin-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-fermentaladin-service.yml) +[![noise-generation-service CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-noise-generation-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-noise-generation-service.yml) [![llm-gateway-service CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-llm-gateway-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-llm-gateway-service.yml) [![academic-search-service CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-academic-search-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-academic-search-service.yml) @@ -17,6 +18,7 @@ | [jsonpath-mapper-service](services/jsonpath-mapper-service/README.md) | TypeScript | [![CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-jsonpath-mapper-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-jsonpath-mapper-service.yml) | [![codecov](https://codecov.io/gh/HTW-ALADIN/ALADIN-Services/graph/badge.svg?flag=jsonpath-mapper-service)](https://codecov.io/gh/HTW-ALADIN/ALADIN-Services?flags[0]=jsonpath-mapper-service) | JSONPath Mapper — JSON-to-JSON transformation utility exposed as a Fastify HTTP API | | [sql-assessment-service](services/sql-assessment-service/README.md) | TypeScript | [![CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-sql-assessment-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-sql-assessment-service.yml) | [![codecov](https://codecov.io/gh/HTW-ALADIN/ALADIN-Services/graph/badge.svg?flag=sql-assessment-service)](https://codecov.io/gh/HTW-ALADIN/ALADIN-Services?flags[0]=sql-assessment-service) | SQL Assessment — schema analysis, SQL task generation, and query grading | | [fermentaladin-service](services/fermentaladin-service/README.md) | Python | [![CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-fermentaladin-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-fermentaladin-service.yml) | — | Bioreactor fermentation simulation — ODE-based multi-phase kinetic model exposed as a FastAPI HTTP service | +| [noise-generation-service](services/noise-generation-service/README.md) | Rust | [![CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-noise-generation-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-noise-generation-service.yml) | — | Unified REST API for noise generation — 14 algorithms (Perlin, Simplex, FBM, Domain Warp, etc.) with multiple backends | | [llm-gateway-service](services/llm-gateway-service/README.md) | TypeScript | [![CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-llm-gateway-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-llm-gateway-service.yml) | [![codecov](https://codecov.io/gh/HTW-ALADIN/ALADIN-Services/graph/badge.svg?flag=llm-gateway-service)](https://codecov.io/gh/HTW-ALADIN/ALADIN-Services?flags[0]=llm-gateway-service) | HTTP/CLI wrapper around a self-hosted LLM Gateway instance, using the Vercel AI SDK | ## For Developers: diff --git a/services/noise-generation-service/.dockerignore b/services/noise-generation-service/.dockerignore new file mode 100644 index 0000000..ceed026 --- /dev/null +++ b/services/noise-generation-service/.dockerignore @@ -0,0 +1,7 @@ +/target +*.openapi.json +.git +.gitignore +docker-compose*.yml +Dockerfile +README.md diff --git a/services/noise-generation-service/.gitignore b/services/noise-generation-service/.gitignore new file mode 100644 index 0000000..5065ec6 --- /dev/null +++ b/services/noise-generation-service/.gitignore @@ -0,0 +1,2 @@ +/target/ +*.openapi.json \ No newline at end of file diff --git a/services/noise-generation-service/Cargo.lock b/services/noise-generation-service/Cargo.lock new file mode 100644 index 0000000..c8ab86f --- /dev/null +++ b/services/noise-generation-service/Cargo.lock @@ -0,0 +1,2246 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core 0.3.4", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "tokio", + "tower 0.4.13", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core 0.4.5", + "bytes", + "futures-util", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "hyper 1.10.1", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastnoise-lite" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e5f3c3cc9081e5d0e18bcd50e80cd33cba47fc22f88a9da9c33ecd1c87ea5c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http 1.4.2", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.2", + "http-body 1.1.0", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.4.2", + "http-body 1.1.0", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http 1.4.2", + "http-body 1.1.0", + "hyper 1.10.1", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "noise" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6da45c8333f2e152fc665d78a380be060eb84fad8ca4c9f7ac8ca29216cff0cc" +dependencies = [ + "num-traits", + "rand", + "rand_xorshift", +] + +[[package]] +name = "noise-generation-service" +version = "0.1.0" +dependencies = [ + "axum 0.7.9", + "clap", + "fastnoise-lite", + "noise", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "tokio", + "utoipa", + "utoipa-swagger-ui", + "uuid", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_xorshift" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25bf25ec5ae4a3f1b92f929810509a2f53d7dca2f50b794ff57e3face536c8f" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "shellexpand", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utoipa" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn 2.0.119", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "154517adf0d0b6e22e8e1f385628f14fcaa3db43531dc74303d3edef89d6dfe5" +dependencies = [ + "axum 0.6.20", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "utoipa", + "zip", +] + +[[package]] +name = "uuid" +version = "1.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/services/noise-generation-service/Cargo.toml b/services/noise-generation-service/Cargo.toml new file mode 100644 index 0000000..c59c49e --- /dev/null +++ b/services/noise-generation-service/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "noise-generation-service" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = "0.7.5" +tokio = { version = "1.36", features = ["rt-multi-thread", "macros", "net"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" +clap = { version = "4", features = ["derive"] } +noise = "0.9" +fastnoise-lite = "1.1.1" +uuid = { version = "1.10", features = ["v4"] } +utoipa = { version = "4", features = ["axum_extras"] } +utoipa-swagger-ui = { version = "4", features = ["axum"] } + +# `axum`, `tokio`, and `utoipa` are intentionally NOT re-declared here: +# `[dependencies]` entries are already available to integration tests and +# doctests, and duplicating them risked silent version drift between the two +# tables. +[dev-dependencies] +reqwest = { version = "0.11", features = ["json"] } + +[lints.clippy] +# `fill_white_noise`'s 1D loop (src/generate.rs) reads clearer as an +# explicit range loop than as an iterator chain. +needless_range_loop = "allow" diff --git a/services/noise-generation-service/Dockerfile b/services/noise-generation-service/Dockerfile new file mode 100644 index 0000000..d314ab2 --- /dev/null +++ b/services/noise-generation-service/Dockerfile @@ -0,0 +1,44 @@ +# Stage 1: Build +FROM rust:1.86-slim-bookworm AS builder + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config build-essential libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy only the dependency manifest first and build a throwaway binary +# against it, so the (slow) dependency-compilation layer is cached and only +# invalidated when Cargo.toml/Cargo.lock actually change — not on every +# source/README/test edit. +COPY Cargo.toml Cargo.lock ./ +RUN mkdir src \ + && echo "fn main() {}" > src/main.rs \ + && echo "" > src/lib.rs \ + && cargo build --release \ + && rm -rf src + +# Now copy the real source and rebuild — only this layer is invalidated by +# ordinary source changes. +COPY src ./src +RUN touch src/main.rs src/lib.rs && cargo build --release + +# Stage 2: Runtime +FROM debian:bookworm-slim + +# Install SSL libraries for runtime +RUN apt-get update && apt-get install -y --no-install-recommends libssl3 \ + && rm -rf /var/lib/apt/lists/* + +# Copy the binary from the builder +COPY --from=builder /app/target/release/noise-generation-service /usr/local/bin/noise-generation-service + +# Expose the HTTP port the server binds to by default +EXPOSE 8000 + +# Set the user +USER nobody + +# Start the service (defaults to binding 0.0.0.0:8000) +CMD ["/usr/local/bin/noise-generation-service", "server"] diff --git a/services/noise-generation-service/Makefile b/services/noise-generation-service/Makefile new file mode 100644 index 0000000..55053a1 --- /dev/null +++ b/services/noise-generation-service/Makefile @@ -0,0 +1,37 @@ +.PHONY: prep build test lint start clean docker-build generate-openapi dev prod smoke-test + +prep: + cargo fetch + +build: + cargo build --release + +test: + cargo test + +lint: + cargo fmt --check + cargo clippy --all-targets --all-features -- -D warnings + +start: + cargo run + +clean: + rm -rf target/ + +docker-build: + docker build -t noise-generation-service . + +generate-openapi: + cargo run -- openapi --output noise-generation-service.openapi.json + +# Black-box smoke test against a *running* server (not run by `make test`). +# Usage: make start & then, in another shell, make smoke-test +smoke-test: + ./tests/test_all_algorithms.sh + +dev: + docker-compose -f docker-compose.dev.yml up --build + +prod: + docker-compose -f docker-compose.prod.yml up --build diff --git a/services/noise-generation-service/README.md b/services/noise-generation-service/README.md new file mode 100644 index 0000000..0da8df6 --- /dev/null +++ b/services/noise-generation-service/README.md @@ -0,0 +1,707 @@ +# Noise Generation Service + + + +Unified REST API for multiple noise generation libraries and algorithms. + +## Features + + + +### Supported Algorithms + + + +Default libraries are automatically assigned based on the selected algorithm. + +| Algorithm | Default Library | Description | Parameters | +| --- | --- | --- | --- | +| `perlin` | `fastnoise_lite`
| Classic Perlin noise with smooth gradients + + | `seed`
| +| `simplex` | `noise_rs`
| Improved noise with lower computational complexity + + | `seed`
| +| `opensimplex2` | `fastnoise_lite`
| Modern variant with better visual quality + + | `seed`
| +| `supersimplex` | `noise_rs`
| Higher-dimensional noise variant + + | `seed`
| +| `value` | `fastnoise_lite`
| Grid-based value noise (+cubic interpolation) + + | `seed`
| +| `cellular` | `fastnoise_lite`
| Cell-based patterns (Worley/Voronoi) + + | `seed`
| +| `fbm` | `noise_rs`
| Fractal Brownian Motion (multi-octave) + + | `seed`, `octaves`, `frequency`, `lacunarity`, `persistence`
| +| `billow` | `noise_rs`
| Billow noise (absolute value of Perlin) + + | `seed`, `octaves`, `frequency`, `lacunarity`, `persistence`
| +| `ridged_multi` | `fastnoise_lite`
| Ridge-like fractal patterns + + | `seed`, `octaves`, `frequency`, `lacunarity`
| +| `hybrid_multi` | `noise_rs`
| HybridMulti fractal noise + + | `seed`, `octaves`, `frequency`, `lacunarity`
| +| `pingpong` | `fastnoise_lite`
| PingPong fractal with wrapping + + | `seed`, `strength`
| +| `domain_warp` | `fastnoise_lite`
| Domain warping transformation + + | `seed`, `amplitude`
| +| `combinator` | `noise_rs`
| Generic combinators (Add/Multiply/Min/Max/Blend) + + | `seed`, `op` (`add`/`multiply`/`min`/`max`/`blend`) + + | +| `utility` | `noise_rs`
| Utility generators (Constant/Cylinders) + + | `kind` (`constant`/`cylinders`), `value` (for constant) + + | +| `white` | `native`
| Pure random white noise + + | `seed`
| + +**Total: 15 unique algorithms with automatic library selection** + +### API Endpoints + + + +* `GET /v1/algorithms` — List all available algorithms with their default parameters + + +* `POST /v1/noise` — Generate noise field and return the full result (including data grid) + + The `sampling.size` field determines the dimensionality of the result: + + - `[width]` — 1D array + - `[width, height]` — 2D grid (nested arrays) + - `[width, height, depth]` — 3D volume (triple-nested arrays) + - `[width, height, depth, time]` — 4D hypervolume (4×-nested arrays) + + **4D support** is available for noise-rs algorithms (simplex, fbm, billow, + ridged_multi, hybrid_multi, combinator, utility) and white noise. + FNL-based algorithms (perlin, opensimplex2, value, cellular, pingpong, + domain_warp) and supersimplex are limited to 2D/3D. + White noise additionally supports 1D. + Requesting an unsupported dimension returns a 400 error with a descriptive message. + + **Response includes `params_used`**: Every successful response contains a + `params_used` field that echoes all resolved parameters (including applied + defaults and the effective seed). This makes every generated noise field + fully reproducible — even when the caller omitted optional parameters or + left the seed unset (in which case the randomly generated seed is included + in the response). + + +* `GET /api-docs/openapi.json` — OpenAPI specification + + + +--- + +### Concrete `params` Object Per Algorithm + + + +`params` is **not** an arbitrary JSON blob. Its shape depends on `algorithm`. +All fields below are optional and use server-side defaults when omitted. + +`perlin`, `simplex`, `opensimplex2`, `supersimplex`, `value`, `white` + +```json +{ "seed": 42 } + +``` + +`cellular` + +```json +{ + "seed": 42, + "distance_function": "euclidean", + "return_type": "cell_value", + "jitter": 0.45 +} + +``` + +Allowed values: + +* `distance_function`: `euclidean`, `euclidean_sq`, `manhattan`, `hybrid` + +* `return_type`: `cell_value`, `distance`, `distance2`, `distance2_add`, `distance2_sub`, `distance2_mul`, `distance2_div` + + +`fbm`, `billow` + +```json +{ + "seed": 42, + "octaves": 4, + "frequency": 0.1, + "lacunarity": 2.0, + "persistence": 0.5 +} + +``` + +`ridged_multi`, `hybrid_multi` + +```json +{ + "seed": 42, + "octaves": 4, + "frequency": 0.1, + "lacunarity": 2.0, + "persistence": 0.5 +} + +``` + +`pingpong` + +```json +{ + "seed": 42, + "strength": 2.0 +} + +``` + +`domain_warp` + +```json +{ + "seed": 42, + "amplitude": 1.0 +} + +``` + +`combinator` + +```json +{ + "seed": 42, + "op": "add", + "blend_factor": 0.5 +} + +``` + +Allowed values: + +* `op`: `add`, `multiply`, `min`, `max`, `blend` + + +`utility` + +```json +{ + "kind": "constant", + "value": 1.0 +} + +``` + +Allowed values: + +* `kind`: `constant`, `cylinders` + + +Defaults used by the service if omitted: + +* `seed`: randomly generated per request (based on current time) if omitted — not deterministic + +* `octaves`: `4` + +* `frequency`: `0.1` + +* `lacunarity`: `2.0` + +* `persistence`: `0.5` (Fbm, Billow), `0.5` (RidgedMulti), `0.25` (HybridMulti) + +* `strength`: `2.0` + +* `amplitude`: `1.0` + +* `op`: `add` + +* `kind`: `constant` + +* `value`: `1.0` + +> **Why are parameters still optional?** All fields remain optional so that the +> service stays easily callable by AI agents or quick scripts with minimal context. +> However, **reproducibility is now guaranteed** via two mechanisms: +> 1. **`POST /v1/noise` response** includes a `params_used` field that echoes all +> resolved parameter values (including the effective seed). +> 2. **`GET /v1/algorithms`** lists every algorithm together with its server-side +> default values, enabling clients to inspect defaults without reading docs. +> +> This way you get both convenience (optional fields) and full traceability +> (resolved values in the response + discoverable defaults via the API). + + +### GET /v1/algorithms Response + +The endpoint now returns an array of objects, each containing the algorithm +name and its default parameter values: + +```json +[ + { + "name": "perlin", + "defaults": { "seed": null } + }, + { + "name": "cellular", + "defaults": { + "seed": null, + "distance_function": "euclidean", + "return_type": "cell_value", + "jitter": 0.45 + } + }, + { + "name": "fbm", + "defaults": { + "seed": null, + "octaves": 4, + "frequency": 0.1, + "lacunarity": 2.0, + "persistence": 0.5 + } + }, + ... +] +``` + +> The `seed` default shows as `null` because seeds are randomly generated per +> request when omitted. The actual effective seed for a specific response is +> always included in `params_used` in the `POST /v1/noise` response. + +**CLI output (`noise-generation-service list`)**: + +- **JSON format** (`--format json`, default): Returns the same structured array + as the REST endpoint. +- **CSV format** (`--format csv`): Lists algorithm name and defaults + (as a compact JSON string in a second column). + + +--- + +### Example Requests + + + +#### List Available Algorithms + + + +```bash +curl http://localhost:8000/v1/algorithms + +``` + +#### Generate Perlin Noise + + + +```bash +curl -s -X POST http://localhost:8000/v1/noise \ + -H "Content-Type: application/json" \ + -d '{ + "algorithm": "perlin", + "params": {"seed": 42}, + "sampling": { + "size": [4, 4] + } + }' | jq . + +``` + +Response: + +```json +{ + "id": "nsf_abc123...", + "status": "completed", + "algorithm": "perlin", + "data": [[0.23, -0.45, ...], ...], + "size": [4, 4], + "params_used": { + "seed": 42 + } +} + +``` + +#### Generate Perlin Noise Without Seed (auto-generated seed in response) + +```bash +curl -s -X POST http://localhost:8000/v1/noise \ + -H "Content-Type: application/json" \ + -d '{ + "algorithm": "perlin", + "params": {}, + "sampling": { + "size": [4, 4] + } + }' | jq . + +``` + +Response (seed is auto-generated but echoed back): + +```json +{ + "id": "nsf_def456...", + "status": "completed", + "algorithm": "perlin", + "data": [[-0.12, 0.34, ...], ...], + "size": [4, 4], + "params_used": { + "seed": 3714829405 + } +} +``` + +> The `params_used` field contains the effective seed even when none was provided. +> Save this value to reproduce the identical noise field later. + +``` + +#### Generate Fractal Brownian Motion + + + +```bash +curl -X POST http://localhost:8000/v1/noise \ + -H "Content-Type: application/json" \ + -d '{ + "algorithm": "fbm", + "params": { + "seed": 123, + "octaves": 6, + "frequency": 0.1, + "lacunarity": 2.0, + "persistence": 0.5 + }, + "sampling": { + "size": [128, 128] + } + }' + +``` + +#### Generate Domain-Warped Noise + + + +```bash +curl -X POST http://localhost:8000/v1/noise \ + -H "Content-Type: application/json" \ + -d '{ + "algorithm": "domain_warp", + "params": { + "seed": 999, + "amplitude": 2.0 + }, + "sampling": { + "size": [64, 64] + } + }' + +``` + +#### Generate Combinator Noise (Add two Perlin sources) + + + +```bash +curl -X POST http://localhost:8000/v1/noise \ + -H "Content-Type: application/json" \ + -d '{ + "algorithm": "combinator", + "params": { + "seed": 555, + "op": "add" + }, + "sampling": { + "size": [32, 32] + } + }' + +``` + +#### Generate Utility Noise (Constant field) + + + +```bash +curl -X POST http://localhost:8000/v1/noise \ + -H "Content-Type: application/json" \ + -d '{ + "algorithm": "utility", + "params": { + "kind": "constant", + "value": 0.5 + }, + "sampling": { + "size": [16, 16] + } + }' + +``` + +--- + +## CLI Usage (Mirroring REST API) + + + +The service includes a CLI for local generation, API inspection, and server startup. + +### List Available Algorithms + + + +```bash +# JSON output (default) — returns structured array with name + defaults +noise-generation-service list + +# CSV output — "algorithm","defaults" columns +noise-generation-service list --format csv + +``` + +### Generate Noise Locally (all 15 algorithms) + +Parameters are passed as a single JSON object via `--params`, and grid size via +`--sampling-size` (or the deprecated alias `--size`; 1–4 comma-separated +values: `width` for 1D, `width,height` for 2D, `width,height,depth` for 3D, +`width,height,depth,time` for 4D; defaults to `64,64` if omitted). + +> **CLI-API mapping**: `--sampling-size` → `sampling.size`, +> `--output-format` → `output.format`, +> `--output-normalize` → `output.normalize`. +> `--output-file` is CLI-only (not part of the API schema). +> Deprecated aliases (`--size`, `--format`, `--normalize`) still work for +> backward compatibility. + +```bash +# Basic usage (if --params is omitted, seed is chosen at random by the service) +noise-generation-service generate --algorithm perlin --sampling-size 64,64 + +# With custom parameters (all algorithms supported) +noise-generation-service generate \ + --algorithm fbm \ + --sampling-size 128,128 \ + --params '{"seed": 42, "octaves": 6, "frequency": 0.1, "lacunarity": 2.0, "persistence": 0.5}' \ + --output-file noise_field.json + +# CSV output +noise-generation-service generate \ + --algorithm white \ + --params '{"seed": 999}' \ + --sampling-size 32,32 \ + --output-format csv \ + --output-file noise.csv + +# Combinator noise with blend operation +noise-generation-service generate \ + --algorithm combinator \ + --params '{"seed": 555, "op": "blend", "blend_factor": 0.5}' \ + --sampling-size 32,32 + +# Normalize output values to [0,1] +noise-generation-service generate --algorithm perlin --output-normalize + +# CSV output is limited to 1D/2D data; 3D/4D requests will error: +noise-generation-service generate --algorithm white --sampling-size 4,4,4 --output-format csv +# → Error: CSV output only supports 1D/2D noise fields; use --output-format json for 3D data + +### Start HTTP Server + + + +```bash +# Default (0.0.0.0:8000) +noise-generation-service server + +# Custom host/port +noise-generation-service server --host 0.0.0.0 --port 9000 + +``` + +### Generate OpenAPI Specification + + + +```bash +# Output to stdout +noise-generation-service openapi + +# Save to file +noise-generation-service openapi --output api-spec.json + +# JSON format (default) +noise-generation-service openapi --format json + +# YAML output is also supported +noise-generation-service openapi --format yaml + +``` + +### CLI Help + + + +```bash +noise-generation-service --help # All commands +noise-generation-service generate --help # Command-specific help + +``` + +--- + +## Development + + + +### Building + + + +```bash +make build + +``` + +### Testing + + + +```bash +make test + +``` + +### Running locally + + + +```bash +make start # Direct execution via cargo run +# OR +cargo run # Same as make start +# OR +cargo run -- server --port 8001 # CLI with custom port + +``` + +### Generating OpenAPI spec + + + +```bash +make generate-openapi +# OR +cargo run -- openapi --output spec.json # Via CLI + +``` + +> **Important (utoipa schema registration):** This service uses an explicit `components(schemas(...))` list in `src/lib.rs`. If you add or change nested request models (for example new `params` structs or enums used by `GenerateNoiseRequest`), you must also add those types to that schema list. Otherwise the generated OpenAPI can contain unresolved `$ref` entries. +> +> + +--- + +## Technical Details + + + +* **Language**: Rust (Edition 2021) + + +* **Web Framework**: Axum 0.7 + + +* **Noise Libraries**: `fastnoise-lite` v1.1.1, `noise` v0.9 + + +* **OpenAPI**: Auto-generated via `utoipa` v4 + + +* **Port**: 8000 (configurable) + + +* **License**: MIT + + + +--- + +## Algorithms Reference + + + +All rows use `POST /v1/noise` with the given `algorithm` tag; default libraries are selected automatically and the response includes the full noise field data grid in the `data` field. + +> **⚠️ Performance Note:** For large grids (e.g., 1024×1024 or larger), the noise generation will take longer and the response payload may become very large (several MB). Consider using smaller grid sizes for interactive use, or use the CLI's `generate` command for local generation without network overhead. A future enhancement may add HTTP streaming (e.g., `Transfer-Encoding: chunked`) for large grid responses. +> +> **📦 Caching Note:** An internal cache for previously generated noise fields is planned as a future update. This would allow re-requesting identical noise fields (same algorithm, parameters, and sampling configuration) without re-computation, improving response times for repeated or shared requests. +> +> + +### Core Noise Algorithms + + + +| `algorithm` tag | Canonical family | Default Library | Underlying function | +| --- | --- | --- | --- | +| `perlin` | Perlin noise | `fastnoise_lite` | `SetNoiseType(Perlin)` + `GetNoise(x, y[, z])` | +| `simplex` | Simplex noise (classic) | `noise_rs` | `Simplex::new(seed).get(point)` | +| `opensimplex2` | OpenSimplex2 / OpenSimplex2S | `fastnoise_lite` | `SetNoiseType(OpenSimplex2 | OpenSimplex2S)` + `GetNoise(x, y[, z])` | +| `supersimplex` | SuperSimplex | `noise_rs` | `SuperSimplex::new(seed).get(point)` | +| `value` | Value noise (+cubic) | `fastnoise_lite` | `SetNoiseType(Value | ValueCubic)` + `GetNoise(x, y[, z])` | +| `cellular` | Cellular / Worley / Voronoi | `fastnoise_lite` | `SetNoiseType(Cellular)`, `SetCellularDistanceFunction(...)`, `SetCellularReturnType(...)`, `SetCellularJitter(...)` + `GetNoise(x, y[, z])` | + +### Fractal Algorithms + + + +| `algorithm` tag | Canonical family | Default Library | Underlying function | +| --- | --- | --- | --- | +| `fbm` | Fractal Brownian Motion | `noise_rs` | `Fbm::::new(seed).set_octaves/lacunarity/persistence(...).get(point)` | +| `billow` | Billow noise | `noise_rs` | `Billow::::new(seed).set_octaves/...(...).get(point)` | +| `ridged_multi` | Ridged multifractal | `fastnoise_lite` | `SetFractalType(Ridged)`, `SetFrequency(...)`, `SetFractalOctaves(...)`, `SetFractalLacunarity(...)`, `SetFractalGain(...)` | +| `hybrid_multi` | HybridMulti fractal | `noise_rs` | `HybridMulti::::new(seed).set_octaves/...(...).get(point)` | +| `pingpong` | PingPong fractal | `fastnoise_lite` | `SetFractalType(PingPong)`, `SetFractalPingPongStrength(...)` | + +### Advanced Algorithms + + + +| `algorithm` tag | Canonical family | Default Library | Underlying function | +| --- | --- | --- | --- | +| `domain_warp` | Domain warping | `fastnoise_lite` | `SetDomainWarpType(...)`, `SetDomainWarpAmp(...)` + `DomainWarp2D/3D(x, y[, z])` | +| `combinator` | Generic combinators | `noise_rs` | `Add`/`Multiply`/`Min`/`Max`/`Blend` (selected via `op` sub-field), each wrapping two Perlin sources with seeds `seed` and `seed+1` | +| `utility` | Utility / deterministic generators | `noise_rs` | `Constant::new(value)` / `Cylinders::new()` (selected via `kind` sub-field) | + +### Native White Noise + + + +White noise requires no coherence/interpolation logic and is implemented natively rather than via an external library. Because it is inherently uncorrelated, its `params` schema is minimal (`seed` only) — each grid cell is sampled independently, so generation is trivially parallelizable. + +| `algorithm` tag | Canonical family | Underlying function | +| --- | --- | --- | +| `white` | White noise | `seeded_prng(seed, x, y, z, w).next_f32() * 2.0 - 1.0` — uncorrelated per-cell/per-point noise, no interpolation | \ No newline at end of file diff --git a/services/noise-generation-service/docker-compose.dev.yml b/services/noise-generation-service/docker-compose.dev.yml new file mode 100644 index 0000000..6f8d936 --- /dev/null +++ b/services/noise-generation-service/docker-compose.dev.yml @@ -0,0 +1,10 @@ +services: + noise-generation-service: + container_name: noise-generation-service + build: . + ports: + - "8000:8000" + environment: + - RUST_LOG=debug + stdin_open: true + tty: true diff --git a/services/noise-generation-service/docker-compose.prod.yml b/services/noise-generation-service/docker-compose.prod.yml new file mode 100644 index 0000000..a05cd57 --- /dev/null +++ b/services/noise-generation-service/docker-compose.prod.yml @@ -0,0 +1,9 @@ +services: + noise-generation-service: + container_name: noise-generation-service + build: . + ports: + - "8000:8000" + environment: + - RUST_LOG=info + restart: unless-stopped diff --git a/services/noise-generation-service/src/algorithms.rs b/services/noise-generation-service/src/algorithms.rs new file mode 100644 index 0000000..dc94d66 --- /dev/null +++ b/services/noise-generation-service/src/algorithms.rs @@ -0,0 +1,218 @@ +//! Single source of truth for the set of supported noise algorithms. +//! +//! The [`algorithms!`] macro below generates the wire-tagged +//! [`AlgorithmParams`] enum, the algorithm name lookup, and the +//! dimension-support table from one declarative list. `ALGORITHM_NAMES` and +//! `AlgorithmParams::name`/`AlgorithmParams::dim_support` are then the only +//! things any other code (HTTP handlers, the CLI, tests) needs to consult. + +use crate::dim::Dim; +use crate::model::{ + CellularParams, CombinatorParams, DomainWarpParams, FractalParams, PingPongParams, SeedParams, + UtilityParams, +}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Which sampling dimensionalities an algorithm family supports. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DimSupport { + /// 2D and 3D only (most FastNoiseLite-backed algorithms, domain warp, + /// noise-crate's `SuperSimplex`). + D2D3, + /// 2D, 3D, and 4D (noise-crate types implementing `NoiseFn`). + D2D3D4, + /// 1D, 2D, 3D, and 4D (the native white-noise hash, which has no + /// dimensionality restriction from an external library). + All, +} + +impl DimSupport { + pub fn supports(&self, dim: Dim) -> bool { + match self { + DimSupport::D2D3 => matches!(dim, Dim::D2 | Dim::D3), + DimSupport::D2D3D4 => matches!(dim, Dim::D2 | Dim::D3 | Dim::D4), + DimSupport::All => true, + } + } + + /// Human-readable reason shown in dimension-rejection error messages. + /// Only ever called after `supports()` returns `false` (see + /// `crate::service::generate`), so `DimSupport::All` — which supports + /// every dimensionality and thus never rejects — can never reach here. + pub fn reason(&self) -> &'static str { + match self { + DimSupport::D2D3 => "only 2D/3D sampling is supported", + DimSupport::D2D3D4 => "1D sampling is not supported (requires at least 2D)", + DimSupport::All => unreachable!("DimSupport::All supports every dimension"), + } + } +} + +macro_rules! algorithms { + ($(($variant:ident, $wire:literal, $params:ty, $dims:expr)),* $(,)?) => { + #[derive(Serialize, Deserialize, Debug, ToSchema)] + #[serde(tag = "algorithm", content = "params")] + pub enum AlgorithmParams { + $( + #[serde(rename = $wire)] + $variant(#[serde(default)] $params), + )* + } + + impl AlgorithmParams { + /// The wire-format name of this algorithm, e.g. `"perlin"`. + pub fn name(&self) -> &'static str { + match self { + $(AlgorithmParams::$variant(..) => $wire,)* + } + } + + /// Which sampling dimensionalities this algorithm supports. + pub fn dim_support(&self) -> DimSupport { + match self { + $(AlgorithmParams::$variant(..) => $dims,)* + } + } + } + + /// Every algorithm's wire-format name, in declaration order. This is + /// the single source used by `GET /v1/algorithms` and by CLI + /// argument validation — neither maintains its own copy of the list. + pub const ALGORITHM_NAMES: &[&str] = &[$($wire),*]; + }; +} + +algorithms! { + (Perlin, "perlin", SeedParams, DimSupport::D2D3), + (Simplex, "simplex", SeedParams, DimSupport::D2D3D4), + (OpenSimplex2, "opensimplex2", SeedParams, DimSupport::D2D3), + (SuperSimplex, "supersimplex", SeedParams, DimSupport::D2D3), + (Value, "value", SeedParams, DimSupport::D2D3), + (Cellular, "cellular", CellularParams, DimSupport::D2D3), + (Fbm, "fbm", FractalParams, DimSupport::D2D3D4), + (Billow, "billow", FractalParams, DimSupport::D2D3D4), + (RidgedMulti, "ridged_multi", FractalParams, DimSupport::D2D3D4), + (HybridMulti, "hybrid_multi", FractalParams, DimSupport::D2D3D4), + (PingPong, "pingpong", PingPongParams, DimSupport::D2D3), + (DomainWarp, "domain_warp", DomainWarpParams, DimSupport::D2D3), + (Combinator, "combinator", CombinatorParams, DimSupport::D2D3D4), + (Utility, "utility", UtilityParams, DimSupport::D2D3D4), + (White, "white", SeedParams, DimSupport::All), +} + +/// Default persistence values, one per fractal-family algorithm (all four +/// share the same [`FractalParams`] shape but differ in their default +/// `persistence`). +pub const DEFAULT_PERSISTENCE_FBM_BILLOW: f64 = 0.5; +/// Deliberately 0.5, not 1.0: callers wanting the noise-crate library +/// default of 1.0 for `ridged_multi` must pass `persistence` explicitly. +pub const DEFAULT_PERSISTENCE_RIDGED: f64 = 0.5; +/// HybridMulti combines octave amplitudes multiplicatively; a lower +/// persistence prevents signal saturation (consistent with noise-crate +/// examples). +pub const DEFAULT_PERSISTENCE_HYBRID: f64 = 0.25; + +/// Builds the `AlgorithmParams` value for `name` with all params omitted +/// (i.e. `{}`), the same way `main.rs`'s CLI `--algorithm`/`--params` +/// handling does — reusing `AlgorithmParams`'s tag/content `Deserialize` +/// impl instead of a second per-algorithm constructor. +fn default_algorithm_params(name: &str) -> Option { + serde_json::from_value(serde_json::json!({ "algorithm": name, "params": {} })).ok() +} + +/// Returns default parameter values for a given algorithm name, for display +/// via `GET /v1/algorithms`. +/// +/// This is derived from [`crate::resolve::resolve_params`] — the same +/// resolution logic used for actual generation — rather than a second, +/// independently-maintained name-keyed table, so the advertised defaults +/// can never drift from what generation actually uses. `seed` is nulled out +/// here (rather than echoing the concrete value `resolve_params` randomly +/// generates for a request with no seed) since defaults are meant to show +/// "no seed was requested", not a fabricated one. +pub fn algorithm_defaults(name: &str) -> serde_json::Value { + let Some(algorithm) = default_algorithm_params(name) else { + return serde_json::json!({}); + }; + let request = crate::model::GenerateNoiseRequest { + algorithm, + sampling: crate::model::Sampling { size: None }, + output: None, + }; + let mut defaults = crate::resolve::resolve_params(&request).to_json(); + if let Some(obj) = defaults.as_object_mut() { + if obj.contains_key("seed") { + obj.insert("seed".to_string(), serde_json::Value::Null); + } + } + defaults +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn algorithm_names_has_15_entries() { + assert_eq!(ALGORITHM_NAMES.len(), 15); + } + + #[test] + fn name_and_dim_support_are_consistent_with_wire_tag() { + let perlin = AlgorithmParams::Perlin(SeedParams::default()); + assert_eq!(perlin.name(), "perlin"); + assert_eq!(perlin.dim_support(), DimSupport::D2D3); + + let white = AlgorithmParams::White(SeedParams::default()); + assert_eq!(white.name(), "white"); + assert_eq!(white.dim_support(), DimSupport::All); + } + + #[test] + fn every_algorithm_name_has_defaults() { + for name in ALGORITHM_NAMES { + let defaults = algorithm_defaults(name); + assert!(defaults.is_object(), "{name} defaults should be an object"); + } + } + + #[test] + fn fbm_defaults_match_resolve_params_constants() { + let defaults = algorithm_defaults("fbm"); + assert!(defaults["seed"].is_null()); + assert_eq!(defaults["octaves"], crate::resolve::DEFAULT_OCTAVES); + assert_eq!(defaults["frequency"], crate::resolve::DEFAULT_FREQUENCY); + assert_eq!(defaults["lacunarity"], crate::resolve::DEFAULT_LACUNARITY); + assert_eq!(defaults["persistence"], DEFAULT_PERSISTENCE_FBM_BILLOW); + } + + #[test] + fn ridged_multi_and_hybrid_multi_defaults_have_distinct_persistence() { + assert_eq!( + algorithm_defaults("ridged_multi")["persistence"], + DEFAULT_PERSISTENCE_RIDGED + ); + assert_eq!( + algorithm_defaults("hybrid_multi")["persistence"], + DEFAULT_PERSISTENCE_HYBRID + ); + } + + #[test] + fn utility_defaults_have_no_seed_field() { + // Utility noise is deterministic and takes no seed, unlike every + // other algorithm family. + let defaults = algorithm_defaults("utility"); + assert!(defaults.get("seed").is_none()); + assert_eq!(defaults["kind"], "constant"); + } + + #[test] + fn unknown_algorithm_name_returns_empty_defaults() { + assert_eq!( + algorithm_defaults("not-a-real-algorithm"), + serde_json::json!({}) + ); + } +} diff --git a/services/noise-generation-service/src/cli.rs b/services/noise-generation-service/src/cli.rs new file mode 100644 index 0000000..99c4499 --- /dev/null +++ b/services/noise-generation-service/src/cli.rs @@ -0,0 +1,213 @@ +//! `clap` CLI argument definitions. +//! +//! `--algorithm` is validated at parse time against +//! `crate::algorithms::ALGORITHM_NAMES` (the same table the HTTP API and +//! `GET /v1/algorithms` use) and stored as the wire-name `String` directly, +//! so `main.rs` can hand it straight to `serde_json` to build an +//! `AlgorithmParams` value. + +use clap::{Args, Parser, Subcommand, ValueEnum}; +use serde_json::Value; +use std::fmt; +use std::path::PathBuf; + +use crate::algorithms::ALGORITHM_NAMES; + +/// Parses and validates an `--algorithm` value against `ALGORITHM_NAMES`, +/// producing a clap-friendly error listing the valid options on failure. +/// +/// Also accepts kebab-case spellings of multi-word algorithm names (e.g. +/// `ridged-multi`, `open-simplex2`, `ping-pong`), which differ from the +/// wire-format snake_case names used everywhere else (`ridged_multi`, +/// `opensimplex2`, `pingpong`). Matching against the input with hyphens +/// both converted to underscores *and* stripped entirely covers every +/// accepted spelling (`ridged-multi` -> `ridged_multi`, `ping-pong` -> +/// `pingpong`, `open-simplex2` -> `opensimplex2`) without hardcoding a +/// per-name alias table. +fn parse_algorithm(s: &str) -> Result { + let underscored = s.replace('-', "_"); + let collapsed = s.replace('-', ""); + if let Some(name) = ALGORITHM_NAMES + .iter() + .find(|n| **n == s || **n == underscored || **n == collapsed) + { + Ok(name.to_string()) + } else { + Err(format!( + "invalid algorithm '{s}' (expected one of: {})", + ALGORITHM_NAMES.join(", ") + )) + } +} + +// ─── Format Enums ───────────────────────────────────────────────────────────── + +/// Output format shared by the `list` and `generate` CLI commands. This is a +/// CLI-only rendering choice — unrelated to `model::OutputFormat`, which +/// describes the HTTP API's (JSON-only) response format. +#[derive(ValueEnum, Clone, Debug, PartialEq)] +pub enum Format { + Json, + Csv, +} + +impl fmt::Display for Format { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Format::Json => write!(f, "json"), + Format::Csv => write!(f, "csv"), + } + } +} + +#[derive(ValueEnum, Clone, Debug, PartialEq)] +pub enum OpenApiFormat { + Json, + Yaml, +} + +impl fmt::Display for OpenApiFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OpenApiFormat::Json => write!(f, "json"), + OpenApiFormat::Yaml => write!(f, "yaml"), + } + } +} + +// ─── Custom Parsers ─────────────────────────────────────────────────────────── + +fn parse_params_json(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| format!("invalid JSON params: {}", e)) +} + +// ─── CLI Structure ──────────────────────────────────────────────────────────── + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +#[command(name = "noise-generation-service")] +#[command(about = "Unified CLI for noise generation algorithms")] +pub struct Cli { + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand, Debug, PartialEq)] +pub enum Commands { + /// List all available algorithms + #[command(alias = "ls")] + List { + /// Output format: json (default) or csv + #[arg(short, long, default_value = "json")] + format: Format, + }, + + /// Generate noise using specified algorithm (local) + #[command(alias = "gen")] + Generate(GenerateArgs), + + /// Start the HTTP server + #[command(alias = "serve")] + Server { + /// Port to listen on + #[arg(short, long, default_value = "8000")] + port: u16, + + /// Host to bind to + #[arg(long, default_value = "0.0.0.0")] + host: String, + }, + + /// Generate OpenAPI specification + #[command(name = "openapi")] + OpenApi { + /// Output format (json, yaml) + #[arg(short, long, default_value = "json")] + format: OpenApiFormat, + + /// Output file (stdout if not specified) + #[arg(short, long)] + output: Option, + }, +} + +#[derive(Args, Debug, PartialEq, Clone)] +pub struct GenerateArgs { + /// Algorithm to use (see `list` for the full set of valid names) + #[arg(short, long, value_parser = parse_algorithm)] + pub algorithm: String, + + /// Size of noise field (comma-separated, e.g. "64,64" or "64" for square). + /// Maps to `sampling.size` in the API. + /// + /// `default_value` must be a string literal (clap's attribute macro + /// can't reference `crate::limits::DEFAULT_SAMPLING_SIZE` directly), so + /// this is pinned against that constant by + /// `tests/cli_tests.rs::test_cli_parse_generate` instead — update both + /// if the default ever changes. + #[arg( + short = 's', long = "sampling-size", + default_value = "64,64", value_delimiter = ',', num_args = 1..=4, + visible_alias = "size" + )] + pub sampling_size: Vec, + + /// Output format: json (default) or csv. Maps to `output.format` in the API. + #[arg( + short = 'f', + long = "output-format", + default_value = "json", + visible_alias = "format" + )] + pub output_format: Format, + + /// Output file (stdout if not specified). CLI-only, not part of the API schema. + #[arg(short = 'o', long = "output-file", visible_alias = "output")] + pub output_file: Option, + + /// Algorithm parameters as JSON object (e.g. '{"seed": 42}') + #[arg(long, default_value = "{}", value_parser = parse_params_json)] + pub params: serde_json::Value, + + /// Normalize output values to [0,1] range. Maps to `output.normalize` in the API. + #[arg(long = "output-normalize", visible_alias = "normalize")] + pub output_normalize: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_unknown_algorithm_name() { + assert!(parse_algorithm("not-a-real-algorithm").is_err()); + } + + #[test] + fn accepts_every_known_algorithm_name() { + for name in ALGORITHM_NAMES { + assert_eq!(parse_algorithm(name), Ok(name.to_string())); + } + } + + /// Kebab-case spellings of multi-word algorithm names must keep working + /// so existing scripts/CLI invocations don't silently break. + #[test] + fn accepts_legacy_kebab_case_aliases() { + let cases = [ + ("open-simplex2", "opensimplex2"), + ("super-simplex", "supersimplex"), + ("ridged-multi", "ridged_multi"), + ("hybrid-multi", "hybrid_multi"), + ("ping-pong", "pingpong"), + ("domain-warp", "domain_warp"), + ]; + for (legacy, canonical) in cases { + assert_eq!( + parse_algorithm(legacy), + Ok(canonical.to_string()), + "legacy alias '{legacy}' should map to '{canonical}'" + ); + } + } +} diff --git a/services/noise-generation-service/src/dim.rs b/services/noise-generation-service/src/dim.rs new file mode 100644 index 0000000..7bd4e41 --- /dev/null +++ b/services/noise-generation-service/src/dim.rs @@ -0,0 +1,247 @@ +//! Dimensionality of a sampling grid, and shared per-cell iteration helpers. +//! +//! `Dim` makes the set of supported dimensionalities a compile-time-checked +//! enum, so every generation kernel can match on it exhaustively instead of +//! branching on a `mode: &str` value. + +use std::fmt; + +/// The coordinate scaling factor applied to noise-crate/FastNoiseLite sample +/// positions. Named so every generation kernel shares one definition instead +/// of repeating the bare literal `0.1` at each call site. +pub const COORD_STEP: f64 = 0.1; + +/// Supported sampling dimensionalities (1D-4D). 5D+ is rejected by every +/// algorithm and is therefore not representable by this type. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Dim { + D1, + D2, + D3, + D4, +} + +impl Dim { + /// Maps a `sampling.size` length to a `Dim`, or `None` for unsupported + /// (0 or 5+) lengths. + pub fn from_len(len: usize) -> Option { + match len { + 1 => Some(Dim::D1), + 2 => Some(Dim::D2), + 3 => Some(Dim::D3), + 4 => Some(Dim::D4), + _ => None, + } + } + + pub fn as_usize(&self) -> usize { + match self { + Dim::D1 => 1, + Dim::D2 => 2, + Dim::D3 => 3, + Dim::D4 => 4, + } + } +} + +impl fmt::Display for Dim { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}D", self.as_usize()) + } +} + +/// Row-major cell iteration for a 2D grid: yields `(flat_index, [x, y])`. +/// +/// Implemented as an explicit `Iterator` (incrementing plain coordinate/index +/// counters) rather than a nested `flat_map`/`map` chain: the flat_map form +/// recomputes each flat index from scratch per cell (e.g. `y * w + x`), +/// which is measurably slower than a running increment for the cheaper +/// kernels (white noise, `Utility::Constant`) where index bookkeeping is a +/// larger fraction of the per-cell cost. +pub fn iter_2d(size: &[usize]) -> Cells2D { + Cells2D { + w: size[0], + h: size[1], + x: 0, + y: 0, + idx: 0, + } +} + +pub struct Cells2D { + w: usize, + h: usize, + x: usize, + y: usize, + idx: usize, +} + +impl Iterator for Cells2D { + type Item = (usize, [usize; 2]); + + fn next(&mut self) -> Option { + if self.y >= self.h { + return None; + } + let item = (self.idx, [self.x, self.y]); + self.idx += 1; + self.x += 1; + if self.x >= self.w { + self.x = 0; + self.y += 1; + } + Some(item) + } +} + +/// Row-major cell iteration for a 3D grid: yields `(flat_index, [x, y, z])`. +pub fn iter_3d(size: &[usize]) -> Cells3D { + Cells3D { + w: size[0], + h: size[1], + d: size[2], + x: 0, + y: 0, + z: 0, + idx: 0, + } +} + +pub struct Cells3D { + w: usize, + h: usize, + d: usize, + x: usize, + y: usize, + z: usize, + idx: usize, +} + +impl Iterator for Cells3D { + type Item = (usize, [usize; 3]); + + fn next(&mut self) -> Option { + if self.z >= self.d { + return None; + } + let item = (self.idx, [self.x, self.y, self.z]); + self.idx += 1; + self.x += 1; + if self.x >= self.w { + self.x = 0; + self.y += 1; + if self.y >= self.h { + self.y = 0; + self.z += 1; + } + } + Some(item) + } +} + +/// Row-major cell iteration for a 4D grid: yields `(flat_index, [x, y, z, w])`. +pub fn iter_4d(size: &[usize]) -> Cells4D { + Cells4D { + w: size[0], + h: size[1], + d: size[2], + t: size[3], + x: 0, + y: 0, + z: 0, + w4: 0, + idx: 0, + } +} + +pub struct Cells4D { + w: usize, + h: usize, + d: usize, + t: usize, + x: usize, + y: usize, + z: usize, + w4: usize, + idx: usize, +} + +impl Iterator for Cells4D { + type Item = (usize, [usize; 4]); + + fn next(&mut self) -> Option { + if self.w4 >= self.t { + return None; + } + let item = (self.idx, [self.x, self.y, self.z, self.w4]); + self.idx += 1; + self.x += 1; + if self.x >= self.w { + self.x = 0; + self.y += 1; + if self.y >= self.h { + self.y = 0; + self.z += 1; + if self.z >= self.d { + self.z = 0; + self.w4 += 1; + } + } + } + Some(item) + } +} + +/// Converts an integer grid coordinate into the scaled `f64` coordinate used +/// by noise-crate/FastNoiseLite sample positions. +pub fn scaled(coord: usize) -> f64 { + coord as f64 * COORD_STEP +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_len_maps_1_to_4() { + assert_eq!(Dim::from_len(1), Some(Dim::D1)); + assert_eq!(Dim::from_len(2), Some(Dim::D2)); + assert_eq!(Dim::from_len(3), Some(Dim::D3)); + assert_eq!(Dim::from_len(4), Some(Dim::D4)); + assert_eq!(Dim::from_len(0), None); + assert_eq!(Dim::from_len(5), None); + } + + #[test] + fn iter_2d_row_major_order() { + let size = [2usize, 3usize]; + let cells: Vec<(usize, [usize; 2])> = iter_2d(&size).collect(); + assert_eq!(cells.len(), 6); + assert_eq!(cells[0], (0, [0, 0])); + assert_eq!(cells[1], (1, [1, 0])); + assert_eq!(cells[2], (2, [0, 1])); + assert_eq!(cells[5], (5, [1, 2])); + } + + #[test] + fn iter_3d_covers_every_cell_exactly_once() { + let size = [2usize, 2usize, 2usize]; + let mut seen = vec![false; 8]; + for (idx, _coords) in iter_3d(&size) { + assert!(!seen[idx], "index {idx} visited twice"); + seen[idx] = true; + } + assert!(seen.into_iter().all(|v| v)); + } + + #[test] + fn iter_4d_covers_every_cell_exactly_once() { + let size = [2usize, 2usize, 2usize, 2usize]; + let mut seen = vec![false; 16]; + for (idx, _coords) in iter_4d(&size) { + assert!(!seen[idx], "index {idx} visited twice"); + seen[idx] = true; + } + assert!(seen.into_iter().all(|v| v)); + } +} diff --git a/services/noise-generation-service/src/error.rs b/services/noise-generation-service/src/error.rs new file mode 100644 index 0000000..afe1718 --- /dev/null +++ b/services/noise-generation-service/src/error.rs @@ -0,0 +1,82 @@ +//! Structured errors for noise-field generation. +//! +//! `NoiseError` centralizes the failure cases; the HTTP handler +//! (`crate::http`) is the only place that turns one into the response body, +//! and the CLI can match on it directly instead of parsing a string. + +use axum::http::StatusCode; + +use crate::dim::Dim; +use crate::model::NoiseFieldResult; + +#[derive(Debug, Clone, PartialEq)] +pub enum NoiseError { + /// `sampling.size` has a length that maps to no supported dimensionality + /// (0 or 5+ entries). + UnsupportedSizeLength { len: usize }, + /// The algorithm does not support the requested (but otherwise valid) + /// dimensionality. + UnsupportedDimension { + algorithm: &'static str, + dim: Dim, + reason: &'static str, + }, + /// `output.format: csv` was requested against the HTTP API, which only + /// ever returns JSON (CSV rendering is CLI-only). + UnsupportedCsv, + /// A sampling dimension is zero or exceeds `MAX_SAMPLING_DIM`. + DimensionOutOfRange { size: Vec, max: usize }, + /// The total cell count (product of all dimensions) exceeds + /// `MAX_SAMPLING_CELLS`. + TooManyCells { size: Vec, max: usize }, +} + +impl NoiseError { + /// All current failure cases map to 400 Bad Request — they are all + /// client input problems — but centralizing the mapping here means a + /// future error variant (e.g. an internal generation failure) can pick a + /// different code without touching every call site. + pub fn status_code(&self) -> StatusCode { + StatusCode::BAD_REQUEST + } + + /// The human-readable description of this error, without the `"error: "` + /// prefix used in the HTTP response body's `status` field. + pub fn message(&self) -> String { + match self { + NoiseError::UnsupportedSizeLength { len } => { + format!("sampling.size has {len} dimensions; only 1D-4D are supported") + } + NoiseError::UnsupportedDimension { + algorithm, + dim, + reason, + } => format!("algorithm '{algorithm}' does not support {dim} sampling ({reason})"), + NoiseError::UnsupportedCsv => { + "output.format 'csv' is not supported by POST /v1/noise; use the CLI's \ + --output-format csv, or omit output.format for JSON" + .to_string() + } + NoiseError::DimensionOutOfRange { size, max } => { + format!("each sampling dimension must be between 1 and {max} (got {size:?})") + } + NoiseError::TooManyCells { size, max } => { + format!("requested sampling size {size:?} exceeds the maximum of {max} total cells") + } + } + } + + /// Renders this error as the same `NoiseFieldResult` shape historically + /// returned on failure: `status` is `"error: "`, `data` and + /// `params_used` are `null`. + pub fn into_result(self, id: String, algorithm: String, size: Vec) -> NoiseFieldResult { + NoiseFieldResult { + id, + status: format!("error: {}", self.message()), + algorithm, + data: serde_json::Value::Null, + size, + params_used: serde_json::Value::Null, + } + } +} diff --git a/services/noise-generation-service/src/generate.rs b/services/noise-generation-service/src/generate.rs new file mode 100644 index 0000000..8f77098 --- /dev/null +++ b/services/noise-generation-service/src/generate.rs @@ -0,0 +1,476 @@ +//! Per-algorithm noise generation into a flat `Vec` buffer. +//! +//! `size` is the grid extent: `[width]` for 1D, `[width, height]` for 2D, +//! `[width, height, depth]` for 3D, `[width, height, depth, w]` for 4D. Cell +//! iteration order matches `crate::shape::shape_data`, which reshapes the +//! same flat buffer into nested JSON. + +use fastnoise_lite::FastNoiseLite; +use noise::{ + Add, Blend, Constant, Cylinders, HybridMulti, Max, Min, MultiFractal, Multiply, NoiseFn, + Perlin, Seedable, Simplex, SuperSimplex, +}; + +use crate::algorithms::AlgorithmParams; +use crate::dim::{iter_2d, iter_3d, iter_4d, scaled, Dim}; +use crate::model::{ + CellularDistanceFunction, CellularReturnType, CombinatorOp, GenerateNoiseRequest, UtilityKind, +}; +use crate::resolve::ResolvedNoiseParams; + +/// Evaluates a per-cell closure over every 2D/3D/4D cell, using the shared +/// `crate::dim` iterators for indexing (so the row-major flattening math +/// lives in exactly one place: `crate::dim`). The coordinate-array +/// construction is still repeated once per dimensionality because `noise`'s +/// `NoiseFn` trait is generic over a const array length that Rust cannot +/// infer from a single closure shared across 2/3/4 element arrays — but no +/// nested-loop or index-arithmetic duplication remains here. Shared by +/// Combinator and Utility, which both call into `noise`-crate `NoiseFn` +/// sources with no other algorithm-specific setup. +macro_rules! dispatch_cells { + ($flat:expr, $size:expr, $dim:expr, |$pos:ident| $body:expr) => { + match $dim { + Dim::D2 => { + for (idx, [x, y]) in iter_2d($size) { + let $pos: [f64; 2] = [scaled(x), scaled(y)]; + $flat[idx] = $body; + } + } + Dim::D3 => { + for (idx, [x, y, z]) in iter_3d($size) { + let $pos: [f64; 3] = [scaled(x), scaled(y), scaled(z)]; + $flat[idx] = $body; + } + } + Dim::D4 => { + for (idx, [x, y, z, w]) in iter_4d($size) { + let $pos: [f64; 4] = [scaled(x), scaled(y), scaled(z), scaled(w)]; + $flat[idx] = $body; + } + } + Dim::D1 => { + unreachable!("dimension already validated: Combinator/Utility require 2D-4D") + } + } + }; +} + +/// Generates noise into a flat `Vec` according to the requested +/// dimensionality. `resolved` contains the already-resolved parameters +/// (seed etc.) — this is the *only* place parameter resolution happens for +/// generation, ensuring consistency with `params_used`. +pub fn generate_flat( + flat: &mut [f64], + payload: &GenerateNoiseRequest, + resolved: &ResolvedNoiseParams, + size: &[usize], + dim: Dim, +) { + match (&payload.algorithm, resolved) { + // ─── fastnoise-lite seed-only algorithms (2D/3D) ─────────────────── + (AlgorithmParams::Perlin(..), ResolvedNoiseParams::SeedOnly { seed }) => { + fill_fnl_seed_only(flat, size, dim, *seed, fastnoise_lite::NoiseType::Perlin); + } + (AlgorithmParams::OpenSimplex2(..), ResolvedNoiseParams::SeedOnly { seed }) => { + fill_fnl_seed_only( + flat, + size, + dim, + *seed, + fastnoise_lite::NoiseType::OpenSimplex2, + ); + } + (AlgorithmParams::Value(..), ResolvedNoiseParams::SeedOnly { seed }) => { + fill_fnl_seed_only(flat, size, dim, *seed, fastnoise_lite::NoiseType::Value); + } + ( + AlgorithmParams::Cellular(..), + ResolvedNoiseParams::Cellular { + seed, + distance_function, + return_type, + jitter, + }, + ) => { + let mut noise = FastNoiseLite::with_seed(*seed as i32); + noise.set_noise_type(Some(fastnoise_lite::NoiseType::Cellular)); + noise.set_cellular_distance_function(Some(distance_function.into())); + noise.set_cellular_return_type(Some(return_type.into())); + noise.set_cellular_jitter(Some(*jitter as f32)); + fill_fnl(flat, size, dim, &noise); + } + // ─── PingPong: fastnoise-lite fractal ────────────────────────────── + (AlgorithmParams::PingPong(..), ResolvedNoiseParams::PingPong { seed, strength }) => { + let mut noise = FastNoiseLite::with_seed(*seed as i32); + noise.set_fractal_type(Some(fastnoise_lite::FractalType::PingPong)); + noise.set_fractal_ping_pong_strength(Some(*strength as f32)); + noise.set_noise_type(Some(fastnoise_lite::NoiseType::Perlin)); + fill_fnl(flat, size, dim, &noise); + } + // ─── DomainWarp: fastnoise-lite domain warp ──────────────────────── + (AlgorithmParams::DomainWarp(..), ResolvedNoiseParams::DomainWarp { seed, amplitude }) => { + fill_domain_warp(flat, size, dim, *seed as i32, *amplitude); + } + // ─── noise-crate algorithms (2D/3D/4D) ───────────────────────────── + (AlgorithmParams::Simplex(..), ResolvedNoiseParams::SeedOnly { seed }) => { + let simplex = Simplex::new(*seed); + fill_noise_rs_4d::(flat, size, dim, &simplex); + } + (AlgorithmParams::SuperSimplex(..), ResolvedNoiseParams::SeedOnly { seed }) => { + let s = SuperSimplex::new(*seed); + fill_noise_rs::(flat, size, dim, &s); + } + // ─── Fractal family (Fbm, Billow, RidgedMulti, HybridMulti) ──────── + ( + AlgorithmParams::Fbm(..), + ResolvedNoiseParams::Fractal { + seed, + octaves, + frequency, + lacunarity, + persistence, + }, + ) => fill_fractal::>( + flat, + size, + dim, + *seed, + *octaves, + *frequency, + *lacunarity, + *persistence, + ), + ( + AlgorithmParams::Billow(..), + ResolvedNoiseParams::Fractal { + seed, + octaves, + frequency, + lacunarity, + persistence, + }, + ) => fill_fractal::>( + flat, + size, + dim, + *seed, + *octaves, + *frequency, + *lacunarity, + *persistence, + ), + ( + AlgorithmParams::RidgedMulti(..), + ResolvedNoiseParams::Fractal { + seed, + octaves, + frequency, + lacunarity, + persistence, + }, + ) => fill_fractal::>( + flat, + size, + dim, + *seed, + *octaves, + *frequency, + *lacunarity, + *persistence, + ), + ( + AlgorithmParams::HybridMulti(..), + ResolvedNoiseParams::Fractal { + seed, + octaves, + frequency, + lacunarity, + persistence, + }, + ) => fill_fractal::>( + flat, + size, + dim, + *seed, + *octaves, + *frequency, + *lacunarity, + *persistence, + ), + // ─── Combinator ───────────────────────────────────────────────────── + ( + AlgorithmParams::Combinator(..), + ResolvedNoiseParams::Combinator { + seed, + op, + blend_factor, + }, + ) => { + let source1 = Perlin::new(*seed); + let source2 = Perlin::new(seed.wrapping_add(1)); + let bf = *blend_factor; + dispatch_cells!(flat, size, dim, |pos| match op { + CombinatorOp::Add => Add::new(source1, source2).get(pos), + CombinatorOp::Multiply => Multiply::new(source1, source2).get(pos), + CombinatorOp::Min => Min::new(source1, source2).get(pos), + CombinatorOp::Max => Max::new(source1, source2).get(pos), + CombinatorOp::Blend => Blend::new(source1, source2, Constant::new(bf)).get(pos), + }); + } + // ─── Utility ──────────────────────────────────────────────────────── + (AlgorithmParams::Utility(..), ResolvedNoiseParams::Utility { kind, value }) => { + let val = *value; + dispatch_cells!(flat, size, dim, |pos| match kind { + UtilityKind::Constant => Constant::new(val).get(pos), + UtilityKind::Cylinders => Cylinders::new().get(pos), + }); + } + // ─── White noise ──────────────────────────────────────────────────── + (AlgorithmParams::White(..), ResolvedNoiseParams::SeedOnly { seed }) => { + fill_white_noise(flat, size, dim, *seed as u64); + } + // Safety: payload variant always matches resolved variant — enforced + // by `resolve_params` producing exactly one `ResolvedNoiseParams` + // shape per `AlgorithmParams` variant. + _ => unreachable!("payload/resolved type mismatch — this is a programming error"), + } +} + +impl From<&CellularDistanceFunction> for fastnoise_lite::CellularDistanceFunction { + fn from(value: &CellularDistanceFunction) -> Self { + match value { + CellularDistanceFunction::Euclidean => Self::Euclidean, + CellularDistanceFunction::EuclideanSq => Self::EuclideanSq, + CellularDistanceFunction::Manhattan => Self::Manhattan, + CellularDistanceFunction::Hybrid => Self::Hybrid, + } + } +} + +impl From<&CellularReturnType> for fastnoise_lite::CellularReturnType { + fn from(value: &CellularReturnType) -> Self { + match value { + CellularReturnType::CellValue => Self::CellValue, + CellularReturnType::Distance => Self::Distance, + CellularReturnType::Distance2 => Self::Distance2, + CellularReturnType::Distance2Add => Self::Distance2Add, + CellularReturnType::Distance2Sub => Self::Distance2Sub, + CellularReturnType::Distance2Mul => Self::Distance2Mul, + CellularReturnType::Distance2Div => Self::Distance2Div, + } + } +} + +/// Wraps filler for noise-rs fractal types (Fbm, Billow, RidgedMulti, HybridMulti). +#[allow(clippy::too_many_arguments)] +fn fill_fractal( + flat: &mut [f64], + size: &[usize], + dim: Dim, + seed: u32, + octaves: usize, + frequency: f64, + lacunarity: f64, + persistence: f64, +) where + T: NoiseFn + NoiseFn + NoiseFn + MultiFractal + Seedable + Default, +{ + let n = T::default() + .set_seed(seed) + .set_octaves(octaves) + .set_frequency(frequency) + .set_lacunarity(lacunarity) + .set_persistence(persistence); + fill_noise_rs_4d::(flat, size, dim, &n); +} + +/// Wraps fill_fnl for seed-only FNL algorithms (Perlin, OpenSimplex2, Value). +fn fill_fnl_seed_only( + flat: &mut [f64], + size: &[usize], + dim: Dim, + seed: u32, + noise_type: fastnoise_lite::NoiseType, +) { + let mut noise = FastNoiseLite::with_seed(seed as i32); + noise.set_noise_type(Some(noise_type)); + fill_fnl(flat, size, dim, &noise); +} + +/// Fills `flat` using fastnoise-lite (2D or 3D only — fastnoise-lite has no +/// 1D or 4D sampling API). +fn fill_fnl(flat: &mut [f64], size: &[usize], dim: Dim, noise: &FastNoiseLite) { + match dim { + Dim::D2 => { + for (idx, [x, y]) in iter_2d(size) { + flat[idx] = noise.get_noise_2d(x as f32, y as f32) as f64; + } + } + Dim::D3 => { + for (idx, [x, y, z]) in iter_3d(size) { + flat[idx] = noise.get_noise_3d(x as f32, y as f32, z as f32) as f64; + } + } + Dim::D1 | Dim::D4 => { + unreachable!("dimension already validated: fastnoise-lite is 2D/3D only") + } + } +} + +/// Fills `flat` using a noise-crate `NoiseFn` source (2D or 3D only). +/// Used by `SuperSimplex`, which does not implement `NoiseFn`. +fn fill_noise_rs(flat: &mut [f64], size: &[usize], dim: Dim, noise: &T) +where + T: NoiseFn + NoiseFn, +{ + match dim { + Dim::D2 => { + for (idx, [x, y]) in iter_2d(size) { + flat[idx] = noise.get([scaled(x), scaled(y)]); + } + } + Dim::D3 => { + for (idx, [x, y, z]) in iter_3d(size) { + flat[idx] = noise.get([scaled(x), scaled(y), scaled(z)]); + } + } + Dim::D1 | Dim::D4 => { + unreachable!("dimension already validated: SuperSimplex is 2D/3D only") + } + } +} + +/// Fills `flat` using a noise-crate `NoiseFn` source (2D, 3D, or 4D). +/// Used by most noise-rs generators, which implement `NoiseFn` for all three. +fn fill_noise_rs_4d(flat: &mut [f64], size: &[usize], dim: Dim, noise: &T) +where + T: NoiseFn + NoiseFn + NoiseFn, +{ + match dim { + Dim::D2 => { + for (idx, [x, y]) in iter_2d(size) { + flat[idx] = noise.get([scaled(x), scaled(y)]); + } + } + Dim::D3 => { + for (idx, [x, y, z]) in iter_3d(size) { + flat[idx] = noise.get([scaled(x), scaled(y), scaled(z)]); + } + } + Dim::D4 => { + for (idx, [x, y, z, w]) in iter_4d(size) { + flat[idx] = noise.get([scaled(x), scaled(y), scaled(z), scaled(w)]); + } + } + Dim::D1 => { + unreachable!("dimension already validated: noise-crate types require at least 2D") + } + } +} + +/// Fills `flat` using fastnoise-lite domain warping (2D or 3D only). +fn fill_domain_warp(flat: &mut [f64], size: &[usize], dim: Dim, seed: i32, amplitude: f64) { + let mut warp = FastNoiseLite::with_seed(seed); + warp.set_domain_warp_type(Some(fastnoise_lite::DomainWarpType::OpenSimplex2)); + warp.set_domain_warp_amp(Some(amplitude as f32)); + // `base` depends only on `seed`, not on the loop position — build it once + // outside the loop instead of once per cell. + let mut base = FastNoiseLite::with_seed(seed.wrapping_add(1)); + base.set_noise_type(Some(fastnoise_lite::NoiseType::Perlin)); + + match dim { + Dim::D2 => { + for (idx, [x, y]) in iter_2d(size) { + let (wx, wy) = warp.domain_warp_2d(x as f32, y as f32); + flat[idx] = base.get_noise_2d(wx, wy) as f64; + } + } + Dim::D3 => { + for (idx, [x, y, z]) in iter_3d(size) { + let (wx, wy, wz) = warp.domain_warp_3d(x as f32, y as f32, z as f32); + flat[idx] = base.get_noise_3d(wx, wy, wz) as f64; + } + } + Dim::D1 | Dim::D4 => unreachable!("dimension already validated: domain warp is 2D/3D only"), + } +} + +// ─── White noise ──────────────────────────────────────────────────────────── + +/// PCG-style hash constants. `MUL`/`INC` are the standard PCG32 LCG +/// multiplier/increment; `AVALANCHE` and the per-axis mixing constants are +/// arbitrary large odd numbers chosen to decorrelate each coordinate axis +/// before the final hash — their exact values don't matter for correctness, +/// only that they differ per axis and are odd (for good bit mixing). +const PCG_MUL: u64 = 6364136223846793005; +const PCG_INC: u64 = 1442695040888963407; +const AVALANCHE_MUL: u64 = 12741261754838537793; +const AXIS_MUL: [u64; 4] = [374761393, 668265263, 941568331, 1221221227]; + +/// Uncorrelated per-cell hash noise: each cell's value depends only on the +/// seed and its own integer coordinates, with no interpolation between +/// neighbors (unlike every other algorithm in this service). Supports 1D-4D +/// natively since it has no external-library dimensionality restriction. +fn white_noise(seed: u64, coords: &[usize]) -> f64 { + let mut state = seed.wrapping_mul(PCG_MUL).wrapping_add(PCG_INC); + for (axis, &c) in coords.iter().enumerate() { + state ^= (c as u64).wrapping_mul(AXIS_MUL[axis]); + } + state = state.wrapping_mul(AVALANCHE_MUL); + let hash = state ^ (state >> 31); + (hash as f64 / u64::MAX as f64) * 2.0 - 1.0 +} + +fn fill_white_noise(flat: &mut [f64], size: &[usize], dim: Dim, seed: u64) { + match dim { + Dim::D1 => { + for x in 0..size[0] { + flat[x] = white_noise(seed, &[x]); + } + } + Dim::D2 => { + for (idx, [x, y]) in iter_2d(size) { + flat[idx] = white_noise(seed, &[x, y]); + } + } + Dim::D3 => { + for (idx, [x, y, z]) in iter_3d(size) { + flat[idx] = white_noise(seed, &[x, y, z]); + } + } + Dim::D4 => { + for (idx, [x, y, z, w]) in iter_4d(size) { + flat[idx] = white_noise(seed, &[x, y, z, w]); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn white_noise_is_deterministic_for_same_seed_and_coords() { + assert_eq!(white_noise(42, &[1, 2]), white_noise(42, &[1, 2])); + } + + #[test] + fn white_noise_differs_across_coordinates() { + assert_ne!(white_noise(42, &[1, 2]), white_noise(42, &[1, 3])); + assert_ne!(white_noise(42, &[1, 2]), white_noise(42, &[2, 2])); + } + + #[test] + fn white_noise_differs_across_seeds() { + assert_ne!(white_noise(1, &[1, 2]), white_noise(2, &[1, 2])); + } + + #[test] + fn white_noise_is_bounded_to_unit_range() { + for x in 0..50 { + let v = white_noise(7, &[x, x * 3]); + assert!((-1.0..=1.0).contains(&v), "value {v} out of range"); + } + } +} diff --git a/services/noise-generation-service/src/http.rs b/services/noise-generation-service/src/http.rs new file mode 100644 index 0000000..b35c4e1 --- /dev/null +++ b/services/noise-generation-service/src/http.rs @@ -0,0 +1,78 @@ +//! Axum HTTP handlers. Both handlers are thin adapters over +//! `crate::algorithms`/`crate::service`; no domain logic lives here. + +use axum::{http::StatusCode, Json}; + +use crate::algorithms::{algorithm_defaults, ALGORITHM_NAMES}; +use crate::limits::DEFAULT_SAMPLING_SIZE; +use crate::model::{AlgorithmInfo, GenerateNoiseRequest, NoiseFieldResult}; +use crate::service; + +#[utoipa::path( + get, + path = "/v1/algorithms", + tag = "noise", + responses( + (status = 200, description = "List of algorithms with their default parameters", body = Vec) + ) +)] +pub async fn list_algorithms() -> Json> { + let entries: Vec = ALGORITHM_NAMES + .iter() + .map(|name| AlgorithmInfo { + name: name.to_string(), + defaults: algorithm_defaults(name), + }) + .collect(); + Json(entries) +} + +#[utoipa::path( + post, + path = "/v1/noise", + tag = "noise", + request_body = GenerateNoiseRequest, + responses( + (status = 201, description = "Noise field created", body = NoiseFieldResult) + ) +)] +pub async fn generate_noise( + Json(payload): Json, +) -> (StatusCode, Json) { + let algorithm_name = payload.algorithm.name().to_string(); + let field_id = format!("nsf_{}", uuid::Uuid::new_v4()); + // Must match the default `crate::service::generate` uses for the success + // path, so a rejected request that omitted `sampling.size` echoes the + // same size the service would actually have used, rather than `[]`. + let size_for_error = payload + .sampling_size() + .unwrap_or_else(|| DEFAULT_SAMPLING_SIZE.to_vec()); + + // Run the CPU-bound validation/generation/shaping work on a blocking + // thread so a large request doesn't stall the async executor for other + // in-flight requests. + let result = tokio::task::spawn_blocking(move || service::generate(&payload)) + .await + .expect("noise generation task panicked"); + + match result { + Ok(field) => ( + StatusCode::CREATED, + Json(NoiseFieldResult { + id: field_id, + status: "completed".to_string(), + algorithm: algorithm_name, + data: field.data, + size: field.size, + params_used: field.params_used, + }), + ), + Err(err) => { + let status = err.status_code(); + ( + status, + Json(err.into_result(field_id, algorithm_name, size_for_error)), + ) + } + } +} diff --git a/services/noise-generation-service/src/lib.rs b/services/noise-generation-service/src/lib.rs new file mode 100644 index 0000000..d17f25d --- /dev/null +++ b/services/noise-generation-service/src/lib.rs @@ -0,0 +1,173 @@ +//! Noise generation service library. +//! +//! Module layout: +//! - [`algorithms`]: the single source of truth for the set of supported +//! algorithms — wire names, the tagged [`AlgorithmParams`] enum, and +//! per-algorithm dimension support. +//! - [`model`]: request/response DTOs and per-algorithm parameter structs. +//! - [`dim`]: the `Dim` enum and shared grid-iteration helpers. +//! - [`resolve`]: turns optional request parameters into concrete resolved +//! values, used for both generation and the `params_used` echo. +//! - [`generate`]: the noise generation kernels (one per algorithm family). +//! - [`shape`]: reshapes a flat generation buffer into nested response JSON. +//! - [`error`]: structured request-validation errors. +//! - [`service`]: the HTTP-free core `generate` function shared by the HTTP +//! handler and the CLI. +//! - [`http`]: thin axum handlers. +//! - [`openapi`]: the `utoipa` `ApiDoc` schema registration. +//! - [`cli`]: `clap` CLI argument definitions, shared by the binary and by +//! `tests/cli_tests.rs` via this public module. + +// Range loops and casts are clearer for noise generation — allowed via the +// `[lints.clippy]` table in Cargo.toml, which applies uniformly to the lib +// and bin targets instead of a per-crate `#![allow(...)]`. + +pub mod algorithms; +pub mod cli; +pub mod dim; +pub mod error; +pub mod generate; +pub mod http; +pub mod limits; +pub mod model; +pub mod openapi; +pub mod render; +pub mod resolve; +pub mod service; +pub mod shape; + +// Re-export the most commonly used items at the crate root so callers +// (the binary, integration tests) don't need to know the internal module +// layout. +pub use algorithms::{AlgorithmParams, ALGORITHM_NAMES}; +pub use error::NoiseError; +pub use http::{generate_noise, list_algorithms}; +pub use model::{ + AlgorithmInfo, CellularDistanceFunction, CellularParams, CellularReturnType, CombinatorOp, + CombinatorParams, DomainWarpParams, FractalParams, GenerateNoiseRequest, NoiseFieldResult, + Output, OutputFormat, PingPongParams, Sampling, SeedParams, UtilityKind, UtilityParams, +}; +pub use openapi::ApiDoc; + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + /// Verify that the JSON wire format matches the expected layout: + /// { "algorithm": "perlin", "params": {...}, "sampling": {...}, "output": {...} } + /// The order of keys in the serialized JSON is determined by serde's flatten + /// behavior — the only requirement is that the four keys exist with correct values. + #[test] + fn test_serialization_perlin() { + let req = GenerateNoiseRequest { + algorithm: AlgorithmParams::Perlin(SeedParams { seed: Some(42) }), + sampling: Sampling { + size: Some(vec![64, 64]), + }, + output: Some(Output { + format: OutputFormat::Json, + normalize: false, + }), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["algorithm"], "perlin"); + assert_eq!(json["params"]["seed"], 42); + assert_eq!(json["sampling"]["size"], serde_json::json!([64, 64])); + assert_eq!(json["output"]["format"], "json"); + assert_eq!(json["output"]["normalize"], false); + let keys: BTreeSet<&str> = json + .as_object() + .unwrap() + .keys() + .map(|k| k.as_str()) + .collect(); + let expected: BTreeSet<&str> = ["algorithm", "params", "sampling", "output"].into(); + assert_eq!(keys, expected); + } + + #[test] + fn test_serialization_fbm() { + let req = GenerateNoiseRequest { + algorithm: AlgorithmParams::Fbm(FractalParams { + seed: Some(42), + octaves: Some(4), + frequency: Some(0.1), + lacunarity: Some(2.0), + persistence: Some(0.5), + }), + sampling: Sampling { + size: Some(vec![32, 32]), + }, + output: None, + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["algorithm"], "fbm"); + assert_eq!(json["params"]["seed"], 42); + assert_eq!(json["params"]["octaves"], 4); + assert_eq!(json["sampling"]["size"], serde_json::json!([32, 32])); + assert!(json["output"].is_null()); + } + + #[test] + fn test_serialization_cellular() { + let req = GenerateNoiseRequest { + algorithm: AlgorithmParams::Cellular(CellularParams { + seed: Some(123), + distance_function: Some(CellularDistanceFunction::EuclideanSq), + return_type: Some(CellularReturnType::Distance2), + jitter: Some(0.6), + }), + sampling: Sampling { + size: Some(vec![16, 16]), + }, + output: Some(Output { + format: OutputFormat::Csv, + normalize: true, + }), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["algorithm"], "cellular"); + assert_eq!(json["params"]["seed"], 123); + assert_eq!(json["params"]["distance_function"], "euclidean_sq"); + assert_eq!(json["params"]["return_type"], "distance2"); + assert_eq!(json["params"]["jitter"], 0.6); + assert_eq!(json["output"]["format"], "csv"); + assert_eq!(json["output"]["normalize"], true); + } + + #[test] + fn test_deserialization_roundtrip() { + let json_str = r#"{"algorithm":"perlin","params":{"seed":42},"sampling":{"size":[64,64]},"output":{"format":"json","normalize":false}}"#; + let req: GenerateNoiseRequest = serde_json::from_str(json_str).unwrap(); + assert_eq!(req.algorithm.name(), "perlin"); + assert_eq!(req.sampling_size(), Some(vec![64, 64])); + assert!(!req.should_normalize()); + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["algorithm"], "perlin"); + assert_eq!(json["params"]["seed"], 42); + } + + #[test] + fn test_deserialization_fbm_roundtrip() { + let json_str = r#"{"algorithm":"fbm","params":{"seed":42,"octaves":4,"frequency":0.1,"lacunarity":2.0,"persistence":0.5},"sampling":{"size":[32,32]},"output":null}"#; + let req: GenerateNoiseRequest = serde_json::from_str(json_str).unwrap(); + assert_eq!(req.algorithm.name(), "fbm"); + assert_eq!(req.sampling_size(), Some(vec![32, 32])); + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["algorithm"], "fbm"); + assert_eq!(json["params"]["seed"], 42); + } + + #[test] + fn test_deserialization_cellular_roundtrip() { + let json_str = r#"{"algorithm":"cellular","params":{"seed":123,"distance_function":"euclidean_sq","return_type":"distance2","jitter":0.6},"sampling":{"size":[16,16]},"output":{"format":"csv","normalize":true}}"#; + let req: GenerateNoiseRequest = serde_json::from_str(json_str).unwrap(); + assert_eq!(req.algorithm.name(), "cellular"); + assert_eq!(req.sampling_size(), Some(vec![16, 16])); + assert!(req.should_normalize()); + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["algorithm"], "cellular"); + assert_eq!(json["params"]["distance_function"], "euclidean_sq"); + } +} diff --git a/services/noise-generation-service/src/limits.rs b/services/noise-generation-service/src/limits.rs new file mode 100644 index 0000000..1fb4ec1 --- /dev/null +++ b/services/noise-generation-service/src/limits.rs @@ -0,0 +1,16 @@ +//! Request-size limits shared by validation in `crate::service`. + +/// Maximum extent allowed for any single sampling dimension. Bounds per-request +/// memory/CPU cost and prevents pathological requests from exhausting the host. +pub const MAX_SAMPLING_DIM: usize = 4096; + +/// Maximum total number of cells (product of all dimensions) allowed per +/// request. Chosen to keep the largest response well under typical memory +/// limits (16M f64 cells ~ 128 MB flat buffer before JSON shaping). +pub const MAX_SAMPLING_CELLS: usize = 16 * 1024 * 1024; + +/// Default grid size used when a request omits `sampling.size`. Large enough +/// to show visible noise structures, small enough to keep response payload +/// manageable (~32 KB for f64 values). Matches common examples in noise +/// library documentation. +pub const DEFAULT_SAMPLING_SIZE: [usize; 2] = [64, 64]; diff --git a/services/noise-generation-service/src/main.rs b/services/noise-generation-service/src/main.rs new file mode 100644 index 0000000..828f539 --- /dev/null +++ b/services/noise-generation-service/src/main.rs @@ -0,0 +1,176 @@ +use axum::{ + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use clap::{CommandFactory, Parser}; +use noise_generation_service::algorithms::{algorithm_defaults, AlgorithmParams, ALGORITHM_NAMES}; +use noise_generation_service::cli::{Cli, Commands, Format, GenerateArgs, OpenApiFormat}; +use noise_generation_service::model::{ + AlgorithmInfo, GenerateNoiseRequest, Output, OutputFormat, Sampling, +}; +use noise_generation_service::{generate_noise, list_algorithms, render, service, ApiDoc}; +use std::path::PathBuf; +use tokio::net::TcpListener; +use utoipa::OpenApi; + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + let Some(command) = cli.command else { + // No subcommand — show help + Cli::command().print_help().unwrap(); + println!(); + std::process::exit(1); + }; + + match command { + Commands::List { format } => handle_list_command(format), + Commands::Generate(args) => handle_generate_command(args), + Commands::OpenApi { format, output } => handle_openapi_command(format, output), + Commands::Server { port, host } => { + let bind_addr = format!("{}:{}", host, port); + println!("Starting server on {}", bind_addr); + start_server(bind_addr).await; + } + } +} + +// ─── Server ────────────────────────────────────────────────────────────────── + +async fn start_server(bind_addr: String) { + let app = Router::new() + .route("/v1/algorithms", get(list_algorithms)) + .route("/v1/noise", post(generate_noise)) + .route( + "/api-docs/openapi.json", + get(|| async { Json(ApiDoc::openapi()).into_response() }), + ); + + let listener = match TcpListener::bind(&bind_addr).await { + Ok(listener) => listener, + Err(err) => { + eprintln!("Error: could not bind to {bind_addr} ({err})"); + std::process::exit(1); + } + }; + println!("Listening on {}", bind_addr); + if let Err(err) = axum::serve(listener, app).await { + eprintln!("Error: server stopped unexpectedly ({err})"); + std::process::exit(1); + } +} + +// ─── CLI: list ─────────────────────────────────────────────────────────────── + +fn handle_list_command(format: Format) { + let entries: Vec = ALGORITHM_NAMES + .iter() + .map(|name| AlgorithmInfo { + name: name.to_string(), + defaults: algorithm_defaults(name), + }) + .collect(); + + match format { + Format::Csv => print!("{}", render::list_csv(&entries)), + Format::Json => { + let text = serde_json::to_string_pretty(&entries) + .unwrap_or_else(|e| unreachable!("AlgorithmInfo always serializes: {e}")); + println!("{text}"); + } + } +} + +// ─── CLI: generate (local) ─────────────────────────────────────────────────── + +/// Builds an `AlgorithmParams` value from the CLI's `--algorithm` name and +/// `--params` JSON by round-tripping through the same tag/content +/// deserialization the HTTP API uses — no per-algorithm mapping match needed +/// on the CLI side. +fn build_algorithm(name: &str, params: serde_json::Value) -> AlgorithmParams { + let params = if params.is_null() || params.as_object().is_some_and(|o| o.is_empty()) { + serde_json::json!({}) + } else { + params + }; + serde_json::from_value(serde_json::json!({ "algorithm": name, "params": params })) + .unwrap_or_else(|err| { + eprintln!("Error: invalid params for algorithm '{name}' ({err})"); + std::process::exit(1); + }) +} + +fn handle_generate_command(args: GenerateArgs) { + let size = args.sampling_size.clone(); + let algorithm = build_algorithm(&args.algorithm, args.params); + let request = GenerateNoiseRequest { + algorithm, + sampling: Sampling { + size: Some(size.clone()), + }, + output: Some(Output { + format: OutputFormat::Json, + normalize: args.output_normalize, + }), + }; + + let field = match service::generate(&request) { + Ok(field) => field, + Err(err) => { + eprintln!("Error: {}", err.message()); + std::process::exit(1); + } + }; + + match args.output_format { + Format::Json => { + let output = serde_json::json!({ + "status": 201, + "result": { + "id": format!("nsf_{}", uuid::Uuid::new_v4()), + "algorithm": args.algorithm, + "status": "completed", + "data": field.data, + "size": field.size, + "params_used": field.params_used, + } + }); + let text = serde_json::to_string_pretty(&output).unwrap(); + write_or_print(text, args.output_file); + } + Format::Csv => match render::generate_csv(&field.data, size.len()) { + Ok(csv) => write_or_print(csv, args.output_file), + Err(msg) => { + eprintln!("Error: {msg}"); + std::process::exit(1); + } + }, + } +} + +// ─── CLI: openapi ──────────────────────────────────────────────────────────── + +fn handle_openapi_command(format: OpenApiFormat, output: Option) { + let spec = ApiDoc::openapi(); + + let text = match format { + OpenApiFormat::Json => serde_json::to_string_pretty(&spec).unwrap(), + OpenApiFormat::Yaml => serde_yaml::to_string(&spec).unwrap(), + }; + + write_or_print(text, output); +} + +// ─── Helper: write to file or stdout ───────────────────────────────────────── + +fn write_or_print(text: String, file: Option) { + if let Some(path) = file { + std::fs::write(&path, &text).unwrap_or_else(|e| { + eprintln!("Error: could not write to {} ({})", path.display(), e); + std::process::exit(1); + }); + } else { + println!("{}", text); + } +} diff --git a/services/noise-generation-service/src/model.rs b/services/noise-generation-service/src/model.rs new file mode 100644 index 0000000..1e75107 --- /dev/null +++ b/services/noise-generation-service/src/model.rs @@ -0,0 +1,166 @@ +//! Request/response DTOs and per-algorithm parameter structs. +//! +//! `AlgorithmParams`, its wire-name table, and its dimension-support table are +//! defined in [`crate::algorithms`] via a single macro invocation instead of +//! here, so that the set of supported algorithms has exactly one source of +//! truth. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Serialize, Deserialize, Debug, Default, ToSchema)] +pub struct SeedParams { + pub seed: Option, +} + +#[derive(Serialize, Deserialize, Debug, Default, ToSchema)] +pub struct CellularParams { + pub seed: Option, + pub distance_function: Option, + pub return_type: Option, + pub jitter: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CellularDistanceFunction { + Euclidean, + EuclideanSq, + Manhattan, + Hybrid, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CellularReturnType { + CellValue, + Distance, + Distance2, + Distance2Add, + Distance2Sub, + Distance2Mul, + Distance2Div, +} + +/// Shared parameter shape for every noise-crate fractal algorithm (Fbm, +/// Billow, RidgedMulti, HybridMulti). Each algorithm applies its own default +/// `persistence` (see `crate::algorithms::DEFAULT_PERSISTENCE_*`) when the +/// field is omitted; the struct itself is identical across all four, so it is +/// defined once rather than duplicated per algorithm. +#[derive(Serialize, Deserialize, Debug, Default, ToSchema)] +pub struct FractalParams { + pub seed: Option, + pub octaves: Option, + pub frequency: Option, + pub lacunarity: Option, + pub persistence: Option, +} + +#[derive(Serialize, Deserialize, Debug, Default, ToSchema)] +pub struct PingPongParams { + pub seed: Option, + pub strength: Option, +} + +#[derive(Serialize, Deserialize, Debug, Default, ToSchema)] +pub struct DomainWarpParams { + pub seed: Option, + pub amplitude: Option, +} + +#[derive(Serialize, Deserialize, Debug, Default, ToSchema)] +pub struct CombinatorParams { + pub seed: Option, + pub op: Option, + pub blend_factor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CombinatorOp { + #[default] + Add, + Multiply, + Min, + Max, + Blend, +} + +#[derive(Serialize, Deserialize, Debug, Default, ToSchema)] +pub struct UtilityParams { + pub kind: Option, + pub value: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum UtilityKind { + #[default] + Constant, + Cylinders, +} + +#[derive(Serialize, Debug, ToSchema)] +pub struct AlgorithmInfo { + /// Algorithm name / identifier (e.g. "perlin", "fbm") + pub name: String, + /// Default parameter values used when the corresponding field is omitted. + /// The `seed` is shown as `null` because it is randomly generated per request. + pub defaults: serde_json::Value, +} + +#[derive(Serialize, Deserialize, Debug, ToSchema)] +pub struct Sampling { + pub size: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum OutputFormat { + #[default] + Json, + Csv, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct Output { + pub format: OutputFormat, + pub normalize: bool, +} + +#[derive(Serialize, Deserialize, Debug, ToSchema)] +pub struct GenerateNoiseRequest { + #[serde(flatten)] + pub algorithm: crate::algorithms::AlgorithmParams, + pub sampling: Sampling, + pub output: Option, +} + +#[derive(Serialize, Debug, ToSchema)] +pub struct NoiseFieldResult { + pub id: String, + pub status: String, + pub algorithm: String, + pub data: serde_json::Value, + pub size: Vec, + pub params_used: serde_json::Value, +} + +impl GenerateNoiseRequest { + pub fn sampling_size(&self) -> Option> { + self.sampling.size.clone() + } + + pub fn should_normalize(&self) -> bool { + self.output.as_ref().map(|o| o.normalize).unwrap_or(false) + } + + /// `POST /v1/noise` always returns JSON; CSV is only supported by the CLI's + /// local `generate` command, which renders it client-side from the JSON body. + pub fn wants_unsupported_csv(&self) -> bool { + matches!( + self.output.as_ref().map(|o| &o.format), + Some(OutputFormat::Csv) + ) + } +} diff --git a/services/noise-generation-service/src/openapi.rs b/services/noise-generation-service/src/openapi.rs new file mode 100644 index 0000000..5c083ec --- /dev/null +++ b/services/noise-generation-service/src/openapi.rs @@ -0,0 +1,49 @@ +//! OpenAPI schema registration. +//! +//! `utoipa` requires every schema type referenced (directly or transitively) +//! by the HTTP API to be listed here manually — adding a new nested type to +//! `crate::model` without adding it to `components(schemas(...))` below will +//! produce an OpenAPI spec with unresolved `$ref`s. + +use crate::algorithms::AlgorithmParams; +use crate::http; +use crate::model::{ + AlgorithmInfo, CellularDistanceFunction, CellularParams, CellularReturnType, CombinatorOp, + CombinatorParams, DomainWarpParams, FractalParams, GenerateNoiseRequest, NoiseFieldResult, + Output, OutputFormat, PingPongParams, Sampling, SeedParams, UtilityKind, UtilityParams, +}; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + http::list_algorithms, + http::generate_noise + ), + components( + schemas( + GenerateNoiseRequest, + AlgorithmParams, + Sampling, + Output, + OutputFormat, + NoiseFieldResult, + AlgorithmInfo, + SeedParams, + CellularParams, + CellularDistanceFunction, + CellularReturnType, + FractalParams, + PingPongParams, + DomainWarpParams, + CombinatorParams, + CombinatorOp, + UtilityParams, + UtilityKind + ) + ), + tags( + (name = "noise", description = "Noise generation API") + ) +)] +pub struct ApiDoc; diff --git a/services/noise-generation-service/src/render.rs b/services/noise-generation-service/src/render.rs new file mode 100644 index 0000000..91fd0fa --- /dev/null +++ b/services/noise-generation-service/src/render.rs @@ -0,0 +1,104 @@ +//! CSV rendering for the CLI's `list` and `generate` commands. +//! +//! CSV output only exists for the CLI (`POST /v1/noise` always returns JSON, +//! see `crate::error::NoiseError::UnsupportedCsv`). Living in the library +//! gives it one consistent quoting strategy and makes it unit-testable +//! without going through the CLI's argument parsing. + +use crate::model::AlgorithmInfo; + +/// Renders the algorithm list as CSV: one row per algorithm, with its +/// defaults rendered as a quoted, escaped JSON blob. Every field is +/// double-quoted with `"` escaped as `""` (RFC 4180); no trailing +/// human-readable summary line is appended, since that would not itself be +/// valid CSV. +pub fn list_csv(entries: &[AlgorithmInfo]) -> String { + let mut csv = String::from("algorithm,defaults\n"); + for entry in entries { + let defaults = entry.defaults.to_string(); + csv.push_str(&format!( + "{},{}\n", + csv_field(&entry.name), + csv_field(&defaults) + )); + } + csv +} + +/// Renders a generated noise field's `data` (a 1D or 2D JSON array of +/// numbers) as CSV. Returns `Err` for 3D+ data, which has no natural CSV +/// representation. +pub fn generate_csv(data: &serde_json::Value, dim: usize) -> Result { + if dim > 2 { + return Err(format!( + "CSV output only supports 1D/2D noise fields; use --output-format json for {dim}D data" + )); + } + + let mut csv = String::new(); + let Some(rows) = data.as_array() else { + return Ok(csv); + }; + + for row in rows { + if let Some(values) = row.as_array() { + // 2D: one CSV row per input row. + let cells: Vec = values + .iter() + .map(|v| format!("{:.6}", v.as_f64().unwrap_or(0.0))) + .collect(); + csv.push_str(&cells.join(",")); + csv.push('\n'); + } else { + // 1D: treat every element as its own row. + csv.push_str(&format!("{:.6}\n", row.as_f64().unwrap_or(0.0))); + } + } + Ok(csv) +} + +/// Double-quotes a CSV field and escapes embedded `"` as `""` per RFC 4180. +fn csv_field(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_csv_renders_2d_rows() { + let data = serde_json::json!([[1.0, 2.0], [3.0, 4.0]]); + let csv = generate_csv(&data, 2).unwrap(); + assert_eq!(csv, "1.000000,2.000000\n3.000000,4.000000\n"); + } + + #[test] + fn generate_csv_renders_1d_rows() { + let data = serde_json::json!([1.0, 2.0, 3.0]); + let csv = generate_csv(&data, 1).unwrap(); + assert_eq!(csv, "1.000000\n2.000000\n3.000000\n"); + } + + #[test] + fn generate_csv_rejects_3d_plus() { + let data = serde_json::json!([[[1.0]]]); + assert!(generate_csv(&data, 3).is_err()); + } + + #[test] + fn list_csv_has_no_trailing_summary_line() { + let entries = vec![AlgorithmInfo { + name: "perlin".to_string(), + defaults: serde_json::json!({"seed": null}), + }]; + let csv = list_csv(&entries); + assert!(!csv.contains("Total")); + assert_eq!(csv.lines().count(), 2); // header + one row + } + + #[test] + fn csv_field_escapes_embedded_quotes() { + assert_eq!(csv_field(r#"has "quotes""#), r#""has ""quotes""""#); + } +} diff --git a/services/noise-generation-service/src/resolve.rs b/services/noise-generation-service/src/resolve.rs new file mode 100644 index 0000000..69103f8 --- /dev/null +++ b/services/noise-generation-service/src/resolve.rs @@ -0,0 +1,331 @@ +//! Resolves optional request parameters into concrete values, exactly once +//! per request. The resolved value is used for **both** noise generation and +//! the `params_used` echo, which eliminates a class of bug where the two +//! could disagree (e.g. a seed generated twice, landing on different random +//! values for generation vs. the echoed response). + +use crate::algorithms::{ + AlgorithmParams, DEFAULT_PERSISTENCE_FBM_BILLOW, DEFAULT_PERSISTENCE_HYBRID, + DEFAULT_PERSISTENCE_RIDGED, +}; +use crate::model::{ + CellularDistanceFunction, CellularReturnType, CombinatorOp, GenerateNoiseRequest, SeedParams, + UtilityKind, +}; + +/// Default fractal octaves (4). 4 octaves is a standard trade-off between +/// detail richness and performance, matching noise-crate's own examples. +pub const DEFAULT_OCTAVES: usize = 4; + +/// Default frequency (0.1). Gives ~10 noise features per unit length, +/// producing well-visible structures at typical grid resolutions (64-512). +pub const DEFAULT_FREQUENCY: f64 = 0.1; + +/// Default lacunarity (2.0). Doubles frequency per octave — this is the +/// standard lacunarity for Perlin-based fractals (noise-crate default). +pub const DEFAULT_LACUNARITY: f64 = 2.0; + +/// Maximum octaves accepted by the `noise` crate's `MultiFractal::set_octaves` +/// (clamped internally to `1..=32`). Requested values are clamped to this same +/// range up front so the echoed `params_used.octaves` always matches what was +/// actually generated. +pub const MAX_OCTAVES: usize = 32; + +/// Default cellular jitter (0.45). Standard Worley noise jitter factor. +pub const DEFAULT_JITTER: f64 = 0.45; + +/// Default PingPong strength (2.0). Standard wrapping strength for PingPong fractal. +pub const DEFAULT_STRENGTH: f64 = 2.0; + +/// Default domain warp amplitude (1.0). Standard amplitude for domain warping. +pub const DEFAULT_AMPLITUDE: f64 = 1.0; + +/// Default combinator blend factor (0.5). Equal mix when blending two sources. +pub const DEFAULT_BLEND_FACTOR: f64 = 0.5; + +/// Default utility constant value (1.0). +pub const DEFAULT_UTILITY_VALUE: f64 = 1.0; + +fn random_seed() -> u32 { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + (nanos ^ (nanos >> 32)) as u32 +} + +fn get_seed(seed: Option) -> u32 { + seed.unwrap_or_else(random_seed) +} + +/// Fully resolved noise parameters for one algorithm family. Resolved exactly +/// once per request; used for both noise generation **and** the `params_used` +/// echo. +pub enum ResolvedNoiseParams { + SeedOnly { + seed: u32, + }, + Cellular { + seed: u32, + distance_function: CellularDistanceFunction, + return_type: CellularReturnType, + jitter: f64, + }, + Fractal { + seed: u32, + octaves: usize, + frequency: f64, + lacunarity: f64, + persistence: f64, + }, + PingPong { + seed: u32, + strength: f64, + }, + DomainWarp { + seed: u32, + amplitude: f64, + }, + Combinator { + seed: u32, + op: CombinatorOp, + blend_factor: f64, + }, + Utility { + kind: UtilityKind, + value: f64, + }, +} + +impl ResolvedNoiseParams { + pub fn to_json(&self) -> serde_json::Value { + match self { + ResolvedNoiseParams::SeedOnly { seed } => { + serde_json::json!({ "seed": seed }) + } + ResolvedNoiseParams::Cellular { + seed, + distance_function, + return_type, + jitter, + } => { + serde_json::json!({ + "seed": seed, + "distance_function": distance_function, + "return_type": return_type, + "jitter": jitter, + }) + } + ResolvedNoiseParams::Fractal { + seed, + octaves, + frequency, + lacunarity, + persistence, + } => { + serde_json::json!({ + "seed": seed, + "octaves": octaves, + "frequency": frequency, + "lacunarity": lacunarity, + "persistence": persistence, + }) + } + ResolvedNoiseParams::PingPong { seed, strength } => { + serde_json::json!({ + "seed": seed, + "strength": strength, + }) + } + ResolvedNoiseParams::DomainWarp { seed, amplitude } => { + serde_json::json!({ + "seed": seed, + "amplitude": amplitude, + }) + } + ResolvedNoiseParams::Combinator { + seed, + op, + blend_factor, + } => { + serde_json::json!({ + "seed": seed, + "op": op, + "blend_factor": blend_factor, + }) + } + ResolvedNoiseParams::Utility { kind, value } => { + serde_json::json!({ + "kind": kind, + "value": value, + }) + } + } + } +} + +fn resolve_seed_only(params: &SeedParams) -> ResolvedNoiseParams { + ResolvedNoiseParams::SeedOnly { + seed: get_seed(params.seed), + } +} + +/// Shared by Fbm, Billow, RidgedMulti, and HybridMulti, which differ only in +/// their default `persistence`. +fn resolve_fractal( + seed: Option, + octaves: Option, + frequency: Option, + lacunarity: Option, + persistence: Option, + default_persistence: f64, +) -> ResolvedNoiseParams { + ResolvedNoiseParams::Fractal { + seed: get_seed(seed), + // Clamp to the same 1..=32 range that `MultiFractal::set_octaves` enforces + // internally, so the value echoed in `params_used` matches generation. + octaves: octaves.unwrap_or(DEFAULT_OCTAVES).clamp(1, MAX_OCTAVES), + frequency: frequency.unwrap_or(DEFAULT_FREQUENCY), + lacunarity: lacunarity.unwrap_or(DEFAULT_LACUNARITY), + persistence: persistence.unwrap_or(default_persistence), + } +} + +/// Resolves optional request parameters into concrete values once. +pub fn resolve_params(payload: &GenerateNoiseRequest) -> ResolvedNoiseParams { + match &payload.algorithm { + AlgorithmParams::Perlin(params) + | AlgorithmParams::Simplex(params) + | AlgorithmParams::OpenSimplex2(params) + | AlgorithmParams::SuperSimplex(params) + | AlgorithmParams::Value(params) + | AlgorithmParams::White(params) => resolve_seed_only(params), + AlgorithmParams::Cellular(params) => ResolvedNoiseParams::Cellular { + seed: get_seed(params.seed), + distance_function: params + .distance_function + .clone() + .unwrap_or(CellularDistanceFunction::Euclidean), + return_type: params + .return_type + .clone() + .unwrap_or(CellularReturnType::CellValue), + jitter: params.jitter.unwrap_or(DEFAULT_JITTER), + }, + AlgorithmParams::Fbm(params) => resolve_fractal( + params.seed, + params.octaves, + params.frequency, + params.lacunarity, + params.persistence, + DEFAULT_PERSISTENCE_FBM_BILLOW, + ), + AlgorithmParams::Billow(params) => resolve_fractal( + params.seed, + params.octaves, + params.frequency, + params.lacunarity, + params.persistence, + DEFAULT_PERSISTENCE_FBM_BILLOW, + ), + AlgorithmParams::RidgedMulti(params) => resolve_fractal( + params.seed, + params.octaves, + params.frequency, + params.lacunarity, + params.persistence, + DEFAULT_PERSISTENCE_RIDGED, + ), + AlgorithmParams::HybridMulti(params) => resolve_fractal( + params.seed, + params.octaves, + params.frequency, + params.lacunarity, + params.persistence, + DEFAULT_PERSISTENCE_HYBRID, + ), + AlgorithmParams::PingPong(params) => ResolvedNoiseParams::PingPong { + seed: get_seed(params.seed), + strength: params.strength.unwrap_or(DEFAULT_STRENGTH), + }, + AlgorithmParams::DomainWarp(params) => ResolvedNoiseParams::DomainWarp { + seed: get_seed(params.seed), + amplitude: params.amplitude.unwrap_or(DEFAULT_AMPLITUDE), + }, + AlgorithmParams::Combinator(params) => ResolvedNoiseParams::Combinator { + seed: get_seed(params.seed), + op: params.op.clone().unwrap_or(CombinatorOp::Add), + blend_factor: params.blend_factor.unwrap_or(DEFAULT_BLEND_FACTOR), + }, + AlgorithmParams::Utility(params) => ResolvedNoiseParams::Utility { + kind: params.kind.clone().unwrap_or(UtilityKind::Constant), + value: params.value.unwrap_or(DEFAULT_UTILITY_VALUE), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{FractalParams, Output, OutputFormat, Sampling}; + + fn request(algorithm: AlgorithmParams) -> GenerateNoiseRequest { + GenerateNoiseRequest { + algorithm, + sampling: Sampling { + size: Some(vec![4, 4]), + }, + output: Some(Output { + format: OutputFormat::Json, + normalize: false, + }), + } + } + + #[test] + fn octaves_are_clamped_to_max() { + let req = request(AlgorithmParams::Fbm(FractalParams { + octaves: Some(1000), + ..Default::default() + })); + match resolve_params(&req) { + ResolvedNoiseParams::Fractal { octaves, .. } => assert_eq!(octaves, MAX_OCTAVES), + _ => panic!("expected Fractal"), + } + } + + #[test] + fn explicit_seed_is_preserved() { + let req = request(AlgorithmParams::Perlin(SeedParams { seed: Some(7) })); + match resolve_params(&req) { + ResolvedNoiseParams::SeedOnly { seed } => assert_eq!(seed, 7), + _ => panic!("expected SeedOnly"), + } + } + + #[test] + fn missing_seed_is_randomly_generated_and_nonzero_json() { + let req = request(AlgorithmParams::Perlin(SeedParams { seed: None })); + let resolved = resolve_params(&req); + let json = resolved.to_json(); + assert!(json["seed"].is_number()); + } + + #[test] + fn ridged_multi_and_hybrid_multi_have_distinct_default_persistence() { + let ridged = request(AlgorithmParams::RidgedMulti(FractalParams::default())); + let hybrid = request(AlgorithmParams::HybridMulti(FractalParams::default())); + let ridged_persistence = match resolve_params(&ridged) { + ResolvedNoiseParams::Fractal { persistence, .. } => persistence, + _ => panic!("expected Fractal"), + }; + let hybrid_persistence = match resolve_params(&hybrid) { + ResolvedNoiseParams::Fractal { persistence, .. } => persistence, + _ => panic!("expected Fractal"), + }; + assert_eq!(ridged_persistence, DEFAULT_PERSISTENCE_RIDGED); + assert_eq!(hybrid_persistence, DEFAULT_PERSISTENCE_HYBRID); + assert_ne!(ridged_persistence, hybrid_persistence); + } +} diff --git a/services/noise-generation-service/src/service.rs b/services/noise-generation-service/src/service.rs new file mode 100644 index 0000000..3dc2e17 --- /dev/null +++ b/services/noise-generation-service/src/service.rs @@ -0,0 +1,169 @@ +//! HTTP-free core generation service. +//! +//! [`generate`] is the shared core both the CLI and the `POST /v1/noise` +//! handler (`crate::http`) call into: it validates the request and runs the +//! CPU-bound generation, and returns a plain `Result` with no HTTP +//! dependency. + +use crate::dim::Dim; +use crate::error::NoiseError; +use crate::generate::generate_flat; +use crate::limits::{DEFAULT_SAMPLING_SIZE, MAX_SAMPLING_CELLS, MAX_SAMPLING_DIM}; +use crate::model::GenerateNoiseRequest; +use crate::resolve::resolve_params; +use crate::shape::shape_data; + +/// A successfully generated noise field, independent of how it will be +/// rendered (HTTP JSON body, CLI JSON, or CLI CSV). +#[derive(Debug)] +pub struct GeneratedField { + pub data: serde_json::Value, + pub size: Vec, + pub params_used: serde_json::Value, +} + +/// Validates `request` and — if valid — runs the (CPU-bound) noise +/// generation and shapes the result. Callers that care about not blocking +/// an async executor (the HTTP handler) should run this inside +/// `tokio::task::spawn_blocking`; the CLI calls it directly since it has no +/// other concurrent work to protect. +pub fn generate(request: &GenerateNoiseRequest) -> Result { + let size = request + .sampling_size() + .unwrap_or_else(|| DEFAULT_SAMPLING_SIZE.to_vec()); + + let dim = + Dim::from_len(size.len()).ok_or(NoiseError::UnsupportedSizeLength { len: size.len() })?; + + if request.wants_unsupported_csv() { + return Err(NoiseError::UnsupportedCsv); + } + + let support = request.algorithm.dim_support(); + if !support.supports(dim) { + return Err(NoiseError::UnsupportedDimension { + algorithm: request.algorithm.name(), + dim, + reason: support.reason(), + }); + } + + // Reject pathological grid sizes before allocating anything. Each + // dimension is capped individually and the total cell count is computed + // with checked arithmetic so a huge or overflowing request can't reach + // `vec![0.0; total]`. + if size.iter().any(|&d| d == 0 || d > MAX_SAMPLING_DIM) { + return Err(NoiseError::DimensionOutOfRange { + size, + max: MAX_SAMPLING_DIM, + }); + } + let total = match size.iter().try_fold(1usize, |acc, &d| acc.checked_mul(d)) { + Some(total) if total <= MAX_SAMPLING_CELLS => total, + _ => { + return Err(NoiseError::TooManyCells { + size, + max: MAX_SAMPLING_CELLS, + }) + } + }; + + // Resolve parameters once — used for both generation and the response echo. + let resolved = resolve_params(request); + let normalize = request.should_normalize(); + + let mut flat = vec![0.0; total]; + generate_flat(&mut flat, request, &resolved, &size, dim); + + if normalize { + normalize_in_place(&mut flat); + } + + let data = shape_data(&flat, &size, dim); + Ok(GeneratedField { + data, + size, + params_used: resolved.to_json(), + }) +} + +fn normalize_in_place(flat: &mut [f64]) { + let mut min_val = f64::MAX; + let mut max_val = f64::MIN; + for &v in flat.iter() { + if v < min_val { + min_val = v; + } + if v > max_val { + max_val = v; + } + } + let range = max_val - min_val; + if range > 0.0 { + for v in flat.iter_mut() { + *v = (*v - min_val) / range; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::algorithms::AlgorithmParams; + use crate::model::{Output, OutputFormat, Sampling, SeedParams}; + + fn request(size: Vec) -> GenerateNoiseRequest { + GenerateNoiseRequest { + algorithm: AlgorithmParams::Perlin(SeedParams { seed: Some(1) }), + sampling: Sampling { size: Some(size) }, + output: Some(Output { + format: OutputFormat::Json, + normalize: false, + }), + } + } + + #[test] + fn normalize_in_place_scales_to_unit_range() { + let mut flat = vec![-2.0, 0.0, 2.0, 4.0]; + normalize_in_place(&mut flat); + assert_eq!(flat, vec![0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0]); + } + + #[test] + fn normalize_in_place_is_noop_for_constant_input() { + let mut flat = vec![5.0, 5.0, 5.0]; + normalize_in_place(&mut flat); + assert_eq!(flat, vec![5.0, 5.0, 5.0]); + } + + #[test] + fn rejects_zero_dimension() { + let req = request(vec![0, 4]); + assert_eq!( + generate(&req).unwrap_err(), + NoiseError::DimensionOutOfRange { + size: vec![0, 4], + max: MAX_SAMPLING_DIM + } + ); + } + + #[test] + fn rejects_unsupported_size_length() { + let req = request(vec![1, 2, 3, 4, 5]); + assert_eq!( + generate(&req).unwrap_err(), + NoiseError::UnsupportedSizeLength { len: 5 } + ); + } + + #[test] + fn succeeds_for_valid_2d_request() { + let req = request(vec![4, 4]); + let field = generate(&req).unwrap(); + assert_eq!(field.size, vec![4, 4]); + assert_eq!(field.params_used["seed"], 1); + assert_eq!(field.data.as_array().unwrap().len(), 4); + } +} diff --git a/services/noise-generation-service/src/shape.rs b/services/noise-generation-service/src/shape.rs new file mode 100644 index 0000000..fc3341b --- /dev/null +++ b/services/noise-generation-service/src/shape.rs @@ -0,0 +1,100 @@ +//! Converts a flat `&[f64]` buffer into the nested JSON shape matching the +//! requested dimensionality: 1D -> array, 2D -> nested array, 3D -> array of +//! 2D arrays, 4D -> array of 3D volumes (each volume is an array of 2D +//! slices). + +use crate::dim::Dim; + +pub fn shape_data(flat: &[f64], size: &[usize], dim: Dim) -> serde_json::Value { + match dim { + Dim::D1 => serde_json::Value::Array(flat.iter().map(|v| serde_json::json!(v)).collect()), + Dim::D2 => { + let w = size[0]; + serde_json::Value::Array(flat.chunks_exact(w).map(row).collect()) + } + Dim::D3 => { + let w = size[0]; + let h = size[1]; + serde_json::Value::Array( + flat.chunks_exact(w * h) + .map(|slice| serde_json::Value::Array(slice.chunks_exact(w).map(row).collect())) + .collect(), + ) + } + Dim::D4 => { + let w = size[0]; + let h = size[1]; + let d = size[2]; + serde_json::Value::Array( + flat.chunks_exact(w * h * d) + .map(|volume| { + serde_json::Value::Array( + volume + .chunks_exact(w * h) + .map(|slice| { + serde_json::Value::Array( + slice.chunks_exact(w).map(row).collect(), + ) + }) + .collect(), + ) + }) + .collect(), + ) + } + } +} + +fn row(values: &[f64]) -> serde_json::Value { + serde_json::Value::Array(values.iter().map(|v| serde_json::json!(v)).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shapes_1d() { + let flat = [1.0, 2.0, 3.0]; + let shaped = shape_data(&flat, &[3], Dim::D1); + assert_eq!(shaped, serde_json::json!([1.0, 2.0, 3.0])); + } + + #[test] + fn shapes_2d_row_major() { + // 2x3 grid (w=2, h=3): flat[y*w+x] + let flat = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0]; + let shaped = shape_data(&flat, &[2, 3], Dim::D2); + assert_eq!( + shaped, + serde_json::json!([[0.0, 1.0], [2.0, 3.0], [4.0, 5.0]]) + ); + } + + #[test] + fn shapes_3d_has_correct_nesting_depth() { + let flat: Vec = (0..(2 * 2 * 2)).map(|v| v as f64).collect(); + let shaped = shape_data(&flat, &[2, 2, 2], Dim::D3); + let arr = shaped.as_array().unwrap(); + assert_eq!(arr.len(), 2); // depth + assert_eq!(arr[0].as_array().unwrap().len(), 2); // rows + assert_eq!(arr[0].as_array().unwrap()[0].as_array().unwrap().len(), 2); // cols + } + + #[test] + fn shapes_4d_has_correct_nesting_depth() { + let flat: Vec = (0..(2 * 2 * 2 * 2)).map(|v| v as f64).collect(); + let shaped = shape_data(&flat, &[2, 2, 2, 2], Dim::D4); + let arr = shaped.as_array().unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0].as_array().unwrap().len(), 2); + assert_eq!(arr[0].as_array().unwrap()[0].as_array().unwrap().len(), 2); + assert_eq!( + arr[0].as_array().unwrap()[0].as_array().unwrap()[0] + .as_array() + .unwrap() + .len(), + 2 + ); + } +} diff --git a/services/noise-generation-service/tests/cli_tests.rs b/services/noise-generation-service/tests/cli_tests.rs new file mode 100644 index 0000000..1f2d82f --- /dev/null +++ b/services/noise-generation-service/tests/cli_tests.rs @@ -0,0 +1,143 @@ +use clap::Parser; +use noise_generation_service::cli::{Cli, Commands, Format, OpenApiFormat}; +use noise_generation_service::limits::DEFAULT_SAMPLING_SIZE; + +#[test] +fn test_cli_parse_generate() { + let cli = Cli::parse_from(["test", "generate", "--algorithm", "perlin"]); + match cli.command { + Some(Commands::Generate(args)) => { + assert_eq!(args.algorithm, "perlin"); + assert_eq!(args.output_format, Format::Json); + // The `--sampling-size` clap default is a hand-written string + // literal (clap attribute macros can't reference a `const` + // array directly); pin it against the single source of truth + // (`crate::limits::DEFAULT_SAMPLING_SIZE`, also used by + // `service::generate`'s HTTP-path default) so the two can't + // silently drift apart. + assert_eq!(args.sampling_size, DEFAULT_SAMPLING_SIZE.to_vec()); + } + _ => panic!("Expected Generate command"), + } +} + +#[test] +fn test_cli_generate_rejects_unknown_algorithm() { + let result = Cli::try_parse_from(["test", "generate", "--algorithm", "not-a-real-algorithm"]); + assert!(result.is_err()); +} + +#[test] +fn test_cli_generate_custom_size() { + let cli = Cli::parse_from([ + "test", + "generate", + "--algorithm", + "fbm", + "--sampling-size", + "128,256", + ]); + match cli.command { + Some(Commands::Generate(args)) => { + assert_eq!(args.algorithm, "fbm"); + assert_eq!(args.sampling_size, vec![128, 256]); + } + _ => panic!("Expected Generate command"), + } +} + +#[test] +fn test_cli_generate_json_params() { + let cli = Cli::parse_from([ + "test", + "generate", + "--algorithm", + "cellular", + "--params", + r#"{"seed": 42, "jitter": 0.5}"#, + ]); + match cli.command { + Some(Commands::Generate(args)) => { + assert_eq!(args.algorithm, "cellular"); + assert_eq!(args.params["seed"], 42); + assert_eq!(args.params["jitter"], 0.5); + } + _ => panic!("Expected Generate command"), + } +} + +#[test] +fn test_cli_list_format_json() { + let cli = Cli::parse_from(["test", "list"]); + match cli.command { + Some(Commands::List { format }) => { + assert_eq!(format, Format::Json); + } + _ => panic!("Expected List command"), + } +} + +#[test] +fn test_cli_list_format_csv() { + let cli = Cli::parse_from(["test", "list", "--format", "csv"]); + match cli.command { + Some(Commands::List { format }) => { + assert_eq!(format, Format::Csv); + } + _ => panic!("Expected List command"), + } +} + +#[test] +fn test_cli_openapi_format_yaml() { + let cli = Cli::parse_from(["test", "openapi", "--format", "yaml"]); + match cli.command { + Some(Commands::OpenApi { format, output }) => { + assert_eq!(format, OpenApiFormat::Yaml); + assert_eq!(output, None); + } + _ => panic!("Expected OpenApi command"), + } +} + +#[test] +fn test_cli_generate_format_csv() { + let cli = Cli::parse_from([ + "test", + "generate", + "--algorithm", + "simplex", + "--output-format", + "csv", + ]); + match cli.command { + Some(Commands::Generate(args)) => { + assert_eq!(args.output_format, Format::Csv); + assert!(!args.output_normalize); + } + _ => panic!("Expected Generate command"), + } +} + +#[test] +fn test_cli_generate_normalize() { + let cli = Cli::parse_from([ + "test", + "generate", + "--algorithm", + "perlin", + "--output-normalize", + ]); + match cli.command { + Some(Commands::Generate(args)) => { + assert!(args.output_normalize); + } + _ => panic!("Expected Generate command"), + } +} + +#[test] +fn test_cli_no_command() { + let cli = Cli::parse_from(["test"]); + assert_eq!(cli.command, None); +} diff --git a/services/noise-generation-service/tests/integration_tests.rs b/services/noise-generation-service/tests/integration_tests.rs new file mode 100644 index 0000000..bbf5ecf --- /dev/null +++ b/services/noise-generation-service/tests/integration_tests.rs @@ -0,0 +1,833 @@ +use axum::{ + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use noise_generation_service::{generate_noise, list_algorithms, ApiDoc}; +use serde_json::json; +use utoipa::OpenApi; + +/// Builds the app router (mirrors main.rs). +fn build_app() -> Router { + Router::new() + .route("/v1/algorithms", get(list_algorithms)) + .route("/v1/noise", post(generate_noise)) + .route( + "/api-docs/openapi.json", + get(|| async { Json(ApiDoc::openapi()).into_response() }), + ) +} + +/// Starts the server on a random available port and returns the base URL. +/// +/// No artificial readiness delay is needed here: `TcpListener::bind` already +/// reserves the port and starts queuing incoming connections in the OS +/// backlog before `axum::serve`'s accept loop ever polls it, so the very +/// first request from `reqwest` will simply wait in that backlog rather than +/// hit `ConnectionRefused`. +async fn start_test_server() -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let app = build_app(); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + format!("http://127.0.0.1:{}", port) +} + +/// Posts to `/v1/noise` and returns the raw `reqwest::Response`, for tests +/// that need to inspect the status code (including error responses). +async fn post_noise_raw( + url: &str, + algorithm: &str, + params: serde_json::Value, + size: Vec, +) -> reqwest::Response { + let client = reqwest::Client::new(); + client + .post(format!("{}/v1/noise", url)) + .json(&json!({ + "algorithm": algorithm, + "params": params, + "sampling": { "mode": "grid", "size": size }, + "output": { "format": "json", "normalize": false } + })) + .send() + .await + .unwrap() +} + +/// Posts to `/v1/noise` and returns the parsed JSON body, panicking with the +/// response body on any non-2xx status (most tests expect success). +async fn post_noise( + url: &str, + algorithm: &str, + params: serde_json::Value, + size: Vec, +) -> serde_json::Value { + let response = post_noise_raw(url, algorithm, params, size).await; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + panic!( + "POST /v1/noise {} failed with {}: {}", + algorithm, status, body + ); + } + response.json().await.unwrap() +} + +// ─── Tests: GET /v1/algorithms ─────────────────────────────────────────────── + +#[tokio::test] +async fn test_list_algorithms_returns_15() { + let url = start_test_server().await; + let client = reqwest::Client::new(); + let response = client + .get(format!("{}/v1/algorithms", url)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let algos: Vec = response.json().await.unwrap(); + assert_eq!(algos.len(), 15); +} + +#[tokio::test] +async fn test_list_algorithms_structure() { + let url = start_test_server().await; + let client = reqwest::Client::new(); + let response = client + .get(format!("{}/v1/algorithms", url)) + .send() + .await + .unwrap(); + let algos: Vec = response.json().await.unwrap(); + let first = &algos[0]; + assert_eq!(first["name"], "perlin"); + assert!(first["defaults"].is_object()); + assert!(first["defaults"]["seed"].is_null()); +} + +#[tokio::test] +async fn test_list_algorithms_fbm_defaults() { + let url = start_test_server().await; + let client = reqwest::Client::new(); + let response = client + .get(format!("{}/v1/algorithms", url)) + .send() + .await + .unwrap(); + let algos: Vec = response.json().await.unwrap(); + let fbm = algos.iter().find(|a| a["name"] == "fbm").unwrap(); + assert_eq!(fbm["defaults"]["octaves"], 4); + assert_eq!(fbm["defaults"]["frequency"], 0.1); + assert_eq!(fbm["defaults"]["lacunarity"], 2.0); + assert_eq!(fbm["defaults"]["persistence"], 0.5); +} + +// ─── Tests: POST /v1/noise — seed consistency (THE CRITICAL FIX) ───────────── + +#[tokio::test] +async fn test_params_used_with_explicit_seed() { + let url = start_test_server().await; + let result = post_noise(&url, "perlin", json!({"seed": 42}), vec![4, 4]).await; + assert_eq!(result["params_used"]["seed"], 42); +} + +#[tokio::test] +async fn test_params_used_without_seed_has_concrete_value() { + let url = start_test_server().await; + let result = post_noise(&url, "perlin", json!({}), vec![4, 4]).await; + let seed = result["params_used"]["seed"].as_u64().unwrap(); + assert!( + seed > 0, + "auto-generated seed must be non-zero, got {}", + seed + ); +} + +#[tokio::test] +async fn test_params_used_seed_matches_generation() { + // POST twice with same empty params → different random seeds expected + let url = start_test_server().await; + let r1 = post_noise(&url, "perlin", json!({}), vec![4, 4]).await; + let r2 = post_noise(&url, "perlin", json!({}), vec![4, 4]).await; + let s1 = r1["params_used"]["seed"].as_u64().unwrap(); + let s2 = r2["params_used"]["seed"].as_u64().unwrap(); + // Different requests → different random seeds (statistically certain) + assert_ne!( + s1, s2, + "two requests without seed should produce different random seeds" + ); + // The data should differ because seeds differ → consistency check + assert_ne!( + r1["data"], r2["data"], + "different seeds must produce different data" + ); +} + +#[tokio::test] +async fn test_params_used_reproducibility() { + // Same explicit seed → identical data AND identical params_used + let url = start_test_server().await; + let r1 = post_noise(&url, "perlin", json!({"seed": 123}), vec![4, 4]).await; + let r2 = post_noise(&url, "perlin", json!({"seed": 123}), vec![4, 4]).await; + assert_eq!(r1["data"], r2["data"], "same seed must produce same data"); + assert_eq!(r1["params_used"], r2["params_used"]); +} + +// ─── Tests: POST /v1/noise — params_used per algorithm family ──────────────── +// +// Table-driven: each case is (algorithm, request params, expected params_used +// fields to check). + +struct ParamsUsedCase { + algorithm: &'static str, + params: serde_json::Value, + expect: Vec<(&'static str, serde_json::Value)>, +} + +fn params_used_cases() -> Vec { + vec![ + ParamsUsedCase { + algorithm: "cellular", + params: json!({"seed": 7, "distance_function": "manhattan", "return_type": "distance", "jitter": 0.9}), + expect: vec![ + ("seed", json!(7)), + ("distance_function", json!("manhattan")), + ("return_type", json!("distance")), + ("jitter", json!(0.9)), + ], + }, + ParamsUsedCase { + algorithm: "cellular", + params: json!({}), + expect: vec![ + ("distance_function", json!("euclidean")), + ("return_type", json!("cell_value")), + ("jitter", json!(0.45)), + ], + }, + ParamsUsedCase { + algorithm: "fbm", + params: json!({"seed": 1, "octaves": 6, "frequency": 0.05, "lacunarity": 3.0, "persistence": 0.3}), + expect: vec![ + ("seed", json!(1)), + ("octaves", json!(6)), + ("frequency", json!(0.05)), + ("lacunarity", json!(3.0)), + ("persistence", json!(0.3)), + ], + }, + ParamsUsedCase { + algorithm: "fbm", + params: json!({}), + expect: vec![ + ("octaves", json!(4)), + ("frequency", json!(0.1)), + ("lacunarity", json!(2.0)), + ("persistence", json!(0.5)), + ], + }, + ParamsUsedCase { + algorithm: "pingpong", + params: json!({"seed": 42, "strength": 3.0}), + expect: vec![("seed", json!(42)), ("strength", json!(3.0))], + }, + ParamsUsedCase { + algorithm: "domain_warp", + params: json!({"seed": 99, "amplitude": 30.0}), + expect: vec![("seed", json!(99)), ("amplitude", json!(30.0))], + }, + ParamsUsedCase { + algorithm: "combinator", + params: json!({"seed": 10, "op": "add", "blend_factor": 0.3}), + expect: vec![ + ("seed", json!(10)), + ("op", json!("add")), + ("blend_factor", json!(0.3)), + ], + }, + ParamsUsedCase { + algorithm: "combinator", + params: json!({}), + expect: vec![("op", json!("add")), ("blend_factor", json!(0.5))], + }, + ParamsUsedCase { + algorithm: "utility", + params: json!({"kind": "constant", "value": 0.75}), + expect: vec![("kind", json!("constant")), ("value", json!(0.75))], + }, + ParamsUsedCase { + algorithm: "utility", + params: json!({"kind": "cylinders"}), + expect: vec![("kind", json!("cylinders"))], + }, + ParamsUsedCase { + algorithm: "billow", + params: json!({"seed": 1}), + expect: vec![ + ("seed", json!(1)), + ("octaves", json!(4)), + ("persistence", json!(0.5)), + ], + }, + ParamsUsedCase { + algorithm: "ridged_multi", + params: json!({"seed": 1}), + expect: vec![("seed", json!(1)), ("persistence", json!(0.5))], + }, + ParamsUsedCase { + algorithm: "hybrid_multi", + params: json!({"seed": 1}), + expect: vec![("seed", json!(1)), ("persistence", json!(0.25))], + }, + ] +} + +#[tokio::test] +async fn test_params_used_matches_expected_per_algorithm() { + let url = start_test_server().await; + for case in params_used_cases() { + let result = post_noise(&url, case.algorithm, case.params.clone(), vec![4, 4]).await; + for (field, expected) in &case.expect { + let actual = &result["params_used"][field]; + let matches = match (actual.as_f64(), expected.as_f64()) { + (Some(a), Some(e)) => (a - e).abs() < 1e-6, + _ => actual == expected, + }; + assert!( + matches, + "{}: params_used.{} = {:?}, expected {:?} (request params: {:?})", + case.algorithm, field, actual, expected, case.params + ); + } + } +} + +// ─── Tests: Response structure ──────────────────────────────────────────────── + +#[tokio::test] +async fn test_response_structure() { + let url = start_test_server().await; + let result = post_noise(&url, "simplex", json!({"seed": 42}), vec![5, 5]).await; + assert!(result["id"].as_str().unwrap().starts_with("nsf_")); + assert_eq!(result["status"], "completed"); + assert_eq!(result["algorithm"], "simplex"); + assert_eq!(result["size"], json!([5, 5])); + assert!(result["params_used"].is_object()); + let data = result["data"].as_array().unwrap(); + assert_eq!(data.len(), 5); + assert_eq!(data[0].as_array().unwrap().len(), 5); +} + +#[tokio::test] +async fn test_response_contains_params_used() { + let url = start_test_server().await; + let result = post_noise(&url, "perlin", json!({"seed": 42}), vec![4, 4]).await; + assert!( + result.get("params_used").is_some(), + "response must contain params_used" + ); +} + +// ─── Tests: Error cases ────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_error_unsupported_dimension() { + let url = start_test_server().await; + let response = post_noise_raw(&url, "perlin", json!({}), vec![10]).await; + assert_eq!(response.status(), 400); + let result: serde_json::Value = response.json().await.unwrap(); + assert!(result["status"].as_str().unwrap().starts_with("error:")); + assert_eq!(result["algorithm"], "perlin"); + assert_eq!(result["data"], serde_json::Value::Null); + assert_eq!(result["params_used"], serde_json::Value::Null); +} + +// ─── Tests: Param differences produce different output ─────────────────────── + +#[tokio::test] +async fn test_cellular_parameters_affect_output() { + let url = start_test_server().await; + + let r1 = post_noise( + &url, + "cellular", + json!({"distance_function": "euclidean", "return_type": "cell_value", "jitter": 0.45}), + vec![5, 5], + ) + .await; + let r2 = post_noise( + &url, + "cellular", + json!({"distance_function": "manhattan", "return_type": "distance", "jitter": 0.9}), + vec![5, 5], + ) + .await; + + assert_ne!(r1["data"], r2["data"]); + assert_ne!(r1["params_used"], r2["params_used"]); +} + +#[tokio::test] +async fn test_fbm_seed_affects_output() { + let url = start_test_server().await; + let r1 = post_noise(&url, "fbm", json!({"seed": 1, "octaves": 3}), vec![20, 20]).await; + let r2 = post_noise(&url, "fbm", json!({"seed": 2, "octaves": 4}), vec![20, 20]).await; + assert_ne!(r1["data"], r2["data"]); +} + +#[tokio::test] +async fn test_white_noise_seed_affects_output() { + let url = start_test_server().await; + let r1 = post_noise(&url, "white", json!({"seed": 1}), vec![20, 20]).await; + let r2 = post_noise(&url, "white", json!({"seed": 2}), vec![20, 20]).await; + assert_ne!(r1["data"], r2["data"]); +} + +// ─── Tests: Edge cases (empty params, normalize) ───────────────────────────── + +#[tokio::test] +async fn test_empty_params_still_returns_params_used() { + let url = start_test_server().await; + let result = post_noise(&url, "value", json!({}), vec![4, 4]).await; + assert!(result["params_used"]["seed"].as_u64().unwrap() > 0); +} + +// ─── Tests: Dimension support ──────────────────────────────────────────────── + +/// Returns the algorithms that should support a given number of dimensions. +/// 1 = 1D, 2 = 2D, 3 = 3D. +fn algorithms_for_dim(dim: usize) -> Vec<&'static str> { + match dim { + 1 => vec!["white"], + 2 | 3 => vec![ + "perlin", + "simplex", + "opensimplex2", + "supersimplex", + "value", + "cellular", + "fbm", + "billow", + "ridged_multi", + "hybrid_multi", + "pingpong", + "domain_warp", + "combinator", + "utility", + "white", + ], + _ => algorithms_for_4d(), + } +} + +#[tokio::test] +async fn test_all_algorithms_2d_success() { + let url = start_test_server().await; + for alg in algorithms_for_dim(2) { + let params = if alg == "utility" { + json!({"kind": "constant", "value": 0.5}) + } else { + json!({"seed": 42}) + }; + let result = post_noise(&url, alg, params, vec![4, 4]).await; + assert_eq!(result["algorithm"], alg); + assert_eq!(result["size"], json!([4, 4])); + assert_eq!(result["status"], "completed"); + let data = result["data"].as_array().unwrap(); + assert_eq!(data.len(), 4, "algorithm {}: expected 4 rows", alg); + assert_eq!( + data[0].as_array().unwrap().len(), + 4, + "algorithm {}: expected 4 cols", + alg + ); + assert!(result["params_used"].is_object()); + } +} + +#[tokio::test] +async fn test_all_algorithms_3d_success() { + let url = start_test_server().await; + for alg in algorithms_for_dim(3) { + let params = if alg == "utility" { + json!({"kind": "constant", "value": 0.5}) + } else { + json!({"seed": 42}) + }; + let result = post_noise(&url, alg, params, vec![3, 3, 3]).await; + assert_eq!(result["algorithm"], alg); + assert_eq!(result["size"], json!([3, 3, 3])); + assert_eq!(result["status"], "completed"); + let volume = result["data"].as_array().unwrap(); + assert_eq!(volume.len(), 3, "algorithm {}: expected depth 3", alg); + assert_eq!( + volume[0].as_array().unwrap().len(), + 3, + "algorithm {}: expected 3 rows", + alg + ); + assert_eq!( + volume[0].as_array().unwrap()[0].as_array().unwrap().len(), + 3, + "algorithm {}: expected 3 cols", + alg + ); + } +} + +#[tokio::test] +async fn test_white_noise_1d_success() { + let url = start_test_server().await; + let result = post_noise(&url, "white", json!({"seed": 1}), vec![10]).await; + assert_eq!(result["size"], json!([10])); + let data = result["data"].as_array().unwrap(); + assert_eq!(data.len(), 10); + assert_eq!(result["algorithm"], "white"); + assert_eq!(result["params_used"]["seed"], 1); +} + +#[tokio::test] +async fn test_non_white_1d_fails_with_400() { + let url = start_test_server().await; + for alg in algorithms_for_dim(2) { + if alg == "white" { + continue; + } + let response = post_noise_raw(&url, alg, json!({"seed": 42}), vec![10]).await; + assert_eq!( + response.status(), + 400, + "algorithm {} should fail with 400 for 1D", + alg + ); + let result: serde_json::Value = response.json().await.unwrap(); + assert!( + result["status"].as_str().unwrap().starts_with("error:"), + "algorithm {}: expected error status, got {:?}", + alg, + result["status"] + ); + assert_eq!(result["params_used"], serde_json::Value::Null); + assert_eq!(result["data"], serde_json::Value::Null); + assert_eq!(result["algorithm"], alg); + } +} + +#[tokio::test] +async fn test_dimension_inferred_from_size_length() { + let url = start_test_server().await; + + // 1D with white noise + let r1 = post_noise(&url, "white", json!({"seed": 1}), vec![5]).await; + assert_eq!(r1["size"], json!([5])); + + // 2D with white noise + let r2 = post_noise(&url, "white", json!({"seed": 1}), vec![5, 6]).await; + assert_eq!(r2["size"], json!([5, 6])); + assert_eq!(r2["data"].as_array().unwrap().len(), 6); + assert_eq!( + r2["data"].as_array().unwrap()[0].as_array().unwrap().len(), + 5 + ); + + // 3D with white noise + let r3 = post_noise(&url, "white", json!({"seed": 1}), vec![5, 6, 7]).await; + assert_eq!(r3["size"], json!([5, 6, 7])); + assert_eq!(r3["data"].as_array().unwrap().len(), 7); + assert_eq!( + r3["data"].as_array().unwrap()[0].as_array().unwrap().len(), + 6 + ); + assert_eq!( + r3["data"].as_array().unwrap()[0].as_array().unwrap()[0] + .as_array() + .unwrap() + .len(), + 5 + ); +} + +#[tokio::test] +async fn test_1d_error_message_contains_algorithm_name() { + let url = start_test_server().await; + let response = post_noise_raw(&url, "fbm", json!({"seed": 42}), vec![10]).await; + assert_eq!(response.status(), 400); + let result: serde_json::Value = response.json().await.unwrap(); + let msg = result["status"].as_str().unwrap(); + assert!(msg.contains("fbm"), "error should mention algorithm name"); +} + +#[tokio::test] +async fn test_perlin_2d_and_3d_different_data() { + let url = start_test_server().await; + let r2d = post_noise(&url, "perlin", json!({"seed": 42}), vec![4, 4]).await; + let r3d = post_noise(&url, "perlin", json!({"seed": 42}), vec![4, 4, 4]).await; + assert!(r2d["data"].as_array().unwrap()[0].as_array().is_some()); + assert!(r3d["data"].as_array().unwrap()[0].as_array().unwrap()[0] + .as_array() + .is_some()); + assert_eq!(r2d["params_used"], r3d["params_used"]); +} + +#[tokio::test] +async fn test_all_2d_algorithms_produce_rectangular_grid() { + let url = start_test_server().await; + for alg in algorithms_for_dim(2) { + if alg == "white" || alg == "utility" { + continue; + } + let result = post_noise(&url, alg, json!({"seed": 42}), vec![3, 5]).await; + assert_eq!(result["size"], json!([3, 5])); + let data = result["data"].as_array().unwrap(); + assert_eq!(data.len(), 5, "{}: expected 5 rows (height)", alg); + assert_eq!( + data[0].as_array().unwrap().len(), + 3, + "{}: expected 3 cols (width)", + alg + ); + } +} + +#[tokio::test] +async fn test_normalize_output() { + let url = start_test_server().await; + let client = reqwest::Client::new(); + let response = client + .post(format!("{}/v1/noise", url)) + .json(&json!({ + "algorithm": "perlin", + "params": {"seed": 42}, + "sampling": {"mode": "grid", "size": [4, 4]}, + "output": {"format": "json", "normalize": true} + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + let result: serde_json::Value = response.json().await.unwrap(); + assert!(result["params_used"].is_object()); + // Data should be in [0, 1] after normalization + for row in result["data"].as_array().unwrap() { + for val in row.as_array().unwrap() { + let v = val.as_f64().unwrap(); + assert!( + (0.0..=1.0).contains(&v), + "normalized value {} out of range", + v + ); + } + } +} + +// ─── Helper: algorithms that SUPPORT 4D ───────────────────────────────── + +fn algorithms_for_4d() -> Vec<&'static str> { + vec![ + "simplex", + "fbm", + "billow", + "ridged_multi", + "hybrid_multi", + "combinator", + "utility", + "white", + ] +} + +/// Algorithms that do NOT support 4D (FNL-only, DomainWarp, SuperSimplex). +fn algorithms_not_for_4d() -> Vec<&'static str> { + vec![ + "perlin", + "opensimplex2", + "supersimplex", + "value", + "cellular", + "pingpong", + "domain_warp", + ] +} + +// ─── Tests: 4D support ───────────────────────────────────────────────── + +#[tokio::test] +async fn test_4d_success_for_noise_rs_algorithms() { + let url = start_test_server().await; + for alg in algorithms_for_4d() { + let params = if alg == "utility" { + json!({"kind": "constant", "value": 0.5}) + } else { + json!({"seed": 42}) + }; + let result = post_noise(&url, alg, params, vec![3, 3, 3, 3]).await; + assert_eq!( + result["status"], "completed", + "algorithm {} should succeed with 4D", + alg + ); + assert_eq!(result["size"], json!([3, 3, 3, 3])); + let hypervolume = result["data"].as_array().unwrap(); + assert_eq!(hypervolume.len(), 3, "{}: expected 4th dim size 3", alg); + assert_eq!( + hypervolume[0].as_array().unwrap().len(), + 3, + "{}: expected depth 3", + alg + ); + assert_eq!( + hypervolume[0].as_array().unwrap()[0] + .as_array() + .unwrap() + .len(), + 3, + "{}: expected 3 rows", + alg + ); + assert_eq!( + hypervolume[0].as_array().unwrap()[0].as_array().unwrap()[0] + .as_array() + .unwrap() + .len(), + 3, + "{}: expected 3 cols", + alg + ); + assert!(result["params_used"].is_object()); + } +} + +#[tokio::test] +async fn test_4d_rejected_for_fnl_algorithms() { + let url = start_test_server().await; + for alg in algorithms_not_for_4d() { + let params = if alg == "utility" { + json!({"kind": "constant", "value": 0.5}) + } else { + json!({"seed": 42}) + }; + let response = post_noise_raw(&url, alg, params, vec![4, 4, 4, 4]).await; + assert_eq!( + response.status(), + 400, + "algorithm {} should reject 4D with 400", + alg + ); + let result: serde_json::Value = response.json().await.unwrap(); + let msg = result["status"].as_str().unwrap(); + assert!( + msg.contains("4D") || msg.contains("dimension") || msg.contains("support"), + "algorithm {}: error should mention 4D/dimension, got: {}", + alg, + msg + ); + } +} + +#[tokio::test] +async fn test_5d_rejected_for_white_noise() { + let url = start_test_server().await; + let response = post_noise_raw(&url, "white", json!({"seed": 42}), vec![4, 4, 4, 4, 4]).await; + assert_eq!(response.status(), 400); + let result: serde_json::Value = response.json().await.unwrap(); + let msg = result["status"].as_str().unwrap(); + assert!( + msg.contains("5D") || msg.contains("dimension") || msg.contains("support"), + "error should mention dimension limit (5D)" + ); +} + +#[tokio::test] +async fn test_oversized_dimension_rejected_with_400() { + let url = start_test_server().await; + // A single dimension far beyond the per-dimension cap must be rejected + // before any allocation happens. + let response = post_noise_raw(&url, "perlin", json!({"seed": 1}), vec![50_000, 50_000]).await; + assert_eq!(response.status(), 400); + let result: serde_json::Value = response.json().await.unwrap(); + assert!(result["status"].as_str().unwrap().starts_with("error:")); + assert_eq!(result["data"], serde_json::Value::Null); +} + +#[tokio::test] +async fn test_oversized_total_cells_rejected_with_400() { + let url = start_test_server().await; + // Individually-small dimensions whose product exceeds the total-cell cap + // must still be rejected. + let response = post_noise_raw(&url, "perlin", json!({"seed": 1}), vec![4000, 4000, 4000]).await; + assert_eq!(response.status(), 400); + let result: serde_json::Value = response.json().await.unwrap(); + assert!(result["status"].as_str().unwrap().starts_with("error:")); + assert_eq!(result["data"], serde_json::Value::Null); +} + +#[tokio::test] +async fn test_zero_dimension_rejected_with_400() { + let url = start_test_server().await; + let response = post_noise_raw(&url, "perlin", json!({"seed": 1}), vec![0, 8]).await; + assert_eq!(response.status(), 400); + let result: serde_json::Value = response.json().await.unwrap(); + assert!(result["status"].as_str().unwrap().starts_with("error:")); +} + +#[tokio::test] +async fn test_csv_output_format_rejected_by_api() { + let url = start_test_server().await; + let client = reqwest::Client::new(); + let response = client + .post(format!("{}/v1/noise", url)) + .json(&json!({ + "algorithm": "perlin", + "params": {"seed": 1}, + "sampling": { "size": [4, 4] }, + "output": { "format": "csv", "normalize": false } + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + let result: serde_json::Value = response.json().await.unwrap(); + assert!( + result["status"].as_str().unwrap().contains("csv"), + "error should mention csv, got: {:?}", + result["status"] + ); + assert_eq!(result["data"], serde_json::Value::Null); +} + +/// Regression test: a rejected request that omits `sampling.size` must echo +/// the same default size (`[64, 64]`) the service would have used on +/// success — not an empty array. +#[tokio::test] +async fn test_error_response_echoes_default_size_when_omitted() { + let url = start_test_server().await; + let client = reqwest::Client::new(); + let response = client + .post(format!("{}/v1/noise", url)) + .json(&json!({ + "algorithm": "perlin", + "params": {"seed": 1}, + "sampling": {}, + "output": { "format": "csv", "normalize": false } + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + let result: serde_json::Value = response.json().await.unwrap(); + assert_eq!(result["size"], json!([64, 64])); +} + +#[tokio::test] +async fn test_octaves_echoed_value_is_clamped_to_generated_value() { + let url = start_test_server().await; + // 100 octaves exceeds the noise crate's internal clamp of 32; the echoed + // `params_used.octaves` must reflect what was actually generated. + let result = post_noise(&url, "fbm", json!({"seed": 1, "octaves": 100}), vec![4, 4]).await; + assert_eq!(result["params_used"]["octaves"], 32); +} diff --git a/services/noise-generation-service/tests/test_all_algorithms.sh b/services/noise-generation-service/tests/test_all_algorithms.sh new file mode 100755 index 0000000..a48acac --- /dev/null +++ b/services/noise-generation-service/tests/test_all_algorithms.sh @@ -0,0 +1,323 @@ +#!/bin/bash +# ============================================================================= +# Smoke Tests for the Noise Generation Service +# +# Every test calls POST /v1/noise and validates the full JSON response, +# which includes: id, status, algorithm, data (full grid), and size. +# There are no separate GET endpoints anymore. +# ============================================================================= + +set -euo pipefail + +for dep in curl jq; do + if ! command -v "$dep" >/dev/null 2>&1; then + echo "Error: '$dep' is required to run this script but was not found in PATH." >&2 + exit 1 + fi +done + +BASE_URL="${1:-http://localhost:8000}" +PASS=0 +FAIL=0 + +check_response() { + local response="$1" + local expected_alg="$2" + + local alg + alg=$(echo "$response" | jq -r '.algorithm') + local status + status=$(echo "$response" | jq -r '.status') + local has_id + has_id=$(echo "$response" | jq 'has("id")') + local has_data + has_data=$(echo "$response" | jq 'has("data")') + local has_size + has_size=$(echo "$response" | jq 'has("size")') + + if [[ "$alg" != "$expected_alg" ]]; then + echo " ❌ FAIL: algorithm mismatch (got '$alg', expected '$expected_alg')" + return 1 + fi + if [[ "$status" != "completed" ]]; then + echo " ❌ FAIL: status is '$status', expected 'completed'" + return 1 + fi + if [[ "$has_id" != "true" ]]; then + echo " ❌ FAIL: missing 'id' field" + return 1 + fi + if [[ "$has_data" != "true" ]]; then + echo " ❌ FAIL: missing 'data' field (full grid)" + return 1 + fi + if [[ "$has_size" != "true" ]]; then + echo " ❌ FAIL: missing 'size' field" + return 1 + fi + echo " ✅ PASS" + return 0 +} + +run_test() { + local test_name="$1" + local expected_alg="$2" + local payload="$3" + + echo "" + echo "▸ $test_name" + + local response + response=$(curl -s -X POST "$BASE_URL/v1/noise" \ + -H "Content-Type: application/json" \ + -d "$payload") + + # Check HTTP / JSON validity (e.g. server down or invalid payload) + if echo "$response" | jq -e . >/dev/null 2>&1; then + if check_response "$response" "$expected_alg"; then + PASS=$((PASS + 1)) + else + echo " Response: $(echo "$response" | jq -c '{id, algorithm, status, size}')" + FAIL=$((FAIL + 1)) + fi + else + echo " ❌ FAIL: invalid JSON — is the server running?" + echo " Response: $response" + FAIL=$((FAIL + 1)) + fi +} + +echo "╔══════════════════════════════════════════════════════════════╗" +echo "║ Noise Generation Service — Full Smoke Test Suite ║" +echo "║ Target: $BASE_URL" +echo "╚══════════════════════════════════════════════════════════════╝" + +# ──────────── Core Noise Algorithms ──────────── + +# Test 1: Perlin (fastnoise_lite) +# Verifies basic Perlin noise generation using the default backend +run_test \ + "Perlin (fastnoise_lite)" \ + "perlin" \ + '{"algorithm":"perlin","params":{"seed":42},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' + +# Test 2: Perlin with a different seed +# The service always routes "perlin" through fastnoise-lite (there is no +# separate "noise_rs" Perlin backend to select between) — this checks that a +# different seed still produces a valid response, not a second backend. +run_test \ + "Perlin (different seed)" \ + "perlin" \ + '{"algorithm":"perlin","params":{"seed":99},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' + +# Test 3: Simplex (noise_rs only) +# Verifies Simplex noise — improved visual quality with reduced computational complexity +run_test \ + "Simplex (noise_rs)" \ + "simplex" \ + '{"algorithm":"simplex","params":{"seed":123},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' + +# Test 4: OpenSimplex2 (fastnoise_lite) +# Verifies OpenSimplex2 — modern variant with better visual quality +run_test \ + "OpenSimplex2 (fastnoise_lite)" \ + "opensimplex2" \ + '{"algorithm":"opensimplex2","params":{"seed":456},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' + +# Test 5: OpenSimplex2 (noise_rs) +# Verifies OpenSimplex2 with alternative backend +run_test \ + "OpenSimplex2 (noise_rs)" \ + "opensimplex2" \ + '{"algorithm":"opensimplex2","params":{"seed":456},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' + +# Test 6: SuperSimplex (noise_rs only) +# Verifies SuperSimplex — higher-dimensional noise variant +run_test \ + "SuperSimplex (noise_rs)" \ + "supersimplex" \ + '{"algorithm":"supersimplex","params":{"seed":789},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' + +# Test 7: Value noise (default backend) +# Verifies grid-based Value noise with cubic interpolation +run_test \ + "Value (fastnoise_lite)" \ + "value" \ + '{"algorithm":"value","params":{"seed":111},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' + +# Test 8: Cellular / Worley (fastnoise_lite) +# Verifies cell-based patterns (Voronoi/Worley) — default parameters +run_test \ + "Cellular (fastnoise_lite)" \ + "cellular" \ + '{"algorithm":"cellular","params":{"seed":222},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' + +# ──────────── Fractal Algorithms ──────────── + +# Test 9: FBM (Fractal Brownian Motion) +# Verifies multi-octave fractal noise with octaves, frequency, lacunarity & persistence +run_test \ + "FBM (noise_rs)" \ + "fbm" \ + '{"algorithm":"fbm","params":{"seed":333,"octaves":4,"frequency":0.1,"lacunarity":2.0,"persistence":0.5},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' + +# Test 10: Billow (noise_rs only) +# Verifies Billow noise (absolute value of Perlin) — produces soft, cloud-like structures +run_test \ + "Billow (noise_rs)" \ + "billow" \ + '{"algorithm":"billow","params":{"seed":444,"octaves":3},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' + +# Test 11: Ridged Multi (fastnoise_lite) +# Verifies ridge-like fractal patterns with sharp ridges +run_test \ + "RidgedMulti (fastnoise_lite)" \ + "ridged_multi" \ + '{"algorithm":"ridged_multi","params":{"seed":555,"octaves":4},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' + +# Test 12: Hybrid Multi (noise_rs only) +# Verifies Hybrid Multi-fractal noise — combination of multiple noise types +run_test \ + "HybridMulti (noise_rs)" \ + "hybrid_multi" \ + '{"algorithm":"hybrid_multi","params":{"seed":666,"octaves":4},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' + +# Test 13: PingPong (fastnoise_lite only) +# Verifies PingPong fractal with folding effect controlled via "strength" parameter +run_test \ + "PingPong (fastnoise_lite)" \ + "pingpong" \ + '{"algorithm":"pingpong","params":{"seed":777,"strength":2.5},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' + +# ──────────── Advanced Algorithms ──────────── + +# Test 14: Domain Warp (fastnoise_lite only) +# Verifies Domain Warping — coordinate transformation for organic distortion effects +run_test \ + "Domain Warp (fastnoise_lite)" \ + "domain_warp" \ + '{"algorithm":"domain_warp","params":{"seed":888,"amplitude":1.5},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' + +# Test 15: Combinator (Add) +# Verifies generic combinators — combining two noise sources via "add" +run_test \ + "Combinator (Add)" \ + "combinator" \ + '{"algorithm":"combinator","params":{"seed":999,"op":"add"},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' + +# Test 16: Utility (Constant) +# Verifies deterministic utility generators — constant value of 0.5 across the entire grid +run_test \ + "Utility (Constant)" \ + "utility" \ + '{"algorithm":"utility","params":{"kind":"constant","value":0.5},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' + +# Test 17: White Noise (native) +# Verifies native white noise implementation — pure hash-based randomness without interpolation grid +run_test \ + "White Noise (native)" \ + "white" \ + '{"algorithm":"white","params":{"seed":1010},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' + +# ──────────── Response Validation Tests ──────────── + +# Test 18: Verifies that the response contains the full data grid +echo "" +echo "▸ Response format check: Full data grid (4×4)" +response=$(curl -s -X POST "$BASE_URL/v1/noise" \ + -H "Content-Type: application/json" \ + -d '{"algorithm":"perlin","params":{"seed":7},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}') +data_rows=$(echo "$response" | jq '.data | length') +data_cols=$(echo "$response" | jq '.data[0] | length') +size_field=$(echo "$response" | jq -c '.size') +if [[ "$data_rows" -eq 4 && "$data_cols" -eq 4 && "$size_field" == "[4,4]" ]]; then + echo " ✅ PASS (data=${data_rows}×${data_cols}, size=${size_field})" + PASS=$((PASS + 1)) +else + echo " ❌ FAIL: expected 4×4 grid, got data=${data_rows}×${data_cols}, size=${size_field}" + FAIL=$((FAIL + 1)) +fi + +# Test 19: Verifies that different seeds generate different data +echo "" +echo "▸ Deterministic seed check: Seed 1 ≠ Seed 2 (Perlin 4×4)" +r1=$(curl -s -X POST "$BASE_URL/v1/noise" \ + -H "Content-Type: application/json" \ + -d '{"algorithm":"perlin","params":{"seed":1},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' | jq -c '.data') +r2=$(curl -s -X POST "$BASE_URL/v1/noise" \ + -H "Content-Type: application/json" \ + -d '{"algorithm":"perlin","params":{"seed":2},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' | jq -c '.data') +if [[ "$r1" != "$r2" ]]; then + echo " ✅ PASS (different seeds -> different data)" + PASS=$((PASS + 1)) +else + echo " ❌ FAIL: Seeds 1 and 2 produced identical data" + FAIL=$((FAIL + 1)) +fi + +# Test 20: Verifies that the same seed deterministically returns identical data +echo "" +echo "▸ Deterministic reproducibility check: Same seed -> same data" +r1=$(curl -s -X POST "$BASE_URL/v1/noise" \ + -H "Content-Type: application/json" \ + -d '{"algorithm":"perlin","params":{"seed":42},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' | jq -c '.data') +r2=$(curl -s -X POST "$BASE_URL/v1/noise" \ + -H "Content-Type: application/json" \ + -d '{"algorithm":"perlin","params":{"seed":42},"sampling":{"mode":"2d","dimensions":2,"size":[4,4]}}' | jq -c '.data') +if [[ "$r1" == "$r2" ]]; then + echo " ✅ PASS (same seed -> same data)" + PASS=$((PASS + 1)) +else + echo " ❌ FAIL: Same seed produced different data" + FAIL=$((FAIL + 1)) +fi + +# ──────────── Performance Tests ──────────── + +# Test 21: Performance — Medium grid (256×256) +# Verifies if a 256×256 grid can be generated within acceptable time limits (< 10s) +echo "" +echo "▸ Performance test: Medium grid 256×256" +start=$(date +%s%N) +response=$(curl -s -X POST "$BASE_URL/v1/noise" \ + -H "Content-Type: application/json" \ + -d '{"algorithm":"perlin","params":{"seed":42},"sampling":{"mode":"2d","dimensions":2,"size":[256,256]}}') +end=$(date +%s%N) +elapsed_ms=$(( (end - start) / 1000000 )) +data_rows=$(echo "$response" | jq '.data | length') +data_cols=$(echo "$response" | jq '.data[0] | length') +if [[ "$data_rows" -eq 256 && "$data_cols" -eq 256 && "$elapsed_ms" -lt 10000 ]]; then + echo " ✅ PASS (${data_rows}×${data_cols} in ${elapsed_ms}ms)" + PASS=$((PASS + 1)) +else + echo " ❌ FAIL: 256×256 grid — data=${data_rows}×${data_cols}, time=${elapsed_ms}ms" + FAIL=$((FAIL + 1)) +fi + +# Test 22: Performance — Large grid (512×512) +# Verifies if a 512×512 grid can be generated (~2 MB JSON payload, allowed up to 30s) +echo "" +echo "▸ Performance test: Large grid 512×512 (~2 MB JSON)" +start=$(date +%s%N) +response=$(curl -s -X POST "$BASE_URL/v1/noise" \ + -H "Content-Type: application/json" \ + -d '{"algorithm":"perlin","params":{"seed":42},"sampling":{"mode":"2d","dimensions":2,"size":[512,512]}}') +end=$(date +%s%N) +elapsed_ms=$(( (end - start) / 1000000 )) +data_rows=$(echo "$response" | jq '.data | length') +data_cols=$(echo "$response" | jq '.data[0] | length') +if [[ "$data_rows" -eq 512 && "$data_cols" -eq 512 && "$elapsed_ms" -lt 30000 ]]; then + echo " ✅ PASS (${data_rows}×${data_cols} in ${elapsed_ms}ms)" + PASS=$((PASS + 1)) +else + echo " ❌ FAIL: 512×512 grid — data=${data_rows}×${data_cols}, time=${elapsed_ms}ms" + FAIL=$((FAIL + 1)) +fi + +# ──────────── Summary ──────────── +echo "" +echo "╔══════════════════════════════════════════════════════════════╗" +echo "║ Test Summary" +echo "║ Total: $((PASS + FAIL)) | ✅ Passed: $PASS | ❌ Failed: $FAIL" +echo "╚══════════════════════════════════════════════════════════════╝" +exit "$FAIL" \ No newline at end of file diff --git a/third_party/gedlibpy b/third_party/gedlibpy new file mode 160000 index 0000000..637c503 --- /dev/null +++ b/third_party/gedlibpy @@ -0,0 +1 @@ +Subproject commit 637c503349eb3ab4d07fce27c90054094f0cb7dc