diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..bbfcb51 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,181 @@ +# # This file is autogenerated by maturin v1.7.4 +# # To update, run +# # +# # maturin generate-ci github +# # +# name: CI + +# on: +# push: +# branches: +# - main +# - master +# tags: +# - '*' +# pull_request: +# workflow_dispatch: + +# permissions: +# contents: read + +# jobs: +# linux: +# runs-on: ${{ matrix.platform.runner }} +# strategy: +# matrix: +# platform: +# - runner: ubuntu-latest +# target: x86_64 +# - runner: ubuntu-latest +# target: x86 +# - runner: ubuntu-latest +# target: aarch64 +# - runner: ubuntu-latest +# target: armv7 +# - runner: ubuntu-latest +# target: s390x +# - runner: ubuntu-latest +# target: ppc64le +# steps: +# - uses: actions/checkout@v4 +# - uses: actions/setup-python@v5 +# with: +# python-version: 3.x +# - name: Build wheels +# uses: PyO3/maturin-action@v1 +# with: +# target: ${{ matrix.platform.target }} +# args: --release --out dist --find-interpreter +# sccache: 'true' +# manylinux: auto +# - name: Upload wheels +# uses: actions/upload-artifact@v4 +# with: +# name: wheels-linux-${{ matrix.platform.target }} +# path: dist + +# musllinux: +# runs-on: ${{ matrix.platform.runner }} +# strategy: +# matrix: +# platform: +# - runner: ubuntu-latest +# target: x86_64 +# - runner: ubuntu-latest +# target: x86 +# - runner: ubuntu-latest +# target: aarch64 +# - runner: ubuntu-latest +# target: armv7 +# steps: +# - uses: actions/checkout@v4 +# - uses: actions/setup-python@v5 +# with: +# python-version: 3.x +# - name: Build wheels +# uses: PyO3/maturin-action@v1 +# with: +# target: ${{ matrix.platform.target }} +# args: --release --out dist --find-interpreter +# sccache: 'true' +# manylinux: musllinux_1_2 +# - name: Upload wheels +# uses: actions/upload-artifact@v4 +# with: +# name: wheels-musllinux-${{ matrix.platform.target }} +# path: dist + +# windows: +# runs-on: ${{ matrix.platform.runner }} +# strategy: +# matrix: +# platform: +# - runner: windows-latest +# target: x64 +# - runner: windows-latest +# target: x86 +# steps: +# - uses: actions/checkout@v4 +# - uses: actions/setup-python@v5 +# with: +# python-version: 3.x +# architecture: ${{ matrix.platform.target }} +# - name: Build wheels +# uses: PyO3/maturin-action@v1 +# with: +# target: ${{ matrix.platform.target }} +# args: --release --out dist --find-interpreter +# sccache: 'true' +# - name: Upload wheels +# uses: actions/upload-artifact@v4 +# with: +# name: wheels-windows-${{ matrix.platform.target }} +# path: dist + +# macos: +# runs-on: ${{ matrix.platform.runner }} +# strategy: +# matrix: +# platform: +# - runner: macos-12 +# target: x86_64 +# - runner: macos-14 +# target: aarch64 +# steps: +# - uses: actions/checkout@v4 +# - uses: actions/setup-python@v5 +# with: +# python-version: 3.x +# - name: Build wheels +# uses: PyO3/maturin-action@v1 +# with: +# target: ${{ matrix.platform.target }} +# args: --release --out dist --find-interpreter +# sccache: 'true' +# - name: Upload wheels +# uses: actions/upload-artifact@v4 +# with: +# name: wheels-macos-${{ matrix.platform.target }} +# path: dist + +# sdist: +# runs-on: ubuntu-latest +# steps: +# - uses: actions/checkout@v4 +# - name: Build sdist +# uses: PyO3/maturin-action@v1 +# with: +# command: sdist +# args: --out dist +# - name: Upload sdist +# uses: actions/upload-artifact@v4 +# with: +# name: wheels-sdist +# path: dist + +# release: +# name: Release +# runs-on: ubuntu-latest +# if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }} +# needs: [linux, musllinux, windows, macos, sdist] +# permissions: +# # Use to sign the release artifacts +# id-token: write +# # Used to upload release artifacts +# contents: write +# # Used to generate artifact attestation +# attestations: write +# steps: +# - uses: actions/download-artifact@v4 +# - name: Generate artifact attestation +# uses: actions/attest-build-provenance@v1 +# with: +# subject-path: 'wheels-*/*' +# - name: Publish to PyPI +# if: "startsWith(github.ref, 'refs/tags/')" +# uses: PyO3/maturin-action@v1 +# env: +# MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} +# with: +# command: upload +# args: --non-interactive --skip-existing wheels-*/* diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6ed2153..1a721db 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,23 +1,23 @@ -name: Publish to PyPI +# name: Publish to PyPI -on: - push: - tags: - - "v*.*.*" +# on: +# push: +# tags: +# - "v*.*.*" -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 +# jobs: +# build: +# runs-on: ubuntu-latest +# steps: +# - uses: actions/checkout@v4 - - name: Install uv - run: curl -LsSf https://astral.sh/uv/install.sh | sh +# - name: Install uv +# run: curl -LsSf https://astral.sh/uv/install.sh | sh - - name: Build package - run: uv build +# - name: Build package +# run: uv build - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} +# - name: Publish to PyPI +# uses: pypa/gh-action-pypi-publish@release/v1 +# with: +# password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore index 15201ac..5de5780 100644 --- a/.gitignore +++ b/.gitignore @@ -1,171 +1,76 @@ +/target + # Byte-compiled / optimized / DLL files __pycache__/ +.pytest_cache/ *.py[cod] -*$py.class # C extensions *.so # Distribution / packaging .Python +.venv/ +env/ +bin/ +.env build/ develop-eggs/ dist/ -downloads/ eggs/ -.eggs/ lib/ lib64/ parts/ sdist/ var/ -wheels/ -share/python-wheels/ +include/ +man/ +venv/ *.egg-info/ .installed.cfg *.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec # Installer logs pip-log.txt pip-delete-this-directory.txt +pip-selfcheck.json # Unit test / coverage reports htmlcov/ .tox/ -.nox/ .coverage -.coverage.* .cache nosetests.xml coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ # Translations *.mo -*.pot + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject # Django stuff: *.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache +*.pot -# Scrapy stuff: -.scrapy +.DS_Store # Sphinx documentation docs/_build/ -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid +# PyCharm +.idea/ -# SageMath parsed files -*.sage.py +# VSCode +.vscode/ -# Environments +# Pyenv +.python-version +ideas/ .env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# PyPI configuration file -.pypirc +.env.local diff --git a/.python-version b/.python-version deleted file mode 100644 index bd28b9c..0000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.9 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..29d0f9d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +All notable changes to this project are documented here. Format inspired by +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows +[SemVer](https://semver.org/). Pre-1.0, expect breaking changes in minor +releases. + +## [Unreleased] + +## [0.1.0] - 2026-05-21 + +Initial public release. + +### Added +- Workspace layout split across `superstac-core`, `superstac-config`, + `superstac-search`, `superstac-engine`, and `superstac-cli`. +- Federated STAC search with concurrent fan-out, retry with exponential + backoff, per-catalog timeouts, and concurrency cap. +- Collection alias rewriting (canonical ↔ local) so users can query with one + name across heterogeneous catalogs. +- Asset alias rewriting for canonical asset key normalization. +- Item deduplication across catalogs with `seen_in` provenance. +- Response unification (collection + asset name rewriting on incoming items), + settings-gated. +- Source selection: catalogs whose introspected `/collections` doesn't include + the request are skipped. +- Per-catalog failure reporting via `SearchMetadata.failures`. +- Discovery API: `list_collections`, `catalogs_supporting`, + `collections_by_catalog`, `describe_collection`. +- Background health monitoring with configurable check frequency. +- `superstac` CLI with `search` and `collections` subcommands, `--json` + output, and `--verbose`/`--quiet` log control. +- `tracing` instrumentation across the engine, with `RUST_LOG` env var + override. +- YAML configuration with shared workspace settings + per-catalog overrides. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c2dfe28 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3628 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "annotate-snippets" +version = "0.12.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86cd1c51b95d71dde52bca69ed225008f6ff4c8cc825b08042aa1ef823e1980" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +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 = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +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 = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "as-slice" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45403b49e3954a4b8428a0ac21a4b7afadccf92bfd96273f1a58cd4812496ae0" +dependencies = [ + "generic-array 0.12.4", + "generic-array 0.13.3", + "generic-array 0.14.9", + "stable_deref_trait", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.9", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[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.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cql2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74b72642b4c3243fb843c3beb0ec9d0406eecfaa7e01d172e7cdfa437605c884" +dependencies = [ + "geo", + "geo-types", + "geojson", + "geozero", + "jiff", + "json_dotpath", + "jsonschema", + "lazy_static", + "like", + "pest", + "pest_derive", + "pg_escape", + "serde", + "serde_json", + "sqlparser", + "thiserror 2.0.18", + "unaccent", + "wkt 0.14.0", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array 0.14.9", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "earcutr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01" +dependencies = [ + "itertools", + "num-traits", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[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 = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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 = "fraction" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f158e3ff0a1b334408dc9fb811cd99b446986f4d8b741bb08f9df1604085ae7" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f797e67af32588215eaaab8327027ee8e71b9dd0b2b26996aedf20c030fce309" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "geo" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc1a1678e54befc9b4bcab6cd43b8e7f834ae8ea121118b0fd8c42747675b4a" +dependencies = [ + "earcutr", + "float_next_after", + "geo-types", + "geographiclib-rs", + "i_overlay", + "log", + "num-traits", + "robust", + "rstar 0.12.2", + "spade", +] + +[[package]] +name = "geo-traits" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7c353d12a704ccfab1ba8bfb1a7fe6cb18b665bf89d37f4f7890edcd260206" +dependencies = [ + "geo-types", +] + +[[package]] +name = "geo-types" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24f8647af4005fa11da47cd56252c6ef030be8fa97bdbf355e7dfb6348f0a82c" +dependencies = [ + "approx", + "num-traits", + "rayon", + "rstar 0.10.0", + "rstar 0.11.0", + "rstar 0.12.2", + "rstar 0.8.4", + "rstar 0.9.3", + "serde", +] + +[[package]] +name = "geographiclib-rs" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc8f647bd562db28a15e0dce4a77d89e3a78f6f85943e782418ebdbb420ea3c4" +dependencies = [ + "libm", +] + +[[package]] +name = "geojson" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e26f3c45b36fccc9cf2805e61d4da6bc4bbd5a3a9589b01afa3a40eff703bd79" +dependencies = [ + "geo-types", + "log", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "geozero" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5f28f34864745eb2f123c990c6ffd92c1584bd39439b3f27ff2a0f4ea5b309b" +dependencies = [ + "geo-types", + "geojson", + "log", + "serde_json", + "thiserror 1.0.69", + "wkt 0.11.1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "granit-parser" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7e736dfe3881c53a7dce0685eb18202d0d9fe6911782f9870946eb9ee89d778" +dependencies = [ + "arraydeque", + "smallvec", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hash32" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4041af86e63ac4298ce40e5cca669066e75b6f1aa3390fe2561ffa5e1d9f4cc" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heapless" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634bd4d29cbf24424d0a4bfcbf80c6960129dc24424752a7d1d1390607023422" +dependencies = [ + "as-slice", + "generic-array 0.14.9", + "hash32 0.1.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "i_float" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010025c2c532c8d82e42d0b8bb5184afa449fa6f06c709ea9adcb16c49ae405b" +dependencies = [ + "libm", +] + +[[package]] +name = "i_key_sort" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9190f86706ca38ac8add223b2aed8b1330002b5cdbbce28fb58b10914d38fc27" + +[[package]] +name = "i_overlay" +version = "4.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413183068e6e0289e18d7d0a1f661b81546e6918d5453a44570b9ab30cbed1b3" +dependencies = [ + "i_float", + "i_key_sort", + "i_shape", + "i_tree", + "rayon", +] + +[[package]] +name = "i_shape" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ea154b742f7d43dae2897fcd5ead86bc7b5eefcedd305a7ebf9f69d44d61082" +dependencies = [ + "i_float", +] + +[[package]] +name = "i_tree" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e6d558e6d4c7b82bc51d9c771e7a927862a161a7d87bf2b0541450e0e20915" + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jiff" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c867c356cc096b33f4981825ab281ecba3db0acefe60329f044c1789d94c6543" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-sys 0.61.2", +] + +[[package]] +name = "jiff-static" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7946b4325269738f270bb55b3c19ab5c5040525f83fd625259422a9d25d9be5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68971ebff725b9e2ca27a601c5eb38a4c5d64422c4cbab0c535f248087eda5c2" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json_dotpath" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbdcfef3cf5591f0cef62da413ae795e3d1f5a00936ccec0b2071499a32efd1a" +dependencies = [ + "serde", + "serde_derive", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonschema" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46662859bc5f60a145b75f4632fbadc84e829e45df6c5de74cfc8e05acb96b5" +dependencies = [ + "ahash", + "base64", + "bytecount", + "email_address", + "fancy-regex", + "fraction", + "idna", + "itoa", + "num-cmp", + "num-traits", + "once_cell", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "serde", + "serde_json", + "uuid-simd", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "like" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7281e4b2b1a1fae03463a7c49dd21464de50251a450f6da9715c40c7b21a70" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pdqselect" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec91767ecc0a0bbe558ce8c9da33c068066c57ecc8bb8477ef8c1ad3ef77c27" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pg_escape" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c7bc82ccbe2c7ef7ceed38dcac90d7ff46681e061e9d7310cbcd409113e303" +dependencies = [ + "phf", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "recursive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e" +dependencies = [ + "recursive-proc-macro-impl", + "stacker", +] + +[[package]] +name = "recursive-proc-macro-impl" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "referencing" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9c261f7ce75418b3beadfb3f0eb1299fe8eb9640deba45ffa2cb783098697d" +dependencies = [ + "ahash", + "fluent-uri", + "once_cell", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + +[[package]] +name = "rstar" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a45c0e8804d37e4d97e55c6f258bc9ad9c5ee7b07437009dd152d764949a27c" +dependencies = [ + "heapless 0.6.1", + "num-traits", + "pdqselect", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40f1bfe5acdab44bc63e6699c28b74f75ec43afb59f3eda01e145aff86a25fa" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f39465655a1e3d8ae79c6d9e007f4953bfc5d55297602df9dc38f9ae9f1359a" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73111312eb7a2287d229f06c00ff35b51ddee180f017ab6dec1f69d62ac098d6" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +dependencies = [ + "heapless 0.8.0", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[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.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[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-saphyr" +version = "0.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcc7fe48e34d02a97bc8e6253b8b91e5a47fe2c47eaacb5149cefbb69922eaf0" +dependencies = [ + "ahash", + "annotate-snippets", + "base64", + "encoding_rs_io", + "getrandom 0.3.4", + "granit-parser", + "nohash-hasher", + "num-traits", + "serde", + "smallvec", + "zmij", +] + +[[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", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "ryu", + "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 = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spade" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb313e1c8afee5b5647e00ee0fe6855e3d529eb863a0fdae1d60006c4d1e9990" +dependencies = [ + "hashbrown 0.15.5", + "num-traits", + "robust", + "smallvec", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlparser" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4b661c54b1e4b603b37873a18c59920e4c51ea8ea2cf527d925424dbd4437c" +dependencies = [ + "log", + "recursive", + "sqlparser_derive", +] + +[[package]] +name = "sqlparser_derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5fc6819faabb412da764b99d3b713bb55083c11e7e0c00144d386cd6a1939c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stac" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "705b19b985079cdfc77b7df8109efea75748eed92bc493e0bd3a591eafd9ee57" +dependencies = [ + "bytes", + "chrono", + "cql2", + "geojson", + "indexmap", + "log", + "mime", + "serde", + "serde_json", + "serde_urlencoded", + "stac-derive", + "thiserror 2.0.18", + "tracing", + "url", +] + +[[package]] +name = "stac-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "087057f442c3a980721f249f723db459460ce74221730c9b2115bd63957027ae" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "stac-io" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd33c3432a790d1f0533a62a29d8ff52026b8d76227b8265b420f60547b62981" +dependencies = [ + "async-stream", + "bytes", + "futures", + "http", + "reqwest", + "serde", + "serde_json", + "stac", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "stacker" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "superstac-cli" +version = "0.1.0" +dependencies = [ + "clap", + "serde", + "serde_json", + "stac", + "superstac-config", + "superstac-core", + "superstac-engine", + "superstac-search", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "superstac-config" +version = "0.1.0" +dependencies = [ + "serde", + "serde-saphyr", + "superstac-core", + "tracing", +] + +[[package]] +name = "superstac-core" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "thiserror 1.0.69", + "tracing", + "url", +] + +[[package]] +name = "superstac-engine" +version = "0.1.0" +dependencies = [ + "parking_lot", + "reqwest", + "serde", + "stac", + "stac-io", + "superstac-core", + "superstac-search", + "tokio", + "tracing", +] + +[[package]] +name = "superstac-search" +version = "0.1.0" +dependencies = [ + "futures", + "reqwest", + "serde", + "serde_json", + "stac", + "stac-io", + "superstac-core", + "tokio", + "tracing", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "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.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[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 = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[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.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unaccent" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e732eb499ccf541e76badc7eb2a929cf8749cfd79022dde9e2cb4628d885a69a" +dependencies = [ + "unicode-normalization", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +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 = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "uuid", + "vsimd", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[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 = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[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-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[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 0.52.6", + "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-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "wkt" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f7f1ff4ea4c18936d6cd26a6fd24f0003af37e951a8e0e8b9e9a2d0bd0a46d" +dependencies = [ + "geo-types", + "log", + "num-traits", + "thiserror 1.0.69", +] + +[[package]] +name = "wkt" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efb2b923ccc882312e559ffaa832a055ba9d1ac0cc8e86b3e25453247e4b81d7" +dependencies = [ + "geo-traits", + "geo-types", + "log", + "num-traits", + "thiserror 1.0.69", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..24258da --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[workspace] +resolver = "3" +members = [ + "crates/core", + "crates/config", + "crates/search", + "crates/engine", + "crates/cli", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +license = "MIT" +repository = "https://github.com/spatialnode/superstac" +homepage = "https://github.com/spatialnode/superstac" +authors = ["Emmanuel Jolaiya "] diff --git a/README.md b/README.md index 2d6cdd1..4ae223c 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,164 @@ -# SuperSTAC +# superstac -[![PyPI version](https://img.shields.io/pypi/v/superstac.svg)](https://pypi.org/project/superstac/) [![License](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE) -**SuperSTAC** is a Python library (with planned Rust backend) for **high-availability satellite imagery retrieval**. -Instead of relying on a single STAC endpoint (e.g., Sentinel from Element84), SuperSTAC can query **multiple catalogs** and automatically fall back to alternatives when a source is missing data or unavailable. +Federated [STAC](https://stacspec.org/) search across multiple catalogs. Query +Element84, Microsoft Planetary Computer, and others through one API. +Items come back deduplicated, with their collection IDs and asset keys +normalized to canonical names — regardless of which catalog they came from. -⚠️ **Note:** This is an **early work-in-progress**. The initial release is to start iterating in public. Expect breaking changes. +> **Status: alpha.** APIs and YAML schema are not yet stable. Pre-1.0; expect +> breaking changes. ---- +## Why -## Features (planned) +A single STAC catalog isn't always enough: -- Query multiple STAC catalogs through a single unified API. -- Automatic fallback when a catalog has no data or is down. -- Configurable authentication for protected catalogs. -- Resolution & band matching across heterogeneous catalogs. -- CLI and Python API for flexible workflows. -- Optional LLM-assisted natural language queries. -- Rust backend (planned) +- The collection you need lives somewhere else. +- The catalog you usually use is down or rate-limited. +- Different providers index the same scenes under different names. ---- +superstac queries every catalog you've registered, drops the ones that don't +serve the requested collection, runs the rest concurrently with retry and +timeouts, then merges and dedupes the results. -## Installation +## Install + +There is no published binary yet. Build from source: ```bash -pip install superstac +git clone https://github.com/spatialnode/superstac +cd superstac +cargo build --release ``` -## Configuration +The CLI binary lands at `target/release/superstac`. -SuperSTAC loads its catalog configuration from a YAML file, typically referenced via the environment variable `SUPERSTAC_CATALOG_CONFIG`. +## Quickstart -Example `.superstac.yml`: +Drop a `superstac.yml` next to where you run the binary: -```bash +```yaml catalogs: - Element84 Sentinel: - url: https://earth-search.aws.element84.com/v0 - Planet: - url: https://api.planet.com/stac/v1 - auth: - type: basic - username: youruser - password: yourpass - Microsoft PC: + - id: earth-search + url: https://earth-search.aws.element84.com/v1 + - id: microsoft url: https://planetarycomputer.microsoft.com/api/stac/v1 - auth: - type: bearer - token: "YOUR_MICROSOFT_PC_TOKEN" ``` -See [superstac/.superstac.yml](superstac/.superstac.yml) for an example config file. - - -## Usage (very early draft) - -```python - from superstac import get_catalog_registry, federated_search_async - - cr = get_catalog_registry() - cr.load_catalogs_from_config() - - print("\nRunning asynchronous federated_search_async...") - start_async = time.perf_counter() - results_async = asyncio.run( - federated_search_async( - registry=cr, - collections=["sentinel-2-l2a"], - bbox=[6.0, 49.0, 7.0, 50.0], - datetime="2024-01-01/2024-01-31", - query={"eo:cloud_cover": {"lt": 20}}, - sortby=[{"field": "properties.datetime", "direction": "desc"}], - ) - ) - end_async = time.perf_counter() - print( - f"Asynchronous search found {len(results_async)} items in {end_async - start_async:.2f} seconds." - ) - - for x in results_async: - print(x.self_href) + +Then: + +```bash +# what collections does each catalog serve? +superstac collections + +# search across all of them +superstac search -c sentinel-2-l2a -b 6.0,49.0,7.0,50.0 -d 2024-01-01/2024-01-31 -l 50 + +# pipe to jq +superstac --json search -c landsat-c2-l2 -l 10 | jq '.metadata' + +# inspect a single collection +superstac collections microsoft sentinel-2-l2a +``` + +Run `superstac --help` for the full surface. + +## Configuration + +Only `id` and `url` are required per catalog. Common optional fields: + +```yaml +catalogs: + - id: cdse + url: catalog-url + # Only needed when the catalog uses non-canonical names. + collection_aliases: + sentinel-2-l2a: S2MSI2A + asset_aliases: + sentinel-2-l2a: + blue: B02 + green: B03 + red: B04 + +settings: + health_check_strategy: "15m" + deduplicate_items: true + unify_response: true + max_concurrent_catalogs: 8 + per_catalog_timeout_seconds: 30 + max_retry_attempts: 2 +``` + +The full schema and every setting is documented inline at +[`crates/core/src/models/settings.rs`](./crates/core/src/models/settings.rs). + +## Library usage + +The CLI is a thin wrapper over [`superstac-engine`]. To embed in your own +binary: + +```rust +use superstac_config::init_from_yaml; +use superstac_core::models::storage::Storage; +use superstac_engine::SuperSTACEngine; +use superstac_search::query::SearchQuery; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let db = init_from_yaml(Storage::Memory, "superstac.yml")?; + let engine = SuperSTACEngine::new(db); + engine.start().await?; + + let response = engine + .search(SearchQuery { + collections: vec!["sentinel-2-l2a".to_string()], + limit: Some(20), + bbox: None, + datetime: None, + ids: None, + intersects: None, + sortby: None, + }) + .await?; + + println!("found {} items", response.metadata.total_items); + Ok(()) +} ``` -Also see [main.py](./main.py). +## Crates + +| Crate | Purpose | +|-------|---------| +| [`superstac-core`](./crates/core) | domain models, errors, storage trait | +| [`superstac-config`](./crates/config) | YAML config loading | +| [`superstac-search`](./crates/search) | federated search logic | +| [`superstac-engine`](./crates/engine) | runtime (health, introspection, search orchestration) | +| [`superstac-cli`](./crates/cli) | the `superstac` binary | + +## Logs and debugging + +Logs flow through `tracing`. The default level comes from `settings.log_level` +in your config; override at runtime: + +```bash +superstac -v search -c sentinel-2-l2a # debug +superstac -q search -c sentinel-2-l2a # warn only +RUST_LOG=superstac_search=debug superstac search -c sentinel-2-l2a +``` -## Development Status / Roadmap +## Roadmap -Planned enhancements: - - Authentication configuration & documentation - - Retry logic - - Result modifiers - - Catalog refresh & health checks - - Latency tracking and fallback ranking - - Band matching across heterogeneous catalogs - - CLI tool - - Example notebooks (illegal mining detection, disaster response, LLM-assisted search) +Some things on the way: +- Authentication (per-catalog headers, OAuth, API keys) +- SQLite + Postgres backends +- Python bindings via PyO3 +- and many more. ## License -MIT License. See [LICENSE](LICENSE). +MIT. See [LICENSE](./LICENSE). -Feedback, issues, and contributions are welcome! This package is at a very early stage, so opening issues for missing features or edge cases will directly shape the roadmap. \ No newline at end of file +Feedback and issues welcome — this is early. If you try it and you see any bug, feel free to open an issue! \ No newline at end of file diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 0000000..b00169b --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "superstac-cli" +description = "Command-line interface for federated STAC search across multiple catalogs." +keywords = ["stac", "geospatial", "cli", "search", "satellite"] +categories = ["science::geo", "command-line-utilities"] +readme = "../../README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true + +[[bin]] +name = "superstac" +path = "src/main.rs" + +[dependencies] +superstac-core = { path = "../core", version = "0.1.0" } +superstac-config = { path = "../config", version = "0.1.0" } +superstac-search = { path = "../search", version = "0.1.0" } +superstac-engine = { path = "../engine", version = "0.1.0" } +clap = { version = "4", features = ["derive"] } +serde = "1" +serde_json = "1" +stac = "0.16" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "ansi", "tracing-log"] } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs new file mode 100644 index 0000000..2532f89 --- /dev/null +++ b/crates/cli/src/main.rs @@ -0,0 +1,319 @@ +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Args, Parser, Subcommand}; +use stac::Bbox; +use superstac_config::init_from_yaml; +use superstac_core::models::{settings::LogLevel, settings::Settings, storage::Storage}; +use superstac_engine::SuperSTACEngine; +use superstac_search::query::SearchQuery; +use tracing_subscriber::EnvFilter; + +/// Federated STAC search across multiple catalogs. +#[derive(Parser)] +#[command(name = "superstac", version, about, long_about = None)] +struct Cli { + /// Path to the superstac config file. + #[arg(long, default_value = "superstac.yml", global = true)] + config: PathBuf, + + /// Increase log verbosity (debug-level). + #[arg(short, long, global = true, conflicts_with = "quiet")] + verbose: bool, + + /// Quiet logs (warn-level only). + #[arg(short, long, global = true)] + quiet: bool, + + /// Output as JSON instead of human-readable text. + #[arg(long, global = true)] + json: bool, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Federated search across catalogs. + Search(SearchArgs), + + /// Inspect available collections. + Collections(CollectionsArgs), +} + +#[derive(Args)] +struct SearchArgs { + /// Collection IDs to query. Repeatable; canonical names — alias mapping + /// happens internally. + #[arg(short, long = "collection")] + collections: Vec, + + /// Bounding box `w,s,e,n` (four comma-separated floats). + #[arg(short, long, value_parser = parse_bbox)] + bbox: Option, + + /// Datetime range, e.g. `2024-01-01/2024-01-31` or a single instant. + #[arg(short, long)] + datetime: Option, + + /// Max items per catalog. + #[arg(short, long, default_value_t = 10)] + limit: usize, + + /// Specific item IDs to fetch (repeatable). + #[arg(long = "id")] + ids: Vec, +} + +#[derive(Args)] +struct CollectionsArgs { + /// Catalog ID. If omitted, lists all collections across catalogs. + catalog: Option, + + /// Collection ID (requires `catalog`). Prints full metadata. + collection: Option, +} + +#[tokio::main] +async fn main() -> ExitCode { + let cli = Cli::parse(); + + let db = match init_from_yaml(Storage::Memory, &cli.config.to_string_lossy()) { + Ok(db) => db, + Err(e) => { + eprintln!("error: failed to load config: {}", e); + return ExitCode::FAILURE; + } + }; + + init_tracing(&db.get_settings(), cli.verbose, cli.quiet); + + if !cli.json { + let n = db.list_catalogs(None).map(|c| c.len()).unwrap_or(0); + println!("loaded {} catalogs", n); + } + + let engine = SuperSTACEngine::new(db); + + if let Err(e) = engine.start().await { + eprintln!("error: engine failed to start: {}", e); + return ExitCode::FAILURE; + } + + let exit = match cli.command { + Command::Search(args) => run_search(&engine, args, cli.json).await, + Command::Collections(args) => run_collections(&engine, args, cli.json).await, + }; + + engine.shutdown().await; + exit +} + +fn parse_bbox(s: &str) -> Result { + let parts: Vec = s + .split(',') + .map(|p| p.trim().parse::().map_err(|e| e.to_string())) + .collect::>()?; + + match parts.as_slice() { + [w, s, e, n] => Ok(Bbox::new(*w, *s, *e, *n)), + _ => Err(format!("expected 4 comma-separated floats, got {}", parts.len())), + } +} + +fn init_tracing(settings: &Settings, verbose: bool, quiet: bool) { + if !settings.logging_enabled { + return; + } + + // CLI flags override settings; RUST_LOG overrides both. + let default_level = if verbose { + "debug" + } else if quiet { + "warn" + } else { + match settings.log_level { + LogLevel::Debug => "debug", + LogLevel::Info => "info", + LogLevel::Warning => "warn", + } + }; + + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new(default_level)); + + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .init(); +} + +async fn run_collections( + engine: &SuperSTACEngine, + args: CollectionsArgs, + json: bool, +) -> ExitCode { + match (args.catalog, args.collection) { + (Some(catalog_id), Some(collection_id)) => { + describe_collection(engine, &catalog_id, &collection_id, json).await + } + (Some(catalog_id), None) => list_one_catalog(engine, &catalog_id, json).await, + (None, _) => list_all_collections(engine, json).await, + } +} + +async fn list_all_collections(engine: &SuperSTACEngine, json: bool) -> ExitCode { + match engine.list_collections().await { + Ok(items) => { + if json { + let view: Vec<_> = items + .iter() + .map(|c| serde_json::json!({ "id": c.id, "catalogs": c.catalogs })) + .collect(); + print_json(&view); + } else { + for ca in items { + println!("{}\t{}", ca.id, ca.catalogs.join(", ")); + } + } + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("error: failed to list collections: {}", e); + ExitCode::FAILURE + } + } +} + +async fn list_one_catalog(engine: &SuperSTACEngine, catalog_id: &str, json: bool) -> ExitCode { + match engine.collections_by_catalog().await { + Ok(map) => match map.get(catalog_id) { + Some(collections) => { + if json { + print_json(collections); + } else { + for c in collections { + println!("{}", c); + } + } + ExitCode::SUCCESS + } + None => { + eprintln!( + "error: no introspection data for '{}' (unknown or not yet introspected)", + catalog_id + ); + ExitCode::FAILURE + } + }, + Err(e) => { + eprintln!("error: failed to fetch collections: {}", e); + ExitCode::FAILURE + } + } +} + +async fn describe_collection( + engine: &SuperSTACEngine, + catalog_id: &str, + collection_id: &str, + json: bool, +) -> ExitCode { + match engine.describe_collection(catalog_id, collection_id).await { + Ok(Some(c)) => { + if json { + print_json(&c); + } else { + println!( + "{} — {}", + c.id, + c.title.as_deref().unwrap_or("(no title)") + ); + println!("catalog: {}", catalog_id); + println!(); + match serde_json::to_string_pretty(&c) { + Ok(json) => println!("{}", json), + Err(e) => { + eprintln!("error: failed to serialize collection: {}", e); + return ExitCode::FAILURE; + } + } + } + ExitCode::SUCCESS + } + Ok(None) => { + eprintln!( + "error: collection '{}' not found in catalog '{}'", + collection_id, catalog_id + ); + ExitCode::FAILURE + } + Err(e) => { + eprintln!("error: failed to describe collection: {}", e); + ExitCode::FAILURE + } + } +} + +async fn run_search(engine: &SuperSTACEngine, args: SearchArgs, json: bool) -> ExitCode { + let query = SearchQuery { + collections: args.collections, + ids: if args.ids.is_empty() { None } else { Some(args.ids) }, + intersects: None, + bbox: args.bbox, + datetime: args.datetime, + limit: Some(args.limit), + sortby: None, + }; + + let response = match engine.search(query).await { + Ok(r) => r, + Err(e) => { + eprintln!("error: search failed: {}", e); + return ExitCode::FAILURE; + } + }; + + if json { + print_json(&response); + return ExitCode::SUCCESS; + } + + let m = &response.metadata; + println!( + "found {} items ({}/{} catalogs)", + m.total_items, m.catalogs_succeeded, m.catalogs_queried + ); + + if m.duplicates_removed > 0 { + println!("({} duplicates removed)", m.duplicates_removed); + } + + for f in &m.failures { + println!(" ! {}: {}", f.catalog_id, f.reason); + } + + if !m.unsupported_collections.is_empty() { + println!( + " ? no catalog serves: {}", + m.unsupported_collections.join(", ") + ); + } + + if !response.items.is_empty() { + println!(); + for item in response.items { + println!("{}\t{}", item.item.id, item.seen_in.join(", ")); + } + } + + ExitCode::SUCCESS +} + +fn print_json(value: &T) { + match serde_json::to_string_pretty(value) { + Ok(s) => println!("{}", s), + Err(e) => eprintln!("error: failed to serialize as JSON: {}", e), + } +} diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml new file mode 100644 index 0000000..b750fdd --- /dev/null +++ b/crates/config/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "superstac-config" +description = "YAML config loading for superstac federated STAC search." +keywords = ["stac", "geospatial", "config"] +categories = ["science::geo", "config"] +readme = "../../README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true + +[dependencies] +superstac-core = { path = "../core", version = "0.1.0" } +serde = { version = "1.0", features = ["derive"] } +serde-saphyr = "0.0.26" +tracing = "0.1" diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs new file mode 100644 index 0000000..f997c90 --- /dev/null +++ b/crates/config/src/config.rs @@ -0,0 +1,15 @@ +use serde::Deserialize; + +use superstac_core::models::{ + catalog::CatalogConfig, + provider::CatalogProviderConfig, + settings::Settings, +}; + +/// Configuration for SuperSTAC, Spatial Temporal Asset Catalog services +#[derive(Debug, Deserialize)] +pub struct SuperStacConfig { + pub catalogs: Vec, + pub providers: Vec, + pub settings: Settings, +} diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs new file mode 100644 index 0000000..1f5433c --- /dev/null +++ b/crates/config/src/lib.rs @@ -0,0 +1,10 @@ +//! Config-file loading for superstac. Reads `superstac.yml` and seeds a +//! storage backend with providers, catalogs, and settings. + +pub mod config; +pub mod populate; +pub mod yaml; + +pub use config::SuperStacConfig; +pub use populate::populate_backend_from_config; +pub use yaml::init_from_yaml; diff --git a/crates/config/src/populate.rs b/crates/config/src/populate.rs new file mode 100644 index 0000000..827a071 --- /dev/null +++ b/crates/config/src/populate.rs @@ -0,0 +1,52 @@ +use superstac_core::{ + errors::SuperSTACError, + models::{catalog::Catalog, provider::CatalogProvider, settings::SettingsUpdate}, + storages::factory::StorageBackend, +}; + +use crate::config::SuperStacConfig; + +/// Seed a backend with the providers, catalogs, and settings parsed from a +/// [`SuperStacConfig`]. Generic over the backend trait so future SQLite / +/// Postgres backends work without changes here. +pub fn populate_backend_from_config( + backend: &mut dyn StorageBackend, + config: SuperStacConfig, +) -> Result<(), SuperSTACError> { + tracing::debug!( + providers = config.providers.len(), + catalogs = config.catalogs.len(), + "seeding backend from config" + ); + + backend.update_settings(SettingsUpdate { + health_check_strategy: Some(config.settings.health_check_strategy), + healthy_status_code_range: Some(config.settings.healthy_status_code_range), + auto_fix_duplicate_catalog_id: Some(config.settings.auto_fix_duplicate_catalog_id), + auto_fix_duplicate_provider_id: Some(config.settings.auto_fix_duplicate_provider_id), + logging_enabled: Some(config.settings.logging_enabled), + log_level: Some(config.settings.log_level), + search_healthy_catalogs_only: config.settings.search_healthy_catalogs_only, + deduplicate_items: config.settings.deduplicate_items, + unify_response: config.settings.unify_response, + max_concurrent_catalogs: config.settings.max_concurrent_catalogs, + per_catalog_timeout_seconds: config.settings.per_catalog_timeout_seconds, + max_retry_attempts: config.settings.max_retry_attempts, + retry_initial_backoff_ms: config.settings.retry_initial_backoff_ms, + retry_max_backoff_ms: config.settings.retry_max_backoff_ms, + max_items_per_catalog: config.settings.max_items_per_catalog, + }); + + for provider_cfg in config.providers { + let provider = CatalogProvider::try_from(provider_cfg)?; + backend.create_provider(provider)?; + } + + for catalog_cfg in config.catalogs { + let provider_ref = catalog_cfg.provider.clone(); + let catalog = Catalog::try_from(catalog_cfg)?; + backend.create_catalog(catalog, provider_ref.as_deref())?; + } + + Ok(()) +} diff --git a/crates/config/src/yaml.rs b/crates/config/src/yaml.rs new file mode 100644 index 0000000..e76d2d6 --- /dev/null +++ b/crates/config/src/yaml.rs @@ -0,0 +1,53 @@ +use std::{fs::File, io::Read, path::Path}; + +use superstac_core::{ + errors::{StorageError, SuperSTACError}, + models::storage::Storage, + storages::factory::StorageBackend, +}; + +use crate::{config::SuperStacConfig, populate::populate_backend_from_config}; + +/// Read a `superstac.yml` (or `.yaml`) file and return a populated backend. +/// +/// Filename must be exactly `superstac.yml` or `superstac.yaml` — anything +/// else is rejected upfront to catch typos. +pub fn init_from_yaml( + storage: Storage, + file_path: &str, +) -> Result, SuperSTACError> { + let path = Path::new(file_path); + + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + let ext = path.extension().and_then(|s| s.to_str()).unwrap_or(""); + + let is_name_valid = stem == "superstac"; + let is_ext_valid = ext == "yml" || ext == "yaml"; + + if !is_name_valid || !is_ext_valid { + return Err(StorageError::Io(format!( + "invalid config file. expected `superstac.yml` or `superstac.yaml`, found `{}`", + path.display() + )) + .into()); + } + } else { + return Err(StorageError::Io("invalid file path".to_string()).into()); + } + + tracing::debug!(path = %path.display(), "loading config"); + + let mut file = File::open(path) + .map_err(|e| StorageError::Io(format!("failed to open YAML file: {}", e)))?; + + let mut yaml_str = String::new(); + file.read_to_string(&mut yaml_str) + .map_err(|e| StorageError::Io(format!("failed to read YAML file: {}", e)))?; + + let config: SuperStacConfig = serde_saphyr::from_str(&yaml_str) + .map_err(|e| StorageError::Serde(format!("failed to parse YAML: {}", e)))?; + + let mut backend = storage.init(); + populate_backend_from_config(backend.as_mut(), config)?; + Ok(backend) +} diff --git a/crates/config/tests/fixtures/superstac.yaml b/crates/config/tests/fixtures/superstac.yaml new file mode 100644 index 0000000..0451e48 --- /dev/null +++ b/crates/config/tests/fixtures/superstac.yaml @@ -0,0 +1 @@ +# Invalid config file \ No newline at end of file diff --git a/crates/config/tests/fixtures/superstac.yml b/crates/config/tests/fixtures/superstac.yml new file mode 100644 index 0000000..6c43048 --- /dev/null +++ b/crates/config/tests/fixtures/superstac.yml @@ -0,0 +1,52 @@ +providers: + # A provider with catalog + - id: microsoft + name: Microsoft Planetary Computer + description: Microsoft's planetary-scale catalog + website_url: https://planetarycomputer.microsoft.com/ + stac_version: "1.0.0" + logo_url: https://planetarycomputer.microsoft.com/logo.png + + # A provider without a catalog. + - id: google + name: Google Earth Engine + description: Google Earth Engine catalog + website_url: https://google.com + stac_version: "1.0.0" + logo_url: https://planetarycomputer.microsoft.com/logo.png + + +catalogs: + # Fixture exercises both alias forms with realistic non-identity mappings, + # so test assertions verify the loader handles real rename rules. + - id: earth-search + provider: microsoft + title: Earth Search + url: https://earth-search.aws.element84.com/v1 + description: STAC catalog for Earth Search + collection_aliases: + sentinel-2-l2a: S2MSI2A + sentinel-1-grd: S1GRD + asset_aliases: + sentinel-2-l2a: + blue: B02 + green: B03 + red: B04 + nir: B08 + + # A catalog with settings and without provider + - id: microsoft + title: Planetary Computer + url: https://planetarycomputer.microsoft.com/api/stac/v1 + description: Microsoft Planetary Computer catalog + settings: + health_check_strategy: Hourly + healthy_status_code_range: [200, 299] + +settings: + health_check_strategy: "15m" + healthy_status_code_range: [200, 299] + auto_fix_duplicate_catalog_id: true + auto_fix_duplicate_provider_id: true + log_level: info + logging_enabled: true diff --git a/crates/config/tests/yaml_loader.rs b/crates/config/tests/yaml_loader.rs new file mode 100644 index 0000000..1ddcf87 --- /dev/null +++ b/crates/config/tests/yaml_loader.rs @@ -0,0 +1,164 @@ +use std::path::PathBuf; +use std::time::Duration; + +use superstac_config::init_from_yaml; +use superstac_core::{ + errors::SuperSTACError, + models::{catalog::HealthCheckFrequencyStrategy, settings::LogLevel, storage::Storage}, + storages::factory::StorageBackend, +}; + +fn get_store(config_file: &str) -> Result, SuperSTACError> { + let config_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(config_file); + + init_from_yaml(Storage::Memory, config_path.to_str().unwrap()) +} + +#[test] +fn storage_will_initialize_configurations_from_valid_config_file() { + let store = get_store("superstac.yml"); + assert!(store.is_ok()); + + let store = store.expect("Could not initialize storage from YAML config."); + + let catalogs = store.list_catalogs(None).expect("Could not list catalogs"); + assert_eq!(catalogs.len(), 2, "Should have loaded 2 catalogs"); + + let earth_search = catalogs + .iter() + .find(|c| c.id == "earth-search") + .expect("Missing 'earth-search' catalog"); + assert_eq!( + earth_search.url, + "https://earth-search.aws.element84.com/v1" + ); + assert_eq!( + earth_search.settings.health_check_strategy, + HealthCheckFrequencyStrategy::Hourly + ); + + let settings = store.get_settings(); + assert_eq!(settings.log_level, LogLevel::Info); + assert!(settings.logging_enabled); + + assert_eq!( + settings.health_check_strategy, + HealthCheckFrequencyStrategy::Custom(Duration::from_secs(900)) + ); + + let providers = store + .list_providers(None) + .expect("Could not list providers"); + assert_eq!(providers.len(), 2, "Should have loaded 2 providers"); + + let google_provider = providers + .iter() + .find(|p| p.id == "google") + .expect("Missing 'google' provider"); + assert_eq!(google_provider.name.as_deref(), Some("Google Earth Engine")); + assert_eq!( + google_provider.website_url.as_deref(), + Some("https://google.com") + ); +} + +#[test] +fn storage_will_not_initialize_configuration_from_invalid_config_file_name() { + let store = get_store(".invalid_name.yml"); + + assert!(store.is_err()); + + match store { + Err(SuperSTACError::Storage(msg)) => { + assert!(msg + .to_string() + .contains("invalid config file. expected `superstac.yml` or `superstac.yaml`")); + } + _ => panic!("Expected a specific Io error"), + } +} + +#[test] +fn storage_will_not_initialize_configuration_from_invalid_config_file() { + let store = get_store("superstac.yaml"); + + assert!(store.is_err()); + + match store { + Err(SuperSTACError::Storage(msg)) => { + assert!(msg.to_string().contains( + "serialization error: failed to parse YAML: unexpected end of input at line 2, column 1" + )); + } + _ => panic!("Expected a specific Io error"), + } +} + +#[test] +fn yaml_config_loads_collection_aliases() { + let store = get_store("superstac.yml") + .expect("Could not initialize storage from YAML config."); + + let catalogs = store.list_catalogs(None).expect("Could not list catalogs"); + + let earth_search = catalogs + .iter() + .find(|c| c.id == "earth-search") + .expect("Missing 'earth-search' catalog"); + + assert_eq!(earth_search.collection_aliases.len(), 2); + assert_eq!( + earth_search.collection_aliases.get("sentinel-2-l2a"), + Some(&"S2MSI2A".to_string()) + ); + assert_eq!( + earth_search.collection_aliases.get("sentinel-1-grd"), + Some(&"S1GRD".to_string()) + ); + + // A catalog without aliases declared should have an empty map (pass-through). + let microsoft = catalogs + .iter() + .find(|c| c.id == "microsoft") + .expect("Missing 'microsoft' catalog"); + assert!(microsoft.collection_aliases.is_empty()); +} + +#[test] +fn yaml_config_loads_asset_aliases() { + let store = get_store("superstac.yml") + .expect("Could not initialize storage from YAML config."); + + let catalogs = store.list_catalogs(None).expect("Could not list catalogs"); + + let earth_search = catalogs + .iter() + .find(|c| c.id == "earth-search") + .expect("Missing 'earth-search' catalog"); + + let s2 = earth_search + .asset_aliases + .get("sentinel-2-l2a") + .expect("Missing sentinel-2-l2a asset aliases"); + assert_eq!(s2.get("blue"), Some(&"B02".to_string())); + assert_eq!(s2.get("nir"), Some(&"B08".to_string())); +} + +#[test] +fn storage_will_not_initialize_configuration_from_unknown_file() { + let store = get_store("does-not-exist/superstac.yaml"); + + assert!(store.is_err()); + + match store { + Err(SuperSTACError::Storage(msg)) => { + assert!(msg.to_string().contains( + "failed to read/write storage: failed to open YAML file: No such file or directory (os error 2)" + )); + } + _ => panic!("Expected a specific Io error"), + } +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..0738d0b --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "superstac-core" +description = "Domain models, storage trait, and shared utilities for superstac federated STAC search." +keywords = ["stac", "geospatial", "satellite", "remote-sensing"] +categories = ["science::geo"] +readme = "../../README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true + +[dependencies] +chrono = { version = "0.4.42", features = ["serde"] } +serde = { version = "1.0", features = ["derive"] } +thiserror = "1.0" +tracing = "0.1" +url = { version = "2", features = ["serde"] } diff --git a/crates/core/src/errors.rs b/crates/core/src/errors.rs new file mode 100644 index 0000000..a3cee40 --- /dev/null +++ b/crates/core/src/errors.rs @@ -0,0 +1,51 @@ +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum StorageError { + #[error("failed to read/write storage: {0}")] + Io(String), + + #[error("serialization error: {0}")] + Serde(String), + + #[error("catalog id '{0}' already exists (set auto_fix_duplicate_catalog_id=true or use a unique id)")] + CatalogIdAlreadyExist(String), + + #[error("provider id '{0}' already exists (set auto_fix_duplicate_provider_id=true or use a unique id)")] + CatalogProviderIdAlreadyExist(String), + + #[error("provider '{0}' does not exist")] + CatalogProviderDoesNotExist(String), + + #[error("catalog '{0}' does not exist")] + CatalogDoesNotExist(String), + + #[error("catalogs not found: {0:?}")] + CatalogsNotFound(Vec), + + #[error("providers not found: {0:?}")] + ProvidersNotFound(Vec), + + #[error("{0}")] + CatalogAlreadyHasProvider(String), +} + +#[derive(Error, Debug, PartialEq)] +pub enum ValidationError { + #[error("invalid url: {0}")] + InvalidUrl(String), + #[error("missing field: {0}")] + MissingField(String), + #[error("invalid identifier: {0}")] + InvalidIdentifier(String), +} + +#[derive(Debug, Error, PartialEq)] +pub enum SuperSTACError { + #[error("validation: {0}")] + Validation(#[from] ValidationError), + #[error("storage: {0}")] + Storage(#[from] StorageError), + #[error("search failed: {0}")] + SearchFailed(String), +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..4851bac --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,11 @@ +//! Domain models, storage trait, and shared utilities for superstac. +//! +//! - [`models`]: `Catalog`, `Provider`, `Settings`, etc. +//! - [`storages`]: the [`storages::factory::StorageBackend`] trait and the +//! in-memory implementation. +//! - [`errors`]: typed error variants used across the workspace. + +pub mod storages; +pub mod models; +pub mod errors; +pub mod utils; diff --git a/crates/core/src/models/catalog.rs b/crates/core/src/models/catalog.rs new file mode 100644 index 0000000..f45bc35 --- /dev/null +++ b/crates/core/src/models/catalog.rs @@ -0,0 +1,407 @@ +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Deserializer, Serialize}; + +use crate::errors::{SuperSTACError, ValidationError}; + +use crate::utils::{get_date_time, parse_url, validate_identifier}; + +/// How often to poll a catalog's health endpoint. +/// +/// YAML accepts a named variant (`minutely`, `hourly`, ...) or a +/// `Custom` duration string like `"15s"`, `"30m"`, `"2h"`. +#[derive(Clone, Debug, Serialize, Default, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum HealthCheckFrequencyStrategy { + Minutely, + #[default] + Hourly, + Daily, + Weekly, + Monthly, + Custom(Duration), +} + +/// Per-catalog overrides. Anything unset falls back to [`Default`]. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CatalogSettings { + pub health_check_strategy: HealthCheckFrequencyStrategy, + /// Status codes considered "healthy" (inclusive). Default `(200, 299)`. + pub healthy_status_code_range: (u16, u16), +} + +impl Default for CatalogSettings { + fn default() -> Self { + CatalogSettings { + health_check_strategy: HealthCheckFrequencyStrategy::Hourly, + healthy_status_code_range: (200, 299), + } + } +} +impl HealthCheckFrequencyStrategy { + pub fn as_duration(&self) -> Duration { + match self { + HealthCheckFrequencyStrategy::Minutely => Duration::from_secs(60), + HealthCheckFrequencyStrategy::Hourly => Duration::from_secs(60 * 60), + HealthCheckFrequencyStrategy::Daily => Duration::from_secs(60 * 60 * 24), + HealthCheckFrequencyStrategy::Weekly => Duration::from_secs(60 * 60 * 24 * 7), + HealthCheckFrequencyStrategy::Monthly => Duration::from_secs(60 * 60 * 24 * 30), + HealthCheckFrequencyStrategy::Custom(dur) => *dur, + } + } +} + +impl<'de> Deserialize<'de> for HealthCheckFrequencyStrategy { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let s = s.trim().to_lowercase(); + + match s.as_str() { + "minutely" => Ok(HealthCheckFrequencyStrategy::Minutely), + "hourly" => Ok(HealthCheckFrequencyStrategy::Hourly), + "daily" => Ok(HealthCheckFrequencyStrategy::Daily), + "weekly" => Ok(HealthCheckFrequencyStrategy::Weekly), + "monthly" => Ok(HealthCheckFrequencyStrategy::Monthly), + _ => { + // Parse custom duration like "15m", "30s", "1h" + let dur = if s.ends_with("s") { + let n = &s[..s.len() - 1] + .parse::() + .map_err(serde::de::Error::custom)?; + Duration::from_secs(*n) + } else if s.ends_with("m") { + let n = &s[..s.len() - 1] + .parse::() + .map_err(serde::de::Error::custom)?; + Duration::from_secs(*n * 60) + } else if s.ends_with("h") { + let n = &s[..s.len() - 1] + .parse::() + .map_err(serde::de::Error::custom)?; + Duration::from_secs(*n * 3600) + } else { + return Err(serde::de::Error::custom(format!("Invalid duration: {}", s))); + }; + Ok(HealthCheckFrequencyStrategy::Custom(dur)) + } + } + } +} + +impl FromStr for HealthCheckFrequencyStrategy { + type Err = String; + + fn from_str(s: &str) -> Result { + let s = s.trim(); + if s.ends_with("s") { + let secs = s[..s.len() - 1] + .parse::() + .map_err(|_| "Invalid seconds")?; + Ok(HealthCheckFrequencyStrategy::Custom(Duration::from_secs( + secs, + ))) + } else if s.ends_with("m") { + let mins = s[..s.len() - 1] + .parse::() + .map_err(|_| "Invalid minutes")?; + Ok(HealthCheckFrequencyStrategy::Custom(Duration::from_secs( + mins * 60, + ))) + } else if s.ends_with("h") { + let hours = s[..s.len() - 1] + .parse::() + .map_err(|_| "Invalid hours")?; + Ok(HealthCheckFrequencyStrategy::Custom(Duration::from_secs( + hours * 3600, + ))) + } else { + Err(format!("Invalid custom duration: {}", s)) + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct HealthStatus { + pub endpoint: String, + pub available: bool, + pub last_checked: Option>, + /// The status code response from the STAC Catalog Server. + pub status_code: u16, +} + +fn get_default_health_status(url: String) -> HealthStatus { + HealthStatus { + // defaults to false. Always assumes the health status is down. It will be updated after the first health check. + available: false, + // Defaults to the catalog url. + endpoint: url, + last_checked: Some(get_date_time()), + status_code: 200, + } +} +/// The capabilities of the STAC Catalog. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CatalogCapabilities { + filtering: String, +} + +/// A data structure for a STAC catalog from the YAML file. +#[derive(Debug, Deserialize)] +pub struct CatalogConfig { + pub id: String, + pub provider: Option, + pub title: Option, + pub url: Option, + pub description: Option, + pub settings: Option, + /// Maps canonical collection IDs to this catalog's local collection IDs. + /// E.g. `sentinel-2-l2a: S2MSI2A` on a CDSE-style catalog. + pub collection_aliases: Option>, + /// Per-collection asset rename rules, keyed by canonical collection ID. + /// Inner map: canonical asset key -> this catalog's local asset key. + /// E.g. `{ "sentinel-2-l2a": { "blue": "B02", "green": "B03" } }`. + pub asset_aliases: Option>>, +} + +impl TryFrom for Catalog { + type Error = SuperSTACError; + + fn try_from(cfg: CatalogConfig) -> Result { + validate_identifier(&cfg.id)?; + + let url = match cfg.url { + Some(w) => { + parse_url(&w).map_err(|e| ValidationError::InvalidUrl(e.to_string()))?; + Some(w) + } + None => None, + }; + + Ok(Self { + id: cfg.id, + provider: None, + title: cfg.title, + url: url.clone().unwrap(), + description: cfg.description, + settings: cfg.settings.unwrap_or(CatalogSettings::default()), + health_status: get_default_health_status(url.unwrap()), + capabilities: None, + collection_aliases: cfg.collection_aliases.unwrap_or_default(), + asset_aliases: cfg.asset_aliases.unwrap_or_default(), + supported_collections: None, + created_at: Some(get_date_time()), + updated_at: None, + }) + } +} + +/// A STAC catalog endpoint registered with superstac. +/// +/// Construct via [`Catalog::new`] for direct use, or by loading a YAML +/// `superstac.yml` (which deserializes [`CatalogConfig`] and converts via +/// `TryFrom`). `health_status` and `supported_collections` are managed by +/// the engine — don't set them manually. +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct Catalog { + pub id: String, + /// Linked provider id, or `None` if standalone. + pub provider: Option, + pub title: Option, + pub url: String, + pub description: Option, + /// Per-catalog overrides for health check frequency, healthy status range, etc. + pub settings: CatalogSettings, + /// Updated by the engine's health monitor — don't set manually. + pub health_status: HealthStatus, + pub capabilities: Option, + /// Maps canonical collection IDs (e.g. `sentinel-2-l2a`) to this catalog's + /// local collection IDs (e.g. `S2MSI2A`). Empty map means pass-through. + #[serde(default)] + pub collection_aliases: HashMap, + /// Per-collection asset rename rules, keyed by canonical collection ID. + /// Inner map: canonical asset key -> this catalog's local asset key. + /// Empty means pass-through (catalog already uses canonical names). + #[serde(default)] + pub asset_aliases: HashMap>, + /// The set of canonical collection IDs this catalog is known to support. + /// `None` means not yet introspected — searches pass through to this catalog. + /// `Some(set)` is authoritative — searches for collections outside the set + /// will skip this catalog. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub supported_collections: Option>, + /// Auto-populated on creation. + pub created_at: Option>, + /// Auto-populated on every update. + pub updated_at: Option>, +} + +impl Catalog { + /// Validate and construct a catalog. Errors if the id contains non-ASCII + /// characters or the URL doesn't parse. + pub fn new( + id: &str, + title: Option>, + url: &str, + description: Option>, + settings: Option, + ) -> Result { + + validate_identifier(&id)?; + + let valid_url = parse_url(url) + .map_err(|err| SuperSTACError::from(ValidationError::InvalidUrl(err.to_string())))?; + + let url_string = valid_url.to_string(); + + Ok(Self { + id: id.to_string(), + provider: None, + title: title.map(|t| t.into()), + url: url_string.clone(), + description: description.map(|d| d.into()), + settings: settings.unwrap_or_default(), + health_status: get_default_health_status(url_string), + capabilities: None, + collection_aliases: HashMap::new(), + asset_aliases: HashMap::new(), + supported_collections: None, + created_at: Some(get_date_time()), + updated_at: None, + }) + } + + /// Builder-style: attach collection aliases. + pub fn with_collection_aliases(mut self, aliases: HashMap) -> Self { + self.collection_aliases = aliases; + self + } + + /// Returns this catalog's local collection name for `canonical`, or + /// `canonical` itself when no alias is configured. + pub fn resolve_collection<'a>(&'a self, canonical: &'a str) -> &'a str { + self.collection_aliases + .get(canonical) + .map(String::as_str) + .unwrap_or(canonical) + } + + /// Inverse of `resolve_collection`: returns the canonical collection name + /// for the given catalog-local name. Falls back to `local` when no alias + /// maps to it. + pub fn canonical_collection<'a>(&'a self, local: &'a str) -> &'a str { + for (canonical, l) in &self.collection_aliases { + if l == local { + return canonical; + } + } + local + } + + /// Returns true if this catalog should be queried for the given canonical + /// collection IDs. Catalogs with `supported_collections = None` (not yet + /// introspected) pass through. Empty `requested` (no collection filter) + /// matches every catalog. + pub fn supports_any_of(&self, requested: &[String]) -> bool { + match &self.supported_collections { + None => true, + Some(set) => requested.is_empty() || requested.iter().any(|c| set.contains(c)), + } + } + + /// Stamp `updated_at` with the current time. + pub fn set_update_date(&mut self) { + self.updated_at = Some(get_date_time()); + } + + /// Apply a partial update. `None` fields are left alone. + pub fn update( + &mut self, + description: Option, + url: Option, + title: Option, + settings: Option, + ) -> Result<(), ValidationError> { + if let Some(updated_url) = url { + self.url = parse_url(updated_url.as_str()) + .map_err(|err| ValidationError::InvalidUrl(err.to_string()))? + .to_string(); + } + + self.title = title; + self.description = description; + + if let Some(updated_settings) = settings { + self.settings = updated_settings; + } + + self.set_update_date(); + Ok(()) + } + + pub fn set_id(&mut self, id: String) { + self.id = id + } + + pub fn set_provider(&mut self, provider: &str) { + self.provider = Some(provider.to_owned()) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CatalogFilters { + /// Performs an exact match on the `id` field. + pub id: Option, + /// Performs a string search on the catalog provider's name and description. + pub provider: Option, + /// Performs a string search on the catalog title. + pub title: Option, + /// Performs a string search on the catalog description. + pub description: Option, + /// Performs a boolean search on the catalog health status. + pub available: Option, + + /// Performs a date search on the `created_at` field. Filters for for date `after` the provided date. + pub created_after: Option>, + /// Performs a date search on the `created_at` field. Filters for for date `before` the provided date. + pub created_before: Option>, + /// Performs a date search on the `updated_at` field. Filters for for date `after` the provided date. + pub updated_after: Option>, + /// Performs a date search on the `updated_at` field. Filters for for date `before` the provided date. + pub updated_before: Option>, +} + +impl Default for CatalogFilters { + fn default() -> Self { + CatalogFilters { + id: None, + provider: None, + title: None, + description: None, + available: None, + created_after: None, + created_before: None, + updated_after: None, + updated_before: None, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CatalogUpdate { + /// Attach a provider to a catalog using the provider id. + pub provider: Option, + /// Updates the catalog title. + pub title: Option, + /// Updates the catalog description. + pub description: Option, + /// Updates the catalog url. + pub url: Option, + /// Updates the catalog settings. + pub settings: Option, +} diff --git a/crates/core/src/models/mod.rs b/crates/core/src/models/mod.rs new file mode 100644 index 0000000..42ee79e --- /dev/null +++ b/crates/core/src/models/mod.rs @@ -0,0 +1,4 @@ +pub mod storage; +pub mod catalog; +pub mod provider; +pub mod settings; diff --git a/crates/core/src/models/provider.rs b/crates/core/src/models/provider.rs new file mode 100644 index 0000000..f87e673 --- /dev/null +++ b/crates/core/src/models/provider.rs @@ -0,0 +1,277 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::{ + errors::{SuperSTACError, ValidationError}, + utils::{get_date_time, parse_url, validate_identifier}, +}; + +/// YAML-deserialization shape for a provider entry. Converted to +/// [`CatalogProvider`] via `TryFrom`. +#[derive(Debug, Deserialize)] +pub struct CatalogProviderConfig { + pub id: String, + pub name: Option, + pub description: Option, + pub logo_url: Option, + pub website_url: Option, + pub stac_version: Option, + pub catalog_ids: Option>, +} + +impl TryFrom for CatalogProvider { + type Error = SuperSTACError; + + fn try_from(cfg: CatalogProviderConfig) -> Result { + validate_identifier(&cfg.id)?; + + let website_url = match cfg.website_url { + Some(w) => { + parse_url(&w).map_err(|e| ValidationError::InvalidUrl(e.to_string()))?; + Some(w) + } + None => None, + }; + + let logo_url = match cfg.logo_url { + Some(l) => { + parse_url(&l).map_err(|e| ValidationError::InvalidUrl(e.to_string()))?; + Some(l) + } + None => None, + }; + + let stac_version = cfg + .stac_version + .ok_or_else(|| ValidationError::MissingField("stac_version".into()))?; + + Ok(Self { + id: cfg.id, + name: cfg.name, + description: cfg.description, + website_url, + logo_url, + stac_version: Some(stac_version), + catalog_ids: None, + created_at: Some(get_date_time()), + updated_at: None, + }) + } +} + +/// A vendor/organization that operates one or more STAC catalogs. Mostly +/// metadata (name, logo, website) — the actual API endpoints live on +/// [`super::catalog::Catalog`], linked back via `catalog_ids`. +#[derive(Clone, Debug, Serialize, Deserialize, Default,PartialEq)] +pub struct CatalogProvider { + /// A unique id for the provider. E.g microsoft, google, element84 e.t.c. + pub id: String, + + /// The name of the provider. E.g Microsoft, Google, Element 84 e.t.c + pub name: Option, + + /// Detailed description of the provider. + pub description: Option, + + /// URL to the provider logo. Could be a URL or a file path. + pub logo_url: Option, + + /// The STAC version the provider is conforming to. + pub stac_version: Option, + + /// The URL to the provider website/public page. + pub website_url: Option, + + /// The related catalogs to this provider + pub catalog_ids: Option>, + + /// The date created. This is automatically populated after creation. + pub created_at: Option>, + /// The date updated. The is automatically populated after an update activity. + pub updated_at: Option>, +} + +impl CatalogProvider { + /// Validate and construct a provider. Errors if the id contains + /// non-ASCII characters or either URL doesn't parse. + pub fn new( + id: String, + name: Option, + description: Option, + website_url: Option, + logo_url: Option, + stac_version: Option, + catalog_ids: Option>, + ) -> Result { + validate_identifier(&id)?; + + let valid_website = if let Some(w) = website_url { + parse_url(w.as_str()) + .map_err(|e| ValidationError::InvalidUrl(format!("Invalid website URL: {}", e)))?; + Some(w.to_string()) + } else { + None + }; + + // Validate logo_url if provided + let valid_logo = if let Some(l) = logo_url { + parse_url(l.as_str()) + .map_err(|e| ValidationError::InvalidUrl(format!("Invalid logo URL: {}", e)))?; + Some(l.to_string()) + } else { + None + }; + + // How do validate the catalogs ?, access db.catalogs here ? pass db as a parameter ? + + Ok(Self { + id, + name, + website_url: valid_website, + stac_version, + logo_url: valid_logo, + description, + catalog_ids, + created_at: Some(get_date_time()), + updated_at: None, + }) + } + + pub fn set_id(&mut self, id: String) { + self.id = id + } + + /// Apply a partial update. `None` fields are left alone. + pub fn update( + &mut self, + name: Option, + website: Option, + logo_url: Option, + description: Option, + stac_version: Option, + catalog_ids: Option>, + ) -> Result<(), ValidationError> { + if let Some(updated_url) = logo_url { + match parse_url(&updated_url) { + Ok(valid_url) => { + self.logo_url = Some(valid_url.to_string()); + } + Err(err) => { + return Err(ValidationError::InvalidUrl(err.to_string())); + } + } + } + + if let Some(updated_url) = website { + match parse_url(&updated_url) { + Ok(valid_url) => { + self.website_url = Some(valid_url.to_string()); + } + Err(err) => { + return Err(ValidationError::InvalidUrl(err.to_string())); + } + } + } + + self.name = name; + self.description = description; + self.stac_version = stac_version; + self.catalog_ids = catalog_ids; + self.set_update_date(); + Ok(()) + } + + /// Stamp `updated_at` with the current time. + pub fn set_update_date(&mut self) { + self.updated_at = Some(get_date_time()); + } + + /// Stamp `created_at` with the current time. + pub fn set_created_date(&mut self) { + self.created_at = Some(get_date_time()); + } + + /// Detach a catalog from this provider. No-op if not linked. + pub fn remove_catalog(&mut self, catalog_id: &str) { + if let Some(catalog_ids) = &mut self.catalog_ids { + catalog_ids.retain(|id| id != catalog_id); + + if catalog_ids.is_empty() { + self.catalog_ids = None; + } + } + } + + /// Attach a catalog to this provider. No-op if already linked. + pub fn add_catalog(&mut self, catalog_id: &str) { + let catalog_ids = self.catalog_ids.get_or_insert_with(Vec::new); + + if !catalog_ids.iter().any(|id| id == &catalog_id) { + catalog_ids.push(catalog_id.to_string()); + + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CatalogProviderFilters { + /// Performs an exact match on the `id` field. + pub id: Option, + + /// Performs a string search on the provider name. + pub name: Option, + + /// Performs a string search on the provider description. + pub description: Option, + + /// Performs a string search on the provider stac_version. + pub stac_version: Option, + + /// Performs a string search on the provider catalogs. + pub catalog_id: Option, + + /// Performs a date search on the `created_at` field. Filters for for date `after` the provided date. + pub created_after: Option>, + /// Performs a date search on the `created_at` field. Filters for for date `before` the provided date. + pub created_before: Option>, + /// Performs a date search on the `updated_at` field. Filters for for date `after` the provided date. + pub updated_after: Option>, + /// Performs a date search on the `updated_at` field. Filters for for date `before` the provided date. + pub updated_before: Option>, +} + +impl Default for CatalogProviderFilters { + fn default() -> Self { + CatalogProviderFilters{ + id: None, + name:None, + stac_version:None, + catalog_id:None, + + description: None, + + created_after: None, + created_before: None, + updated_after: None, + updated_before: None, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CatalogProviderUpdate { + /// Updates the provider name. + pub name: Option, + /// Updates the catalog description. + pub description: Option, + /// Updates the provider logo url. + pub logo_url: Option, + /// Updates the provider logo url. + pub website_url: Option, + + /// Updates the provider stac_version. + pub stac_version: Option, + + /// Updates the provider stac_version. + pub catalog_ids: Option>, +} diff --git a/crates/core/src/models/settings.rs b/crates/core/src/models/settings.rs new file mode 100644 index 0000000..74f7138 --- /dev/null +++ b/crates/core/src/models/settings.rs @@ -0,0 +1,113 @@ +use serde::{Deserialize, Serialize}; + +use crate::{errors::ValidationError, models::catalog::HealthCheckFrequencyStrategy}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum LogLevel { + Info, + Warning, + Debug, +} + +/// Workspace-wide settings. Loaded from YAML (`settings:` block) or built +/// from [`Settings::default`]. Field-level docs cover each knob. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Settings { + pub health_check_strategy: HealthCheckFrequencyStrategy, + /// Status codes considered "healthy" (inclusive). Default `(200, 299)`. + pub healthy_status_code_range: (u16, u16), + /// When true, duplicate catalog ids get auto-suffixed instead of erroring. + pub auto_fix_duplicate_catalog_id: bool, + /// Same as above, for provider ids. + pub auto_fix_duplicate_provider_id: bool, + pub log_level: LogLevel, + /// Disable tracing-subscriber init entirely when false. + pub logging_enabled: bool, + /// Drop unhealthy catalogs from federated search. Default true. + pub search_healthy_catalogs_only: Option, + /// Collapse items sharing the same `Item.id` across catalogs into one, + /// with provenance tracked via `SearchItem.seen_in`. Defaults to true. + pub deduplicate_items: Option, + /// Rewrite each item's `collection` and `assets` to canonical names + /// using each catalog's alias maps. Defaults to true. + pub unify_response: Option, + /// Maximum number of catalogs to query concurrently. Defaults to 8. + pub max_concurrent_catalogs: Option, + /// Per-catalog request timeout in seconds. Defaults to 30. + pub per_catalog_timeout_seconds: Option, + /// Maximum attempts per catalog (1 = no retry). Defaults to 2. + pub max_retry_attempts: Option, + /// Initial retry backoff in milliseconds. Defaults to 100. + pub retry_initial_backoff_ms: Option, + /// Maximum retry backoff in milliseconds (caps exponential growth). Defaults to 2000. + pub retry_max_backoff_ms: Option, + /// Hard cap on items returned per catalog (prevents runaway pagination). + /// Defaults to 1000. + pub max_items_per_catalog: Option, +} + +impl Default for Settings { + fn default() -> Self { + Self { + health_check_strategy: HealthCheckFrequencyStrategy::Hourly, + healthy_status_code_range: (200, 299), + auto_fix_duplicate_catalog_id: true, + auto_fix_duplicate_provider_id: true, + log_level: LogLevel::Info, + logging_enabled: true, + search_healthy_catalogs_only: Some(true), + deduplicate_items: Some(true), + unify_response: Some(true), + max_concurrent_catalogs: Some(8), + per_catalog_timeout_seconds: Some(30), + max_retry_attempts: Some(2), + retry_initial_backoff_ms: Some(100), + retry_max_backoff_ms: Some(2000), + max_items_per_catalog: Some(1000), + } + } +} + +/// Partial [`Settings`] update — `None` fields are left alone. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SettingsUpdate { + pub health_check_strategy: Option, + pub healthy_status_code_range: Option<(u16, u16)>, + pub auto_fix_duplicate_catalog_id: Option, + pub auto_fix_duplicate_provider_id: Option, + pub log_level: Option, + pub logging_enabled: Option, + pub search_healthy_catalogs_only: Option, + pub deduplicate_items: Option, + pub unify_response: Option, + pub max_concurrent_catalogs: Option, + pub per_catalog_timeout_seconds: Option, + pub max_retry_attempts: Option, + pub retry_initial_backoff_ms: Option, + pub retry_max_backoff_ms: Option, + pub max_items_per_catalog: Option, +} + +impl TryFrom for SettingsUpdate { + type Error = ValidationError; + fn try_from(value: Settings) -> Result { + Ok(SettingsUpdate { + healthy_status_code_range: Some(value.healthy_status_code_range), + health_check_strategy: Some(value.health_check_strategy), + auto_fix_duplicate_catalog_id: Some(value.auto_fix_duplicate_catalog_id), + auto_fix_duplicate_provider_id: Some(value.auto_fix_duplicate_provider_id), + log_level: Some(value.log_level), + logging_enabled: Some(value.logging_enabled), + search_healthy_catalogs_only: value.search_healthy_catalogs_only, + deduplicate_items: value.deduplicate_items, + unify_response: value.unify_response, + max_concurrent_catalogs: value.max_concurrent_catalogs, + per_catalog_timeout_seconds: value.per_catalog_timeout_seconds, + max_retry_attempts: value.max_retry_attempts, + retry_initial_backoff_ms: value.retry_initial_backoff_ms, + retry_max_backoff_ms: value.retry_max_backoff_ms, + max_items_per_catalog: value.max_items_per_catalog, + }) + } +} diff --git a/crates/core/src/models/storage.rs b/crates/core/src/models/storage.rs new file mode 100644 index 0000000..2bb0ece --- /dev/null +++ b/crates/core/src/models/storage.rs @@ -0,0 +1,23 @@ +use crate::storages::{factory::StorageBackend, memory::MemoryStorage}; +use serde::{Deserialize, Serialize}; + +/// Backend selector. SQLite and Postgres variants currently fall back to +/// the in-memory backend — they're placeholders for future work. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum Storage { + Memory, + Sqlite(String), + Postgres(String), +} + +impl Storage { + /// Construct an empty backend. Config loaders (e.g. YAML) call this and + /// then populate the returned backend. + pub fn init(self) -> Box { + match self { + Storage::Memory => Box::new(MemoryStorage::init()), + Storage::Sqlite(_path) => Box::new(MemoryStorage::init()), + Storage::Postgres(_url) => Box::new(MemoryStorage::init()), + } + } +} diff --git a/crates/core/src/storages/factory.rs b/crates/core/src/storages/factory.rs new file mode 100644 index 0000000..4342f27 --- /dev/null +++ b/crates/core/src/storages/factory.rs @@ -0,0 +1,92 @@ +use std::collections::HashSet; + +use crate::{ + errors::SuperSTACError, + models::{ + catalog::{Catalog, CatalogFilters, CatalogUpdate}, + provider::{CatalogProvider, CatalogProviderFilters, CatalogProviderUpdate}, + settings::{Settings, SettingsUpdate}, + }, +}; + +/// Storage trait implemented by each backend (memory, sqlite, postgres, ...). +/// +/// All federated-search state — catalogs, providers, settings — lives behind +/// this trait so the rest of the codebase doesn't know or care which backend +/// is in use. +pub trait StorageBackend: Send + Sync { + /// Apply partial settings update. `None` fields are left alone. + fn update_settings(&self, update: SettingsUpdate); + + /// Current settings snapshot. + fn get_settings(&self) -> Settings; + + /// Get a provider by id. + fn get_provider(&self, id: &str) -> Result<&CatalogProvider, SuperSTACError>; + + /// Insert a provider. Honors `auto_fix_duplicate_provider_id` if id collides. + fn create_provider( + &mut self, + provider: CatalogProvider, + ) -> Result; + + /// Delete a provider by id. + fn delete_provider(&mut self, id: &str) -> Result<(), SuperSTACError>; + + /// Delete multiple providers. Errors list ids that weren't found. + fn delete_providers(&mut self, ids: Vec<&str>) -> Result<(), SuperSTACError>; + + /// List providers, optionally filtered. See [`CatalogProviderFilters`]. + fn list_providers( + &self, + filters: Option, + ) -> Result, SuperSTACError>; + + /// Apply a partial provider update by id. `None` fields are left alone. + fn update_provider( + &mut self, + id: &str, + update: CatalogProviderUpdate, + ) -> Result; + + /// Get a catalog by id. + fn get_catalog(&self, id: &str) -> Result<&Catalog, SuperSTACError>; + + /// Apply a partial catalog update by id. `None` fields are left alone. + fn update_catalog( + &mut self, + id: &str, + update: CatalogUpdate, + ) -> Result; + + /// Insert a catalog, optionally linked to a provider. + /// Honors `auto_fix_duplicate_catalog_id` if id collides. + fn create_catalog( + &mut self, + catalog: Catalog, + provider: Option<&str>, + ) -> Result; + + /// List catalogs, optionally filtered. See [`CatalogFilters`]. + fn list_catalogs( + &self, + filters: Option, + ) -> Result, SuperSTACError>; + + /// Delete a catalog by id. + fn delete_catalog(&mut self, id: &str) -> Result<(), SuperSTACError>; + + /// Delete multiple catalogs. Errors list ids that weren't found. + fn delete_catalogs(&mut self, ids: Vec<&str>) -> Result<(), SuperSTACError>; + + /// Mark a catalog healthy or unhealthy. Called by the health monitor. + fn update_health(&mut self, id: &str, healthy: bool) -> Result<(), SuperSTACError>; + + /// Set the canonical collection IDs this catalog supports. + /// `None` clears prior introspection. Called after `/collections` fetch. + fn update_supported_collections( + &mut self, + id: &str, + collections: Option>, + ) -> Result<(), SuperSTACError>; +} diff --git a/crates/core/src/storages/memory.rs b/crates/core/src/storages/memory.rs new file mode 100644 index 0000000..90c3638 --- /dev/null +++ b/crates/core/src/storages/memory.rs @@ -0,0 +1,605 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::RwLock; + +use crate::storages::factory::{StorageBackend}; +use crate::errors::{StorageError, SuperSTACError}; +use crate::models::catalog::CatalogUpdate; +use crate::models::provider::{CatalogProviderFilters, CatalogProviderUpdate}; +use crate::models::settings::{Settings, SettingsUpdate}; +use crate::models::{ + catalog::{Catalog, CatalogFilters}, + provider::CatalogProvider, +}; + +// todo - should state be shared and come from a single source, or be different depending on backend ? + +// Memory backend state. +#[derive(Default, Debug)] +pub struct MemoryStorage { + catalogs: HashMap, + providers: HashMap, + settings: RwLock, +} + +impl MemoryStorage { + /// Initialize the in-memory database. + pub fn init() -> Self { + Self { + catalogs: HashMap::new(), + providers: HashMap::new(), + settings: RwLock::new(Settings::default()), + } + } + + fn set_catalog_provider( + &mut self, + catalog_id: &str, + new_provider_id: Option<&str>, + ) -> Result<(), StorageError> { + let catalog = self + .catalogs + .get_mut(catalog_id) + .ok_or(StorageError::CatalogDoesNotExist(catalog_id.to_string()))?; + + let old_provider = &catalog.provider; + + if let Some(old_provider) = &old_provider { + if Some(old_provider.as_str()) == new_provider_id { + return Ok(()); + } + } + + // Detach from old provider + if let Some(old) = &old_provider { + if let Some(old_provider) = self.providers.get_mut(old) { + old_provider.remove_catalog(catalog_id); + } + } + + // Attach to new provider + if let Some(new_id) = new_provider_id { + let new_provider = self + .providers + .get_mut(new_id) + .ok_or_else(|| { + StorageError::CatalogProviderDoesNotExist(new_id.to_string()) + })?; + + new_provider.add_catalog(catalog_id); + catalog.set_provider(new_id); + } + + Ok(()) + } +} + +impl StorageBackend for MemoryStorage { + fn get_settings(&self) -> Settings { + self.settings.read().unwrap().clone() + } + + fn update_settings(&self, update: SettingsUpdate) { + let mut settings = self.settings.write().unwrap(); + + if let Some(health_check_strategy) = update.health_check_strategy { + settings.health_check_strategy = health_check_strategy; + } + + if let Some(healthy_status_code_range) = update.healthy_status_code_range { + settings.healthy_status_code_range = healthy_status_code_range; + } + + if let Some(auto_fix_duplicate_catalog_id) = update.auto_fix_duplicate_catalog_id { + settings.auto_fix_duplicate_catalog_id = auto_fix_duplicate_catalog_id; + } + + if let Some(auto_fix_duplicate_provider_id) = update.auto_fix_duplicate_provider_id { + settings.auto_fix_duplicate_provider_id = auto_fix_duplicate_provider_id; + } + if let Some(logging_enabled) = update.logging_enabled { + settings.logging_enabled = logging_enabled; + } + + if let Some(log_level) = update.log_level { + settings.log_level = log_level; + } + if let Some(search_healthy_catalogs_only) = update.search_healthy_catalogs_only { + settings.search_healthy_catalogs_only = Some(search_healthy_catalogs_only); + } + if let Some(deduplicate_items) = update.deduplicate_items { + settings.deduplicate_items = Some(deduplicate_items); + } + if let Some(unify_response) = update.unify_response { + settings.unify_response = Some(unify_response); + } + if let Some(max_concurrent_catalogs) = update.max_concurrent_catalogs { + settings.max_concurrent_catalogs = Some(max_concurrent_catalogs); + } + if let Some(per_catalog_timeout_seconds) = update.per_catalog_timeout_seconds { + settings.per_catalog_timeout_seconds = Some(per_catalog_timeout_seconds); + } + if let Some(max_retry_attempts) = update.max_retry_attempts { + settings.max_retry_attempts = Some(max_retry_attempts); + } + if let Some(retry_initial_backoff_ms) = update.retry_initial_backoff_ms { + settings.retry_initial_backoff_ms = Some(retry_initial_backoff_ms); + } + if let Some(retry_max_backoff_ms) = update.retry_max_backoff_ms { + settings.retry_max_backoff_ms = Some(retry_max_backoff_ms); + } + if let Some(max_items_per_catalog) = update.max_items_per_catalog { + settings.max_items_per_catalog = Some(max_items_per_catalog); + } + } + + fn create_provider( + &mut self, + mut provider: CatalogProvider, + ) -> Result { + if self.providers.contains_key(&provider.id) { + if self.get_settings().auto_fix_duplicate_provider_id { + let new_provider_id: String = + format!("{}-{}", &provider.id, self.providers.len() + 1); + tracing::warn!( + old_id = %provider.id, + new_id = %new_provider_id, + "duplicate provider id auto-fixed" + ); + provider.set_id(new_provider_id); + } else { + return Err( + StorageError::CatalogProviderIdAlreadyExist(provider.id.to_owned()).into(), + ); + } + } + + // Validate catalog references + if let Some(catalog_ids) = &provider.catalog_ids { + for catalog_id in catalog_ids { + if !self.catalogs.contains_key(catalog_id) { + return Err(StorageError::CatalogDoesNotExist(catalog_id.to_owned()).into()); + } else { + // Associate the provider to the catalogs + for catalog_id in catalog_ids { + match self.catalogs.get_mut(catalog_id) { + Some(catalog) => { + if let Some(existing_provider) = &catalog.provider { + return Err(StorageError::CatalogAlreadyHasProvider(format!( + "Catalog '{}' is already linked to provider '{}'", + catalog_id, existing_provider + )) + .into()); + } else { + // Attach the new provider + catalog.set_provider(&provider.id) + } + } + None => (), + } + } + } + } + } + + self.providers.insert(provider.id.clone(), provider.clone()); + Ok(provider) + } + + fn update_provider( + &mut self, + id: &str, + update: CatalogProviderUpdate, + ) -> Result { + + let matched_provider = self + .providers + .get_mut(id) + .ok_or_else(|| StorageError::CatalogProviderDoesNotExist(id.to_owned()))?; + + if let Some(catalog_ids) = &update.catalog_ids { + for catalog_id in catalog_ids { + if !self.catalogs.contains_key(catalog_id) { + return Err( + StorageError::CatalogDoesNotExist(catalog_id.to_owned()).into(), + ); + } + } + } + + matched_provider.update( + update.name, + update.website_url, + update.logo_url, + update.description, + update.stac_version, + update.catalog_ids, + )?; + + Ok(matched_provider.clone()) + } + + fn get_provider(&self, id: &str) -> Result<&CatalogProvider, SuperSTACError> { + let provider = self.providers.get(id); + match provider { + Some(provider) => Ok(provider), + None => Err(StorageError::CatalogProviderDoesNotExist(id.to_owned()))?, + } + } + + fn delete_provider(&mut self, id: &str) -> Result<(), SuperSTACError> { + match self.providers.remove(id) { + Some(_) => { + // Detach the provider from any associated catalog. + for catalog in self.catalogs.values_mut() { + if catalog.provider.is_some() { + catalog.provider = None; + } + } + Ok(()) + } + None => Err(StorageError::CatalogProviderDoesNotExist(id.to_owned()))?, + } + } + + fn delete_providers(&mut self, ids: Vec<&str>) -> Result<(), SuperSTACError> { + let mut not_found = Vec::new(); + + for id in ids { + if self.delete_provider(id).is_err() { + not_found.push(id.to_string()); + } + } + + if not_found.is_empty() { + Ok(()) + } else { + Err(StorageError::ProvidersNotFound(not_found))? + } + } + + fn list_providers( + &self, + filters: Option, + ) -> Result, SuperSTACError> { + let mut providers: Vec = self.providers.values().cloned().collect(); + + // Apply filters. + if let Some(filters) = filters { + if let Some(id_val) = filters.id { + providers.retain(|c| c.id.to_lowercase() == id_val.to_lowercase()); + return Ok(providers); + } + + let name_filter = filters.name.as_ref(); + let description_filter = filters.description.as_ref(); + let catalog_id_filter = filters.catalog_id.as_ref(); + let stac_version_filter = filters.stac_version.as_ref(); + let created_after_filter = filters.created_after.as_ref(); + let created_before_filter = filters.created_before.as_ref(); + let updated_after_filter = filters.updated_after.as_ref(); + let updated_before_filter = filters.updated_before.as_ref(); + + providers.retain(|provider| { + let name_match = match name_filter { + Some(name_filter_ref) => provider + .name + .as_ref() + .map(|name_ref| { + name_ref + .to_lowercase() + .contains(name_filter_ref.to_lowercase().as_str()) + }) + .unwrap_or(false), + None => true, + }; + + let catalog_id_match = match catalog_id_filter { + Some(catalog_id_filter_ref) => provider + .catalog_ids + .as_ref() + .map(|catalog_id_ref| { + catalog_id_ref + .iter() + .any(|id| id.to_lowercase() == catalog_id_filter_ref.as_str()) + }) + .unwrap_or(false), + None => true, + }; + + let stac_version_match = match stac_version_filter { + Some(stac_version_filter_ref) => provider + .stac_version + .as_ref() + .map(|stac_version_ref| { + stac_version_ref + .to_lowercase() + .contains(stac_version_filter_ref.to_lowercase().as_str()) + }) + .unwrap_or(false), + None => true, + }; + + let description_match = match description_filter { + Some(d_filter_ref) => provider + .description + .as_ref() + .map(|desc_ref| { + desc_ref + .to_lowercase() + .contains(d_filter_ref.to_lowercase().as_str()) + }) + .unwrap_or(false), + None => true, + }; + + let created_at_match = match (created_after_filter, created_before_filter) { + (Some(after), Some(before)) => provider + .created_at + .map(|c| c >= *after && c <= *before) + .unwrap_or(false), + (Some(after), None) => { + provider.created_at.map(|c| c >= *after).unwrap_or(false) + } + (None, Some(before)) => { + provider.created_at.map(|c| c <= *before).unwrap_or(false) + } + (None, None) => true, + }; + + let updated_at_match = match (updated_after_filter, updated_before_filter) { + (Some(after), Some(before)) => provider + .updated_at + .map(|u| u >= *after && u <= *before) + .unwrap_or(false), + (Some(after), None) => { + provider.updated_at.map(|u| u >= *after).unwrap_or(false) + } + (None, Some(before)) => { + provider.updated_at.map(|u| u <= *before).unwrap_or(false) + } + (None, None) => true, + }; + + // Priority: Stac Vesion -> created_at -> then updated_at -> then the strings. + stac_version_match + && created_at_match + && updated_at_match + && name_match + && description_match + && catalog_id_match + }); + } + + Ok(providers) + } + + /// **** Catalog functions **** + + fn get_catalog(&self, id: &str) -> Result<&Catalog, SuperSTACError> { + let catalog = self.catalogs.get(id); + match catalog { + Some(catalog) => Ok(catalog), + None => Err(StorageError::CatalogDoesNotExist(id.to_owned()))?, + } + } + + fn update_catalog( + &mut self, + id: &str, + update: CatalogUpdate, + ) -> Result { + let catalog_id = self + .catalogs + .get(id) + .map(|c| c.id.clone()) + .ok_or_else(|| StorageError::CatalogDoesNotExist(id.to_owned()))? + .clone(); + + // Update provider relationship + self.set_catalog_provider(&catalog_id, update.provider.as_deref())?; + + let matched_catalog = self.catalogs.get_mut(id).unwrap(); + matched_catalog.update( + update.description, + update.url, + update.title, + update.settings, + )?; + Ok(matched_catalog.clone()) + } + + fn create_catalog( + &mut self, + mut catalog: Catalog, + provider_id: Option<&str>, + ) -> Result { + // Merge the pkey with the catalog id if the catalog with the same id already exists. That way it's unique. + if self.catalogs.contains_key(&catalog.id) { + if self.get_settings().auto_fix_duplicate_catalog_id { + let new_catalog_id = format!("{}-{}", catalog.id, self.catalogs.len() + 1); + tracing::warn!( + old_id = %catalog.id, + new_id = %new_catalog_id, + "duplicate catalog id auto-fixed" + ); + catalog.set_id(new_catalog_id); + } else { + return Err(StorageError::CatalogIdAlreadyExist(catalog.id.to_owned()).into()); + } + } + let catalog_id = catalog.id.clone(); + + self.catalogs.insert(catalog_id, catalog.clone()); + + self.set_catalog_provider(&catalog.id, provider_id)?; + + Ok(catalog) + } + + fn list_catalogs( + &self, + filters: Option, + ) -> Result, SuperSTACError> { + let mut catalogs: Vec = self.catalogs.values().cloned().collect(); + + // Apply filters. + if let Some(filters) = filters { + if let Some(id_val) = filters.id { + catalogs.retain(|c| c.id.to_lowercase() == id_val.to_lowercase()); + return Ok(catalogs); + } + + let provider_filter = filters.provider.as_ref(); + let title_filter = filters.title.as_ref(); + let description_filter = filters.description.as_ref(); + let created_after_filter = filters.created_after.as_ref(); + let created_before_filter = filters.created_before.as_ref(); + let updated_after_filter = filters.updated_after.as_ref(); + let updated_before_filter = filters.updated_before.as_ref(); + + catalogs.retain(|catalog| { + let health_status_match = match filters.available { + Some(is_available) => catalog.health_status.available == is_available, + None => true, + }; + + let provider_match = match provider_filter { + Some(filter) => { + let filter_lc = filter.to_lowercase(); + catalog.provider.as_ref().map_or(false, |provider_id| { + self.get_provider(provider_id).ok().map_or(false, |p| { + let name_matches = p + .name + .as_ref() + .map_or(false, |n| n.to_lowercase().contains(&filter_lc)); + + let desc_matches = p + .description + .as_ref() + .map_or(false, |d| d.to_lowercase().contains(&filter_lc)); + + name_matches || desc_matches + }) + }) + } + None => true, + }; + + let title_match = match title_filter { + Some(t_filter_ref) => catalog + .title + .as_ref() + .map(|title_ref| { + title_ref + .to_lowercase() + .contains(t_filter_ref.to_lowercase().as_str()) + }) + .unwrap_or(false), + None => true, + }; + + let description_match = match description_filter { + Some(d_filter_ref) => catalog + .description + .as_ref() + .map(|desc_ref| { + desc_ref + .to_lowercase() + .contains(d_filter_ref.to_lowercase().as_str()) + }) + .unwrap_or(false), + None => true, + }; + + let created_at_match = match (created_after_filter, created_before_filter) { + (Some(after), Some(before)) => catalog + .created_at + .map(|c| c >= *after && c <= *before) + .unwrap_or(false), + (Some(after), None) => catalog.created_at.map(|c| c >= *after).unwrap_or(false), + (None, Some(before)) => { + catalog.created_at.map(|c| c <= *before).unwrap_or(false) + } + (None, None) => true, + }; + + let updated_at_match = match (updated_after_filter, updated_before_filter) { + (Some(after), Some(before)) => catalog + .updated_at + .map(|u| u >= *after && u <= *before) + .unwrap_or(false), + (Some(after), None) => catalog.updated_at.map(|u| u >= *after).unwrap_or(false), + (None, Some(before)) => { + catalog.updated_at.map(|u| u <= *before).unwrap_or(false) + } + (None, None) => true, + }; + + // Priority: Health status first -> then created_at -> then updated_at -> then the strings. + health_status_match + && created_at_match + && updated_at_match + && title_match + && description_match + && provider_match + }); + } + + Ok(catalogs) + } + + fn delete_catalog(&mut self, id: &str) -> Result<(), SuperSTACError> { + match self.catalogs.remove(id) { + Some(_) => { + // Detach the catalog id from any associated providers. + for provider in self.providers.values_mut() { + if let Some(catalogs) = &mut provider.catalog_ids { + catalogs.retain(|catalog_id| catalog_id != id); + } + } + Ok(()) + } + None => Err(StorageError::CatalogDoesNotExist(id.to_owned()))?, + } + } + + fn delete_catalogs(&mut self, ids: Vec<&str>) -> Result<(), SuperSTACError> { + let mut not_found = Vec::new(); + + for id in ids { + if self.delete_catalog(id).is_err() { + not_found.push(id.to_string()); + } + } + + if not_found.is_empty() { + Ok(()) + } else { + Err(StorageError::CatalogsNotFound(not_found))? + } + } + + fn update_health(&mut self, id: &str, healthy: bool) -> Result<(), SuperSTACError> { + match self.catalogs.get_mut(id) { + Some(catalog) => { + catalog.health_status.available = healthy; + Ok(()) + } + None => Err(StorageError::CatalogDoesNotExist(id.to_owned()))?, + } + } + + fn update_supported_collections( + &mut self, + id: &str, + collections: Option>, + ) -> Result<(), SuperSTACError> { + match self.catalogs.get_mut(id) { + Some(catalog) => { + catalog.supported_collections = collections; + Ok(()) + } + None => Err(StorageError::CatalogDoesNotExist(id.to_owned()))?, + } + } +} diff --git a/crates/core/src/storages/mod.rs b/crates/core/src/storages/mod.rs new file mode 100644 index 0000000..315942d --- /dev/null +++ b/crates/core/src/storages/mod.rs @@ -0,0 +1,2 @@ +pub mod memory; +pub mod factory; diff --git a/crates/core/src/utils.rs b/crates/core/src/utils.rs new file mode 100644 index 0000000..e6e37d8 --- /dev/null +++ b/crates/core/src/utils.rs @@ -0,0 +1,31 @@ +use chrono::{DateTime, Utc}; +use url::{ParseError, Url}; + +use crate::errors::{SuperSTACError, ValidationError}; + +/// Current UTC timestamp. Centralized so tests can mock it later. +pub fn get_date_time() -> DateTime { + Utc::now() +} + +/// Parse and validate a URL string. +pub fn parse_url(url: &str) -> Result { + Url::parse(url) +} + +/// Validate a catalog/provider identifier. Allowed: ASCII letters, digits, +/// hyphen, underscore. Empty / whitespace-only is rejected. +pub fn validate_identifier(id: &str) -> Result<(), SuperSTACError> { + if id.trim().is_empty() { + return Err(ValidationError::MissingField("id".into()).into()); + } + + if !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { + return Err(ValidationError::InvalidIdentifier( + "only ASCII letters, digits, hyphen, and underscore are allowed".into(), + ) + .into()); + } + + Ok(()) +} diff --git a/crates/core/tests/mod.rs b/crates/core/tests/mod.rs new file mode 100644 index 0000000..d8daec6 --- /dev/null +++ b/crates/core/tests/mod.rs @@ -0,0 +1,2 @@ +mod utils; +mod storages; diff --git a/crates/core/tests/storages/memory.rs b/crates/core/tests/storages/memory.rs new file mode 100644 index 0000000..4c30052 --- /dev/null +++ b/crates/core/tests/storages/memory.rs @@ -0,0 +1,1212 @@ +use std::collections::HashMap; + +use superstac_core::{ + storages::{factory::StorageBackend, memory::MemoryStorage}, + errors::SuperSTACError, + models::{ + catalog::{Catalog, CatalogFilters, CatalogUpdate, HealthCheckFrequencyStrategy}, + provider::{ CatalogProvider, CatalogProviderFilters, CatalogProviderUpdate}, + settings::{LogLevel, Settings, SettingsUpdate}, + }, + utils::get_date_time, +}; + +////////////////////////######################################## UTILS ########################################//////////////////////////////////////////////////// + +fn create_catalog() -> Catalog { + Catalog::new( + "test-id", + Some("Test STAC"), + "https://test.com", + Some("A test description"), + None, + ) + .expect("Catalog should be created") +} + +fn create_store() -> MemoryStorage { + MemoryStorage::init() +} + +fn create_provider(id: &str) -> CatalogProvider { + CatalogProvider::new( + id.to_string(), + Some("Microsoft Provider".to_string()), + Some("This is the Microsoft Provider".to_string()), + Some("https://website.com".to_string()), + Some("https://www.google.com/logo.png".to_string()), + None, + None, + ) + .expect("Provider should be created") +} + +fn create_provider_with_result(id: &str) -> Result { + CatalogProvider::new( + id.to_string(), + Some("Microsoft Provider".to_string()), + Some("This is the Microsoft Provider".to_string()), + Some("https://website.com".to_string()), + Some("https://www.google.com/logo.png".to_string()), + None, + None, + ) +} + +////////////////////////######################################## GENERAL ########################################//////////////////////////////////////////////////// + +#[test] +fn memory_store_will_initiatialize_with_defaults() { + let store = create_store(); + + assert!(store.list_catalogs(None).unwrap().len() == 0); + assert!(store.list_providers(None).unwrap().len() == 0); + let settings = store.get_settings(); + assert_eq!(settings.auto_fix_duplicate_catalog_id, true); + assert_eq!(settings.auto_fix_duplicate_provider_id, true); + assert_eq!(settings.logging_enabled, true); + assert_eq!(settings.log_level, LogLevel::Info); + assert_eq!( + settings.health_check_strategy, + HealthCheckFrequencyStrategy::Hourly + ); + assert_eq!(settings.healthy_status_code_range, (200, 299)); +} + +////////////////////////######################################## CATALOG ########################################//////////////////////////////////////////////////// + +#[test] +fn memory_store_rejects_catalog_with_invalid_id() { + let catalog_id = "$$$"; + let catalog = Catalog::new( + catalog_id, + Some("Test STAC"), + "https://test.com", + Some("A test description"), + None, + ); + + assert!(catalog.is_err()); + let err = catalog.unwrap_err(); + assert_eq!(err.to_string(), "validation: invalid identifier: only ASCII letters, digits, hyphen, and underscore are allowed"); +} + +#[test] +fn memory_store_creates_catalog_with_defaults_when_provider_and_settings_are_not_provided() { + let mut store = create_store(); + let catalog_id = "test-id"; + // create a catalog in the store + let catalog = store + .create_catalog( + Catalog::new( + catalog_id, + Some("Test STAC"), + "https://test.com", + Some("A test description"), + None, + ) + .unwrap(), + None, + ) + .expect("Could not create catalog even with valid parameters"); + + assert_eq!(catalog.id, catalog_id.to_string()); + assert_eq!( + catalog.created_at.unwrap().date_naive(), + get_date_time().date_naive() + ); + assert_eq!(catalog.provider, None); + + assert_eq!(catalog.health_status.available, false); + + assert_eq!(catalog.health_status.endpoint, catalog.url); + + // get the catalogs to confirm the length and content. + + assert!(store.list_catalogs(None).unwrap().len() == 1); + + assert!(store.get_catalog(catalog_id).unwrap().id == catalog_id); +} + +#[test] +fn memory_store_panics_when_creating_catalog_with_invalid_provider() { + let mut store = create_store(); + + // create a catalog in the store + let catalog = store.create_catalog(create_catalog(), Some(&"unknown-provider".to_string())); + assert!(catalog.is_err()); + + let err = catalog.unwrap_err(); + assert_eq!( + err.to_string(), + "storage: provider 'unknown-provider' does not exist" + ); +} + +#[test] +fn memory_store_does_not_panic_when_creating_catalog_with_duplicate_ids_and_settings_to_auto_fix_is_enabled( +) { + let mut store = create_store(); + + // create first catalog in the store + let _ = store.create_catalog(create_catalog(), None); + // create another catalog with the same id + let catalog2 = store.create_catalog(create_catalog(), None); + + // by default the setting is enabled to autofix duplicate id, so that should happen. + assert!(catalog2.is_ok()); +} + +#[test] +fn memory_store_panics_when_creating_catalog_with_duplicate_id_and_settings_to_auto_fix_is_disabled( +) { + let mut store = create_store(); + + // create first catalog in the store + let _ = store.create_catalog(create_catalog(), None); + + // update settings + store.update_settings(SettingsUpdate { + auto_fix_duplicate_catalog_id: Some(false), + ..SettingsUpdate::try_from(Settings::default()).unwrap() + }); + // create another catalog with the same id + let catalog2 = store.create_catalog(create_catalog(), None); + + assert!(catalog2.is_err()); + let err = catalog2.unwrap_err(); + assert_eq!(err.to_string(), "storage: catalog id 'test-id' already exists (set auto_fix_duplicate_catalog_id=true or use a unique id)"); +} + +#[test] +fn memory_store_accepts_updating_catalog_with_valid_parameters() { + let mut store = create_store(); + + // create a catalog in the store + let catalog = store.create_catalog(create_catalog(), None).unwrap(); + + // update the description + let description = Some("This is a new description".to_string()); + let updated_catalog = store.update_catalog( + &catalog.id, + CatalogUpdate { + provider: None, + title: None, + url: None, + description: description.clone(), + settings: None, + }, + ); + assert!(updated_catalog.is_ok()); + assert_eq!(updated_catalog.as_ref().unwrap().description, description); + // confirm that only description is updated i.e others remains intact + assert_eq!(updated_catalog.unwrap().url, catalog.url); +} + +#[test] +fn memory_store_rejects_updating_catalog_with_invalid_provider() { + let mut store = create_store(); + + // create a catalog in the store + let catalog = store.create_catalog(create_catalog(), None).unwrap(); + + // update the provider + + let updated_catalog = store.update_catalog( + &catalog.id, + CatalogUpdate { + provider: Some("unknown-provider".to_string()), + title: None, + url: None, + description: None, + settings: None, + }, + ); + assert!(updated_catalog.is_err()); + let err = updated_catalog.unwrap_err(); + assert_eq!( + err.to_string(), + "storage: provider 'unknown-provider' does not exist" + ); +} + +#[test] +fn memory_store_deletes_catalog_successfully_if_it_exists() { + let mut store = create_store(); + + // create a catalog in the store + let catalog = store.create_catalog(create_catalog(), None).unwrap(); + + // delete the catalog + let deleted = store.delete_catalog(&catalog.id); + assert!(deleted.is_ok()); + + assert_eq!(store.list_catalogs(None).unwrap().len(), 0); +} + +#[test] +fn memory_store_fails_to_delete_catalog_if_it_does_not_exist() { + let mut store = create_store(); + + // delete the catalog + let deleted = store.delete_catalog(&"unknown-catalog"); + assert!(deleted.is_err()); + let err = deleted.unwrap_err(); + assert_eq!( + err.to_string(), + "storage: catalog 'unknown-catalog' does not exist" + ); +} + +#[test] +fn memory_store_deletes_multiple_catalogs_successfully_if_they_exist() { + let mut store = create_store(); + + // create a catalog in the store + let catalog1 = store.create_catalog(create_catalog(), None).unwrap(); + + let catalog2 = store.create_catalog(create_catalog(), None).unwrap(); + + // delete the catalogs + let deleted = store.delete_catalogs(vec![&catalog1.id, &catalog2.id]); + assert!(deleted.is_ok()); + + assert_eq!(store.list_catalogs(None).unwrap().len(), 0); +} + +#[test] +fn memory_store_fails_to_delete_multiple_catalogs_if_one_does_not_exist() { + let mut store = create_store(); + + // create a catalog in the store + let catalog1 = store.create_catalog(create_catalog(), None).unwrap(); + + // delete the catalogs + let deleted = store.delete_catalogs(vec![&catalog1.id, "unknown_catalog"]); + + assert!(deleted.is_err()); + + // returns the one that wasn't found + let err = deleted.unwrap_err(); + assert_eq!( + err.to_string(), + "storage: catalogs not found: [\"unknown_catalog\"]" + ); + + // deletes the one that was found + assert_eq!(store.list_catalogs(None).unwrap().len(), 0); +} + +#[test] +fn memory_store_retrieves_catalog_if_it_exists() { + let mut store = create_store(); + + // create a catalog in the store + let catalog = store.create_catalog(create_catalog(), None).unwrap(); + + let retrieved_catalog = store.get_catalog(&catalog.id); + + assert_eq!(retrieved_catalog.unwrap().id, catalog.id); +} + +#[test] +fn memory_store_rejects_unknown_catalog_id() { + let mut store = create_store(); + + // create a catalog in the store + let _ = store.create_catalog(create_catalog(), None).unwrap(); + + let retrieved_catalog = store.get_catalog("unknown_id"); + + assert!(retrieved_catalog.is_err()); + + let err = retrieved_catalog.unwrap_err(); + assert_eq!( + err.to_string(), + "storage: catalog 'unknown_id' does not exist" + ); +} + +#[test] +fn memory_store_returns_all_catalogs_without_filtering() { + let mut store = create_store(); + // create a catalog in the store + let _ = store.create_catalog(create_catalog(), None).unwrap(); + // create another catalog in the store + let _ = store.create_catalog(create_catalog(), None).unwrap(); + + let catalogs = store.list_catalogs(None).unwrap(); + // retrieve the catalogs - should be 2 + assert!(catalogs.len() == 2); +} + +#[test] +fn memory_store_returns_matched_catalogs_when_filtered() { + let mut store = create_store(); + // create a catalog in the store + let catalog1 = store.create_catalog(create_catalog(), None).unwrap(); + // create another catalog in the store + let _ = store.create_catalog(create_catalog(), None).unwrap(); + + let catalogs = store.list_catalogs(None).unwrap(); + + // retrieve the catalogs before filtering - should be 2 + assert!(catalogs.len() == 2); + + // filter by id and it should return only one + + let filtered_catalogs = store + .list_catalogs(Some(CatalogFilters { + id: Some("test-id".to_string()), + ..CatalogFilters::default() + })) + .unwrap(); + + assert!(filtered_catalogs.len() == 1); + + // The content should be the one of catalog1 + assert_eq!(filtered_catalogs.first().unwrap().id, catalog1.id); + assert_eq!(filtered_catalogs.first().unwrap().url, catalog1.url); + + // Filter by string search in the title. For this add another catalog with a different name/description + let creation_date = get_date_time(); + + let catalog3 = store + .create_catalog( + Catalog::new( + "new_id", + Some("A different STAC"), + "https://test.com", + Some("A test description"), + None, + ) + .unwrap(), + None, + ) + .expect("Could not create catalog even with valid parameters"); + + // search for 'different' + let filtered_catalogs = store + .list_catalogs(Some(CatalogFilters { + title: Some("different".to_string()), + ..CatalogFilters::default() + })) + .unwrap(); + + assert!(filtered_catalogs.len() == 1); + + // The content should be the one of catalog1 + assert_eq!(filtered_catalogs.first().unwrap().id, catalog3.id); + assert_eq!(filtered_catalogs.first().unwrap().title, catalog3.title); + + // filter for available - all should not be available + + let filtered_catalogs = store + .list_catalogs(Some(CatalogFilters { + available: Some(false), + ..CatalogFilters::default() + })) + .unwrap(); + + assert!(filtered_catalogs.len() == 3); + + let filtered_catalogs = store + .list_catalogs(Some(CatalogFilters { + available: Some(true), + ..CatalogFilters::default() + })) + .unwrap(); + + assert!(filtered_catalogs.len() == 0); + + // catalog3 was created after the 'creation_date' above. So if we filter by `created_after`, it should be one response i.e catalog3. + + let filtered_catalogs = store + .list_catalogs(Some(CatalogFilters { + created_after: Some(creation_date), + ..CatalogFilters::default() + })) + .unwrap(); + + assert!(filtered_catalogs.len() == 1); + assert_eq!(filtered_catalogs.first().unwrap().id, catalog3.id); + // if we filter by `created_before`, then it should be 2 responses, i.e catalog 1 and 2 + + let filtered_catalogs = store + .list_catalogs(Some(CatalogFilters { + created_before: Some(creation_date), + ..CatalogFilters::default() + })) + .unwrap(); + + assert!(filtered_catalogs.len() == 2); + assert!(filtered_catalogs.first().unwrap().id.contains(&catalog1.id)); +} + +////////////////////////######################################## RELATIONSHIPS ########################################//////////////////////////////////////////////////// + +#[test] +fn catalog_provider_relationship_updates_in_provider_when_catalog_is_created_with_a_provider() { + let mut store = create_store(); + + // create providers + let provider1 = store + .create_provider(create_provider("microsoft")) + .expect("Provider could not be created even with valid arguments."); + + // create catalogs linked to the same provider + + let catalog1 = store + .create_catalog(create_catalog(), Some(&provider1.id)) + .expect("Could not create catalog with provider"); + + let catalog2 = store + .create_catalog(create_catalog(), Some(&provider1.id)) + .expect("Could not create catalog with provider"); + + // refetch provider + + let provider1 = store + .get_provider(&provider1.id) + .expect("Should return Provider1 data"); + + // Confirm it's linked correctly in the providers' catalog_id + let provider_catalogs = provider1 + .catalog_ids + .as_ref() + .expect("Should include catalog_ids"); + + assert!(provider_catalogs.len() == 2); + assert!(provider_catalogs.contains(&catalog1.id)); + assert!(provider_catalogs.contains(&catalog2.id)); + + // Also refresh the catalog and confirm the catalog has the provider id + let catalog1 = store + .get_catalog(&catalog1.id) + .expect("Catalog should exist."); + + assert_eq!( + &provider1.id, + catalog1.provider.as_ref().expect("Provider should exist.") + ); +} + +#[test] +fn catalog_provider_relationship_updates_in_provider_when_catalog_is_deleted() { + let mut store = create_store(); + + // create provider + let provider1 = store + .create_provider(create_provider("microsoft")) + .expect("Provider could not be created even with valid arguments."); + + // create catalog linked to the same provider + + let catalog1 = store + .create_catalog(create_catalog(), Some(&provider1.id)) + .expect("Could not create catalog with provider"); + + // delete catalog + store + .delete_catalog(&catalog1.id) + .expect("Could not delete the catalog despite been created earlier."); + + // refresh provider + let provider1 = store + .get_provider(&provider1.id) + .expect("Should return Provider1 data"); + + // confirm provider is updated + let provider_catalogs = provider1 + .catalog_ids + .as_ref() + .expect("Should include catalog_ids"); + + assert!(provider_catalogs.len() == 0); +} + +#[test] +fn catalog_provider_relationship_updates_correctly_when_catalog_is_updated_with_another_provider() { + let mut store = create_store(); + + // create provider + let provider1 = store + .create_provider(create_provider("microsoft")) + .expect("Provider could not be created even with valid arguments."); + + // create provider + let provider2 = store + .create_provider(create_provider("google")) + .expect("Provider could not be created even with valid arguments."); + + // create catalog linked to the first provider + + let catalog1 = store + .create_catalog(create_catalog(), Some(&provider1.id)) + .expect("Could not create catalog with provider"); + + // Update the catalog to use the second provider + + store + .update_catalog( + &catalog1.id, + CatalogUpdate { + provider: Some(provider2.id.clone()), + title: None, + url: None, + description: None, + settings: None, + }, + ) + .expect("Could not update catalog even with the right parameters"); + + // refresh first and second provider + let provider1 = store + .get_provider(&provider1.id) + .expect("Should return Provider1 data"); + + let provider2 = store + .get_provider(&provider2.id) + .expect("Should return Provider1 data"); + + // confirm the first provider doesn't have the catalog again + assert!(provider1.catalog_ids.is_none()); + + // confirm the second provider now have the catalog + let provider2_catalogs = provider2 + .catalog_ids + .as_ref() + .expect("Should include catalog_ids"); + + assert!(provider2_catalogs.len() == 1); + assert!(provider2_catalogs.contains(&catalog1.id)); +} + +#[test] +fn catalog_provider_relationship_updates_correctly_when_provider_is_deleted() { + let mut store = create_store(); + + // create provider + let provider = store + .create_provider(create_provider("microsoft")) + .expect("Provider could not be created even with valid arguments."); + + // create catalog linked to the provider + + let catalog = store + .create_catalog(create_catalog(), Some(&provider.id)) + .expect("Could not create catalog with provider"); + + // Delete the provider + store + .delete_provider(&provider.id) + .expect("Provider should be deleted."); + + // refresh the catalog + let catalog = store + .get_catalog(&catalog.id) + .expect("Should return catalog data"); + + // confirm the catalog doesn't have provider again + assert!(catalog.provider.is_none()); +} + +////////////////////////######################################## SETTINGS ########################################//////////////////////////////////////////////////// + +#[test] +fn settings_update_toggles_deduplicate_items() { + let store = create_store(); + + store.update_settings(SettingsUpdate { + deduplicate_items: Some(false), + ..SettingsUpdate::try_from(Settings::default()).unwrap() + }); + + assert_eq!(store.get_settings().deduplicate_items, Some(false)); +} + +#[test] +fn settings_default_enables_deduplicate_items() { + let store = create_store(); + assert_eq!(store.get_settings().deduplicate_items, Some(true)); +} + +#[test] +fn settings_update_toggles_unify_response() { + let store = create_store(); + + store.update_settings(SettingsUpdate { + unify_response: Some(false), + ..SettingsUpdate::try_from(Settings::default()).unwrap() + }); + + assert_eq!(store.get_settings().unify_response, Some(false)); +} + +#[test] +fn settings_default_enables_unify_response() { + let store = create_store(); + assert_eq!(store.get_settings().unify_response, Some(true)); +} + +#[test] +fn settings_default_includes_federation_hardening_defaults() { + let store = create_store(); + let s = store.get_settings(); + assert_eq!(s.max_concurrent_catalogs, Some(8)); + assert_eq!(s.per_catalog_timeout_seconds, Some(30)); + assert_eq!(s.max_retry_attempts, Some(2)); + assert_eq!(s.retry_initial_backoff_ms, Some(100)); + assert_eq!(s.retry_max_backoff_ms, Some(2000)); +} + +#[test] +fn settings_update_changes_max_concurrent_catalogs() { + let store = create_store(); + + store.update_settings(SettingsUpdate { + max_concurrent_catalogs: Some(16), + ..SettingsUpdate::try_from(Settings::default()).unwrap() + }); + + assert_eq!(store.get_settings().max_concurrent_catalogs, Some(16)); +} + +#[test] +fn settings_update_changes_per_catalog_timeout() { + let store = create_store(); + + store.update_settings(SettingsUpdate { + per_catalog_timeout_seconds: Some(60), + ..SettingsUpdate::try_from(Settings::default()).unwrap() + }); + + assert_eq!(store.get_settings().per_catalog_timeout_seconds, Some(60)); +} + +#[test] +fn settings_update_changes_retry_policy() { + let store = create_store(); + + store.update_settings(SettingsUpdate { + max_retry_attempts: Some(5), + retry_initial_backoff_ms: Some(250), + retry_max_backoff_ms: Some(5000), + ..SettingsUpdate::try_from(Settings::default()).unwrap() + }); + + let s = store.get_settings(); + assert_eq!(s.max_retry_attempts, Some(5)); + assert_eq!(s.retry_initial_backoff_ms, Some(250)); + assert_eq!(s.retry_max_backoff_ms, Some(5000)); +} + +#[test] +fn settings_update_succeeds_with_valid_info() { + let store = create_store(); + + // update settings + store.update_settings(SettingsUpdate { + auto_fix_duplicate_catalog_id: Some(false), + auto_fix_duplicate_provider_id: Some(false), + health_check_strategy: Some(HealthCheckFrequencyStrategy::Daily), + healthy_status_code_range: Some((300, 500)), + ..SettingsUpdate::try_from(Settings::default()).unwrap() + }); + + let settings = store.get_settings(); + + assert_eq!(settings.auto_fix_duplicate_catalog_id, false); + assert_eq!(settings.auto_fix_duplicate_provider_id, false); + assert_eq!( + settings.health_check_strategy, + HealthCheckFrequencyStrategy::Daily + ); + assert_eq!(settings.healthy_status_code_range, (300, 500)); +} + +////////////////////////######################################## PROVIDER ########################################//////////////////////////////////////////////////// + +#[test] +fn memory_store_rejects_provider_with_invalid_id() { + let provider_id = "$$$"; + let provider = create_provider_with_result(provider_id); + + assert!(provider.is_err()); + let err = provider.unwrap_err(); + assert_eq!(err.to_string(), "validation: invalid identifier: only ASCII letters, digits, hyphen, and underscore are allowed"); +} + +#[test] +fn memory_store_creates_provider_with_defaults_when_catalog_ids_and_settings_are_not_provided() { + let mut store = create_store(); + let provider_id = "test-id"; + // create a catalog in the store + let provider = store + .create_provider(create_provider(provider_id)) + .expect("Could not create catalog even with valid parameters"); + + assert_eq!(provider.id, provider_id.to_string()); + assert_eq!( + provider.created_at.unwrap().date_naive(), + get_date_time().date_naive() + ); + assert_eq!(provider.catalog_ids, None); + + // get the catalogs to confirm the length and content. + + assert!(store.list_providers(None).unwrap().len() == 1); + + assert!(store.get_provider(provider_id).unwrap().id == provider_id); +} + +// panic with invalid catalog_ids +#[test] +fn memory_store_panics_when_creating_provider_with_unknown_catalog_ids() { + let mut store = create_store(); + + // create a provider in the store + let provider = CatalogProvider::new( + "test-provider".to_string(), + Some("Microsoft Provider".to_string()), + Some("This is the Microsoft Provider".to_string()), + Some("https://website.com".to_string()), + Some("https://www.google.com/logo.png".to_string()), + None, + Some(vec!["unknown_catalogs".to_string()]), + ) + .expect("Provider should be created"); + + let created_provider = store.create_provider(provider); + + assert!(created_provider.is_err()); + + let err = created_provider.unwrap_err(); + assert_eq!( + err.to_string(), + "storage: catalog 'unknown_catalogs' does not exist" + ); +} + +#[test] +fn memory_store_does_not_panic_when_creating_provider_with_duplicate_ids_and_settings_to_auto_fix_is_enabled( +) { + let mut store = create_store(); + + // create first provider in the store + let _ = store.create_provider(create_provider("test-id")); + + // create another provider with the same id + let provider = store.create_provider(create_provider("test-id")); + + // by default the setting is enabled to autofix duplicate id, so that should happen. + assert!(provider.is_ok()); +} + +#[test] +fn memory_store_panics_when_creating_provider_with_duplicate_id_and_settings_to_auto_fix_is_disabled( +) { + let mut store = create_store(); + + // create first provider in the store + let _ = store.create_provider(create_provider("test-id")); + + // update settings + store.update_settings(SettingsUpdate { + auto_fix_duplicate_provider_id: Some(false), + ..SettingsUpdate::try_from(Settings::default()).unwrap() + }); + // create another provider with the same id + let provider = store.create_provider(create_provider("test-id")); + + assert!(provider.is_err()); + let err = provider.unwrap_err(); + assert_eq!(err.to_string(), "storage: provider id 'test-id' already exists (set auto_fix_duplicate_provider_id=true or use a unique id)"); +} + +#[test] +fn memory_store_accepts_updating_provider_with_valid_parameters() { + let mut store = create_store(); + + // create a provider in the store + let provider = store + .create_provider(create_provider("test-provider")) + .expect("Provider should be created"); + + // update the description + let description = Some("This is a new description".to_string()); + let updated_provider = store.update_provider( + &provider.id, + CatalogProviderUpdate { + stac_version: None, + name: Some("New title".to_string()), + description: description.clone(), + logo_url: None, + website_url: None, + catalog_ids: None, + }, + ); + assert!(updated_provider.is_ok()); + assert_eq!(updated_provider.as_ref().unwrap().description, description); + // confirm that only description is updated i.e others remains intact + assert_eq!(updated_provider.unwrap().website_url, provider.website_url); +} + +#[test] +fn memory_store_rejects_updating_provider_with_invalid_catalog_ids() { + let mut store = create_store(); + + // create a provider in the store + let provider = store + .create_provider(create_provider("test-provider")) + .expect("Provider should be created"); + + // update the provider + let updated_provider = store.update_provider( + &provider.id, + CatalogProviderUpdate { + stac_version: None, + name: Some("New title".to_string()), + description: None, + logo_url: None, + website_url: None, + catalog_ids: Some(vec!["unknown_catalog".to_string()]), + }, + ); + assert!(updated_provider.is_err()); + let err = updated_provider.unwrap_err(); + assert_eq!( + err.to_string(), + "storage: catalog 'unknown_catalog' does not exist" + ); +} + +#[test] +fn memory_store_deletes_provider_successfully_if_it_exists() { + let mut store = create_store(); + + let provider = store + .create_provider(create_provider("test-provider")) + .expect("Provider should be created"); + + // delete the provider + let deleted = store.delete_provider(&provider.id); + + assert!(deleted.is_ok()); + + assert_eq!( + store + .list_providers(None) + .expect("Providers should be returned") + .len(), + 0 + ); +} + +#[test] +fn memory_store_fails_to_delete_provider_if_it_does_not_exist() { + let mut store = create_store(); + + // delete the provider + let deleted = store.delete_provider(&"unknown-provider"); + assert!(deleted.is_err()); + let err = deleted.unwrap_err(); + assert_eq!( + err.to_string(), + "storage: provider 'unknown-provider' does not exist" + ); +} + +#[test] +fn memory_store_deletes_multiple_providers_successfully_if_they_exist() { + let mut store = create_store(); + + let provider1 = store + .create_provider(create_provider("test-provider")) + .expect("Provider should be created"); + + let provider2 = store + .create_provider(create_provider("test-provider")) + .expect("Provider should be created"); + + // delete the providers + let deleted = store.delete_providers(vec![&provider1.id, &provider2.id]); + assert!(deleted.is_ok()); + + assert_eq!( + store + .list_providers(None) + .expect("Providers should be returned") + .len(), + 0 + ); +} + +#[test] +fn memory_store_fails_to_delete_multiple_providers_if_one_does_not_exist() { + let mut store = create_store(); + + let provider1 = store + .create_provider(create_provider("test-provider")) + .expect("Provider should be created"); + + // delete the providers + let deleted = store.delete_providers(vec![&provider1.id, "unknown_catalog"]); + + assert!(deleted.is_err()); + + // returns the one that wasn't found + let err = deleted.unwrap_err(); + assert_eq!( + err.to_string(), + "storage: providers not found: [\"unknown_catalog\"]" + ); + + // deletes the one that was found + assert_eq!( + store + .list_providers(None) + .expect("Providers should be returned") + .len(), + 0 + ); +} + +#[test] +fn memory_store_retrieves_provider_if_it_exists() { + let mut store = create_store(); + + let provider = store + .create_provider(create_provider("test-provider")) + .expect("Provider should be created"); + + let retrieved_provider = store + .get_provider(&provider.id) + .expect("Provider should be retrieved"); + + assert_eq!(retrieved_provider.id, provider.id); +} + +#[test] +fn memory_store_rejects_unknown_provider_id() { + let mut store = create_store(); + + let _ = store + .create_provider(create_provider("test-provider")) + .expect("Provider should be created"); + + let retrieved_provider = store.get_provider("unknown_id"); + + assert!(retrieved_provider.is_err()); + + let err = retrieved_provider.unwrap_err(); + assert_eq!( + err.to_string(), + "storage: provider 'unknown_id' does not exist" + ); +} + +#[test] +fn memory_store_returns_all_providers_without_filtering() { + let mut store = create_store(); + + let _ = store + .create_provider(create_provider("test-provider")) + .expect("Provider should be created"); + let _ = store + .create_provider(create_provider("test-provider")) + .expect("Provider should be created"); + + let providers = store.list_providers(None).unwrap(); + // retrieve the catalogs - should be 2 + assert!(providers.len() == 2); +} + + +#[test] +fn memory_store_returns_matched_providers_when_filtered() { + let mut store = create_store(); + let provider1 = store + .create_provider(create_provider("test-provider")) + .expect("Provider should be created"); + let _ = store + .create_provider(create_provider("test-provider")) + .expect("Provider should be created"); + let providers = store.list_providers(None).unwrap(); + + // retrieve the catalogs before filtering - should be 2 + assert!(providers.len() == 2); + + // filter by id and it should return only one + + let filtered_providers = store + .list_providers(Some(CatalogProviderFilters { + id: Some("test-provider".to_string()), + name: None, + description: None, + stac_version: None, + created_after: None, + created_before: None, + updated_before: None, + updated_after: None, + catalog_id: None, + })) + .unwrap(); + + assert!(filtered_providers.len() == 1); + + // The content should be the one of provider1 + assert_eq!(filtered_providers.first().unwrap().id, provider1.id); + assert_eq!( + filtered_providers.first().unwrap().stac_version, + provider1.stac_version + ); + + // Filter by string search in the title. For this add another catalog with a different name/description + let creation_date = get_date_time(); + + let provider3 = store + .create_provider( + CatalogProvider::new( + "new_id".to_string(), + Some("STAC Provider".to_string()), + Some("A different STAC Provider".to_string()), + Some("https://test.com".to_string()), + Some("https://test.com".to_string()), + Some("A test version".to_string()), + None, + ) + .expect("Provider should be created"), + ) + .expect("Provider should be created"); + + // search for 'different' + let filtered_providers = store + .list_providers(Some(CatalogProviderFilters { + description: Some("different".to_string()), + ..CatalogProviderFilters::default() + })) + .unwrap(); + + assert!(filtered_providers.len() == 1); + + // The content should be the one of provider1 + assert_eq!(filtered_providers.first().unwrap().id, provider3.id); + assert_eq!( + filtered_providers.first().unwrap().description, + provider3.description + ); + + // provider3 was created after the 'creation_date' above. So if we filter by `created_after`, it should be one response i.eprovider3. + + let filtered_providers = store + .list_providers(Some(CatalogProviderFilters { + created_after: Some(creation_date), + ..CatalogProviderFilters::default() + })) + .unwrap(); + assert!(filtered_providers.len() == 1); + assert_eq!(filtered_providers.first().unwrap().id, provider3.id); + + // if we filter by `created_before`, then it should be 2 responses, i.e catalog 1 and 2 + + let filtered_providers = store + .list_providers(Some(CatalogProviderFilters { + created_before: Some(creation_date), + ..CatalogProviderFilters::default() + })) + .unwrap(); + + assert!(filtered_providers.len() == 2); + assert!(filtered_providers + .first() + .unwrap() + .id + .contains(&provider1.id)); +} + +////////////////////////######################################## COLLECTION ALIASES ########################################//////////////////////////////////////////////////// + +#[test] +fn catalog_resolve_collection_returns_alias_when_present() { + let mut catalog = create_catalog(); + catalog + .collection_aliases + .insert("sentinel-2-l2a".to_string(), "S2MSI2A".to_string()); + + assert_eq!(catalog.resolve_collection("sentinel-2-l2a"), "S2MSI2A"); +} + +#[test] +fn catalog_resolve_collection_falls_back_to_canonical_when_no_alias() { + let catalog = create_catalog(); + assert_eq!(catalog.resolve_collection("sentinel-2-l2a"), "sentinel-2-l2a"); +} + +#[test] +fn catalog_with_collection_aliases_attaches_map() { + let mut aliases = HashMap::new(); + aliases.insert("sentinel-2-l2a".to_string(), "S2MSI2A".to_string()); + + let catalog = create_catalog().with_collection_aliases(aliases); + + assert_eq!(catalog.resolve_collection("sentinel-2-l2a"), "S2MSI2A"); + assert_eq!(catalog.collection_aliases.len(), 1); +} + +////////////////////////######################################## SOURCE SELECTION ########################################//////////////////////////////////////////////////// + +#[test] +fn memory_store_updates_supported_collections_for_existing_catalog() { + let mut store = create_store(); + let catalog = store.create_catalog(create_catalog(), None).unwrap(); + + let mut set = std::collections::HashSet::new(); + set.insert("sentinel-2-l2a".to_string()); + set.insert("landsat-c2-l2".to_string()); + + let result = store.update_supported_collections(&catalog.id, Some(set.clone())); + assert!(result.is_ok()); + + let refreshed = store.get_catalog(&catalog.id).unwrap(); + assert_eq!(refreshed.supported_collections.as_ref(), Some(&set)); +} + +#[test] +fn memory_store_rejects_update_supported_collections_for_unknown_catalog() { + let mut store = create_store(); + + let result = store.update_supported_collections("unknown", None); + assert!(result.is_err()); +} + +#[test] +fn catalog_supports_any_of_passes_through_when_uninitialized() { + let catalog = create_catalog(); + assert!(catalog.supported_collections.is_none()); + assert!(catalog.supports_any_of(&["sentinel-2-l2a".to_string()])); +} + +#[test] +fn catalog_supports_any_of_matches_when_introspected_set_overlaps() { + let mut catalog = create_catalog(); + let mut set = std::collections::HashSet::new(); + set.insert("sentinel-2-l2a".to_string()); + catalog.supported_collections = Some(set); + + assert!(catalog.supports_any_of(&["sentinel-2-l2a".to_string()])); + assert!(!catalog.supports_any_of(&["landsat-c2-l2".to_string()])); + // Multi-collection query: at least one match -> include + assert!(catalog.supports_any_of(&[ + "landsat-c2-l2".to_string(), + "sentinel-2-l2a".to_string() + ])); +} + +#[test] +fn catalog_supports_any_of_matches_when_no_collection_filter() { + let mut catalog = create_catalog(); + catalog.supported_collections = Some(std::collections::HashSet::new()); + // Empty request -> match all catalogs regardless of supported set + assert!(catalog.supports_any_of(&[])); +} diff --git a/crates/core/tests/storages/mod.rs b/crates/core/tests/storages/mod.rs new file mode 100644 index 0000000..2363540 --- /dev/null +++ b/crates/core/tests/storages/mod.rs @@ -0,0 +1 @@ +mod memory; diff --git a/crates/core/tests/utils.rs b/crates/core/tests/utils.rs new file mode 100644 index 0000000..5c51e9c --- /dev/null +++ b/crates/core/tests/utils.rs @@ -0,0 +1,92 @@ +use chrono::Utc; + +use superstac_core::{ + errors::{ValidationError}, + utils::{get_date_time, parse_url, validate_identifier}, +}; + +#[test] +fn parse_url_rejects_invalid_url() { + let result = parse_url("test-url"); + assert!(result.is_err()) +} + +#[test] +fn parse_url_accepts_valid_url() { + let result = parse_url("https://example.com"); + assert!(result.is_ok()) +} + +#[test] +fn get_date_time_returns_current_datetime() { + // Between the function call there'll be some milliseconds delay. + // Using 5ms as a safe value. + + let now = Utc::now(); + let dt = get_date_time(); + + let diff = (dt - now).num_milliseconds().abs(); + assert!(diff < 5); +} + +#[test] +fn validate_identifier_test_cases() { + let cases = vec![ + // identifier -> outcome + ("osm_catalog", true), + ("o", true), + ("_", true), + ("-", true), + ("-_", true), + ("-_test-catalog", true), + ("-_test-_catalog", true), + ("-_test!catalog", false), + ("", false), + (" ", false), + (" ", false), + ("0", true), + ("$", false), + ("#", false), + ("%", false), + ("CATALOG_ID", true), + ]; + + for (case, outcome) in cases { + if outcome { + assert!(validate_identifier(case).is_ok()) + } else { + assert!(validate_identifier(case).is_err()) + } + } +} + + +#[test] +fn validate_identifier_rejects_missing_id() { + let cases = vec!["", " ", " "]; + + for case in cases { + assert!(validate_identifier(case).is_err()); + // Also assert the error message + let err = validate_identifier(case).unwrap_err(); + assert_eq!(err, ValidationError::MissingField("id".to_owned()).into()); + } +} + +#[test] +fn validate_identifier_rejects_invalid_characters() { + let cases = vec!["$", "#", "%"]; + + for case in cases { + assert!(validate_identifier(case).is_err()); + // Also assert the error message + let err = validate_identifier(case).unwrap_err(); + assert_eq!( + err, + ValidationError::InvalidIdentifier( + "only ASCII letters, digits, hyphen, and underscore are allowed".to_owned() + ) + .into() + ); + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml new file mode 100644 index 0000000..39db99a --- /dev/null +++ b/crates/engine/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "superstac-engine" +description = "Runtime for superstac: orchestrates federated STAC search, health monitoring, and capability introspection." +keywords = ["stac", "geospatial", "federated", "satellite"] +categories = ["science::geo"] +readme = "../../README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true + +[dependencies] +superstac-core = { path = "../core", version = "0.1.0" } +superstac-search = { path = "../search", version = "0.1.0" } +serde = { version = "1.0", features = ["derive"] } +stac = "0.16" +stac-io = "0.2" +tokio = { version = "1", features = ["rt", "time", "macros"] } +parking_lot = "0.12" +reqwest = { version = "0.13", features = ["json"] } +tracing = "0.1" diff --git a/crates/engine/src/capabilities.rs b/crates/engine/src/capabilities.rs new file mode 100644 index 0000000..2f2c1c1 --- /dev/null +++ b/crates/engine/src/capabilities.rs @@ -0,0 +1,47 @@ +use std::collections::{HashMap, HashSet}; + +use reqwest::Client; +use stac::api::{Collections, UrlBuilder}; +use superstac_core::{errors::SuperSTACError, models::catalog::Catalog}; + +/// Fetch the catalog's `/collections` endpoint and return the set of +/// **canonical** collection IDs it supports, after reversing any configured +/// alias map (catalog-local -> canonical). +/// TODO - What if collection is over 100, do I need to add a limit param and loop with pagination? +pub async fn fetch_supported_collections( + client: &Client, + catalog: &Catalog, +) -> Result, SuperSTACError> { + let builder = UrlBuilder::new(&catalog.url) + .map_err(|e| SuperSTACError::SearchFailed(format!("invalid catalog url: {}", e)))?; + + let response: Collections = client + .get(builder.collections().as_str()) + .send() + .await + .map_err(|e| SuperSTACError::SearchFailed(format!("fetch /collections: {}", e)))? + .error_for_status() + .map_err(|e| SuperSTACError::SearchFailed(format!("/collections status: {}", e)))? + .json() + .await + .map_err(|e| SuperSTACError::SearchFailed(format!("parse /collections: {}", e)))?; + + let reverse: HashMap<&str, &str> = catalog + .collection_aliases + .iter() + .map(|(canonical, local)| (local.as_str(), canonical.as_str())) + .collect(); + + let canonical_ids: HashSet = response + .collections + .into_iter() + .map(|c| { + reverse + .get(c.id.as_str()) + .map(|canonical| canonical.to_string()) + .unwrap_or(c.id) + }) + .collect(); + + Ok(canonical_ids) +} diff --git a/crates/engine/src/discovery.rs b/crates/engine/src/discovery.rs new file mode 100644 index 0000000..7fc19b1 --- /dev/null +++ b/crates/engine/src/discovery.rs @@ -0,0 +1,188 @@ +use std::collections::HashMap; + +use superstac_core::models::catalog::Catalog; + +/// One collection ID aggregated across all catalogs that serve it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CollectionAvailability { + pub id: String, + /// Catalog IDs that serve this collection (sorted). + pub catalogs: Vec, +} + +/// Aggregate the introspected `supported_collections` across all catalogs into +/// a flat list of "collection -> serving catalogs." Catalogs whose +/// `supported_collections` is `None` (not yet introspected) contribute nothing. +pub fn aggregate_collections(catalogs: &[Catalog]) -> Vec { + let mut by_id: HashMap> = HashMap::new(); + for catalog in catalogs { + if let Some(supported) = &catalog.supported_collections { + for collection_id in supported { + by_id + .entry(collection_id.clone()) + .or_default() + .push(catalog.id.clone()); + } + } + } + let mut result: Vec = by_id + .into_iter() + .map(|(id, mut catalogs)| { + catalogs.sort(); + CollectionAvailability { id, catalogs } + }) + .collect(); + result.sort_by(|a, b| a.id.cmp(&b.id)); + result +} + +/// Return the IDs of every catalog whose introspected set contains +/// `collection_id`. Catalogs with `None` (uninitialized) are excluded — we +/// only report definite matches. +pub fn catalogs_supporting(catalogs: &[Catalog], collection_id: &str) -> Vec { + catalogs + .iter() + .filter(|c| { + c.supported_collections + .as_ref() + .map(|s| s.contains(collection_id)) + .unwrap_or(false) + }) + .map(|c| c.id.clone()) + .collect() +} + +/// Per-catalog view: catalog ID -> sorted list of canonical collection IDs. +/// Catalogs with no introspection data are omitted. +pub fn collections_by_catalog(catalogs: &[Catalog]) -> HashMap> { + catalogs + .iter() + .filter_map(|c| { + c.supported_collections.as_ref().map(|s| { + let mut ids: Vec = s.iter().cloned().collect(); + ids.sort(); + (c.id.clone(), ids) + }) + }) + .collect() +} + +/// Return the requested collections that no known-introspected catalog can +/// serve. Conservative: if any catalog has unknown (None) capabilities, we +/// don't flag the collection (it might be served). +pub fn unsupported_collections(catalogs: &[Catalog], requested: &[String]) -> Vec { + requested + .iter() + .filter(|q| { + let could_be_supported = catalogs.iter().any(|c| match &c.supported_collections { + None => true, + Some(set) => set.contains(q.as_str()), + }); + !could_be_supported + }) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + fn catalog(id: &str, supported: Option<&[&str]>) -> Catalog { + let mut c = Catalog::new(id, Some(id), "https://example.com", None::, None).unwrap(); + c.supported_collections = supported.map(|s| s.iter().map(|x| x.to_string()).collect()); + c + } + + #[test] + fn aggregate_collections_groups_by_id_and_sorts() { + let catalogs = vec![ + catalog("a", Some(&["sentinel-2-l2a", "landsat-c2-l2"])), + catalog("b", Some(&["sentinel-2-l2a", "naip"])), + catalog("c", None), // contributes nothing + ]; + + let result = aggregate_collections(&catalogs); + + assert_eq!(result.len(), 3); + // Output is sorted by collection id + assert_eq!(result[0].id, "landsat-c2-l2"); + assert_eq!(result[0].catalogs, vec!["a"]); + assert_eq!(result[1].id, "naip"); + assert_eq!(result[1].catalogs, vec!["b"]); + assert_eq!(result[2].id, "sentinel-2-l2a"); + assert_eq!(result[2].catalogs, vec!["a", "b"]); + } + + #[test] + fn catalogs_supporting_returns_only_definite_matches() { + let catalogs = vec![ + catalog("a", Some(&["sentinel-2-l2a"])), + catalog("b", Some(&["landsat-c2-l2"])), + catalog("c", None), + ]; + + assert_eq!( + catalogs_supporting(&catalogs, "sentinel-2-l2a"), + vec!["a".to_string()] + ); + assert!(catalogs_supporting(&catalogs, "made-up").is_empty()); + } + + #[test] + fn collections_by_catalog_skips_uninitialized() { + let catalogs = vec![ + catalog("a", Some(&["x", "y"])), + catalog("b", None), + ]; + + let map = collections_by_catalog(&catalogs); + assert_eq!(map.len(), 1); + assert_eq!(map["a"], vec!["x".to_string(), "y".to_string()]); + assert!(!map.contains_key("b")); + } + + #[test] + fn unsupported_collections_is_conservative_when_uncertain() { + let catalogs = vec![ + catalog("a", Some(&["sentinel-2-l2a"])), + catalog("b", None), // uncertain — might support anything + ]; + + // typo collection — at least one catalog (b) is uncertain, so we + // can't be sure it's unsupported. Don't flag. + assert!(unsupported_collections(&catalogs, &["typo-collection".to_string()]).is_empty()); + } + + #[test] + fn unsupported_collections_flags_when_all_catalogs_known() { + let catalogs = vec![ + catalog("a", Some(&["sentinel-2-l2a"])), + catalog("b", Some(&["landsat-c2-l2"])), + ]; + + let unsupported = unsupported_collections( + &catalogs, + &[ + "sentinel-2-l2a".to_string(), + "made-up-collection".to_string(), + ], + ); + + assert_eq!(unsupported, vec!["made-up-collection".to_string()]); + } + + #[test] + fn unsupported_collections_empty_for_empty_request() { + let catalogs = vec![catalog("a", Some(&["x"]))]; + assert!(unsupported_collections(&catalogs, &[]).is_empty()); + } + + #[test] + #[allow(dead_code)] + fn aggregate_collections_no_panic_with_empty_input() { + let _: HashSet = HashSet::new(); // touch HashSet import + assert!(aggregate_collections(&[]).is_empty()); + } +} diff --git a/crates/engine/src/engine.rs b/crates/engine/src/engine.rs new file mode 100644 index 0000000..cdd9470 --- /dev/null +++ b/crates/engine/src/engine.rs @@ -0,0 +1,288 @@ +use parking_lot::Mutex; +use reqwest::Client; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + +use std::collections::HashMap; + +use crate::{ + capabilities, + discovery::{self, CollectionAvailability}, + health::HealthManager, + types::SharedStorage, +}; +use superstac_core::{ + errors::SuperSTACError, + models::catalog::{Catalog, CatalogFilters}, + storages::factory::StorageBackend, +}; +use std::time::Duration; + +use superstac_search::{ + executor::SearchExecutor, + options::{FederationOptions, RetryPolicy}, + query::SearchQuery, + response::SearchResponse, +}; + +/// Runtime entry point. Wraps a storage backend, runs background health +/// checks and `/collections` introspection, and exposes the federated search +/// + discovery API. +/// +/// Construct, call `start()`, then use `search()` / `list_collections()` / +/// `describe_collection()`. `start()` is idempotent — `search()` will call +/// it for you if needed. +pub struct SuperSTACEngine { + storage: SharedStorage, + client: Client, + health_manager: Option, + executor: SearchExecutor, + started: AtomicBool, +} + +/// User-Agent attached to every outbound HTTP request. Set as a default +/// header on the shared reqwest client at engine construction. When +/// per-catalog headers are added later, this must be filtered out of any +/// user-supplied set so it can't be overridden. +const USER_AGENT: &str = concat!("superstac/", env!("CARGO_PKG_VERSION")); + +impl SuperSTACEngine { + /// Build an engine over the given storage. Construct the storage via + /// `superstac_config::init_from_yaml` or directly via + /// [`superstac_core::models::storage::Storage::init`]. + pub fn new(storage: Box) -> Self { + let client = Client::builder() + .user_agent(USER_AGENT) + .build() + .expect("failed to build reqwest client"); + + Self { + storage: Arc::new(Mutex::new(storage)), + client: client.clone(), + health_manager: Some(HealthManager::new(client.clone())), + executor: SearchExecutor::new(client), + started: AtomicBool::new(false), + } + } + + /// Run health checks against all catalogs, then introspect `/collections` + /// on the healthy ones. Idempotent — safe to call multiple times. + pub async fn start(&self) -> Result<(), SuperSTACError> { + if self.started.load(Ordering::Relaxed) { + return Ok(()); + } + + if let Some(manager) = &self.health_manager { + let catalogs = { + let storage = self.storage.lock(); + storage.list_catalogs(None)? + }; + + manager.start(Arc::clone(&self.storage), catalogs).await?; + } + + // Introspect each healthy catalog's /collections so we can do source + // selection at search time. Failures here are non-fatal — affected + // catalogs remain `supported_collections = None` (pass-through). + self.introspect_capabilities().await?; + + self.started.store(true, Ordering::Relaxed); + + let catalog_count = self.storage.lock().list_catalogs(None)?.len(); + tracing::info!(catalogs = catalog_count, "engine ready"); + + Ok(()) + } + + /// Cancel background health-monitor tasks. Doesn't drop the storage. + pub async fn shutdown(&self) { + tracing::debug!("shutting down engine"); + + if let Some(manager) = &self.health_manager { + manager.stop_all().await; + } + + self.started.store(false, Ordering::Relaxed); + } + + async fn introspect_capabilities(&self) -> Result<(), SuperSTACError> { + let healthy_catalogs = { + let storage = self.storage.lock(); + storage.list_catalogs(Some(CatalogFilters { + available: Some(true), + ..CatalogFilters::default() + }))? + }; + + for catalog in healthy_catalogs { + match capabilities::fetch_supported_collections(&self.client, &catalog).await { + Ok(set) => { + tracing::debug!( + catalog = %catalog.id, + collections = set.len(), + "introspected /collections" + ); + let mut storage = self.storage.lock(); + if let Err(e) = + storage.update_supported_collections(&catalog.id, Some(set)) + { + tracing::warn!( + catalog = %catalog.id, + error = %e, + "failed to persist /collections result" + ); + } + } + Err(e) => { + tracing::warn!( + catalog = %catalog.id, + error = %e, + "/collections introspection failed; catalog will pass-through" + ); + } + } + } + + Ok(()) + } + + /// `start()` if we haven't already. Called automatically by `search()` + /// and the discovery methods. + pub async fn ensure_started(&self) -> Result<(), SuperSTACError> { + if !self.started.load(Ordering::Relaxed) { + self.start().await?; + } + + Ok(()) + } + + /// Federated search. Applies source selection (filter catalogs that + /// can't possibly serve the requested collections), then fans out. + pub async fn search(&self, query: SearchQuery) -> Result { + self.ensure_started().await?; + + let (candidate_catalogs, options) = { + let storage = self.storage.lock(); + let settings = storage.get_settings(); + + let catalogs = if let Some(true) = settings.search_healthy_catalogs_only { + storage.list_catalogs(Some(CatalogFilters { + available: Some(true), + ..CatalogFilters::default() + }))? + } else { + storage.list_catalogs(None)? + }; + + let options = FederationOptions { + deduplicate: settings.deduplicate_items.unwrap_or(true), + unify_response: settings.unify_response.unwrap_or(true), + max_concurrent: settings.max_concurrent_catalogs.unwrap_or(8), + per_catalog_timeout: Duration::from_secs( + settings.per_catalog_timeout_seconds.unwrap_or(30), + ), + retry: RetryPolicy { + max_attempts: settings.max_retry_attempts.unwrap_or(2), + initial_backoff: Duration::from_millis( + settings.retry_initial_backoff_ms.unwrap_or(100), + ), + max_backoff: Duration::from_millis( + settings.retry_max_backoff_ms.unwrap_or(2000), + ), + }, + max_items_per_catalog: settings.max_items_per_catalog.unwrap_or(1000), + }; + + (catalogs, options) + }; + + // Compute unsupported collections against the full candidate set + // before source-selection filtering. + let unsupported = + discovery::unsupported_collections(&candidate_catalogs, &query.collections); + + // Source selection: drop catalogs whose introspected collection set + // doesn't overlap the requested collections. Catalogs with no + // introspection data pass through. + let catalogs: Vec = candidate_catalogs + .into_iter() + .filter(|c| c.supports_any_of(&query.collections)) + .collect(); + + let mut response = self + .executor + .federated_search(catalogs, query, options) + .await?; + response.metadata.unsupported_collections = unsupported; + + Ok(response) + } + + /// Aggregated view: every collection ID known across healthy catalogs, + /// with the catalogs that serve each. + pub async fn list_collections(&self) -> Result, SuperSTACError> { + self.ensure_started().await?; + let catalogs = { + let storage = self.storage.lock(); + storage.list_catalogs(None)? + }; + Ok(discovery::aggregate_collections(&catalogs)) + } + + /// IDs of every catalog whose introspected `/collections` includes + /// `collection_id`. Catalogs not yet introspected are excluded. + pub async fn catalogs_supporting( + &self, + collection_id: &str, + ) -> Result, SuperSTACError> { + self.ensure_started().await?; + let catalogs = { + let storage = self.storage.lock(); + storage.list_catalogs(None)? + }; + Ok(discovery::catalogs_supporting(&catalogs, collection_id)) + } + + /// Per-catalog inventory: catalog ID -> sorted canonical collection IDs. + /// Catalogs without introspection data are omitted. + pub async fn collections_by_catalog( + &self, + ) -> Result>, SuperSTACError> { + self.ensure_started().await?; + let catalogs = { + let storage = self.storage.lock(); + storage.list_catalogs(None)? + }; + Ok(discovery::collections_by_catalog(&catalogs)) + } + + /// Fetch full collection metadata from a specific catalog. The + /// `collection_id` is interpreted as canonical and resolved to the + /// catalog's local name via `collection_aliases`. Returns `None` if the + /// catalog returns 404 for the collection. + pub async fn describe_collection( + &self, + catalog_id: &str, + collection_id: &str, + ) -> Result, SuperSTACError> { + self.ensure_started().await?; + + let catalog = { + let storage = self.storage.lock(); + storage.get_catalog(catalog_id)?.clone() + }; + + let local_id = catalog.resolve_collection(collection_id).to_string(); + + let stac_client = + stac_io::api::Client::with_client(self.client.clone(), &catalog.url) + .map_err(|e| SuperSTACError::SearchFailed(format!("stac client init: {}", e)))?; + + stac_client + .collection(&local_id) + .await + .map_err(|e| SuperSTACError::SearchFailed(format!("fetch collection: {}", e))) + } +} diff --git a/crates/engine/src/health/manager.rs b/crates/engine/src/health/manager.rs new file mode 100644 index 0000000..a9a4b84 --- /dev/null +++ b/crates/engine/src/health/manager.rs @@ -0,0 +1,140 @@ +use parking_lot::Mutex; +use reqwest::Client; +use std::{collections::HashMap, sync::Arc}; +use tokio::{task::JoinHandle, time::interval}; + +use crate::types::SharedStorage; +use superstac_core::{errors::SuperSTACError, models::catalog::Catalog}; + +pub struct HealthManager { + tasks: Arc>>>, + client: Client, +} + +impl HealthManager { + pub fn new(client: Client) -> Self { + Self { + tasks: Arc::new(Mutex::new(HashMap::new())), + client, + } + } + + pub async fn start( + &self, + storage: SharedStorage, + catalogs: Vec, + ) -> Result<(), SuperSTACError> { + tracing::debug!(catalogs = catalogs.len(), "starting health monitor"); + + for catalog in catalogs { + let healthy = self.check_once(Arc::clone(&storage), &catalog).await; + + if healthy { + // TODO, check settings to decide whether to spawn monitor or not + self.spawn_monitor(Arc::clone(&storage), catalog); + } + } + + Ok(()) + } + + pub async fn stop_all(&self) { + let mut tasks = self.tasks.lock(); + + for (_, handle) in tasks.drain() { + handle.abort(); + } + } + + pub async fn stop(&self, id: &str) { + if let Some(handle) = self.tasks.lock().remove(id) { + handle.abort(); + } + } + + pub async fn check_once(&self, storage: SharedStorage, catalog: &Catalog) -> bool { + let endpoint = &catalog.health_status.endpoint; + + let range = catalog.settings.healthy_status_code_range; + + let healthy = match self.client.get(endpoint).send().await { + Ok(resp) => { + let status = resp.status().as_u16(); + + range.0 <= status && status <= range.1 + } + + Err(_) => false, + }; + + tracing::debug!(catalog = %catalog.id, healthy, "health check"); + + { + let mut storage = storage.lock(); + + if let Err(e) = storage.update_health(&catalog.id, healthy) { + tracing::warn!(catalog = %catalog.id, error = %e, "failed to persist health update"); + } + } + + healthy + } + + pub fn spawn_monitor(&self, storage: SharedStorage, catalog: Catalog) { + let id = catalog.id.clone(); + + if self.tasks.lock().contains_key(&id) { + return; + } + + let endpoint = catalog.health_status.endpoint.clone(); + + let range = catalog.settings.healthy_status_code_range; + + let duration = catalog.settings.health_check_strategy.as_duration(); + + let client = self.client.clone(); + + let task_id = id.clone(); + + let handle = tokio::spawn(async move { + let mut ticker = interval(duration); + + loop { + ticker.tick().await; + + let healthy = match client.get(&endpoint).send().await { + Ok(resp) => { + let status = resp.status().as_u16(); + + range.0 <= status && status <= range.1 + } + + Err(_) => false, + }; + + tracing::debug!(catalog = %task_id, healthy, "health tick"); + + { + let mut storage = storage.lock(); + + if let Err(e) = storage.update_health(&task_id, healthy) { + tracing::warn!( + catalog = %task_id, + error = %e, + "failed to persist health update" + ); + } + } + } + }); + + tracing::debug!( + catalog = %id, + interval_secs = duration.as_secs(), + "spawned health monitor" + ); + + self.tasks.lock().insert(id, handle); + } +} diff --git a/crates/engine/src/health/mod.rs b/crates/engine/src/health/mod.rs new file mode 100644 index 0000000..f1060bb --- /dev/null +++ b/crates/engine/src/health/mod.rs @@ -0,0 +1,3 @@ +pub mod manager; + +pub use manager::HealthManager; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs new file mode 100644 index 0000000..29fc7f9 --- /dev/null +++ b/crates/engine/src/lib.rs @@ -0,0 +1,15 @@ +//! Runtime that ties storage, search, and health monitoring together. +//! +//! [`SuperSTACEngine`] is the entry point: construct with a storage backend, +//! call `start()` to run health checks and `/collections` introspection, then +//! `search()`, `list_collections()`, etc. + +pub mod engine; +pub mod health; +pub mod types; +pub mod capabilities; +pub mod discovery; + +pub use engine::SuperSTACEngine; +pub use discovery::CollectionAvailability; +pub use types::SharedStorage; diff --git a/crates/engine/src/types.rs b/crates/engine/src/types.rs new file mode 100644 index 0000000..f8485f6 --- /dev/null +++ b/crates/engine/src/types.rs @@ -0,0 +1,5 @@ +use parking_lot::Mutex; +use std::sync::Arc; +use superstac_core::storages::factory::StorageBackend; + +pub type SharedStorage = Arc>>; diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml new file mode 100644 index 0000000..a152352 --- /dev/null +++ b/crates/search/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "superstac-search" +description = "Federated STAC search logic with retry, dedup, and response unification." +keywords = ["stac", "geospatial", "search", "satellite"] +categories = ["science::geo"] +readme = "../../README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true + +[dependencies] +superstac-core = { path = "../core", version = "0.1.0" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["time"] } +futures = "0.3" +stac = "0.16" +stac-io = "0.2" +reqwest = { version = "0.13", default-features = false } +tracing = "0.1" diff --git a/crates/search/src/aggregator.rs b/crates/search/src/aggregator.rs new file mode 100644 index 0000000..9bef054 --- /dev/null +++ b/crates/search/src/aggregator.rs @@ -0,0 +1,136 @@ +use std::collections::HashMap; + +use crate::response::{CatalogFailure, SearchItem, SearchMetadata, SearchResponse}; + +/// Folds per-catalog results into a single [`SearchResponse`]. Stateless; +/// kept as a struct so future tweaks (sorting, ranking) have a natural home. +pub struct SearchAggregator; + +impl SearchAggregator { + /// Flatten + dedup (if requested) and emit metadata. + pub fn aggregate( + results: Vec>, + catalogs_queried: usize, + failures: Vec, + deduplicate: bool, + ) -> SearchResponse { + let flat: Vec = results.into_iter().flatten().collect(); + let pre_dedup_count = flat.len(); + + let items = if deduplicate { + Self::dedup(flat) + } else { + flat + }; + + let total_items = items.len(); + let duplicates_removed = pre_dedup_count - total_items; + let catalogs_failed = failures.len(); + + SearchResponse { + items, + metadata: SearchMetadata { + catalogs_queried, + catalogs_succeeded: catalogs_queried - catalogs_failed, + catalogs_failed, + total_items, + duplicates_removed, + failures, + // Filled in by the engine post-aggregate. + unsupported_collections: Vec::new(), + }, + } + } + + /// Collapse items sharing the same `item.id`. First-seen wins; later + /// occurrences contribute their `catalog_id` to the primary's `seen_in`. + fn dedup(items: Vec) -> Vec { + let mut id_to_idx: HashMap = HashMap::new(); + let mut out: Vec = Vec::with_capacity(items.len()); + + for item in items { + let id = item.item.id.clone(); + if let Some(&idx) = id_to_idx.get(&id) { + out[idx].seen_in.extend(item.seen_in); + } else { + id_to_idx.insert(id, out.len()); + out.push(item); + } + } + + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use stac::Item; + + fn make_item(catalog: &str, id: &str) -> SearchItem { + SearchItem { + catalog_id: catalog.to_string(), + seen_in: vec![catalog.to_string()], + item: Item::new(id), + } + } + + #[test] + fn aggregate_with_dedup_collapses_shared_ids() { + let results = vec![ + vec![make_item("a", "scene-1"), make_item("a", "scene-2")], + vec![make_item("b", "scene-1"), make_item("b", "scene-3")], + ]; + + let resp = SearchAggregator::aggregate(results, 2, vec![], true); + + assert_eq!(resp.metadata.total_items, 3); + assert_eq!(resp.metadata.duplicates_removed, 1); + assert_eq!(resp.items.len(), 3); + + let scene1 = resp.items.iter().find(|i| i.item.id == "scene-1").unwrap(); + assert_eq!(scene1.catalog_id, "a"); + assert_eq!(scene1.seen_in, vec!["a", "b"]); + + let scene2 = resp.items.iter().find(|i| i.item.id == "scene-2").unwrap(); + assert_eq!(scene2.seen_in, vec!["a"]); + } + + #[test] + fn aggregate_without_dedup_keeps_duplicates() { + let results = vec![ + vec![make_item("a", "scene-1")], + vec![make_item("b", "scene-1")], + ]; + + let resp = SearchAggregator::aggregate(results, 2, vec![], false); + + assert_eq!(resp.metadata.total_items, 2); + assert_eq!(resp.metadata.duplicates_removed, 0); + assert_eq!(resp.items.len(), 2); + } + + #[test] + fn aggregate_records_per_catalog_failures() { + let results = vec![vec![make_item("a", "scene-1")]]; + let failures = vec![ + CatalogFailure { + catalog_id: "b".to_string(), + reason: "timeout".to_string(), + }, + CatalogFailure { + catalog_id: "c".to_string(), + reason: "503 Service Unavailable".to_string(), + }, + ]; + + let resp = SearchAggregator::aggregate(results, 3, failures, true); + + assert_eq!(resp.metadata.catalogs_queried, 3); + assert_eq!(resp.metadata.catalogs_succeeded, 1); + assert_eq!(resp.metadata.catalogs_failed, 2); + assert_eq!(resp.metadata.failures.len(), 2); + assert_eq!(resp.metadata.failures[0].catalog_id, "b"); + assert_eq!(resp.metadata.failures[1].reason, "503 Service Unavailable"); + } +} diff --git a/crates/search/src/executor.rs b/crates/search/src/executor.rs new file mode 100644 index 0000000..ed57e9d --- /dev/null +++ b/crates/search/src/executor.rs @@ -0,0 +1,203 @@ +use futures::{StreamExt, TryStreamExt}; +use stac::Item; +use superstac_core::{errors::SuperSTACError, models::catalog::Catalog}; +use tokio::time::{sleep, timeout}; + +use crate::{ + aggregator::SearchAggregator, + options::FederationOptions, + query::SearchQuery, + response::{CatalogFailure, SearchItem, SearchResponse}, + translator::to_stac_search, + unifier, +}; + +/// Fans out a search across catalogs with retry, capped concurrency, and +/// per-catalog timeouts. Owns the `reqwest::Client` it borrows from the +/// engine so connection pools and default headers are shared. +pub struct SearchExecutor { + client: reqwest::Client, +} + +impl SearchExecutor { + pub fn new(client: reqwest::Client) -> Self { + Self { client } + } + + /// Query all `catalogs` concurrently and aggregate the results. + /// Per-catalog failures are recorded on the response metadata's + /// `failures` field rather than failing the whole call. + pub async fn federated_search( + &self, + catalogs: Vec, + query: SearchQuery, + options: FederationOptions, + ) -> Result { + let catalogs_queried = catalogs.len(); + let concurrency = options.max_concurrent.max(1); + + tracing::debug!( + catalogs = catalogs_queried, + concurrency, + collections = ?query.collections, + "federated search" + ); + + let attempts = catalogs.into_iter().map(|catalog| { + let query = query.clone(); + async move { + let catalog_id = catalog.id.clone(); + let result = self + .search_catalog_with_retry(catalog, query, options) + .await; + (catalog_id, result) + } + }); + + let results: Vec<(String, Result, SuperSTACError>)> = + futures::stream::iter(attempts) + .buffer_unordered(concurrency) + .collect() + .await; + + let mut successful = Vec::new(); + let mut failures: Vec = Vec::new(); + + for (catalog_id, outcome) in results { + match outcome { + Ok(items) => successful.push(items), + Err(e) => failures.push(CatalogFailure { + catalog_id, + reason: e.to_string(), + }), + } + } + + Ok(SearchAggregator::aggregate( + successful, + catalogs_queried, + failures, + options.deduplicate, + )) + } + + async fn search_catalog_with_retry( + &self, + catalog: Catalog, + query: SearchQuery, + options: FederationOptions, + ) -> Result, SuperSTACError> { + let mut backoff = options.retry.initial_backoff; + let mut last_error: SuperSTACError = + SuperSTACError::SearchFailed("no attempts made".to_string()); + + for attempt in 1..=options.retry.max_attempts { + let attempt_result = timeout( + options.per_catalog_timeout, + self.search_catalog(catalog.clone(), query.clone(), options), + ) + .await; + + match attempt_result { + Ok(Ok(items)) => return Ok(items), + Ok(Err(e)) => { + if !is_retryable(&e) || attempt == options.retry.max_attempts { + return Err(e); + } + last_error = e; + } + Err(_) => { + last_error = SuperSTACError::SearchFailed(format!( + "timeout after {:?}", + options.per_catalog_timeout + )); + if attempt == options.retry.max_attempts { + return Err(last_error); + } + } + } + + tracing::warn!( + catalog = %catalog.id, + attempt, + error = %last_error, + backoff_ms = backoff.as_millis() as u64, + "search attempt failed, retrying" + ); + + sleep(backoff).await; + backoff = (backoff * 2).min(options.retry.max_backoff); + } + + Err(last_error) + } + + async fn search_catalog( + &self, + catalog: Catalog, + mut query: SearchQuery, + options: FederationOptions, + ) -> Result, SuperSTACError> { + // Translate canonical collection names to this catalog's local names. + // Falls back to the canonical name when no alias is declared. + query.collections = query + .collections + .iter() + .map(|c| catalog.resolve_collection(c).to_string()) + .collect(); + + // Cap items at min(user_limit, per-catalog system cap). Set on the + // STAC search so the server doesn't waste a round-trip filling more + // than we'd keep. + let user_limit = query.limit.unwrap_or(10); + let cap = user_limit.min(options.max_items_per_catalog); + query.limit = Some(cap); + + let search = to_stac_search(query); + + let stac_client = stac_io::api::Client::with_client(self.client.clone(), &catalog.url) + .map_err(|e| SuperSTACError::SearchFailed(format!("stac client init: {}", e)))?; + + let stream = stac_client + .search(search) + .await + .map_err(|e| SuperSTACError::SearchFailed(format!("search request: {}", e)))?; + + // Stream of `stac::api::Item` (= `serde_json::Map`), + // paginated internally by stac-io. `take(cap)` bounds the total; + // `try_collect` short-circuits on first per-item stream error. + let raw_items: Vec = stream + .take(cap) + .try_collect() + .await + .map_err(|e| SuperSTACError::SearchFailed(format!("stream item: {}", e)))?; + + let items: Vec = raw_items + .into_iter() + .map(|map_item| { + let mut item: Item = + serde_json::from_value(serde_json::Value::Object(map_item)) + .map_err(|err| SuperSTACError::SearchFailed(err.to_string()))?; + + if options.unify_response { + unifier::unify_item(&mut item, &catalog); + } + + Ok(SearchItem { + catalog_id: catalog.id.clone(), + seen_in: vec![catalog.id.clone()], + item, + }) + }) + .collect::, SuperSTACError>>()?; + + Ok(items) + } +} + +/// For now: treat all `SearchFailed` errors as retryable. The proper fix is a +/// richer error taxonomy (Network / Timeout / Server5xx / Client4xx) which is +/// out of scope here. +fn is_retryable(error: &SuperSTACError) -> bool { + matches!(error, SuperSTACError::SearchFailed(_)) +} diff --git a/crates/search/src/lib.rs b/crates/search/src/lib.rs new file mode 100644 index 0000000..ba2f7f7 --- /dev/null +++ b/crates/search/src/lib.rs @@ -0,0 +1,18 @@ +//! Federated STAC search. +//! +//! Given a list of catalogs and a [`query::SearchQuery`], +//! [`executor::SearchExecutor`] queries them concurrently (with retry, +//! pagination, and per-catalog timeouts), unifies item bodies via +//! [`unifier`], and aggregates results in [`aggregator`]. +//! +//! This crate is pure logic — it knows nothing about engine state or storage. +//! Use `superstac_engine` for the runtime that wires storage + search + +//! health together. + +pub mod query; +pub mod response; +pub mod translator; +pub mod options; +pub mod executor; +pub mod aggregator; +pub mod unifier; diff --git a/crates/search/src/options.rs b/crates/search/src/options.rs new file mode 100644 index 0000000..a6e9ae6 --- /dev/null +++ b/crates/search/src/options.rs @@ -0,0 +1,68 @@ +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +#[derive( + Debug, + Clone, + Serialize, + Deserialize, + Default, +)] +pub struct SearchOptions { + pub max_items: Option, + + pub headers: Vec<(String, String)>, + + pub timeout_seconds: Option, +} + +/// Cross-catalog federation knobs derived from `Settings`. +#[derive(Debug, Clone, Copy)] +pub struct FederationOptions { + /// Collapse items sharing the same `Item.id` across catalogs. + pub deduplicate: bool, + /// Rewrite item collection + asset keys to canonical names. + pub unify_response: bool, + /// Maximum number of catalogs queried concurrently. + pub max_concurrent: usize, + /// Per-catalog request timeout (applied to each attempt). + pub per_catalog_timeout: Duration, + /// Retry policy for transient per-catalog failures. + pub retry: RetryPolicy, + /// Hard cap on items returned per catalog (prevents runaway pagination). + pub max_items_per_catalog: usize, +} + +#[derive(Debug, Clone, Copy)] +pub struct RetryPolicy { + /// Total attempts per catalog (1 = no retry). + pub max_attempts: u8, + /// Backoff before the first retry. + pub initial_backoff: Duration, + /// Cap on exponential backoff growth. + pub max_backoff: Duration, +} + +impl Default for FederationOptions { + fn default() -> Self { + Self { + deduplicate: true, + unify_response: true, + max_concurrent: 8, + per_catalog_timeout: Duration::from_secs(30), + retry: RetryPolicy::default(), + max_items_per_catalog: 1000, + } + } +} + +impl Default for RetryPolicy { + fn default() -> Self { + Self { + max_attempts: 2, + initial_backoff: Duration::from_millis(100), + max_backoff: Duration::from_millis(2000), + } + } +} diff --git a/crates/search/src/query.rs b/crates/search/src/query.rs new file mode 100644 index 0000000..e0e0150 --- /dev/null +++ b/crates/search/src/query.rs @@ -0,0 +1,19 @@ +use serde::Deserialize; +use stac::{Bbox, Geometry}; + +/// A federated STAC search request. Mirrors the STAC API search params: +/// the engine translates this per-catalog before dispatch. +/// +/// `collections` should use **canonical** names — alias-mapping happens +/// internally per catalog. +#[derive(Debug, Clone, Deserialize)] +pub struct SearchQuery { + pub collections: Vec, + pub ids: Option>, + pub intersects: Option, + pub bbox: Option, + pub datetime: Option, + pub limit: Option, + pub sortby: Option>, +} + diff --git a/crates/search/src/response.rs b/crates/search/src/response.rs new file mode 100644 index 0000000..b4fa8aa --- /dev/null +++ b/crates/search/src/response.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; +use stac::Item; + +/// Outcome of a federated search: aggregated items plus run metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResponse { + pub items: Vec, + pub metadata: SearchMetadata, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchItem { + /// The first-seen (primary) catalog. When this item was deduplicated, + /// this is the catalog whose body we kept. + pub catalog_id: String, + pub item: Item, + /// All catalogs that returned this item ID (always includes `catalog_id`). + /// Length > 1 indicates this item was deduplicated across catalogs. + pub seen_in: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchMetadata { + pub catalogs_queried: usize, + pub catalogs_succeeded: usize, + pub catalogs_failed: usize, + /// Number of unique items in the response (post-dedup). + pub total_items: usize, + /// Number of items collapsed by deduplication. + pub duplicates_removed: usize, + /// Per-catalog failure details. Empty when all catalogs succeeded. + pub failures: Vec, + /// Collection IDs from the query that no introspected catalog could serve. + /// Populated by the engine post-aggregate. Conservative: only reported + /// when every catalog had known capabilities and none matched. + #[serde(default)] + pub unsupported_collections: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CatalogFailure { + pub catalog_id: String, + pub reason: String, +} diff --git a/crates/search/src/translator.rs b/crates/search/src/translator.rs new file mode 100644 index 0000000..61c87e0 --- /dev/null +++ b/crates/search/src/translator.rs @@ -0,0 +1,41 @@ +use stac::api::Search; +use crate::query::SearchQuery; + +/// Convert a superstac `SearchQuery` into the typed `stac::api::Search` the +/// stac-io client expects. Field-by-field copy with no canonicalization — +/// alias rewriting happens upstream in the executor. +pub fn to_stac_search( + query: SearchQuery, +) -> Search { + let mut search = Search::new(); + + if !query.collections.is_empty() { + search = search.collections( + query.collections, + ); + } + + if let Some(ids) = query.ids { + search = search.ids(ids); + } + + if let Some(intersects) = + query.intersects + { + search = search.intersects(intersects); + } + + if let Some(limit) = query.limit { + search = search.limit(limit as u64); + } + + if let Some(datetime) = + query.datetime + { + search = search.datetime(datetime); + } + if let Some(bbox) = query.bbox { + search = search.bbox(bbox); +} + search +} \ No newline at end of file diff --git a/crates/search/src/unifier.rs b/crates/search/src/unifier.rs new file mode 100644 index 0000000..62d1c50 --- /dev/null +++ b/crates/search/src/unifier.rs @@ -0,0 +1,142 @@ +use stac::Item; +use superstac_core::models::catalog::Catalog; + +/// Rewrite an item's `collection` and `assets` to canonical names using the +/// catalog's alias maps. No-op when the catalog declares no relevant aliases +/// or the item has no collection. +pub fn unify_item(item: &mut Item, catalog: &Catalog) { + let canonical_collection = match &item.collection { + Some(local) => { + let canonical = catalog.canonical_collection(local).to_string(); + if &canonical != local { + item.collection = Some(canonical.clone()); + } + canonical + } + None => return, + }; + + let asset_map = match catalog.asset_aliases.get(&canonical_collection) { + Some(map) if !map.is_empty() => map, + _ => return, + }; + + // Build reverse: local_asset_key -> canonical_asset_key + let reverse: std::collections::HashMap<&str, &str> = asset_map + .iter() + .map(|(canonical, local)| (local.as_str(), canonical.as_str())) + .collect(); + + // Drain and re-insert to rewrite keys in place (avoids depending on the + // concrete map type used inside stac::Item). + let pairs: Vec<_> = item.assets.drain(..).collect(); + for (key, asset) in pairs { + let new_key = reverse + .get(key.as_str()) + .map(|c| c.to_string()) + .unwrap_or(key); + item.assets.insert(new_key, asset); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use stac::{Asset, Item}; + use std::collections::HashMap; + + fn make_catalog_with_aliases() -> Catalog { + let mut catalog = Catalog::new( + "cdse", + Some("CDSE"), + "https://cdse.example.com", + None::, + None, + ) + .unwrap(); + + catalog + .collection_aliases + .insert("sentinel-2-l2a".to_string(), "S2MSI2A".to_string()); + + let mut s2_assets = HashMap::new(); + s2_assets.insert("blue".to_string(), "B02".to_string()); + s2_assets.insert("green".to_string(), "B03".to_string()); + catalog + .asset_aliases + .insert("sentinel-2-l2a".to_string(), s2_assets); + + catalog + } + + fn make_item_with_assets(collection: &str, keys: &[&str]) -> Item { + let mut item = Item::new("scene-1"); + item.collection = Some(collection.to_string()); + for k in keys { + item.assets.insert( + k.to_string(), + Asset::new(format!("https://example.com/{}.tif", k)), + ); + } + item + } + + #[test] + fn unify_rewrites_collection_to_canonical() { + let catalog = make_catalog_with_aliases(); + let mut item = make_item_with_assets("S2MSI2A", &[]); + + unify_item(&mut item, &catalog); + + assert_eq!(item.collection.as_deref(), Some("sentinel-2-l2a")); + } + + #[test] + fn unify_rewrites_asset_keys_to_canonical() { + let catalog = make_catalog_with_aliases(); + let mut item = make_item_with_assets("S2MSI2A", &["B02", "B03", "thumbnail"]); + + unify_item(&mut item, &catalog); + + assert!(item.assets.contains_key("blue")); + assert!(item.assets.contains_key("green")); + // Unmapped keys pass through unchanged. + assert!(item.assets.contains_key("thumbnail")); + assert!(!item.assets.contains_key("B02")); + } + + #[test] + fn unify_is_noop_when_collection_already_canonical_and_no_asset_rules() { + let mut catalog = Catalog::new( + "element84", + Some("E84"), + "https://example.com", + None::, + None, + ) + .unwrap(); + catalog + .collection_aliases + .insert("sentinel-2-l2a".to_string(), "sentinel-2-l2a".to_string()); + + let mut item = make_item_with_assets("sentinel-2-l2a", &["blue", "green"]); + let before_keys: Vec<_> = item.assets.keys().cloned().collect(); + + unify_item(&mut item, &catalog); + + assert_eq!(item.collection.as_deref(), Some("sentinel-2-l2a")); + let after_keys: Vec<_> = item.assets.keys().cloned().collect(); + assert_eq!(before_keys.len(), after_keys.len()); + } + + #[test] + fn unify_is_noop_when_item_has_no_collection() { + let catalog = make_catalog_with_aliases(); + let mut item = Item::new("scene-1"); + item.collection = None; + + unify_item(&mut item, &catalog); + + assert!(item.collection.is_none()); + } +} diff --git a/main.py b/main.py deleted file mode 100644 index e41c18b..0000000 --- a/main.py +++ /dev/null @@ -1,33 +0,0 @@ -import time -import asyncio - - -def main(): - from superstac import get_catalog_registry, federated_search_async - - cr = get_catalog_registry() - cr.load_catalogs_from_config() - - print("\nRunning asynchronous federated_search_async...") - start_async = time.perf_counter() - results_async = asyncio.run( - federated_search_async( - registry=cr, - collections=["sentinel-2-l2a"], - bbox=[6.0, 49.0, 7.0, 50.0], - datetime="2024-01-01/2024-01-31", - query={"eo:cloud_cover": {"lt": 20}}, - sortby=[{"field": "properties.datetime", "direction": "desc"}], - ) - ) - end_async = time.perf_counter() - print( - f"Asynchronous search found {len(results_async)} items in {end_async - start_async:.2f} seconds." - ) - - for x in results_async: - print(x.self_href) - - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index f8c823e..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,28 +0,0 @@ -[project] -name = "superstac" -version = "0.1.0a2" -description = "SuperSTAC is a Python library for high-availability satellite imagery retrieval across multiple STAC catalogs." -readme = "README.md" -requires-python = ">=3.9" -dependencies = [ - "pystac-client==0.8.5", -] -license = { text = "MIT" } -authors = [{ name = "Emmanuel Jolaiya", email = "emmanuel@spatialnode.net" }] -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "Intended Audience :: Science/Research", - "Topic :: Scientific/Engineering :: GIS", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Operating System :: OS Independent" -] -[tool.uv] -dev-dependencies = [ - "ruff", -] diff --git a/release.toml b/release.toml new file mode 100644 index 0000000..8bfd0aa --- /dev/null +++ b/release.toml @@ -0,0 +1,25 @@ +# cargo-release configuration. See https://github.com/crate-ci/cargo-release. +# +# Run `cargo release --help` for available levels (patch, minor, major, rc, ...). +# Use `--execute` to actually run; without it cargo-release does a dry run. + +# All workspace members share one version. Bumping any crate bumps all. +shared-version = true + +# One commit + one push for the whole workspace bump. +consolidate-commits = true +consolidate-pushes = true + +# Tag format and message. +tag-name = "v{{version}}" +tag-message = "release {{version}}" +pre-release-commit-message = "chore: release {{version}}" + +# Only release from main. +allow-branch = ["main"] + +# Don't sign by default; flip on later if you want. +sign-tag = false +sign-commit = false + +# Bump pre-release ids will look like 0.1.0-alpha.1, etc. Fine defaults. diff --git a/superstac.yml b/superstac.yml new file mode 100644 index 0000000..92265f1 --- /dev/null +++ b/superstac.yml @@ -0,0 +1,68 @@ +providers: + # A provider with catalog + - id: microsoft + name: Microsoft Planetary Computer + description: Microsoft's planetary-scale catalog + website_url: https://planetarycomputer.microsoft.com/ + stac_version: "1.0.0" + logo_url: https://planetarycomputer.microsoft.com/logo.png + + - id: element84 + name: Element 84 + description: Element 84 catalog + website_url: https://element84.com + stac_version: "1.0.0" + logo_url: https://element84.com/logo.png + + # A provider without a catalog. + - id: google + name: Google Earth Engine + description: Google Earth Engine catalog + website_url: https://google.com + stac_version: "1.0.0" + logo_url: https://planetarycomputer.microsoft.com/logo.png + + +catalogs: + # No collection_aliases needed — Element84 already uses canonical names. + # Engine auto-discovers supported collections via /collections at startup. + - id: earth-search + provider: element84 + title: Earth Search + url: https://earth-search.aws.element84.com/v1 + description: STAC catalog for Earth Search + + # No collection_aliases needed — Microsoft PC already uses canonical names. + - id: microsoft + title: Planetary Computer + provider: microsoft + url: https://planetarycomputer.microsoft.com/api/stac/v1 + description: Microsoft Planetary Computer catalog + settings: + health_check_strategy: Hourly + healthy_status_code_range: [200, 299] + + # Example: a catalog that needs aliases because it uses non-canonical names. + # Uncomment and adapt when adding a catalog like CDSE. + # + # - id: cdse + # provider: copernicus + # url: https://catalogue.dataspace.copernicus.eu/stac + # collection_aliases: + # sentinel-2-l2a: S2MSI2A # canonical -> local + # sentinel-1-grd: S1GRD + # asset_aliases: + # sentinel-2-l2a: + # blue: B02 + # green: B03 + # red: B04 + # nir: B08 + +settings: + health_check_strategy: "15m" + healthy_status_code_range: [200, 299] + auto_fix_duplicate_catalog_id: true + auto_fix_duplicate_provider_id: true + log_level: info + logging_enabled: true + search_healthy_catalogs_only: true \ No newline at end of file diff --git a/superstac/.superstac.yml b/superstac/.superstac.yml deleted file mode 100644 index 419932a..0000000 --- a/superstac/.superstac.yml +++ /dev/null @@ -1,7 +0,0 @@ -catalogs: - Earth Search: - url: https://earth-search.aws.element84.com/v1 - is_private: False - Planetary Computer: - url: https://planetarycomputer.microsoft.com/api/stac/v1 - is_private: False diff --git a/superstac/__init__.py b/superstac/__init__.py deleted file mode 100644 index 6d28589..0000000 --- a/superstac/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from superstac.catalog_registry import ( - get_catalog_registry, -) -from superstac.search import federated_search, federated_search_async - -__all__ = ["get_catalog_registry", "federated_search", "federated_search_async"] diff --git a/superstac/_logging.py b/superstac/_logging.py deleted file mode 100644 index 9589609..0000000 --- a/superstac/_logging.py +++ /dev/null @@ -1,69 +0,0 @@ -"""SuperSTAC logging module""" - -import logging - - -PACKAGE_NAME = "superstac" -LOGGING_FORMAT = f"{PACKAGE_NAME}:%(asctime)s - %(module)s.%(funcName)s - %(levelname)s - %(message)s" - -logger = logging.getLogger(PACKAGE_NAME) - - -# Set the default logging level to INFO. -logger.setLevel(logging.INFO) - - -console_handler = logging.StreamHandler() - -console_formatter = logging.Formatter(LOGGING_FORMAT) -console_handler.setFormatter(console_formatter) - -logger.addHandler(console_handler) - - -def add_file_logging(file_path=f"{PACKAGE_NAME}.log"): - """ - Add file logging to the logger. - - Args: - file_path (str): Path to the log file. Defaults to `superstac.log`. - - Example: - from superstac import add_file_logging - # Configure custom file log - add_file_logging("my_log.log") - """ - for h in logger.handlers: - if ( - isinstance(h, logging.FileHandler) - and getattr(h, "baseFilename", None) == file_path - ): - return - file_handler = logging.FileHandler(file_path) - file_formatter = logging.Formatter(LOGGING_FORMAT) - file_handler.setFormatter(file_formatter) - logger.addHandler(file_handler) - - -def configure_logging(level=logging.WARNING): - """ - Configure the logging level for the package. - - Args: - level (int): Logging level (e.g., logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR) - - Example: - # Set logging level to INFO - from superstac import configure_logging - import logging - # Configure logging to INFO level - configure_logging(logging.INFO) - """ - logger.setLevel(level) - if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers): - console_handler = logging.StreamHandler() - console_handler.setLevel(level) - console_handler.setFormatter(logging.Formatter(LOGGING_FORMAT)) - logger.addHandler(console_handler) - for handler in logger.handlers: - handler.setLevel(level) diff --git a/superstac/assets_mapper.py b/superstac/assets_mapper.py deleted file mode 100644 index 98e8904..0000000 --- a/superstac/assets_mapper.py +++ /dev/null @@ -1,82 +0,0 @@ -from typing import Union -from superstac._logging import logger -from pystac import Item - -unified_band_mapper = { - # Sentinel-2 L2A mapping - # Retrieved from '' - "sentinel-2-l2a": { - "coastal": "B01", - "blue": "B02", - "green": "B03", - "red": "B04", - "rededge1": "B05", - "rededge2": "B06", - "rededge3": "B07", - "nir": "B08", - "nir08": "B8A", - "nir09": "B09", - "swir16": "B11", - "cirrus": "B10", - "swir22": "B12", - "aot": "AOT", - "scl": "SCL", - "wvp": "WVP", - }, - # Landsat 8/9 Collection 2 Level 2 mapping - "landsat-8-c2l2": { - "coastal": "SR_B1", - "blue": "SR_B2", - "green": "SR_B3", - "red": "SR_B4", - "nir": "SR_B5", - "swir16": "SR_B6", - "swir22": "SR_B7", - "thermal_infrared": "ST_B10", - "pixel_qa": "QA_PIXEL", - }, - "landsat-9-c2l2": { - "coastal": "SR_B1", - "blue": "SR_B2", - "green": "SR_B3", - "red": "SR_B4", - "nir": "SR_B5", - "swir16": "SR_B6", - "swir22": "SR_B7", - "thermal_infrared": "ST_B10", - "pixel_qa": "QA_PIXEL", - }, -} - - -def get_asset_by_standard_name( - stac_item: Item, standard_band_name: str -) -> Union[str, None]: - """ - Retrieves a STAC asset from an item using a standardized band name. - - Args: - stac_item (pystac.Item): The STAC item to search. - standard_band_name (str): The common, standardized name of the band (e.g., 'red', 'nir'). - - Returns: - pystac.Asset or None: The asset object if found, otherwise None. - """ - - collection_id = stac_item.collection_id or "" - - band_mapping = unified_band_mapper.get(collection_id) - - if band_mapping is None: - logger.info(f"Warning: No mapping found for collection '{collection_id}'.") - return None - - asset_key = band_mapping.get(standard_band_name) - - if asset_key is None: - logger.info( - f"Warning: No asset key found for standard band '{standard_band_name}' in collection '{collection_id}'." - ) - return None - - return stac_item.assets.get(asset_key) # type: ignore diff --git a/superstac/catalog.py b/superstac/catalog.py deleted file mode 100644 index cacb0c6..0000000 --- a/superstac/catalog.py +++ /dev/null @@ -1,230 +0,0 @@ -"""SuperSTAC Catalog Manager""" - -from pathlib import Path -import attr -from typing import Any, Dict, List, Optional, Union - -from pystac_client import Client - -from superstac.enums import CatalogOutputFormat -from superstac.exceptions import ( - CatalogConfigFileNotFound, - InvalidCatalogSchemaError, - InvalidCatalogYAMLError, -) -from superstac.models import CatalogEntry, AuthInfo -import yaml - -from superstac._logging import logger - - -@attr.s(auto_attribs=True) -class CatalogManager: - - catalogs: Dict[str, CatalogEntry] = attr.Factory(dict) - - def __attrs_post_init__(self): - logger.info("Initialized superstac") - - def register_catalog( - self, - name: str, - url: str, - is_private: Optional[bool] = False, - auth: Optional[AuthInfo] = None, - ) -> CatalogEntry: - """Register a single STAC catalog in state. - - Args: - name (str): The name of the catalog. - url (str): A valid URL to the catalog. - is_private (Optional[bool], optional): Indicates if the catalog requires authentication or not. Defaults to False. - summary (Optional[str], optional): A short description of the catalog. Defaults to None. - auth (Optional[AuthInfo], optional): Authentication parameters for the catalog. Defaults to None. - - Raises: - InvalidCatalogSchemaError: If an invalid parameter is encountered. - - Returns: - CatalogEntry: The registered STAC catalog. - """ - logger.info(f"Registering catalog: {name}") - logger.debug(f"Params - url: {url}, is_private: {is_private}, auth: {auth}") - if is_private and auth is None: - logger.error( - f"Private catalog '{name}' requires authentication but none was provided." - ) - raise InvalidCatalogSchemaError( - f"Authentication parameters is required for private catalogs. If this is a mistake, you can set 'is_private' to False or provide the {AuthInfo.__annotations__} parameters." - ) - client = None - try: - # todo - add auth parameters and config... - client = Client.open(url) - metadata = { - "client": client, - "catalog": client.get_root(), - "is_available": True, - } - logger.info(f"Catalog '{name}' is reachable and valid.") - except Exception as e: - logger.warning(f"Catalog '{name}' could not be reached or parsed: {e}") - metadata = { - "client": None, - "catalog": None, - "is_available": False, - } - entry = CatalogEntry( - name=name, - url=url, - is_private=is_private, - auth=AuthInfo(**auth.__dict__) if auth and not is_private else None, - **metadata, - ) - self.catalogs[name] = entry - logger.info(f"Catalog '{name}' registered successfully.") - return entry - - def get_catalogs( - self, - format: Union[str, CatalogOutputFormat] = CatalogOutputFormat.DICT, - available: bool = False, - ) -> list[Union[dict[str, Any], str]]: - """Get the STAC catalogs. - - Args: - format (Union[str, CatalogOutputFormat]): Output format, dict or json string. - available (bool): If True, return only available catalogs; else return all. - - Raises: - ValueError: When an invalid format is provided. - - Returns: - list[CatalogEntry]: List of catalogs in the requested format. - """ - logger.info(f"Retrieving {'available' if available else 'all'} catalogs.") - if isinstance(format, str): - try: - format = CatalogOutputFormat(format.lower()) - except ValueError: - logger.error(f"Invalid output format: {format}") - raise ValueError(f"Invalid format: {format}") - - catalogs = [ - c.as_dict() if format == CatalogOutputFormat.DICT else c.as_json() - for c in self.catalogs.values() - if (c.is_available if available else True) - ] - - logger.info(f"{len(catalogs)} catalogs retrieved in format '{format.value}'.") - return catalogs - - def get_all_collections(self, available: bool = False) -> Dict[str, List[str]]: - """ - Returns a dictionary mapping catalog names to a list of collection IDs - available in each catalog. - - Args: - available (bool): If True, only include catalogs that are available. - - Returns: - Dict[str, List[str]]: Catalog name -> list of collection IDs - """ - logger.info(f"Getting collections with available={available}") - collections = {} - for name, entry in self.catalogs.items(): - if (entry.is_available if available else True) and entry.catalog: - logger.debug( - f"Processing catalog '{name}' (available={entry.is_available})" - ) - try: - all_collections = entry.catalog.get_all_collections() - if all_collections: - collection_ids = [] - for c in all_collections: - if hasattr(c, "id"): - collection_ids.append(c.id) - elif isinstance(c, dict) and "id" in c: - collection_ids.append(c["id"]) - else: - collection_ids.append(str(c)) - collections[name] = collection_ids - logger.info( - f"Found {len(collection_ids)} collections in catalog '{name}'" - ) - else: - logger.info(f"No collections found in catalog '{name}'") - except Exception as e: - logger.warning( - f"Failed to get collections for catalog '{name}': {e}" - ) - continue - else: - logger.debug(f"Skipping catalog '{name}' due to availability filter") - logger.info(f"Total catalogs with collections returned: {len(collections)}") - return collections - - def load_catalogs_from_config( - self, config: Union[str, Path, None] = None - ) -> Dict[str, CatalogEntry]: - """Load catalogs from configuration file. - - Args: - config (Union[str, Path, None], optional): Path to the configuration file. Defaults to None. - - Raises: - CatalogConfigFileNotFound: Raised when the catalog config file is not founds. - InvalidCatalogYAMLError: Raised when the yaml file is invalid. - InvalidCatalogSchemaError: Raised when there is a schema error in the provided config file. - - Returns: - Dict[str, CatalogEntry]: The registered catalogs. - """ - logger.info("Loading catalogs from configuration file.") - if config is None: - # todo. - make public and environment variable - base_dir = Path(__file__).parent - config = base_dir / ".superstac.yml" - - path = Path(config).expanduser().resolve() - logger.debug(f"Resolved config path: {path}") - - if not path.exists(): - logger.error(f"Config file not found at path: {path}") - raise CatalogConfigFileNotFound(f"Config file not found at {path}") - - try: - with open(path, "r") as f: - data = yaml.safe_load(f) or {} - logger.info(f"Successfully loaded YAML config from: {path}") - except yaml.YAMLError as e: - logger.exception("YAML parsing failed.") - raise InvalidCatalogYAMLError(f"YAML parsing failed: {e}") from e - except Exception as e: - logger.exception("Unexpected error while reading config.") - raise InvalidCatalogYAMLError( - f"Unexpected error reading config: {e}" - ) from e - - catalogs = data.get("catalogs") - if not isinstance(catalogs, dict): - logger.error( - f"Missing or invalid 'catalogs' section in config file: {path}" - ) - raise InvalidCatalogSchemaError( - f"Missing or invalid 'catalogs' section in config file: {path}" - ) - - logger.info(f"Found {len(catalogs)} catalogs to register.") - for name, spec in catalogs.items(): - try: - self.register_catalog( - name=name, - url=spec.get("url"), - is_private=spec.get("is_private", False), - auth=AuthInfo(**spec["auth"]) if "auth" in spec else None, - ) - except Exception as e: - logger.warning(f"Failed to register catalog '{name}': {e}") - logger.info("All catalogs loaded and registered.") - return self.catalogs diff --git a/superstac/catalog_registry.py b/superstac/catalog_registry.py deleted file mode 100644 index dcc9bfb..0000000 --- a/superstac/catalog_registry.py +++ /dev/null @@ -1,25 +0,0 @@ -"""SuperSTAC Catalog Registry""" - -from superstac.catalog import CatalogManager - - -_catalog_registry = None - - -def get_catalog_registry() -> CatalogManager: - """ - Returns the singleton CatalogManager instance. - """ - global _catalog_registry - if _catalog_registry is None: - _catalog_registry = CatalogManager() - return _catalog_registry - - -def clear_registry() -> None: - """ - Reset the registry, mainly for testing. - """ - if _catalog_registry: - _catalog_registry.catalogs.clear() - return None diff --git a/superstac/enums.py b/superstac/enums.py deleted file mode 100644 index 9badaa5..0000000 --- a/superstac/enums.py +++ /dev/null @@ -1,14 +0,0 @@ -"""SuperSTAC Enums""" - -from enum import Enum - - -class AuthType(str, Enum): - BEARER = "bearer" - BASIC = "basic" - API_KEY = "apikey" - - -class CatalogOutputFormat(Enum): - JSON = "json" - DICT = "dict" diff --git a/superstac/exceptions.py b/superstac/exceptions.py deleted file mode 100644 index 2988c5c..0000000 --- a/superstac/exceptions.py +++ /dev/null @@ -1,17 +0,0 @@ -"""SuperSTAC Exceptions""" - - -class InvalidCatalogSchemaError(Exception): - """Raised when an invalid catalog schema is provided.""" - - -class ParametersError(Exception): - """Raised when invalid parameters are used in a query.""" - - -class CatalogConfigFileNotFound(FileNotFoundError): - """Raised when the provided catalog config file is not found.""" - - -class InvalidCatalogYAMLError(Exception): - """Raised when invalid catalog yaml is passed or if does not conform to the schema.""" diff --git a/superstac/models.py b/superstac/models.py deleted file mode 100644 index 59cd619..0000000 --- a/superstac/models.py +++ /dev/null @@ -1,79 +0,0 @@ -"""SuperSTAC Models""" - -from dataclasses import asdict, dataclass -import json -from typing import Optional, Union -from urllib.parse import urlparse - - -from pystac import Catalog -from pystac_client import Client - -from superstac.enums import AuthType -from superstac.utils import compute_catalog_id - - -# Todo - make auth type have a default ? -@dataclass -class AuthInfo: - """AuthInfo""" - - type: AuthType - token: Optional[str] = None - username: Optional[str] = None - password: Optional[str] = None - header_key: Optional[str] = None - - def as_dict(self): - return asdict(self) - - def as_json(self): - return json.dumps(self.as_dict(), indent=2) - - -@dataclass -class CatalogEntry: - """CatalogEntry""" - - name: str - - url: str - """URL to the STAC catalog.""" - - id: Optional[str] = None - """Internal unique ID for the catalog. Will be autogenerated post init.""" - - client: Union[Client, None] = None - """PySTAC Client""" - - catalog: Union[Catalog, None] = None - """Root Catalog""" - - is_private: Optional[bool] = False - """Indicates whether the catalog is a private catalog or not.""" - - auth: Optional[AuthInfo] = None - """Authentication parameters.""" - - is_available: Optional[bool] = True - """Defaults to True on instantiation. It will be updated based on the status code of the catalog from pystac client.""" - - def __post_init__(self): - # Validate the URL - parsed_url = urlparse(self.url) - if not all([parsed_url.scheme, parsed_url.netloc]): - raise ValueError( - f"Invalid URL: '{self.url}' - Must have a scheme (e.g., http/https) and network location." - ) - - if parsed_url.scheme not in ("http", "https"): - raise ValueError( - f"Invalid URL scheme: '{parsed_url.scheme}' for '{self.url}' - Only http and https are allowed." - ) - self.id = compute_catalog_id(self.name, self.url) - - def as_dict(self): - return asdict(self) - - def as_json(self): - return json.dumps(self.as_dict(), indent=2) diff --git a/superstac/query.py b/superstac/query.py deleted file mode 100644 index 98eed79..0000000 --- a/superstac/query.py +++ /dev/null @@ -1,51 +0,0 @@ -"""SuperSTAC Query Manager""" - -from typing import Any, List -from pystac import Item -from superstac._logging import logger -import asyncio - -from superstac.models import CatalogEntry - - -def query_catalog_with_pystac( - catalog: CatalogEntry, - collection: str, - **search_kwargs: Any, -) -> List[Item]: - """ - Search a single catalog using pystac-client and normalize the assets. - - Args: - name: Unique catalog name. - url: STAC API URL. - required_assets: Band aliases to filter and rename assets. - max_items: Maximum number of results. - **search_kwargs: All valid parameters supported by pystac-client's search(). - - Returns: - List of STAC items (as dicts), normalized with filtered assets and catalog_name. - """ - try: - client = catalog.client - logger.debug(f"Querying STAC with: {search_kwargs.items()}") - search = client.search(collections=[collection], **search_kwargs) # type: ignore - items: List[Item] = list(search.items()) - collection_id = collection - logger.info( - f"[{catalog.name}] Returned {len(items)} items for collection '{collection_id}'" - ) - return items - - except Exception as e: - logger.warning(f"[{catalog.name}] Catalog query failed: {e}") - return [] - - -async def query_catalog_with_pystac_async_wrapper(*args, **kwargs) -> List[Item]: - """ - Async wrapper for the synchronous `query_catalog_with_pystac` function, - offloading it to a thread to avoid blocking the event loop. - """ - results = await asyncio.to_thread(query_catalog_with_pystac, *args, **kwargs) - return results diff --git a/superstac/search.py b/superstac/search.py deleted file mode 100644 index bb851be..0000000 --- a/superstac/search.py +++ /dev/null @@ -1,174 +0,0 @@ -"""superSTAC Search Module""" - -import asyncio -from typing import Any, List, Optional - -from pystac import Item -from superstac.catalog import CatalogManager -from superstac.catalog_registry import get_catalog_registry -from superstac.query import ( - query_catalog_with_pystac, - query_catalog_with_pystac_async_wrapper, -) -from superstac._logging import logger - - -def federated_search( - collections: List[str], - registry: Optional[CatalogManager] = None, - catalog_names: Optional[List[str]] = None, - **kwargs: Any, -) -> List[Item]: - """ - Search one or more catalogs for matching STAC items. - - Args: - bbox (List[float]): Bounding box [minx, miny, maxx, maxy]. - datetime (str): Datetime string or range. - collections (List[str]): List of collection IDs to search. - required_bands (Optional[List[str]]): List of required asset keys. - registry (Optional[CatalogManager]): CatalogManager instance. - catalog_names (Optional[List[str]]): Names of catalogs to search. If None, search all available. - max_items (int): Max items per query. - - Returns: - List[Dict[str, Any]]: List of matching STAC items. - """ - cr = registry or get_catalog_registry() - - results = [] - - if catalog_names is None: - search_catalogs = { - name: entry - for name, entry in cr.catalogs.items() - if entry.is_available and entry.catalog - } - else: - - search_catalogs = {} - for name in catalog_names: - entry = cr.catalogs.get(name) - if entry and entry.is_available and entry.catalog: - search_catalogs[name] = entry - else: - logger.warning(f"Catalog '{name}' not found or unavailable.") - - for catalog_name, catalog_entry in search_catalogs.items(): - - try: - catalog_collections = [ - c.id for c in catalog_entry.catalog.get_all_collections() # type: ignore - search catalogs are already confirmed to be available and have the catalog object above. - ] - except Exception as e: - logger.warning(f"Failed to get collections for catalog {catalog_name}: {e}") - continue - - for coll in collections: - if coll in catalog_collections: - logger.info( - f"Searching collection '{coll}' in catalog '{catalog_name}'" - ) - try: - items = query_catalog_with_pystac( - catalog=catalog_entry, - collection=coll, - **kwargs, - ) - results.extend(items) - except Exception as e: - logger.error( - f"Error querying catalog '{catalog_name}', collection '{coll}': {e}" - ) - else: - logger.debug( - f"Collection '{coll}' not found in catalog '{catalog_name}'" - ) - - logger.info(f"Federated search completed with {len(results)} total items found.") - return results - - -async def federated_search_async( - collections: List[str], - registry: Optional[CatalogManager] = None, - catalog_names: Optional[List[str]] = None, - **kwargs: Any, -) -> List[Item]: - """ - Async search one or more catalogs for matching STAC items. - - Args: - collections (List[str]): List of collection IDs to search. - registry (Optional[CatalogManager]): CatalogManager instance. - catalog_names (Optional[List[str]]): Names of catalogs to search. If None, search all available. - **kwargs: Additional keyword arguments forwarded to query_catalog_with_pystac. - - Returns: - List[Dict[str, Any]]: List of matching STAC items. - """ - cr = registry or get_catalog_registry() - - if catalog_names is None: - search_catalogs = { - name: entry - for name, entry in cr.catalogs.items() - if entry.is_available and entry.catalog - } - else: - search_catalogs = {} - for name in catalog_names: - entry = cr.catalogs.get(name) - if entry and entry.is_available and entry.catalog: - search_catalogs[name] = entry - else: - logger.warning(f"Catalog '{name}' not found or unavailable.") - - async def query_catalog(catalog_entry) -> List[Item]: - try: - catalog_collections = [ - c.id for c in catalog_entry.catalog.get_all_collections() # type: ignore - ] - except Exception as e: - logger.warning( - f"Failed to get collections for catalog {catalog_entry.name}: {e}" - ) - return [] - - matched_collections = [ - coll for coll in collections if coll in catalog_collections - ] - - if not matched_collections: - logger.debug( - f"No matching collections found in catalog '{catalog_entry.name}' " - f"for requested collections." - ) - return [] - - logger.info( - f"Searching collections {matched_collections} in catalog '{catalog_entry.name}'" - ) - - try: - items = await query_catalog_with_pystac_async_wrapper( - catalog=catalog_entry, - collection=( - matched_collections - if len(matched_collections) > 1 - else matched_collections[0] - ), - **kwargs, - ) - return items - except Exception as e: - logger.error(f"Error querying catalog '{catalog_entry.name}': {e}") - return [] - - tasks = [query_catalog(entry) for _, entry in search_catalogs.items()] - results_lists = await asyncio.gather(*tasks) - - results = [item for sublist in results_lists for item in sublist] - - logger.info(f"Federated search completed with {len(results)} total items found.") - return results diff --git a/superstac/utils.py b/superstac/utils.py deleted file mode 100644 index e7c984d..0000000 --- a/superstac/utils.py +++ /dev/null @@ -1,26 +0,0 @@ -"""SuperSTAC utils""" - -import uuid - - -def compute_catalog_id(name: str, url: str) -> str: - """Compute a unique catalog id. - - Args: - name (str): The name of the catalog. - url (str): The url of the catalog. - - Returns: - str: A unique uuid for the catalog. - """ - uid = uuid.uuid4().hex[:10] - return f"{name.lower().replace(' ', '_')}_{uid}" - - -# Band map between Element 84's STAC and Planetary Computer -# allow users to provide band map ? -BAND_MAP = { - "sentinel-2-l2a": {"red": "B04", "green": "B03", "blue": "B02", "nir": "B08"}, - "landsat-8": {"red": "SR_B4", "green": "SR_B3", "blue": "SR_B2", "nir": "SR_B5"}, - "modis": {"red": "sur_refl_b01", "nir": "sur_refl_b02"}, -} diff --git a/uv.lock b/uv.lock deleted file mode 100644 index e6d8772..0000000 --- a/uv.lock +++ /dev/null @@ -1,37 +0,0 @@ -version = 1 -requires-python = ">=3.9" - -[[distribution]] -name = "ruff" -version = "0.12.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/3d/d9a195676f25d00dbfcf3cf95fdd4c685c497fcfa7e862a44ac5e4e96480/ruff-0.12.2.tar.gz", hash = "sha256:d7b4f55cd6f325cb7621244f19c873c565a08aff5a4ba9c69aa7355f3f7afd3e", size = 4432239 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/b6/2098d0126d2d3318fd5bec3ad40d06c25d377d95749f7a0c5af17129b3b1/ruff-0.12.2-py3-none-linux_armv6l.whl", hash = "sha256:093ea2b221df1d2b8e7ad92fc6ffdca40a2cb10d8564477a987b44fd4008a7be", size = 10369761 }, - { url = "https://files.pythonhosted.org/packages/b1/4b/5da0142033dbe155dc598cfb99262d8ee2449d76920ea92c4eeb9547c208/ruff-0.12.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:09e4cf27cc10f96b1708100fa851e0daf21767e9709e1649175355280e0d950e", size = 11155659 }, - { url = "https://files.pythonhosted.org/packages/3e/21/967b82550a503d7c5c5c127d11c935344b35e8c521f52915fc858fb3e473/ruff-0.12.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ae64755b22f4ff85e9c52d1f82644abd0b6b6b6deedceb74bd71f35c24044cc", size = 10537769 }, - { url = "https://files.pythonhosted.org/packages/33/91/00cff7102e2ec71a4890fb7ba1803f2cdb122d82787c7d7cf8041fe8cbc1/ruff-0.12.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eb3a6b2db4d6e2c77e682f0b988d4d61aff06860158fdb413118ca133d57922", size = 10717602 }, - { url = "https://files.pythonhosted.org/packages/9b/eb/928814daec4e1ba9115858adcda44a637fb9010618721937491e4e2283b8/ruff-0.12.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73448de992d05517170fc37169cbca857dfeaeaa8c2b9be494d7bcb0d36c8f4b", size = 10198772 }, - { url = "https://files.pythonhosted.org/packages/50/fa/f15089bc20c40f4f72334f9145dde55ab2b680e51afb3b55422effbf2fb6/ruff-0.12.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b8b94317cbc2ae4a2771af641739f933934b03555e51515e6e021c64441532d", size = 11845173 }, - { url = "https://files.pythonhosted.org/packages/43/9f/1f6f98f39f2b9302acc161a4a2187b1e3a97634fe918a8e731e591841cf4/ruff-0.12.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:45fc42c3bf1d30d2008023a0a9a0cfb06bf9835b147f11fe0679f21ae86d34b1", size = 12553002 }, - { url = "https://files.pythonhosted.org/packages/d8/70/08991ac46e38ddd231c8f4fd05ef189b1b94be8883e8c0c146a025c20a19/ruff-0.12.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce48f675c394c37e958bf229fb5c1e843e20945a6d962cf3ea20b7a107dcd9f4", size = 12171330 }, - { url = "https://files.pythonhosted.org/packages/88/a9/5a55266fec474acfd0a1c73285f19dd22461d95a538f29bba02edd07a5d9/ruff-0.12.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:793d8859445ea47591272021a81391350205a4af65a9392401f418a95dfb75c9", size = 11774717 }, - { url = "https://files.pythonhosted.org/packages/87/e5/0c270e458fc73c46c0d0f7cf970bb14786e5fdb88c87b5e423a4bd65232b/ruff-0.12.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6932323db80484dda89153da3d8e58164d01d6da86857c79f1961934354992da", size = 11646659 }, - { url = "https://files.pythonhosted.org/packages/b7/b6/45ab96070c9752af37f0be364d849ed70e9ccede07675b0ec4e3ef76b63b/ruff-0.12.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6aa7e623a3a11538108f61e859ebf016c4f14a7e6e4eba1980190cacb57714ce", size = 10604012 }, - { url = "https://files.pythonhosted.org/packages/86/91/26a6e6a424eb147cc7627eebae095cfa0b4b337a7c1c413c447c9ebb72fd/ruff-0.12.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2a4a20aeed74671b2def096bdf2eac610c7d8ffcbf4fb0e627c06947a1d7078d", size = 10176799 }, - { url = "https://files.pythonhosted.org/packages/f5/0c/9f344583465a61c8918a7cda604226e77b2c548daf8ef7c2bfccf2b37200/ruff-0.12.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:71a4c550195612f486c9d1f2b045a600aeba851b298c667807ae933478fcef04", size = 11241507 }, - { url = "https://files.pythonhosted.org/packages/1c/b7/99c34ded8fb5f86c0280278fa89a0066c3760edc326e935ce0b1550d315d/ruff-0.12.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4987b8f4ceadf597c927beee65a5eaf994c6e2b631df963f86d8ad1bdea99342", size = 11717609 }, - { url = "https://files.pythonhosted.org/packages/51/de/8589fa724590faa057e5a6d171e7f2f6cffe3287406ef40e49c682c07d89/ruff-0.12.2-py3-none-win32.whl", hash = "sha256:369ffb69b70cd55b6c3fc453b9492d98aed98062db9fec828cdfd069555f5f1a", size = 10523823 }, - { url = "https://files.pythonhosted.org/packages/94/47/8abf129102ae4c90cba0c2199a1a9b0fa896f6f806238d6f8c14448cc748/ruff-0.12.2-py3-none-win_amd64.whl", hash = "sha256:dca8a3b6d6dc9810ed8f328d406516bf4d660c00caeaef36eb831cf4871b0639", size = 11629831 }, - { url = "https://files.pythonhosted.org/packages/e2/1f/72d2946e3cc7456bb837e88000eb3437e55f80db339c840c04015a11115d/ruff-0.12.2-py3-none-win_arm64.whl", hash = "sha256:48d6c6bfb4761df68bc05ae630e24f506755e702d4fb08f08460be778c7ccb12", size = 10735334 }, -] - -[[distribution]] -name = "superstac" -version = "0.1.0" -source = { editable = "." } - -[distribution.dev-dependencies] -dev = [ - { name = "ruff" }, -]