├── .github └── workflows │ ├── ci.yaml │ └── validate.yaml ├── .gitignore ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── data └── joplin │ ├── collection.json │ └── items.geojson ├── docker-compose.yml ├── scripts ├── requirements.in ├── requirements.txt ├── validate └── wait-for-it.sh ├── stac-api-backend ├── Cargo.toml └── src │ ├── api │ ├── api.rs │ ├── conformance.rs │ ├── features.rs │ ├── mod.rs │ └── root.rs │ ├── backend.rs │ ├── error.rs │ ├── items.rs │ ├── lib.rs │ ├── memory.rs │ ├── page.rs │ └── pgstac.rs ├── stac-server-cli ├── Cargo.toml ├── data └── src │ ├── config.toml │ ├── lib.rs │ └── main.rs └── stac-server ├── Cargo.toml ├── data ├── src ├── config.rs ├── error.rs ├── lib.rs └── router.rs └── tests └── client.rs /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check out repository code 15 | uses: actions/checkout@v3 16 | - name: Set up Rust cache 17 | uses: Swatinem/rust-cache@v2 18 | - name: Format 19 | run: cargo fmt --verbose 20 | - name: Build 21 | run: cargo build --verbose 22 | - name: Test 23 | run: cargo test --verbose 24 | pgstac-integration-test: 25 | runs-on: ubuntu-latest 26 | services: 27 | pgstac: 28 | image: ghcr.io/stac-utils/pgstac:v0.8.5 29 | env: 30 | POSTGRES_USER: username 31 | POSTGRES_PASSWORD: password 32 | POSTGRES_DB: postgis 33 | PGUSER: username 34 | PGPASSWORD: password 35 | PGDATABASE: postgis 36 | ports: 37 | - 5432:5432 38 | steps: 39 | - name: Check out repository code 40 | uses: actions/checkout@v3 41 | - name: Set up Rust cache 42 | uses: Swatinem/rust-cache@v2 43 | - name: Test 44 | run: cargo test --verbose -- --ignored 45 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | jobs: 11 | memory: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check out repository code 15 | uses: actions/checkout@v3 16 | - name: Set up Rust cache 17 | uses: Swatinem/rust-cache@v2 18 | - uses: actions/setup-python@v4 19 | with: 20 | python-version: "3.11" 21 | cache: "pip" 22 | - name: Install geos 23 | run: sudo apt-get update && sudo apt-get install libgeos-c1v5 && sudo rm -rf /var/lib/apt/lists/* 24 | - name: Install stac-api-validator 25 | run: pip install -r scripts/requirements.txt 26 | - name: Validate 27 | run: scripts/validate 28 | pgstac: 29 | runs-on: ubuntu-latest 30 | services: 31 | pgstac: 32 | image: ghcr.io/stac-utils/pgstac:v0.8.5 33 | env: 34 | POSTGRES_USER: username 35 | POSTGRES_PASSWORD: password 36 | POSTGRES_DB: postgis 37 | PGUSER: username 38 | PGPASSWORD: password 39 | PGDATABASE: postgis 40 | ports: 41 | - 5432:5432 42 | steps: 43 | - name: Check out repository code 44 | uses: actions/checkout@v3 45 | - name: Set up Rust cache 46 | uses: Swatinem/rust-cache@v2 47 | - uses: actions/setup-python@v4 48 | with: 49 | python-version: "3.11" 50 | cache: "pip" 51 | - name: Install geos 52 | run: sudo apt-get update && sudo apt-get install libgeos-c1v5 && sudo rm -rf /var/lib/apt/lists/* 53 | - name: Install stac-api-validator 54 | run: pip install -r scripts/requirements.txt 55 | - name: Validate 56 | run: scripts/validate --pgstac 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["stac-api-backend", "stac-server", "stac-server-cli"] 3 | resolver = "2" 4 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stac-server-rs 2 | 3 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/gadomski/stac-server-rs/ci.yaml?branch=main&style=for-the-badge)](https://github.com/gadomski/stac-server-rs/actions/workflows/ci.yaml) 4 | [![STAC API Validator](https://img.shields.io/github/actions/workflow/status/gadomski/stac-server-rs/validate.yaml?branch=main&label=STAC+API+Validator&style=for-the-badge)](https://github.com/gadomski/stac-server-rs/actions/workflows/validate.yaml) 5 | [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg?style=for-the-badge)](./CODE_OF_CONDUCT) 6 | 7 | *NOTE: this repo is archived, its functionality has been moved to https://github.com/stac-utils/stac-rs* 8 | 9 | A [STAC API](https://github.com/radiantearth/stac-api-spec) written in Rust. 10 | 11 | | Crate | Description | 12 | | ----- | ---- | 13 | | **stac-api-backend** | Generic backend interface for STAC APIs | 14 | | **stac-server** | A STAC API server in [axum](https://github.com/tokio-rs/axum) | 15 | | **stac-server-cli** | A command-line interface for [stac-server](./stac-server/README.md) | 16 | 17 | ## Usage 18 | 19 | You'll need [rust](https://rustup.rs/). 20 | Then: 21 | 22 | ```shell 23 | cargo install --git https://github.com/gadomski/stac-server-rs 24 | ``` 25 | 26 | Any collections, items, or item collections provided on the command line will be ingested into the backend on startup. 27 | To start a memory-backed server populated with one collection and one item from [Earth Search](https://www.element84.com/earth-search/): 28 | 29 | ```shell 30 | stac-server \ 31 | https://earth-search.aws.element84.com/v1/collections/landsat-c2-l2 \ 32 | https://earth-search.aws.element84.com/v1/collections/landsat-c2-l2/items/LC09_L2SR_082111_20231007_02_T2 33 | ``` 34 | 35 | If you have a [pgstac](https://github.com/stac-utils/pgstac) database pre-populated with collections and items, you can point your server there: 36 | 37 | ```shell 38 | stac-server --pgstac postgres://username:password@localhost/postgis 39 | ``` 40 | 41 | For more advanced setups, use a [configuration file](#configuration): 42 | 43 | ```shell 44 | stac-server --config config.toml 45 | ``` 46 | 47 | ## Configuration 48 | 49 | The [`Config` structure](https://docs.rs/stac-server/latest/stac-server-cli/struct.Config.html) defines the configuration attributes available for your server. 50 | This repository includes [a default configuration](./stac-server-cli/src/config.toml) that you can then customize for your use-case. 51 | 52 | ## Conformance classes 53 | 54 | The STAC API spec uses "conformance classes" to describe the functionality of a server. 55 | These are the supported conformance classes for each backend: 56 | 57 | | Conformance class | Memory backend | pgstac backend | 58 | | -- | -- | -- | 59 | | [Core](https://github.com/radiantearth/stac-api-spec/tree/main/core) | ✅ | ✅ | 60 | | [Features](https://github.com/radiantearth/stac-api-spec/tree/main/ogcapi-features) | ✅ | ✅ | 61 | | [Item search](https://github.com/radiantearth/stac-api-spec/tree/main/item-search) | ❌ | ❌ | 62 | 63 | ## Testing 64 | 65 | In addition to unit tests, **stac-server** comes with some integration tests for both the memory and **pgstac** backends. 66 | The **pgstac** test is ignored by default, since it requires a running **pgstac** database. 67 | To run the **pgstac** integration test: 68 | 69 | ```shell 70 | docker-compose up -d 71 | cargo test -- --ignored 72 | docker-compose down 73 | ``` 74 | 75 | ## Validation 76 | 77 | Conformance classes are validated with [stac-api-validator](https://github.com/stac-utils/stac-api-validator) in [CI](https://github.com/gadomski/stac-server-rs/actions/workflows/validate.yaml). 78 | To validate yourself, you'll need to install **stac-api-validator**, preferably in a virtual enviroment: 79 | 80 | ```shell 81 | pip install stac-api-validator 82 | ``` 83 | 84 | Then, with the memory backend: 85 | 86 | ```shell 87 | scripts/validate 88 | ``` 89 | 90 | To validate the server with the pgstac backend, you'll need to start a pgstac server first: 91 | 92 | ```shell 93 | docker-compose up -d 94 | scrips/validate --pgstac 95 | docker-compose down 96 | ``` 97 | 98 | ## License 99 | 100 | **stac-server-rs** is dual-licensed under both the MIT license and the Apache license (Version 2.0). 101 | See [LICENSE-APACHE](./LICENSE-APACHE) and [LICENSE-MIT](./LICENSE-MIT) for details. 102 | -------------------------------------------------------------------------------- /data/joplin/collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "joplin", 3 | "title": "Joplin", 4 | "description": "This imagery was acquired by the NOAA Remote Sensing Division to support NOAA national security and emergency response requirements. In addition, it will be used for ongoing research efforts for testing and developing standards for airborne digital imagery. Individual images have been combined into a larger mosaic and tiled for distribution. The approximate ground sample distance (GSD) for each pixel is 35 cm (1.14 feet).", 5 | "stac_version": "1.0.0", 6 | "license": "public-domain", 7 | "links": [ 8 | { 9 | "rel": "license", 10 | "href": "https://creativecommons.org/licenses/publicdomain/", 11 | "title": "public domain" 12 | } 13 | ], 14 | "type": "Collection", 15 | "extent": { 16 | "spatial": { 17 | "bbox": [ 18 | [ 19 | -94.6911621, 20 | 37.0332547, 21 | -94.402771, 22 | 37.1077651 23 | ] 24 | ] 25 | }, 26 | "temporal": { 27 | "interval": [ 28 | [ 29 | "2000-02-01T00:00:00Z", 30 | "2000-02-12T00:00:00Z" 31 | ] 32 | ] 33 | } 34 | }, 35 | "summaries": {} 36 | } -------------------------------------------------------------------------------- /data/joplin/items.geojson: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "features": [ 4 | { 5 | "id": "f2cca2a3-288b-4518-8a3e-a4492bb60b08", 6 | "type": "Feature", 7 | "collection": "joplin", 8 | "links": [], 9 | "geometry": { 10 | "type": "Polygon", 11 | "coordinates": [ 12 | [ 13 | [ 14 | -94.6884155, 15 | 37.0595608 16 | ], 17 | [ 18 | -94.6884155, 19 | 37.0332547 20 | ], 21 | [ 22 | -94.6554565, 23 | 37.0332547 24 | ], 25 | [ 26 | -94.6554565, 27 | 37.0595608 28 | ], 29 | [ 30 | -94.6884155, 31 | 37.0595608 32 | ] 33 | ] 34 | ] 35 | }, 36 | "properties": { 37 | "proj:epsg": 3857, 38 | "orientation": "nadir", 39 | "height": 2500, 40 | "width": 2500, 41 | "datetime": "2000-02-02T00:00:00Z", 42 | "gsd": 0.5971642834779395 43 | }, 44 | "assets": { 45 | "COG": { 46 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 47 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C350000e4102500n.tif", 48 | "title": "NOAA STORM COG" 49 | } 50 | }, 51 | "bbox": [ 52 | -94.6884155, 53 | 37.0332547, 54 | -94.6554565, 55 | 37.0595608 56 | ], 57 | "stac_extensions": [ 58 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 59 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 60 | ], 61 | "stac_version": "1.0.0" 62 | }, 63 | { 64 | "id": "a7e125ba-565d-4aa2-bbf3-c57a9087c2e3", 65 | "type": "Feature", 66 | "collection": "joplin", 67 | "links": [], 68 | "geometry": { 69 | "type": "Polygon", 70 | "coordinates": [ 71 | [ 72 | [ 73 | -94.6884155, 74 | 37.0814756 75 | ], 76 | [ 77 | -94.6884155, 78 | 37.0551771 79 | ], 80 | [ 81 | -94.6582031, 82 | 37.0551771 83 | ], 84 | [ 85 | -94.6582031, 86 | 37.0814756 87 | ], 88 | [ 89 | -94.6884155, 90 | 37.0814756 91 | ] 92 | ] 93 | ] 94 | }, 95 | "properties": { 96 | "proj:epsg": 3857, 97 | "orientation": "nadir", 98 | "height": 2500, 99 | "width": 2500, 100 | "datetime": "2000-02-02T00:00:00Z", 101 | "gsd": 0.5971642834779395 102 | }, 103 | "assets": { 104 | "COG": { 105 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 106 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C350000e4105000n.tif", 107 | "title": "NOAA STORM COG" 108 | } 109 | }, 110 | "bbox": [ 111 | -94.6884155, 112 | 37.0551771, 113 | -94.6582031, 114 | 37.0814756 115 | ], 116 | "stac_extensions": [ 117 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 118 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 119 | ], 120 | "stac_version": "1.0.0" 121 | }, 122 | { 123 | "id": "f7f164c9-cfdf-436d-a3f0-69864c38ba2a", 124 | "type": "Feature", 125 | "collection": "joplin", 126 | "links": [], 127 | "geometry": { 128 | "type": "Polygon", 129 | "coordinates": [ 130 | [ 131 | [ 132 | -94.6911621, 133 | 37.1033841 134 | ], 135 | [ 136 | -94.6911621, 137 | 37.0770932 138 | ], 139 | [ 140 | -94.6582031, 141 | 37.0770932 142 | ], 143 | [ 144 | -94.6582031, 145 | 37.1033841 146 | ], 147 | [ 148 | -94.6911621, 149 | 37.1033841 150 | ] 151 | ] 152 | ] 153 | }, 154 | "properties": { 155 | "proj:epsg": 3857, 156 | "orientation": "nadir", 157 | "height": 2500, 158 | "width": 2500, 159 | "datetime": "2000-02-02T00:00:00Z", 160 | "gsd": 0.5971642834779395 161 | }, 162 | "assets": { 163 | "COG": { 164 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 165 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C350000e4107500n.tif", 166 | "title": "NOAA STORM COG" 167 | } 168 | }, 169 | "bbox": [ 170 | -94.6911621, 171 | 37.0770932, 172 | -94.6582031, 173 | 37.1033841 174 | ], 175 | "stac_extensions": [ 176 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 177 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 178 | ], 179 | "stac_version": "1.0.0" 180 | }, 181 | { 182 | "id": "ea0fddf4-56f9-4a16-8a0b-f6b0b123b7cf", 183 | "type": "Feature", 184 | "collection": "joplin", 185 | "links": [], 186 | "geometry": { 187 | "type": "Polygon", 188 | "coordinates": [ 189 | [ 190 | [ 191 | -94.6609497, 192 | 37.0595608 193 | ], 194 | [ 195 | -94.6609497, 196 | 37.0332547 197 | ], 198 | [ 199 | -94.6279907, 200 | 37.0332547 201 | ], 202 | [ 203 | -94.6279907, 204 | 37.0595608 205 | ], 206 | [ 207 | -94.6609497, 208 | 37.0595608 209 | ] 210 | ] 211 | ] 212 | }, 213 | "properties": { 214 | "proj:epsg": 3857, 215 | "orientation": "nadir", 216 | "height": 2500, 217 | "width": 2500, 218 | "datetime": "2000-02-02T00:00:00Z", 219 | "gsd": 0.5971642834779395 220 | }, 221 | "assets": { 222 | "COG": { 223 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 224 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C352500e4102500n.tif", 225 | "title": "NOAA STORM COG" 226 | } 227 | }, 228 | "bbox": [ 229 | -94.6609497, 230 | 37.0332547, 231 | -94.6279907, 232 | 37.0595608 233 | ], 234 | "stac_extensions": [ 235 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 236 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 237 | ], 238 | "stac_version": "1.0.0" 239 | }, 240 | { 241 | "id": "c811e716-ab07-4d80-ac95-6670f8713bc4", 242 | "type": "Feature", 243 | "collection": "joplin", 244 | "links": [], 245 | "geometry": { 246 | "type": "Polygon", 247 | "coordinates": [ 248 | [ 249 | [ 250 | -94.6609497, 251 | 37.0814756 252 | ], 253 | [ 254 | -94.6609497, 255 | 37.0551771 256 | ], 257 | [ 258 | -94.6279907, 259 | 37.0551771 260 | ], 261 | [ 262 | -94.6279907, 263 | 37.0814756 264 | ], 265 | [ 266 | -94.6609497, 267 | 37.0814756 268 | ] 269 | ] 270 | ] 271 | }, 272 | "properties": { 273 | "proj:epsg": 3857, 274 | "orientation": "nadir", 275 | "height": 2500, 276 | "width": 2500, 277 | "datetime": "2000-02-02T00:00:00Z", 278 | "gsd": 0.5971642834779395 279 | }, 280 | "assets": { 281 | "COG": { 282 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 283 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C352500e4105000n.tif", 284 | "title": "NOAA STORM COG" 285 | } 286 | }, 287 | "bbox": [ 288 | -94.6609497, 289 | 37.0551771, 290 | -94.6279907, 291 | 37.0814756 292 | ], 293 | "stac_extensions": [ 294 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 295 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 296 | ], 297 | "stac_version": "1.0.0" 298 | }, 299 | { 300 | "id": "d4eccfa2-7d77-4624-9e2a-3f59102285bb", 301 | "type": "Feature", 302 | "collection": "joplin", 303 | "links": [], 304 | "geometry": { 305 | "type": "Polygon", 306 | "coordinates": [ 307 | [ 308 | [ 309 | -94.6609497, 310 | 37.1033841 311 | ], 312 | [ 313 | -94.6609497, 314 | 37.0770932 315 | ], 316 | [ 317 | -94.6279907, 318 | 37.0770932 319 | ], 320 | [ 321 | -94.6279907, 322 | 37.1033841 323 | ], 324 | [ 325 | -94.6609497, 326 | 37.1033841 327 | ] 328 | ] 329 | ] 330 | }, 331 | "properties": { 332 | "proj:epsg": 3857, 333 | "orientation": "nadir", 334 | "height": 2500, 335 | "width": 2500, 336 | "datetime": "2000-02-02T00:00:00Z", 337 | "gsd": 0.5971642834779395 338 | }, 339 | "assets": { 340 | "COG": { 341 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 342 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C352500e4107500n.tif", 343 | "title": "NOAA STORM COG" 344 | } 345 | }, 346 | "bbox": [ 347 | -94.6609497, 348 | 37.0770932, 349 | -94.6279907, 350 | 37.1033841 351 | ], 352 | "stac_extensions": [ 353 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 354 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 355 | ], 356 | "stac_version": "1.0.0" 357 | }, 358 | { 359 | "id": "fe916452-ba6f-4631-9154-c249924a122d", 360 | "type": "Feature", 361 | "collection": "joplin", 362 | "links": [], 363 | "geometry": { 364 | "type": "Polygon", 365 | "coordinates": [ 366 | [ 367 | [ 368 | -94.6334839, 369 | 37.0595608 370 | ], 371 | [ 372 | -94.6334839, 373 | 37.0332547 374 | ], 375 | [ 376 | -94.6005249, 377 | 37.0332547 378 | ], 379 | [ 380 | -94.6005249, 381 | 37.0595608 382 | ], 383 | [ 384 | -94.6334839, 385 | 37.0595608 386 | ] 387 | ] 388 | ] 389 | }, 390 | "properties": { 391 | "proj:epsg": 3857, 392 | "orientation": "nadir", 393 | "height": 2500, 394 | "width": 2500, 395 | "datetime": "2000-02-02T00:00:00Z", 396 | "gsd": 0.5971642834779395 397 | }, 398 | "assets": { 399 | "COG": { 400 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 401 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C355000e4102500n.tif", 402 | "title": "NOAA STORM COG" 403 | } 404 | }, 405 | "bbox": [ 406 | -94.6334839, 407 | 37.0332547, 408 | -94.6005249, 409 | 37.0595608 410 | ], 411 | "stac_extensions": [ 412 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 413 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 414 | ], 415 | "stac_version": "1.0.0" 416 | }, 417 | { 418 | "id": "85f923a5-a81f-4acd-bc7f-96c7c915f357", 419 | "type": "Feature", 420 | "collection": "joplin", 421 | "links": [], 422 | "geometry": { 423 | "type": "Polygon", 424 | "coordinates": [ 425 | [ 426 | [ 427 | -94.6334839, 428 | 37.0814756 429 | ], 430 | [ 431 | -94.6334839, 432 | 37.0551771 433 | ], 434 | [ 435 | -94.6005249, 436 | 37.0551771 437 | ], 438 | [ 439 | -94.6005249, 440 | 37.0814756 441 | ], 442 | [ 443 | -94.6334839, 444 | 37.0814756 445 | ] 446 | ] 447 | ] 448 | }, 449 | "properties": { 450 | "proj:epsg": 3857, 451 | "orientation": "nadir", 452 | "height": 2500, 453 | "width": 2500, 454 | "datetime": "2000-02-02T00:00:00Z", 455 | "gsd": 0.5971642834779395 456 | }, 457 | "assets": { 458 | "COG": { 459 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 460 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C355000e4105000n.tif", 461 | "title": "NOAA STORM COG" 462 | } 463 | }, 464 | "bbox": [ 465 | -94.6334839, 466 | 37.0551771, 467 | -94.6005249, 468 | 37.0814756 469 | ], 470 | "stac_extensions": [ 471 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 472 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 473 | ], 474 | "stac_version": "1.0.0" 475 | }, 476 | { 477 | "id": "29c53e17-d7d1-4394-a80f-36763c8f42dc", 478 | "type": "Feature", 479 | "collection": "joplin", 480 | "links": [], 481 | "geometry": { 482 | "type": "Polygon", 483 | "coordinates": [ 484 | [ 485 | [ 486 | -94.6334839, 487 | 37.1055746 488 | ], 489 | [ 490 | -94.6334839, 491 | 37.0792845 492 | ], 493 | [ 494 | -94.6005249, 495 | 37.0792845 496 | ], 497 | [ 498 | -94.6005249, 499 | 37.1055746 500 | ], 501 | [ 502 | -94.6334839, 503 | 37.1055746 504 | ] 505 | ] 506 | ] 507 | }, 508 | "properties": { 509 | "proj:epsg": 3857, 510 | "orientation": "nadir", 511 | "height": 2500, 512 | "width": 2500, 513 | "datetime": "2000-02-02T00:00:00Z", 514 | "gsd": 0.5971642834779395 515 | }, 516 | "assets": { 517 | "COG": { 518 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 519 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C355000e4107500n.tif", 520 | "title": "NOAA STORM COG" 521 | } 522 | }, 523 | "bbox": [ 524 | -94.6334839, 525 | 37.0792845, 526 | -94.6005249, 527 | 37.1055746 528 | ], 529 | "stac_extensions": [ 530 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 531 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 532 | ], 533 | "stac_version": "1.0.0" 534 | }, 535 | { 536 | "id": "e0a02e4e-aa0c-412e-8f63-6f5344f829df", 537 | "type": "Feature", 538 | "collection": "joplin", 539 | "links": [], 540 | "geometry": { 541 | "type": "Polygon", 542 | "coordinates": [ 543 | [ 544 | [ 545 | -94.6060181, 546 | 37.0595608 547 | ], 548 | [ 549 | -94.6060181, 550 | 37.0332547 551 | ], 552 | [ 553 | -94.5730591, 554 | 37.0332547 555 | ], 556 | [ 557 | -94.5730591, 558 | 37.0595608 559 | ], 560 | [ 561 | -94.6060181, 562 | 37.0595608 563 | ] 564 | ] 565 | ] 566 | }, 567 | "properties": { 568 | "proj:epsg": 3857, 569 | "orientation": "nadir", 570 | "height": 2500, 571 | "width": 2500, 572 | "datetime": "2000-02-02T00:00:00Z", 573 | "gsd": 0.5971642834779395 574 | }, 575 | "assets": { 576 | "COG": { 577 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 578 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C357500e4102500n.tif", 579 | "title": "NOAA STORM COG" 580 | } 581 | }, 582 | "bbox": [ 583 | -94.6060181, 584 | 37.0332547, 585 | -94.5730591, 586 | 37.0595608 587 | ], 588 | "stac_extensions": [ 589 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 590 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 591 | ], 592 | "stac_version": "1.0.0" 593 | }, 594 | { 595 | "id": "047ab5f0-dce1-4166-a00d-425a3dbefe02", 596 | "type": "Feature", 597 | "collection": "joplin", 598 | "links": [], 599 | "geometry": { 600 | "type": "Polygon", 601 | "coordinates": [ 602 | [ 603 | [ 604 | -94.6060181, 605 | 37.0814756 606 | ], 607 | [ 608 | -94.6060181, 609 | 37.057369 610 | ], 611 | [ 612 | -94.5730591, 613 | 37.057369 614 | ], 615 | [ 616 | -94.5730591, 617 | 37.0814756 618 | ], 619 | [ 620 | -94.6060181, 621 | 37.0814756 622 | ] 623 | ] 624 | ] 625 | }, 626 | "properties": { 627 | "proj:epsg": 3857, 628 | "orientation": "nadir", 629 | "height": 2500, 630 | "width": 2500, 631 | "datetime": "2000-02-02T00:00:00Z", 632 | "gsd": 0.5971642834779395 633 | }, 634 | "assets": { 635 | "COG": { 636 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 637 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C357500e4105000n.tif", 638 | "title": "NOAA STORM COG" 639 | } 640 | }, 641 | "bbox": [ 642 | -94.6060181, 643 | 37.057369, 644 | -94.5730591, 645 | 37.0814756 646 | ], 647 | "stac_extensions": [ 648 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 649 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 650 | ], 651 | "stac_version": "1.0.0" 652 | }, 653 | { 654 | "id": "57f88dd2-e4e0-48e6-a2b6-7282d4ab8ea4", 655 | "type": "Feature", 656 | "collection": "joplin", 657 | "links": [], 658 | "geometry": { 659 | "type": "Polygon", 660 | "coordinates": [ 661 | [ 662 | [ 663 | -94.6060181, 664 | 37.1055746 665 | ], 666 | [ 667 | -94.6060181, 668 | 37.0792845 669 | ], 670 | [ 671 | -94.5730591, 672 | 37.0792845 673 | ], 674 | [ 675 | -94.5730591, 676 | 37.1055746 677 | ], 678 | [ 679 | -94.6060181, 680 | 37.1055746 681 | ] 682 | ] 683 | ] 684 | }, 685 | "properties": { 686 | "proj:epsg": 3857, 687 | "orientation": "nadir", 688 | "height": 2500, 689 | "width": 2500, 690 | "datetime": "2000-02-02T00:00:00Z", 691 | "gsd": 0.5971642834779395 692 | }, 693 | "assets": { 694 | "COG": { 695 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 696 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C357500e4107500n.tif", 697 | "title": "NOAA STORM COG" 698 | } 699 | }, 700 | "bbox": [ 701 | -94.6060181, 702 | 37.0792845, 703 | -94.5730591, 704 | 37.1055746 705 | ], 706 | "stac_extensions": [ 707 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 708 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 709 | ], 710 | "stac_version": "1.0.0" 711 | }, 712 | { 713 | "id": "68f2c2b2-4bce-4c40-9a0d-782c1be1f4f2", 714 | "type": "Feature", 715 | "collection": "joplin", 716 | "links": [], 717 | "geometry": { 718 | "type": "Polygon", 719 | "coordinates": [ 720 | [ 721 | [ 722 | -94.5758057, 723 | 37.0595608 724 | ], 725 | [ 726 | -94.5758057, 727 | 37.0332547 728 | ], 729 | [ 730 | -94.5428467, 731 | 37.0332547 732 | ], 733 | [ 734 | -94.5428467, 735 | 37.0595608 736 | ], 737 | [ 738 | -94.5758057, 739 | 37.0595608 740 | ] 741 | ] 742 | ] 743 | }, 744 | "properties": { 745 | "proj:epsg": 3857, 746 | "orientation": "nadir", 747 | "height": 2500, 748 | "width": 2500, 749 | "datetime": "2000-02-02T00:00:00Z", 750 | "gsd": 0.5971642834779395 751 | }, 752 | "assets": { 753 | "COG": { 754 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 755 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C360000e4102500n.tif", 756 | "title": "NOAA STORM COG" 757 | } 758 | }, 759 | "bbox": [ 760 | -94.5758057, 761 | 37.0332547, 762 | -94.5428467, 763 | 37.0595608 764 | ], 765 | "stac_extensions": [ 766 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 767 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 768 | ], 769 | "stac_version": "1.0.0" 770 | }, 771 | { 772 | "id": "d8461d8c-3d2b-4e4e-a931-7ae61ca06dbf", 773 | "type": "Feature", 774 | "collection": "joplin", 775 | "links": [], 776 | "geometry": { 777 | "type": "Polygon", 778 | "coordinates": [ 779 | [ 780 | [ 781 | -94.5758057, 782 | 37.0836668 783 | ], 784 | [ 785 | -94.5758057, 786 | 37.057369 787 | ], 788 | [ 789 | -94.5455933, 790 | 37.057369 791 | ], 792 | [ 793 | -94.5455933, 794 | 37.0836668 795 | ], 796 | [ 797 | -94.5758057, 798 | 37.0836668 799 | ] 800 | ] 801 | ] 802 | }, 803 | "properties": { 804 | "proj:epsg": 3857, 805 | "orientation": "nadir", 806 | "height": 2500, 807 | "width": 2500, 808 | "datetime": "2000-02-02T00:00:00Z", 809 | "gsd": 0.5971642834779395 810 | }, 811 | "assets": { 812 | "COG": { 813 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 814 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C360000e4105000n.tif", 815 | "title": "NOAA STORM COG" 816 | } 817 | }, 818 | "bbox": [ 819 | -94.5758057, 820 | 37.057369, 821 | -94.5455933, 822 | 37.0836668 823 | ], 824 | "stac_extensions": [ 825 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 826 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 827 | ], 828 | "stac_version": "1.0.0" 829 | }, 830 | { 831 | "id": "aeedef30-cbdd-4364-8781-dbb42d148c99", 832 | "type": "Feature", 833 | "collection": "joplin", 834 | "links": [], 835 | "geometry": { 836 | "type": "Polygon", 837 | "coordinates": [ 838 | [ 839 | [ 840 | -94.5785522, 841 | 37.1055746 842 | ], 843 | [ 844 | -94.5785522, 845 | 37.0792845 846 | ], 847 | [ 848 | -94.5455933, 849 | 37.0792845 850 | ], 851 | [ 852 | -94.5455933, 853 | 37.1055746 854 | ], 855 | [ 856 | -94.5785522, 857 | 37.1055746 858 | ] 859 | ] 860 | ] 861 | }, 862 | "properties": { 863 | "proj:epsg": 3857, 864 | "orientation": "nadir", 865 | "height": 2500, 866 | "width": 2500, 867 | "datetime": "2000-02-02T00:00:00Z", 868 | "gsd": 0.5971642834779395 869 | }, 870 | "assets": { 871 | "COG": { 872 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 873 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C360000e4107500n.tif", 874 | "title": "NOAA STORM COG" 875 | } 876 | }, 877 | "bbox": [ 878 | -94.5785522, 879 | 37.0792845, 880 | -94.5455933, 881 | 37.1055746 882 | ], 883 | "stac_extensions": [ 884 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 885 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 886 | ], 887 | "stac_version": "1.0.0" 888 | }, 889 | { 890 | "id": "9ef4279f-386c-40c7-ad71-8de5d9543aa4", 891 | "type": "Feature", 892 | "collection": "joplin", 893 | "links": [], 894 | "geometry": { 895 | "type": "Polygon", 896 | "coordinates": [ 897 | [ 898 | [ 899 | -94.5483398, 900 | 37.0595608 901 | ], 902 | [ 903 | -94.5483398, 904 | 37.0354472 905 | ], 906 | [ 907 | -94.5153809, 908 | 37.0354472 909 | ], 910 | [ 911 | -94.5153809, 912 | 37.0595608 913 | ], 914 | [ 915 | -94.5483398, 916 | 37.0595608 917 | ] 918 | ] 919 | ] 920 | }, 921 | "properties": { 922 | "proj:epsg": 3857, 923 | "orientation": "nadir", 924 | "height": 2500, 925 | "width": 2500, 926 | "datetime": "2000-02-02T00:00:00Z", 927 | "gsd": 0.5971642834779395 928 | }, 929 | "assets": { 930 | "COG": { 931 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 932 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C362500e4102500n.tif", 933 | "title": "NOAA STORM COG" 934 | } 935 | }, 936 | "bbox": [ 937 | -94.5483398, 938 | 37.0354472, 939 | -94.5153809, 940 | 37.0595608 941 | ], 942 | "stac_extensions": [ 943 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 944 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 945 | ], 946 | "stac_version": "1.0.0" 947 | }, 948 | { 949 | "id": "70cc6c05-9fe0-436a-a264-a52515f3f242", 950 | "type": "Feature", 951 | "collection": "joplin", 952 | "links": [], 953 | "geometry": { 954 | "type": "Polygon", 955 | "coordinates": [ 956 | [ 957 | [ 958 | -94.5483398, 959 | 37.0836668 960 | ], 961 | [ 962 | -94.5483398, 963 | 37.057369 964 | ], 965 | [ 966 | -94.5153809, 967 | 37.057369 968 | ], 969 | [ 970 | -94.5153809, 971 | 37.0836668 972 | ], 973 | [ 974 | -94.5483398, 975 | 37.0836668 976 | ] 977 | ] 978 | ] 979 | }, 980 | "properties": { 981 | "proj:epsg": 3857, 982 | "orientation": "nadir", 983 | "height": 2500, 984 | "width": 2500, 985 | "datetime": "2000-02-02T00:00:00Z", 986 | "gsd": 0.5971642834779395 987 | }, 988 | "assets": { 989 | "COG": { 990 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 991 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C362500e4105000n.tif", 992 | "title": "NOAA STORM COG" 993 | } 994 | }, 995 | "bbox": [ 996 | -94.5483398, 997 | 37.057369, 998 | -94.5153809, 999 | 37.0836668 1000 | ], 1001 | "stac_extensions": [ 1002 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 1003 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 1004 | ], 1005 | "stac_version": "1.0.0" 1006 | }, 1007 | { 1008 | "id": "d191a6fd-7881-4421-805c-e246371e5cc4", 1009 | "type": "Feature", 1010 | "collection": "joplin", 1011 | "links": [], 1012 | "geometry": { 1013 | "type": "Polygon", 1014 | "coordinates": [ 1015 | [ 1016 | [ 1017 | -94.5483398, 1018 | 37.1055746 1019 | ], 1020 | [ 1021 | -94.5483398, 1022 | 37.0792845 1023 | ], 1024 | [ 1025 | -94.5181274, 1026 | 37.0792845 1027 | ], 1028 | [ 1029 | -94.5181274, 1030 | 37.1055746 1031 | ], 1032 | [ 1033 | -94.5483398, 1034 | 37.1055746 1035 | ] 1036 | ] 1037 | ] 1038 | }, 1039 | "properties": { 1040 | "proj:epsg": 3857, 1041 | "orientation": "nadir", 1042 | "height": 2500, 1043 | "width": 2500, 1044 | "datetime": "2000-02-02T00:00:00Z", 1045 | "gsd": 0.5971642834779395 1046 | }, 1047 | "assets": { 1048 | "COG": { 1049 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 1050 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C362500e4107500n.tif", 1051 | "title": "NOAA STORM COG" 1052 | } 1053 | }, 1054 | "bbox": [ 1055 | -94.5483398, 1056 | 37.0792845, 1057 | -94.5181274, 1058 | 37.1055746 1059 | ], 1060 | "stac_extensions": [ 1061 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 1062 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 1063 | ], 1064 | "stac_version": "1.0.0" 1065 | }, 1066 | { 1067 | "id": "d144adde-df4a-45e8-bed9-f085f91486a2", 1068 | "type": "Feature", 1069 | "collection": "joplin", 1070 | "links": [], 1071 | "geometry": { 1072 | "type": "Polygon", 1073 | "coordinates": [ 1074 | [ 1075 | [ 1076 | -94.520874, 1077 | 37.0617526 1078 | ], 1079 | [ 1080 | -94.520874, 1081 | 37.0354472 1082 | ], 1083 | [ 1084 | -94.487915, 1085 | 37.0354472 1086 | ], 1087 | [ 1088 | -94.487915, 1089 | 37.0617526 1090 | ], 1091 | [ 1092 | -94.520874, 1093 | 37.0617526 1094 | ] 1095 | ] 1096 | ] 1097 | }, 1098 | "properties": { 1099 | "proj:epsg": 3857, 1100 | "orientation": "nadir", 1101 | "height": 2500, 1102 | "width": 2500, 1103 | "datetime": "2000-02-02T00:00:00Z", 1104 | "gsd": 0.5971642834779395 1105 | }, 1106 | "assets": { 1107 | "COG": { 1108 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 1109 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C365000e4102500n.tif", 1110 | "title": "NOAA STORM COG" 1111 | } 1112 | }, 1113 | "bbox": [ 1114 | -94.520874, 1115 | 37.0354472, 1116 | -94.487915, 1117 | 37.0617526 1118 | ], 1119 | "stac_extensions": [ 1120 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 1121 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 1122 | ], 1123 | "stac_version": "1.0.0" 1124 | }, 1125 | { 1126 | "id": "a4c32abd-9791-422b-87ab-b0f3fa36f053", 1127 | "type": "Feature", 1128 | "collection": "joplin", 1129 | "links": [], 1130 | "geometry": { 1131 | "type": "Polygon", 1132 | "coordinates": [ 1133 | [ 1134 | [ 1135 | -94.520874, 1136 | 37.0836668 1137 | ], 1138 | [ 1139 | -94.520874, 1140 | 37.057369 1141 | ], 1142 | [ 1143 | -94.487915, 1144 | 37.057369 1145 | ], 1146 | [ 1147 | -94.487915, 1148 | 37.0836668 1149 | ], 1150 | [ 1151 | -94.520874, 1152 | 37.0836668 1153 | ] 1154 | ] 1155 | ] 1156 | }, 1157 | "properties": { 1158 | "proj:epsg": 3857, 1159 | "orientation": "nadir", 1160 | "height": 2500, 1161 | "width": 2500, 1162 | "datetime": "2000-02-02T00:00:00Z", 1163 | "gsd": 0.5971642834779395 1164 | }, 1165 | "assets": { 1166 | "COG": { 1167 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 1168 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C365000e4105000n.tif", 1169 | "title": "NOAA STORM COG" 1170 | } 1171 | }, 1172 | "bbox": [ 1173 | -94.520874, 1174 | 37.057369, 1175 | -94.487915, 1176 | 37.0836668 1177 | ], 1178 | "stac_extensions": [ 1179 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 1180 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 1181 | ], 1182 | "stac_version": "1.0.0" 1183 | }, 1184 | { 1185 | "id": "4610c58e-39f4-4d9d-94ba-ceddbf9ac570", 1186 | "type": "Feature", 1187 | "collection": "joplin", 1188 | "links": [], 1189 | "geometry": { 1190 | "type": "Polygon", 1191 | "coordinates": [ 1192 | [ 1193 | [ 1194 | -94.520874, 1195 | 37.1055746 1196 | ], 1197 | [ 1198 | -94.520874, 1199 | 37.0792845 1200 | ], 1201 | [ 1202 | -94.487915, 1203 | 37.0792845 1204 | ], 1205 | [ 1206 | -94.487915, 1207 | 37.1055746 1208 | ], 1209 | [ 1210 | -94.520874, 1211 | 37.1055746 1212 | ] 1213 | ] 1214 | ] 1215 | }, 1216 | "properties": { 1217 | "proj:epsg": 3857, 1218 | "orientation": "nadir", 1219 | "height": 2500, 1220 | "width": 2500, 1221 | "datetime": "2000-02-02T00:00:00Z", 1222 | "gsd": 0.5971642834779395 1223 | }, 1224 | "assets": { 1225 | "COG": { 1226 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 1227 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C365000e4107500n.tif", 1228 | "title": "NOAA STORM COG" 1229 | } 1230 | }, 1231 | "bbox": [ 1232 | -94.520874, 1233 | 37.0792845, 1234 | -94.487915, 1235 | 37.1055746 1236 | ], 1237 | "stac_extensions": [ 1238 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 1239 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 1240 | ], 1241 | "stac_version": "1.0.0" 1242 | }, 1243 | { 1244 | "id": "145fa700-16d4-4d34-98e0-7540d5c0885f", 1245 | "type": "Feature", 1246 | "collection": "joplin", 1247 | "links": [], 1248 | "geometry": { 1249 | "type": "Polygon", 1250 | "coordinates": [ 1251 | [ 1252 | [ 1253 | -94.4934082, 1254 | 37.0617526 1255 | ], 1256 | [ 1257 | -94.4934082, 1258 | 37.0354472 1259 | ], 1260 | [ 1261 | -94.4604492, 1262 | 37.0354472 1263 | ], 1264 | [ 1265 | -94.4604492, 1266 | 37.0617526 1267 | ], 1268 | [ 1269 | -94.4934082, 1270 | 37.0617526 1271 | ] 1272 | ] 1273 | ] 1274 | }, 1275 | "properties": { 1276 | "proj:epsg": 3857, 1277 | "orientation": "nadir", 1278 | "height": 2500, 1279 | "width": 2500, 1280 | "datetime": "2000-02-02T00:00:00Z", 1281 | "gsd": 0.5971642834779395 1282 | }, 1283 | "assets": { 1284 | "COG": { 1285 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 1286 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C367500e4102500n.tif", 1287 | "title": "NOAA STORM COG" 1288 | } 1289 | }, 1290 | "bbox": [ 1291 | -94.4934082, 1292 | 37.0354472, 1293 | -94.4604492, 1294 | 37.0617526 1295 | ], 1296 | "stac_extensions": [ 1297 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 1298 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 1299 | ], 1300 | "stac_version": "1.0.0" 1301 | }, 1302 | { 1303 | "id": "a89dc7b8-a580-435b-8176-d8e4386d620c", 1304 | "type": "Feature", 1305 | "collection": "joplin", 1306 | "links": [], 1307 | "geometry": { 1308 | "type": "Polygon", 1309 | "coordinates": [ 1310 | [ 1311 | [ 1312 | -94.4934082, 1313 | 37.0836668 1314 | ], 1315 | [ 1316 | -94.4934082, 1317 | 37.057369 1318 | ], 1319 | [ 1320 | -94.4604492, 1321 | 37.057369 1322 | ], 1323 | [ 1324 | -94.4604492, 1325 | 37.0836668 1326 | ], 1327 | [ 1328 | -94.4934082, 1329 | 37.0836668 1330 | ] 1331 | ] 1332 | ] 1333 | }, 1334 | "properties": { 1335 | "proj:epsg": 3857, 1336 | "orientation": "nadir", 1337 | "height": 2500, 1338 | "width": 2500, 1339 | "datetime": "2000-02-02T00:00:00Z", 1340 | "gsd": 0.5971642834779395 1341 | }, 1342 | "assets": { 1343 | "COG": { 1344 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 1345 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C367500e4105000n.tif", 1346 | "title": "NOAA STORM COG" 1347 | } 1348 | }, 1349 | "bbox": [ 1350 | -94.4934082, 1351 | 37.057369, 1352 | -94.4604492, 1353 | 37.0836668 1354 | ], 1355 | "stac_extensions": [ 1356 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 1357 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 1358 | ], 1359 | "stac_version": "1.0.0" 1360 | }, 1361 | { 1362 | "id": "386dfa13-c2b4-4ce6-8e6f-fcac73f4e64e", 1363 | "type": "Feature", 1364 | "collection": "joplin", 1365 | "links": [], 1366 | "geometry": { 1367 | "type": "Polygon", 1368 | "coordinates": [ 1369 | [ 1370 | [ 1371 | -94.4934082, 1372 | 37.1055746 1373 | ], 1374 | [ 1375 | -94.4934082, 1376 | 37.0792845 1377 | ], 1378 | [ 1379 | -94.4604492, 1380 | 37.0792845 1381 | ], 1382 | [ 1383 | -94.4604492, 1384 | 37.1055746 1385 | ], 1386 | [ 1387 | -94.4934082, 1388 | 37.1055746 1389 | ] 1390 | ] 1391 | ] 1392 | }, 1393 | "properties": { 1394 | "proj:epsg": 3857, 1395 | "orientation": "nadir", 1396 | "height": 2500, 1397 | "width": 2500, 1398 | "datetime": "2000-02-02T00:00:00Z", 1399 | "gsd": 0.5971642834779395 1400 | }, 1401 | "assets": { 1402 | "COG": { 1403 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 1404 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C367500e4107500n.tif", 1405 | "title": "NOAA STORM COG" 1406 | } 1407 | }, 1408 | "bbox": [ 1409 | -94.4934082, 1410 | 37.0792845, 1411 | -94.4604492, 1412 | 37.1055746 1413 | ], 1414 | "stac_extensions": [ 1415 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 1416 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 1417 | ], 1418 | "stac_version": "1.0.0" 1419 | }, 1420 | { 1421 | "id": "4d8a8e40-d089-4ca7-92c8-27d810ee07bf", 1422 | "type": "Feature", 1423 | "collection": "joplin", 1424 | "links": [], 1425 | "geometry": { 1426 | "type": "Polygon", 1427 | "coordinates": [ 1428 | [ 1429 | [ 1430 | -94.4631958, 1431 | 37.0617526 1432 | ], 1433 | [ 1434 | -94.4631958, 1435 | 37.0354472 1436 | ], 1437 | [ 1438 | -94.4329834, 1439 | 37.0354472 1440 | ], 1441 | [ 1442 | -94.4329834, 1443 | 37.0617526 1444 | ], 1445 | [ 1446 | -94.4631958, 1447 | 37.0617526 1448 | ] 1449 | ] 1450 | ] 1451 | }, 1452 | "properties": { 1453 | "proj:epsg": 3857, 1454 | "orientation": "nadir", 1455 | "height": 2500, 1456 | "width": 2500, 1457 | "datetime": "2000-02-02T00:00:00Z", 1458 | "gsd": 0.5971642834779395 1459 | }, 1460 | "assets": { 1461 | "COG": { 1462 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 1463 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C370000e4102500n.tif", 1464 | "title": "NOAA STORM COG" 1465 | } 1466 | }, 1467 | "bbox": [ 1468 | -94.4631958, 1469 | 37.0354472, 1470 | -94.4329834, 1471 | 37.0617526 1472 | ], 1473 | "stac_extensions": [ 1474 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 1475 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 1476 | ], 1477 | "stac_version": "1.0.0" 1478 | }, 1479 | { 1480 | "id": "f734401c-2df0-4694-a353-cdd3ea760cdc", 1481 | "type": "Feature", 1482 | "collection": "joplin", 1483 | "links": [], 1484 | "geometry": { 1485 | "type": "Polygon", 1486 | "coordinates": [ 1487 | [ 1488 | [ 1489 | -94.4631958, 1490 | 37.0836668 1491 | ], 1492 | [ 1493 | -94.4631958, 1494 | 37.057369 1495 | ], 1496 | [ 1497 | -94.4329834, 1498 | 37.057369 1499 | ], 1500 | [ 1501 | -94.4329834, 1502 | 37.0836668 1503 | ], 1504 | [ 1505 | -94.4631958, 1506 | 37.0836668 1507 | ] 1508 | ] 1509 | ] 1510 | }, 1511 | "properties": { 1512 | "proj:epsg": 3857, 1513 | "orientation": "nadir", 1514 | "height": 2500, 1515 | "width": 2500, 1516 | "datetime": "2000-02-02T00:00:00Z", 1517 | "gsd": 0.5971642834779395 1518 | }, 1519 | "assets": { 1520 | "COG": { 1521 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 1522 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C370000e4105000n.tif", 1523 | "title": "NOAA STORM COG" 1524 | } 1525 | }, 1526 | "bbox": [ 1527 | -94.4631958, 1528 | 37.057369, 1529 | -94.4329834, 1530 | 37.0836668 1531 | ], 1532 | "stac_extensions": [ 1533 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 1534 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 1535 | ], 1536 | "stac_version": "1.0.0" 1537 | }, 1538 | { 1539 | "id": "da6ef938-c58f-4bab-9d4e-89f6ae667da2", 1540 | "type": "Feature", 1541 | "collection": "joplin", 1542 | "links": [], 1543 | "geometry": { 1544 | "type": "Polygon", 1545 | "coordinates": [ 1546 | [ 1547 | [ 1548 | -94.4659424, 1549 | 37.1077651 1550 | ], 1551 | [ 1552 | -94.4659424, 1553 | 37.0814756 1554 | ], 1555 | [ 1556 | -94.4329834, 1557 | 37.0814756 1558 | ], 1559 | [ 1560 | -94.4329834, 1561 | 37.1077651 1562 | ], 1563 | [ 1564 | -94.4659424, 1565 | 37.1077651 1566 | ] 1567 | ] 1568 | ] 1569 | }, 1570 | "properties": { 1571 | "proj:epsg": 3857, 1572 | "orientation": "nadir", 1573 | "height": 2500, 1574 | "width": 2500, 1575 | "datetime": "2000-02-02T00:00:00Z", 1576 | "gsd": 0.5971642834779395 1577 | }, 1578 | "assets": { 1579 | "COG": { 1580 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 1581 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C370000e4107500n.tif", 1582 | "title": "NOAA STORM COG" 1583 | } 1584 | }, 1585 | "bbox": [ 1586 | -94.4659424, 1587 | 37.0814756, 1588 | -94.4329834, 1589 | 37.1077651 1590 | ], 1591 | "stac_extensions": [ 1592 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 1593 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 1594 | ], 1595 | "stac_version": "1.0.0" 1596 | }, 1597 | { 1598 | "id": "ad420ced-b005-472b-a6df-3838c2b74504", 1599 | "type": "Feature", 1600 | "collection": "joplin", 1601 | "links": [], 1602 | "geometry": { 1603 | "type": "Polygon", 1604 | "coordinates": [ 1605 | [ 1606 | [ 1607 | -94.43573, 1608 | 37.0617526 1609 | ], 1610 | [ 1611 | -94.43573, 1612 | 37.0354472 1613 | ], 1614 | [ 1615 | -94.402771, 1616 | 37.0354472 1617 | ], 1618 | [ 1619 | -94.402771, 1620 | 37.0617526 1621 | ], 1622 | [ 1623 | -94.43573, 1624 | 37.0617526 1625 | ] 1626 | ] 1627 | ] 1628 | }, 1629 | "properties": { 1630 | "proj:epsg": 3857, 1631 | "orientation": "nadir", 1632 | "height": 2500, 1633 | "width": 2500, 1634 | "datetime": "2000-02-02T00:00:00Z", 1635 | "gsd": 0.5971642834779395 1636 | }, 1637 | "assets": { 1638 | "COG": { 1639 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 1640 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C372500e4102500n.tif", 1641 | "title": "NOAA STORM COG" 1642 | } 1643 | }, 1644 | "bbox": [ 1645 | -94.43573, 1646 | 37.0354472, 1647 | -94.402771, 1648 | 37.0617526 1649 | ], 1650 | "stac_extensions": [ 1651 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 1652 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 1653 | ], 1654 | "stac_version": "1.0.0" 1655 | }, 1656 | { 1657 | "id": "f490b7af-0019-45e2-854b-3854d07fd063", 1658 | "type": "Feature", 1659 | "collection": "joplin", 1660 | "links": [], 1661 | "geometry": { 1662 | "type": "Polygon", 1663 | "coordinates": [ 1664 | [ 1665 | [ 1666 | -94.43573, 1667 | 37.0836668 1668 | ], 1669 | [ 1670 | -94.43573, 1671 | 37.0595608 1672 | ], 1673 | [ 1674 | -94.402771, 1675 | 37.0595608 1676 | ], 1677 | [ 1678 | -94.402771, 1679 | 37.0836668 1680 | ], 1681 | [ 1682 | -94.43573, 1683 | 37.0836668 1684 | ] 1685 | ] 1686 | ] 1687 | }, 1688 | "properties": { 1689 | "proj:epsg": 3857, 1690 | "orientation": "nadir", 1691 | "height": 2500, 1692 | "width": 2500, 1693 | "datetime": "2000-02-02T00:00:00Z", 1694 | "gsd": 0.5971642834779395 1695 | }, 1696 | "assets": { 1697 | "COG": { 1698 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 1699 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C372500e4105000n.tif", 1700 | "title": "NOAA STORM COG" 1701 | } 1702 | }, 1703 | "bbox": [ 1704 | -94.43573, 1705 | 37.0595608, 1706 | -94.402771, 1707 | 37.0836668 1708 | ], 1709 | "stac_extensions": [ 1710 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 1711 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 1712 | ], 1713 | "stac_version": "1.0.0" 1714 | }, 1715 | { 1716 | "id": "b853f353-4b72-44d5-aa44-c07dfd307138", 1717 | "type": "Feature", 1718 | "collection": "joplin", 1719 | "links": [], 1720 | "geometry": { 1721 | "type": "Polygon", 1722 | "coordinates": [ 1723 | [ 1724 | [ 1725 | -94.43573, 1726 | 37.1077651 1727 | ], 1728 | [ 1729 | -94.43573, 1730 | 37.0814756 1731 | ], 1732 | [ 1733 | -94.4055176, 1734 | 37.0814756 1735 | ], 1736 | [ 1737 | -94.4055176, 1738 | 37.1077651 1739 | ], 1740 | [ 1741 | -94.43573, 1742 | 37.1077651 1743 | ] 1744 | ] 1745 | ] 1746 | }, 1747 | "properties": { 1748 | "proj:epsg": 3857, 1749 | "orientation": "nadir", 1750 | "height": 2500, 1751 | "width": 2500, 1752 | "datetime": "2000-02-02T00:00:00Z", 1753 | "gsd": 0.5971642834779395 1754 | }, 1755 | "assets": { 1756 | "COG": { 1757 | "type": "image/tiff; application=geotiff; profile=cloud-optimized", 1758 | "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C372500e4107500n.tif", 1759 | "title": "NOAA STORM COG" 1760 | } 1761 | }, 1762 | "bbox": [ 1763 | -94.43573, 1764 | 37.0814756, 1765 | -94.4055176, 1766 | 37.1077651 1767 | ], 1768 | "stac_extensions": [ 1769 | "https://stac-extensions.github.io/eo/v1.0.0/schema.json", 1770 | "https://stac-extensions.github.io/projection/v1.0.0/schema.json" 1771 | ], 1772 | "stac_version": "1.0.0" 1773 | } 1774 | ] 1775 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | database: 4 | container_name: pgstac 5 | image: ghcr.io/stac-utils/pgstac:v0.8.5 6 | environment: 7 | - POSTGRES_USER=username 8 | - POSTGRES_PASSWORD=password 9 | - POSTGRES_DB=postgis 10 | - PGUSER=username 11 | - PGPASSWORD=password 12 | - PGDATABASE=postgis 13 | ports: 14 | - "5432:5432" 15 | command: postgres -N 500 16 | -------------------------------------------------------------------------------- /scripts/requirements.in: -------------------------------------------------------------------------------- 1 | stac-api-validator -------------------------------------------------------------------------------- /scripts/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # pip-compile 6 | # 7 | attrs==23.1.0 8 | # via 9 | # jsonschema 10 | # referencing 11 | certifi==2022.12.7 12 | # via 13 | # requests 14 | # stac-api-validator 15 | charset-normalizer==3.3.0 16 | # via requests 17 | click==8.1.7 18 | # via 19 | # stac-api-validator 20 | # stac-check 21 | # stac-validator 22 | deepdiff==6.6.0 23 | # via stac-api-validator 24 | idna==3.4 25 | # via requests 26 | iniconfig==2.0.0 27 | # via pytest 28 | jsonschema==4.19.1 29 | # via 30 | # stac-api-validator 31 | # stac-check 32 | # stac-validator 33 | jsonschema-specifications==2023.7.1 34 | # via jsonschema 35 | more-itertools==8.14.0 36 | # via stac-api-validator 37 | ordered-set==4.1.0 38 | # via deepdiff 39 | orjson==3.9.9 40 | # via pystac 41 | packaging==23.2 42 | # via pytest 43 | pluggy==1.3.0 44 | # via pytest 45 | pystac[orjson]==1.8.4 46 | # via 47 | # pystac 48 | # pystac-client 49 | # stac-api-validator 50 | pystac-client==0.6.1 51 | # via stac-api-validator 52 | pytest==7.4.2 53 | # via stac-check 54 | python-dateutil==2.8.2 55 | # via 56 | # pystac 57 | # pystac-client 58 | python-dotenv==1.0.0 59 | # via stac-check 60 | pyyaml==6.0 61 | # via 62 | # stac-api-validator 63 | # stac-check 64 | referencing==0.30.2 65 | # via 66 | # jsonschema 67 | # jsonschema-specifications 68 | requests==2.31.0 69 | # via 70 | # pystac-client 71 | # stac-api-validator 72 | # stac-check 73 | # stac-validator 74 | rpds-py==0.10.6 75 | # via 76 | # jsonschema 77 | # referencing 78 | shapely==1.8.4 79 | # via stac-api-validator 80 | six==1.16.0 81 | # via python-dateutil 82 | stac-api-validator==0.6.1 83 | # via -r requirements.in 84 | stac-check==1.3.2 85 | # via stac-api-validator 86 | stac-validator==3.3.1 87 | # via 88 | # stac-api-validator 89 | # stac-check 90 | types-setuptools==68.2.0.0 91 | # via 92 | # stac-check 93 | # stac-validator 94 | urllib3==2.0.6 95 | # via requests 96 | -------------------------------------------------------------------------------- /scripts/validate: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -e 4 | args="data/joplin/*" 5 | if [ $# -eq 1 ]; then 6 | if [ "$1" = "--pgstac" ]; then 7 | args="$args --pgstac postgres://username:password@localhost/postgis" 8 | else 9 | echo "Unknown argument: $1" 10 | exit 1 11 | fi 12 | fi 13 | 14 | cargo build 15 | cargo run -- $args & 16 | server_pid=$! 17 | set +e 18 | scripts/wait-for-it.sh localhost:7822 && \ 19 | stac-api-validator \ 20 | --root-url http://localhost:7822 \ 21 | --conformance core \ 22 | --conformance features \ 23 | --collection joplin \ 24 | --geometry '{"type":"Point","coordinates":[-94.5,37.05]}' 25 | status=$? 26 | set -e 27 | kill $server_pid 28 | exit $status 29 | -------------------------------------------------------------------------------- /scripts/wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | # https://github.com/vishnubob/wait-for-it/blob/81b1373f17855a4dc21156cfe1694c31d7d1792e/wait-for-it.sh 4 | 5 | WAITFORIT_cmdname=${0##*/} 6 | 7 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 8 | 9 | usage() 10 | { 11 | cat << USAGE >&2 12 | Usage: 13 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 14 | -h HOST | --host=HOST Host or IP under test 15 | -p PORT | --port=PORT TCP port under test 16 | Alternatively, you specify the host and port as host:port 17 | -s | --strict Only execute subcommand if the test succeeds 18 | -q | --quiet Don't output any status messages 19 | -t TIMEOUT | --timeout=TIMEOUT 20 | Timeout in seconds, zero for no timeout 21 | -- COMMAND ARGS Execute command with args after the test finishes 22 | USAGE 23 | exit 1 24 | } 25 | 26 | wait_for() 27 | { 28 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 29 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 30 | else 31 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 32 | fi 33 | WAITFORIT_start_ts=$(date +%s) 34 | while : 35 | do 36 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 37 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 38 | WAITFORIT_result=$? 39 | else 40 | (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 41 | WAITFORIT_result=$? 42 | fi 43 | if [[ $WAITFORIT_result -eq 0 ]]; then 44 | WAITFORIT_end_ts=$(date +%s) 45 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 46 | break 47 | fi 48 | sleep 1 49 | done 50 | return $WAITFORIT_result 51 | } 52 | 53 | wait_for_wrapper() 54 | { 55 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 56 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 57 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 58 | else 59 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 60 | fi 61 | WAITFORIT_PID=$! 62 | trap "kill -INT -$WAITFORIT_PID" INT 63 | wait $WAITFORIT_PID 64 | WAITFORIT_RESULT=$? 65 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 66 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 67 | fi 68 | return $WAITFORIT_RESULT 69 | } 70 | 71 | # process arguments 72 | while [[ $# -gt 0 ]] 73 | do 74 | case "$1" in 75 | *:* ) 76 | WAITFORIT_hostport=(${1//:/ }) 77 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 78 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 79 | shift 1 80 | ;; 81 | --child) 82 | WAITFORIT_CHILD=1 83 | shift 1 84 | ;; 85 | -q | --quiet) 86 | WAITFORIT_QUIET=1 87 | shift 1 88 | ;; 89 | -s | --strict) 90 | WAITFORIT_STRICT=1 91 | shift 1 92 | ;; 93 | -h) 94 | WAITFORIT_HOST="$2" 95 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 96 | shift 2 97 | ;; 98 | --host=*) 99 | WAITFORIT_HOST="${1#*=}" 100 | shift 1 101 | ;; 102 | -p) 103 | WAITFORIT_PORT="$2" 104 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 105 | shift 2 106 | ;; 107 | --port=*) 108 | WAITFORIT_PORT="${1#*=}" 109 | shift 1 110 | ;; 111 | -t) 112 | WAITFORIT_TIMEOUT="$2" 113 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 114 | shift 2 115 | ;; 116 | --timeout=*) 117 | WAITFORIT_TIMEOUT="${1#*=}" 118 | shift 1 119 | ;; 120 | --) 121 | shift 122 | WAITFORIT_CLI=("$@") 123 | break 124 | ;; 125 | --help) 126 | usage 127 | ;; 128 | *) 129 | echoerr "Unknown argument: $1" 130 | usage 131 | ;; 132 | esac 133 | done 134 | 135 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 136 | echoerr "Error: you need to provide a host and port to test." 137 | usage 138 | fi 139 | 140 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 141 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 142 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 143 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 144 | 145 | # Check to see if timeout is from busybox? 146 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 147 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 148 | 149 | WAITFORIT_BUSYTIMEFLAG="" 150 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 151 | WAITFORIT_ISBUSY=1 152 | # Check if busybox timeout uses -t flag 153 | # (recent Alpine versions don't support -t anymore) 154 | if timeout &>/dev/stdout | grep -q -e '-t '; then 155 | WAITFORIT_BUSYTIMEFLAG="-t" 156 | fi 157 | else 158 | WAITFORIT_ISBUSY=0 159 | fi 160 | 161 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 162 | wait_for 163 | WAITFORIT_RESULT=$? 164 | exit $WAITFORIT_RESULT 165 | else 166 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 167 | wait_for_wrapper 168 | WAITFORIT_RESULT=$? 169 | else 170 | wait_for 171 | WAITFORIT_RESULT=$? 172 | fi 173 | fi 174 | 175 | if [[ $WAITFORIT_CLI != "" ]]; then 176 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 177 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 178 | exit $WAITFORIT_RESULT 179 | fi 180 | exec "${WAITFORIT_CLI[@]}" 181 | else 182 | exit $WAITFORIT_RESULT 183 | fi 184 | -------------------------------------------------------------------------------- /stac-api-backend/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "stac-api-backend" 3 | version = "0.1.0" 4 | authors = ["Pete Gadomski "] 5 | edition = "2021" 6 | description = "STAC API backend" 7 | homepage = "https://github.com/gadomski/stac-server-rs" 8 | repository = "https://github.com/gadomski/stac-server-rs" 9 | license = "MIT OR Apache-2.0" 10 | keywords = ["geospatial", "stac", "metadata", "geo", "raster"] 11 | categories = ["science", "data-structures"] 12 | 13 | [features] 14 | memory = ["stac/geo"] 15 | pgstac = ["dep:bb8", "dep:bb8-postgres", "dep:pgstac", "dep:tokio-postgres"] 16 | 17 | [dependencies] 18 | async-trait = "0.1" 19 | bb8 = { version = "0.8", optional = true } 20 | bb8-postgres = { version = "0.8", optional = true } 21 | http = "1" 22 | pgstac = { version = "0.0.5", optional = true } 23 | serde = { version = "1", features = ["derive"] } 24 | serde_json = "1" 25 | serde_urlencoded = "0.7" 26 | stac = { version = "0.5", features = ["schemars"] } 27 | stac-api = { version = "0.3", features = ["schemars"] } 28 | thiserror = "1" 29 | tokio-postgres = { version = "0.7", optional = true } 30 | url = "2" 31 | 32 | [dev-dependencies] 33 | stac-validate = { version = "0.1" } 34 | tokio = { version = "1.24", features = ["rt", "macros"] } 35 | tokio-test = { version = "0.4" } 36 | -------------------------------------------------------------------------------- /stac-api-backend/src/api/api.rs: -------------------------------------------------------------------------------- 1 | use crate::{Backend, Error, Result, DEFAULT_SERVICE_DESC_MEDIA_TYPE}; 2 | use stac::Catalog; 3 | use stac_api::UrlBuilder; 4 | 5 | /// A structure for generating STAC API endpoints. 6 | #[derive(Clone, Debug)] 7 | pub struct Api { 8 | /// The backend for this API. 9 | pub backend: B, 10 | 11 | /// The url builder for this api. 12 | pub url_builder: UrlBuilder, 13 | 14 | /// If true, this API will include links for the [Features](https://github.com/radiantearth/stac-api-spec/tree/main/ogcapi-features) endpoints. 15 | /// 16 | /// We don't support _just_ collections. 17 | pub features: bool, 18 | 19 | /// The media type for the `service-desc` endpoint. 20 | /// 21 | /// Defaults to [DEFAULT_SERVICE_DESC_MEDIA_TYPE]. 22 | pub service_desc_media_type: String, 23 | 24 | /// The base catalog for this api. 25 | pub catalog: Catalog, 26 | } 27 | 28 | impl Api 29 | where 30 | Error: From<::Error>, 31 | { 32 | /// Creates a new endpoint generator with the given backend, catalog, and root url. 33 | /// 34 | /// The catalog is used as the root endpoint. By default, the API will 35 | /// include links for 36 | /// [Features](https://github.com/radiantearth/stac-api-spec/tree/main/ogcapi-features) 37 | /// -- set `features` to `False` to disable this behavior. 38 | pub fn new(backend: B, catalog: Catalog, url: &str) -> Result> { 39 | Ok(Api { 40 | backend, 41 | catalog, 42 | features: true, 43 | service_desc_media_type: DEFAULT_SERVICE_DESC_MEDIA_TYPE.to_string(), 44 | url_builder: UrlBuilder::new(url)?, 45 | }) 46 | } 47 | 48 | /// Sets the value of `features`. 49 | pub fn features(mut self, features: bool) -> Api { 50 | self.features = features; 51 | self 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /stac-api-backend/src/api/conformance.rs: -------------------------------------------------------------------------------- 1 | use super::Api; 2 | use crate::{Backend, Error}; 3 | use stac_api::{ 4 | Conformance, COLLECTIONS_URI, CORE_URI, FEATURES_URI, GEOJSON_URI, OGC_API_FEATURES_URI, 5 | }; 6 | 7 | impl Api 8 | where 9 | B: Backend, 10 | Error: From<::Error>, 11 | { 12 | /// Returns the conformance structure. 13 | pub fn conformance(&self) -> Conformance { 14 | let mut conforms_to = vec![CORE_URI.to_string()]; 15 | if self.features { 16 | conforms_to.extend([ 17 | FEATURES_URI.to_string(), 18 | COLLECTIONS_URI.to_string(), 19 | OGC_API_FEATURES_URI.to_string(), 20 | GEOJSON_URI.to_string(), 21 | ]) 22 | } 23 | Conformance { conforms_to } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /stac-api-backend/src/api/features.rs: -------------------------------------------------------------------------------- 1 | use super::Api; 2 | use crate::{Backend, Error, Items, Result}; 3 | use http::Method; 4 | use serde_json::Value; 5 | use stac::{Collection, Item, Link}; 6 | use stac_api::{Collections, ItemCollection}; 7 | 8 | impl Api 9 | where 10 | B: Backend, 11 | Error: From<::Error>, 12 | { 13 | /// Returns collections. 14 | pub async fn collections(&self) -> Result { 15 | // TODO collection pagination 16 | // https://github.com/radiantearth/stac-api-spec/tree/release/v1.0.0/ogcapi-features#collection-pagination 17 | let mut collections = self.backend.collections().await?; 18 | for collection in &mut collections { 19 | collection.links.extend([ 20 | Link::root(self.url_builder.root()).title(self.catalog.title.clone()), 21 | Link::parent(self.url_builder.root()).title(self.catalog.title.clone()), 22 | Link::self_(self.url_builder.collection(&collection.id)?) 23 | .title(collection.title.clone()), 24 | Link::new(self.url_builder.items(&collection.id)?, "items") 25 | .title("Items".to_string()), 26 | ]); 27 | } 28 | let links = vec![ 29 | Link::root(self.url_builder.root()).title(self.catalog.title.clone()), 30 | Link::self_(self.url_builder.collections()).title("Collections".to_string()), 31 | ]; 32 | Ok(Collections { 33 | collections, 34 | links, 35 | additional_fields: Default::default(), 36 | }) 37 | } 38 | 39 | /// Returns a collection or None. 40 | pub async fn collection(&self, id: &str) -> Result> { 41 | if let Some(mut collection) = self.backend.collection(id).await? { 42 | collection.links.extend([ 43 | Link::root(self.url_builder.root()).title(self.catalog.title.clone()), 44 | Link::parent(self.url_builder.root()).title(self.catalog.title.clone()), 45 | Link::self_(self.url_builder.collection(&collection.id)?) 46 | .title(collection.title.clone()), 47 | Link::new(self.url_builder.items(&collection.id)?, "items") 48 | .title("Items".to_string()) 49 | .geojson(), 50 | ]); 51 | Ok(Some(collection)) 52 | } else { 53 | Ok(None) 54 | } 55 | } 56 | 57 | /// Returns items. 58 | pub async fn items(&self, id: &str, items: Items) -> Result> { 59 | if let Some(page) = self.backend.items(id, items.clone()).await? { 60 | let mut url = self.url_builder.items(id)?; 61 | 62 | let get_items = stac_api::GetItems::try_from(items.items)?; 63 | let query = serde_urlencoded::to_string(&get_items)?; 64 | if !query.is_empty() { 65 | url.set_query(Some(&query)); 66 | } 67 | let mut item_collection = 68 | page.into_item_collection(&url, &Method::GET, items.paging)?; 69 | item_collection.links.extend([ 70 | Link::root(self.url_builder.root()).title(self.catalog.title.clone()), 71 | Link::collection(self.url_builder.collection(id)?), 72 | ]); 73 | 74 | for item in &mut item_collection.items { 75 | let mut links = vec![ 76 | serde_json::to_value( 77 | Link::root(self.url_builder.root()).title(self.catalog.title.clone()), 78 | )?, 79 | serde_json::to_value(Link::parent(self.url_builder.collection(id)?))?, 80 | serde_json::to_value(Link::collection(self.url_builder.collection(id)?))?, 81 | ]; 82 | if let Some(item_id) = item.get("id").and_then(|value| value.as_str()) { 83 | links.push(serde_json::to_value( 84 | Link::self_(self.url_builder.item(id, item_id)?).geojson(), 85 | )?); 86 | } 87 | if let Some(existing_links) = 88 | item.get_mut("links").and_then(|value| value.as_array_mut()) 89 | { 90 | existing_links.extend(links); 91 | } else { 92 | let _ = item.insert("links".to_string(), Value::Array(links)); 93 | } 94 | } 95 | Ok(Some(item_collection)) 96 | } else { 97 | Ok(None) 98 | } 99 | } 100 | 101 | /// Returns an item. 102 | pub async fn item(&self, collection_id: &str, id: &str) -> Result> { 103 | if let Some(mut item) = self.backend.item(collection_id, id).await? { 104 | let collection_url = self.url_builder.collection(collection_id)?; 105 | item.links.extend([ 106 | Link::root(self.url_builder.root()).title(self.catalog.title.clone()), 107 | Link::parent(collection_url.clone()), 108 | Link::collection(collection_url), 109 | Link::self_(self.url_builder.item(collection_id, id)?).geojson(), 110 | ]); 111 | Ok(Some(item)) 112 | } else { 113 | Ok(None) 114 | } 115 | } 116 | } 117 | 118 | #[cfg(all(test, feature = "memory"))] 119 | mod tests { 120 | use super::super::tests; 121 | use crate::{assert_link, memory::Paging, Backend, Items}; 122 | use stac::{Collection, Item, Links}; 123 | use stac_validate::Validate; 124 | 125 | #[tokio::test] 126 | async fn root_links_with_features() { 127 | let mut api = tests::api(); 128 | api.features = true; 129 | let root = api.root().await.unwrap(); 130 | 131 | assert_link!( 132 | root.catalog, 133 | "data", 134 | "http://stac-api-backend.test/collections", 135 | "application/json" 136 | ); 137 | assert_link!( 138 | root.catalog, 139 | "conformance", 140 | "http://stac-api-backend.test/conformance", 141 | "application/json" 142 | ); 143 | } 144 | 145 | #[tokio::test] 146 | async fn root_links_without_features() { 147 | let mut api = tests::api(); 148 | api.features = false; 149 | let root = api.root().await.unwrap(); 150 | assert!(root.catalog.link("data").is_none()); 151 | assert!(root.catalog.link("conformance").is_none()); 152 | } 153 | 154 | #[tokio::test] 155 | async fn collections_links() { 156 | let collections = tests::api().collections().await.unwrap(); 157 | assert_link!( 158 | collections, 159 | "root", 160 | "http://stac-api-backend.test/", 161 | "application/json" 162 | ); 163 | assert_link!( 164 | collections, 165 | "self", 166 | "http://stac-api-backend.test/collections", 167 | "application/json" 168 | ); 169 | } 170 | 171 | #[tokio::test] 172 | async fn collections() { 173 | let mut api = tests::api(); 174 | assert!(api.collections().await.unwrap().collections.is_empty()); 175 | let _ = api 176 | .backend 177 | .add_collection(Collection::new("an-id", "a description")) 178 | .await 179 | .unwrap(); 180 | assert_eq!(api.collections().await.unwrap().collections.len(), 1); 181 | } 182 | 183 | #[tokio::test] 184 | async fn collection_miss() { 185 | assert!(tests::api().collection("id").await.unwrap().is_none()); 186 | } 187 | 188 | #[tokio::test] 189 | async fn collection() { 190 | let mut api = tests::api(); 191 | let _ = api 192 | .backend 193 | .add_collection(Collection::new("an-id", "a description")) 194 | .await 195 | .unwrap(); 196 | let collection = api.collection("an-id").await.unwrap().unwrap(); 197 | assert_link!( 198 | collection, 199 | "root", 200 | "http://stac-api-backend.test/", 201 | "application/json" 202 | ); 203 | assert_link!( 204 | collection, 205 | "parent", 206 | "http://stac-api-backend.test/", 207 | "application/json" 208 | ); 209 | assert_link!( 210 | collection, 211 | "self", 212 | "http://stac-api-backend.test/collections/an-id", 213 | "application/json" 214 | ); 215 | assert_link!( 216 | collection, 217 | "items", 218 | "http://stac-api-backend.test/collections/an-id/items", 219 | "application/geo+json" 220 | ); 221 | collection.validate().unwrap(); 222 | } 223 | 224 | #[tokio::test] 225 | async fn items_miss() { 226 | let mut api = tests::api(); 227 | assert!(api 228 | .items("an-id", Items::default()) 229 | .await 230 | .unwrap() 231 | .is_none()); 232 | let _ = api 233 | .backend 234 | .add_collection(Collection::new("an-id", "a description")) 235 | .await 236 | .unwrap(); 237 | assert!(api 238 | .items("an-id", Items::default()) 239 | .await 240 | .unwrap() 241 | .unwrap() 242 | .items 243 | .is_empty()); 244 | 245 | let item = Item::new("item-id").collection("an-id"); 246 | api.backend.add_item(item).await.unwrap(); 247 | 248 | let items = api.items("an-id", Items::default()).await.unwrap().unwrap(); 249 | 250 | assert_link!( 251 | items, 252 | "root", 253 | "http://stac-api-backend.test/", 254 | "application/json" 255 | ); 256 | assert_link!( 257 | items, 258 | "self", 259 | "http://stac-api-backend.test/collections/an-id/items", 260 | "application/geo+json" 261 | ); 262 | assert_link!( 263 | items, 264 | "collection", 265 | "http://stac-api-backend.test/collections/an-id", 266 | "application/json" 267 | ); 268 | 269 | let item: Item = items.items[0].clone().try_into().unwrap(); 270 | assert_link!( 271 | item, 272 | "root", 273 | "http://stac-api-backend.test/", 274 | "application/json" 275 | ); 276 | assert_link!( 277 | item, 278 | "parent", 279 | "http://stac-api-backend.test/collections/an-id", 280 | "application/json" 281 | ); 282 | assert_link!( 283 | item, 284 | "collection", 285 | "http://stac-api-backend.test/collections/an-id", 286 | "application/json" 287 | ); 288 | assert_link!( 289 | item, 290 | "self", 291 | "http://stac-api-backend.test/collections/an-id/items/item-id", 292 | "application/geo+json" 293 | ); 294 | item.validate().unwrap(); 295 | } 296 | 297 | #[tokio::test] 298 | async fn item_paging() { 299 | let mut api = tests::api(); 300 | let _ = api 301 | .backend 302 | .add_collection(Collection::new("an-id", "a description")) 303 | .await 304 | .unwrap(); 305 | let item_a = Item::new("item-a").collection("an-id"); 306 | let item_b = Item::new("item-b").collection("an-id"); 307 | api.backend.add_items(vec![item_a, item_b]).await.unwrap(); 308 | let mut items: Items = Items::default(); 309 | items.paging.skip = Some(0); 310 | items.paging.take = Some(1); 311 | let items = api.items("an-id", items).await.unwrap().unwrap(); 312 | assert_eq!(items.items.len(), 1); 313 | assert_link!( 314 | items, 315 | "next", 316 | "http://stac-api-backend.test/collections/an-id/items?skip=1&take=1", 317 | "application/geo+json" 318 | ) 319 | } 320 | 321 | #[tokio::test] 322 | async fn item() { 323 | let mut api = tests::api(); 324 | let _ = api 325 | .backend 326 | .add_collection(Collection::new("an-id", "a description")) 327 | .await 328 | .unwrap(); 329 | let item = Item::new("item-id").collection("an-id"); 330 | api.backend.add_item(item).await.unwrap(); 331 | let item = api.item("an-id", "item-id").await.unwrap().unwrap(); 332 | assert_link!( 333 | item, 334 | "root", 335 | "http://stac-api-backend.test/", 336 | "application/json" 337 | ); 338 | assert_link!( 339 | item, 340 | "parent", 341 | "http://stac-api-backend.test/collections/an-id", 342 | "application/json" 343 | ); 344 | assert_link!( 345 | item, 346 | "collection", 347 | "http://stac-api-backend.test/collections/an-id", 348 | "application/json" 349 | ); 350 | assert_link!( 351 | item, 352 | "self", 353 | "http://stac-api-backend.test/collections/an-id/items/item-id", 354 | "application/geo+json" 355 | ); 356 | item.validate().unwrap(); 357 | } 358 | } 359 | -------------------------------------------------------------------------------- /stac-api-backend/src/api/mod.rs: -------------------------------------------------------------------------------- 1 | mod api; 2 | mod conformance; 3 | mod features; 4 | mod root; 5 | 6 | pub use api::Api; 7 | 8 | /// The default media type for the `service-desc` links. 9 | pub const DEFAULT_SERVICE_DESC_MEDIA_TYPE: &str = "application/vnd.oai.openapi+json;version=3.1"; 10 | 11 | #[cfg(all(test, feature = "memory"))] 12 | mod tests { 13 | use super::Api; 14 | use crate::memory::MemoryBackend; 15 | use stac::Catalog; 16 | 17 | pub(crate) fn api() -> Api { 18 | Api::new( 19 | MemoryBackend::new(), 20 | Catalog::new("test-catalog", "A catalog for testing"), 21 | "http://stac-api-backend.test", 22 | ) 23 | .unwrap() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /stac-api-backend/src/api/root.rs: -------------------------------------------------------------------------------- 1 | use crate::{Api, Backend, Error, Result}; 2 | use stac::Link; 3 | use stac_api::Root; 4 | 5 | impl Api 6 | where 7 | B: Backend, 8 | Error: From<::Error>, 9 | { 10 | /// Returns the [root endpoint](https://github.com/radiantearth/stac-api-spec/tree/main/core#endpoints). 11 | pub async fn root(&self) -> Result { 12 | let mut catalog = self.catalog.clone(); 13 | catalog.links.extend([ 14 | Link::root(self.url_builder.root()), 15 | Link::self_(self.url_builder.root()), 16 | Link::new(self.url_builder.service_desc(), "service-desc") 17 | .r#type(self.service_desc_media_type.clone()), 18 | Link::new( 19 | format!("{}.html", self.url_builder.service_desc()), 20 | "service-doc", 21 | ) 22 | .r#type("text/html".to_string()), 23 | ]); 24 | if self.features { 25 | catalog.links.push( 26 | Link::new(self.url_builder.collections(), "data") 27 | .json() 28 | .title("Collections".to_string()), 29 | ); 30 | catalog.links.push( 31 | Link::new(self.url_builder.conformance(), "conformance") 32 | .json() 33 | .title("Conformance".to_string()), 34 | ); 35 | } 36 | for collection in self.backend.collections().await? { 37 | catalog.links.push( 38 | Link::child(self.url_builder.collection(&collection.id)?).title(collection.title), 39 | ) 40 | } 41 | Ok(Root { 42 | catalog, 43 | conformance: self.conformance(), 44 | }) 45 | } 46 | } 47 | 48 | #[cfg(all(test, feature = "memory"))] 49 | mod tests { 50 | use super::super::tests; 51 | use crate::{assert_link, Backend, DEFAULT_SERVICE_DESC_MEDIA_TYPE}; 52 | use stac::{Collection, Links}; 53 | use stac_api::{COLLECTIONS_URI, CORE_URI, FEATURES_URI, GEOJSON_URI, OGC_API_FEATURES_URI}; 54 | use stac_validate::Validate; 55 | 56 | #[tokio::test] 57 | async fn default_conformance_classes() { 58 | let root = tests::api().root().await.unwrap(); 59 | for uri in [ 60 | CORE_URI, 61 | FEATURES_URI, 62 | COLLECTIONS_URI, 63 | OGC_API_FEATURES_URI, 64 | GEOJSON_URI, 65 | ] { 66 | assert!( 67 | root.conformance.conforms_to.contains(&uri.to_string()), 68 | "does not conform to {}", 69 | uri 70 | ); 71 | } 72 | } 73 | 74 | #[tokio::test] 75 | async fn is_valid() { 76 | let root = tests::api().root().await.unwrap(); 77 | root.catalog.validate().unwrap(); 78 | } 79 | 80 | #[tokio::test] 81 | async fn links() { 82 | let root = tests::api().root().await.unwrap(); 83 | assert_link!( 84 | root.catalog, 85 | "root", 86 | "http://stac-api-backend.test/", 87 | "application/json" 88 | ); 89 | assert_link!( 90 | root.catalog, 91 | "self", 92 | "http://stac-api-backend.test/", 93 | "application/json" 94 | ); 95 | assert_link!( 96 | root.catalog, 97 | "service-desc", 98 | "http://stac-api-backend.test/api", 99 | DEFAULT_SERVICE_DESC_MEDIA_TYPE 100 | ); 101 | assert_link!( 102 | root.catalog, 103 | "service-doc", 104 | "http://stac-api-backend.test/api.html", 105 | "text/html" 106 | ); 107 | } 108 | 109 | #[tokio::test] 110 | async fn child() { 111 | let mut api = tests::api(); 112 | let _ = api 113 | .backend 114 | .add_collection(Collection::new("an-id", "a description")) 115 | .await 116 | .unwrap(); 117 | let root = api.root().await.unwrap(); 118 | assert_eq!(root.catalog.iter_child_links().count(), 1); 119 | assert_link!( 120 | root.catalog, 121 | "child", 122 | "http://stac-api-backend.test/collections/an-id", 123 | "application/json" 124 | ); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /stac-api-backend/src/backend.rs: -------------------------------------------------------------------------------- 1 | use crate::{Items, Page}; 2 | use async_trait::async_trait; 3 | use serde::{de::DeserializeOwned, Serialize}; 4 | use stac::{Collection, Item}; 5 | use std::fmt::Debug; 6 | 7 | /// A STAC API backend builds each STAC API endpoint. 8 | #[async_trait] 9 | pub trait Backend: Send + Sync + Clone + 'static { 10 | /// The error type returned by the backend. 11 | type Error: std::error::Error; 12 | 13 | /// The paging object. 14 | /// 15 | /// Some might use a token, some might use a skip+take, some might do something else. 16 | type Paging: Debug + Clone + Serialize + Default + DeserializeOwned + Send + Sync; 17 | 18 | /// Returns all collections in this backend. 19 | async fn collections(&self) -> Result, Self::Error>; 20 | 21 | /// Returns a single collection. 22 | async fn collection(&self, id: &str) -> Result, Self::Error>; 23 | 24 | /// Returns items. 25 | async fn items( 26 | &self, 27 | id: &str, 28 | items: Items, 29 | ) -> Result>, Self::Error>; 30 | 31 | /// Returns an item. 32 | async fn item(&self, collection_id: &str, id: &str) -> Result, Self::Error>; 33 | 34 | /// Adds a new collection to this backend. 35 | async fn add_collection( 36 | &mut self, 37 | collection: Collection, 38 | ) -> Result, Self::Error>; 39 | 40 | /// Adds or updates a collection in this backend. 41 | async fn upsert_collection( 42 | &mut self, 43 | collection: Collection, 44 | ) -> Result, Self::Error>; 45 | 46 | /// Deletes a collection and its items. 47 | async fn delete_collection(&mut self, id: &str) -> Result<(), Self::Error>; 48 | 49 | /// Adds new items to this backend. 50 | async fn add_items(&mut self, items: Vec) -> Result<(), Self::Error>; 51 | 52 | /// Adds or updates items in this backend. 53 | async fn upsert_items(&mut self, items: Vec) -> Result<(), Self::Error>; 54 | 55 | /// Adds a new item to this backend. 56 | async fn add_item(&mut self, item: Item) -> Result<(), Self::Error>; 57 | } 58 | -------------------------------------------------------------------------------- /stac-api-backend/src/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | /// A crate-specific error type. 4 | #[derive(Debug, Error)] 5 | pub enum Error { 6 | /// An error originating from the backend. 7 | #[error("backend error: {0}")] 8 | Backend(Box), 9 | 10 | /// [serde_json::Error] 11 | #[error(transparent)] 12 | SerdeJson(#[from] serde_json::Error), 13 | 14 | /// [serde_urlencoded::ser::Error] 15 | #[error(transparent)] 16 | SerdeUrlencodedSer(#[from] serde_urlencoded::ser::Error), 17 | 18 | /// [stac::Error] 19 | #[error(transparent)] 20 | Stac(#[from] stac::Error), 21 | 22 | /// [stac_api::Error] 23 | #[error(transparent)] 24 | StacApi(#[from] stac_api::Error), 25 | 26 | /// [url::ParseError] 27 | #[error(transparent)] 28 | UrlParse(#[from] url::ParseError), 29 | } 30 | -------------------------------------------------------------------------------- /stac-api-backend/src/items.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use std::fmt::Debug; 3 | 4 | /// A query for items. 5 | #[derive(Clone, Debug, Default, Serialize)] 6 | pub struct Items

7 | where 8 | P: Debug + Clone + Serialize + Default, 9 | { 10 | #[serde(flatten)] 11 | /// The items query. 12 | pub items: stac_api::Items, 13 | 14 | #[serde(flatten)] 15 | /// The backend-specific paging structure 16 | pub paging: P, 17 | } 18 | 19 | /// A get query for items. 20 | #[derive(Clone, Debug, Default, Serialize)] 21 | pub struct GetItems

22 | where 23 | P: Debug + Clone + Serialize + Default, 24 | { 25 | #[serde(flatten)] 26 | /// The items query. 27 | pub get_items: stac_api::GetItems, 28 | 29 | #[serde(flatten)] 30 | /// The backend-specific paging structure 31 | pub paging: P, 32 | } 33 | -------------------------------------------------------------------------------- /stac-api-backend/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! An opinionated backend for STAC API servers. 2 | //! 3 | //! The [STAC API specification](https://github.com/radiantearth/stac-api-spec) 4 | //! describes how [STAC](https://github.com/radiantearth/stac-spec) objects 5 | //! should be served over the network. This crate defines an interface for 6 | //! fetching STAC objects from storage, and providing JSON endpoints to a STAC 7 | //! API server. 8 | //! 9 | //! The goal of this crate is to provide an abstraction layer between actual 10 | //! server implementations, which might vary from framework to framework, and 11 | //! their backends. This crate is **opinionated** because it sacrifices 12 | //! flexibility in favor of enforcing its chosen interface. 13 | 14 | #![deny( 15 | elided_lifetimes_in_paths, 16 | explicit_outlives_requirements, 17 | keyword_idents, 18 | macro_use_extern_crate, 19 | meta_variable_misuse, 20 | missing_abi, 21 | missing_debug_implementations, 22 | missing_docs, 23 | non_ascii_idents, 24 | noop_method_call, 25 | pointer_structural_match, 26 | rust_2021_incompatible_closure_captures, 27 | rust_2021_incompatible_or_patterns, 28 | rust_2021_prefixes_incompatible_syntax, 29 | rust_2021_prelude_collisions, 30 | single_use_lifetimes, 31 | trivial_casts, 32 | trivial_numeric_casts, 33 | unreachable_pub, 34 | unsafe_code, 35 | unsafe_op_in_unsafe_fn, 36 | unused_crate_dependencies, 37 | unused_extern_crates, 38 | unused_import_braces, 39 | unused_lifetimes, 40 | unused_qualifications, 41 | unused_results 42 | )] 43 | 44 | mod api; 45 | mod backend; 46 | mod error; 47 | mod items; 48 | #[cfg(feature = "memory")] 49 | mod memory; 50 | mod page; 51 | #[cfg(feature = "pgstac")] 52 | mod pgstac; 53 | 54 | #[cfg(feature = "pgstac")] 55 | pub use crate::pgstac::PgstacBackend; 56 | #[cfg(feature = "memory")] 57 | pub use memory::MemoryBackend; 58 | pub use { 59 | api::{Api, DEFAULT_SERVICE_DESC_MEDIA_TYPE}, 60 | backend::Backend, 61 | error::Error, 62 | items::{GetItems, Items}, 63 | page::Page, 64 | }; 65 | 66 | /// A crate-specific result type. 67 | pub type Result = std::result::Result; 68 | 69 | #[cfg(test)] 70 | mod tests { 71 | use {stac_validate as _, tokio as _, tokio_test as _}; 72 | 73 | #[macro_export] 74 | macro_rules! assert_link { 75 | ($links:expr, $rel:expr, $href:expr, $type: expr) => {{ 76 | use stac::Links; 77 | let link = $links.link($rel).unwrap(); 78 | assert_eq!(link.href, $href); 79 | assert_eq!(link.r#type.as_ref().unwrap(), $type); 80 | }}; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /stac-api-backend/src/memory.rs: -------------------------------------------------------------------------------- 1 | use crate::{Backend, Items, Page}; 2 | use async_trait::async_trait; 3 | use serde::{Deserialize, Serialize}; 4 | use stac::{Collection, Item, Links}; 5 | use stac_api::ItemCollection; 6 | use std::{ 7 | collections::BTreeMap, 8 | sync::{Arc, RwLock}, 9 | }; 10 | use thiserror::Error; 11 | 12 | const DEFAULT_TAKE: usize = 20; 13 | 14 | #[derive(Error, Debug)] 15 | pub enum Error { 16 | #[error("no collection id={0}")] 17 | CollectionNotFound(String), 18 | 19 | #[error("no collection set on item with id={}", .0.id)] 20 | NoCollection(Item), 21 | 22 | #[error(transparent)] 23 | ParseIntError(#[from] std::num::ParseIntError), 24 | 25 | #[error(transparent)] 26 | Stac(#[from] stac::Error), 27 | 28 | #[error(transparent)] 29 | StacApi(#[from] stac_api::Error), 30 | 31 | #[error(transparent)] 32 | TryInt(#[from] std::num::TryFromIntError), 33 | } 34 | 35 | type Result = std::result::Result; 36 | 37 | /// A backend that stores its collections and items in memory. 38 | /// 39 | /// Used mostly for testing. 40 | #[derive(Clone, Debug)] 41 | pub struct MemoryBackend { 42 | collections: Arc>>, 43 | items: Arc>>>, 44 | take: usize, 45 | } 46 | 47 | #[derive(Default, Clone, Debug, Deserialize, Serialize)] 48 | pub struct Paging { 49 | /// The number of items to skip. 50 | pub skip: Option, 51 | 52 | /// The number of items to return. 53 | pub take: Option, 54 | } 55 | 56 | impl MemoryBackend { 57 | /// Creates a new memory backend. 58 | /// 59 | /// # Examples 60 | /// 61 | /// ``` 62 | /// use stac_api_backend::MemoryBackend; 63 | /// let backend = MemoryBackend::new(); 64 | /// ``` 65 | pub fn new() -> MemoryBackend { 66 | MemoryBackend { 67 | collections: Arc::new(RwLock::new(BTreeMap::new())), 68 | items: Arc::new(RwLock::new(BTreeMap::new())), 69 | take: DEFAULT_TAKE, 70 | } 71 | } 72 | } 73 | 74 | #[async_trait] 75 | impl Backend for MemoryBackend { 76 | type Error = Error; 77 | type Paging = Paging; 78 | 79 | async fn collections(&self) -> Result> { 80 | let collections = self.collections.read().unwrap(); 81 | Ok(collections.values().cloned().collect()) 82 | } 83 | 84 | async fn collection(&self, id: &str) -> Result> { 85 | let collections = self.collections.read().unwrap(); 86 | Ok(collections.get(id).cloned()) 87 | } 88 | 89 | async fn items(&self, id: &str, query: Items) -> Result>> { 90 | let skip = query.paging.skip.unwrap_or(0); 91 | let mut take = query.paging.take.unwrap_or(self.take); 92 | if let Some(limit) = query.items.limit { 93 | let limit: usize = limit.try_into()?; 94 | if limit < take { 95 | take = limit; 96 | } 97 | } 98 | let items = self.items.read().unwrap(); 99 | if let Some(items) = items.get(id) { 100 | let bbox = query 101 | .items 102 | .bbox 103 | .as_ref() 104 | .map(|bbox| stac::geo::bbox(bbox)) 105 | .transpose()?; 106 | let datetime = query 107 | .items 108 | .datetime 109 | .as_ref() 110 | .map(|datetime| stac::datetime::parse(datetime)) 111 | .transpose()?; 112 | let items: Vec<_> = items 113 | .iter() 114 | .filter(|item| { 115 | bbox.map(|bbox| item.intersects(&bbox).unwrap_or(false)) 116 | .unwrap_or(true) 117 | && datetime 118 | .map(|(start, end)| { 119 | item.intersects_datetimes(start, end).unwrap_or(false) 120 | }) 121 | .unwrap_or(true) 122 | }) 123 | .collect(); 124 | let number_matched = items.len(); 125 | let items = items 126 | .into_iter() 127 | .cloned() 128 | .skip(skip) 129 | .take(take) 130 | .map(|item| item.try_into().map_err(Error::from)) 131 | .collect::>()?; 132 | let mut item_collection = ItemCollection::new(items)?; 133 | item_collection.number_matched = Some(number_matched.try_into()?); 134 | let next = if skip + take < number_matched { 135 | Some(Paging { 136 | skip: Some(skip + take), 137 | take: Some(take), 138 | }) 139 | } else { 140 | None 141 | }; 142 | let prev = if skip > 0 { 143 | if skip >= take { 144 | Some(Paging { 145 | skip: Some(skip - take), 146 | take: Some(take), 147 | }) 148 | } else { 149 | Some(Paging { 150 | skip: None, 151 | take: Some(take), 152 | }) 153 | } 154 | } else { 155 | None 156 | }; 157 | Ok(Some(Page { 158 | item_collection, 159 | next, 160 | prev, 161 | })) 162 | } else { 163 | let collections = self.collections.read().unwrap(); 164 | if collections.contains_key(id) { 165 | let mut item_collection = ItemCollection::new(vec![])?; 166 | item_collection.number_matched = Some(0); 167 | Ok(Some(Page { 168 | item_collection, 169 | next: None, 170 | prev: None, 171 | })) 172 | } else { 173 | Ok(None) 174 | } 175 | } 176 | } 177 | 178 | async fn item(&self, collection_id: &str, id: &str) -> Result> { 179 | let items = self.items.read().unwrap(); 180 | if let Some(item) = items 181 | .get(collection_id) 182 | .and_then(|items| items.iter().find(|item| item.id == id)) 183 | { 184 | Ok(Some(item.clone())) 185 | } else { 186 | Ok(None) 187 | } 188 | } 189 | 190 | async fn add_collection(&mut self, mut collection: Collection) -> Result> { 191 | collection.remove_structural_links(); 192 | let mut collections = self.collections.write().unwrap(); // TODO handle poison gracefully 193 | Ok(collections.insert(collection.id.clone(), collection)) 194 | } 195 | 196 | async fn upsert_collection(&mut self, collection: Collection) -> Result> { 197 | self.add_collection(collection).await 198 | } 199 | 200 | async fn delete_collection(&mut self, id: &str) -> Result<()> { 201 | { 202 | let mut items = self.items.write().unwrap(); 203 | let _ = items.remove(id); 204 | } 205 | { 206 | let mut collections = self.collections.write().unwrap(); 207 | if collections.contains_key(id) { 208 | let _ = collections.remove(id); 209 | Ok(()) 210 | } else { 211 | Err(Error::CollectionNotFound(id.to_string())) 212 | } 213 | } 214 | } 215 | 216 | async fn add_items(&mut self, items: Vec) -> Result<()> { 217 | let collections = self.collections.read().unwrap(); 218 | let mut items_map = self.items.write().unwrap(); 219 | for mut item in items { 220 | if let Some(collection) = item.collection.clone() { 221 | if collections.contains_key(&collection) { 222 | item.remove_structural_links(); 223 | items_map.entry(collection.clone()).or_default().push(item); 224 | } else { 225 | return Err(Error::CollectionNotFound(collection.clone())); 226 | } 227 | } else { 228 | return Err(Error::NoCollection(item)); 229 | } 230 | } 231 | Ok(()) 232 | } 233 | 234 | async fn upsert_items(&mut self, items: Vec) -> Result<()> { 235 | self.add_items(items).await 236 | } 237 | 238 | async fn add_item(&mut self, item: Item) -> Result<()> { 239 | self.add_items(vec![item]).await 240 | } 241 | } 242 | 243 | impl From for crate::Error { 244 | fn from(value: Error) -> Self { 245 | crate::Error::Backend(Box::new(value)) 246 | } 247 | } 248 | 249 | #[cfg(test)] 250 | mod tests { 251 | use super::MemoryBackend; 252 | use crate::Backend; 253 | use stac::Collection; 254 | 255 | #[tokio::test] 256 | async fn add_collection() { 257 | let mut backend = MemoryBackend::new(); 258 | let _ = backend 259 | .add_collection(Collection::new("a-collection", "A description")) 260 | .await 261 | .unwrap(); 262 | assert_eq!(backend.collections().await.unwrap().len(), 1); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /stac-api-backend/src/page.rs: -------------------------------------------------------------------------------- 1 | use crate::Result; 2 | use http::Method; 3 | use serde::Serialize; 4 | use stac::Link; 5 | use stac_api::ItemCollection; 6 | use url::Url; 7 | 8 | /// A page of items. 9 | #[derive(Debug)] 10 | pub struct Page { 11 | /// The items. 12 | pub item_collection: ItemCollection, 13 | 14 | /// The paging data for the next link. 15 | pub next: Option

, 16 | 17 | /// The paging data for the prev link. 18 | pub prev: Option

, 19 | } 20 | 21 | impl Page

{ 22 | /// Converts this page into an item collection. 23 | pub fn into_item_collection( 24 | self, 25 | url: &Url, 26 | method: &Method, 27 | current: P, 28 | ) -> Result { 29 | let mut item_collection = self.item_collection; 30 | add_link(&mut item_collection, &url, "self", current, &method)?; 31 | if let Some(next) = self.next { 32 | add_link(&mut item_collection, &url, "next", next, &method)?; 33 | } 34 | if let Some(prev) = self.prev { 35 | add_link(&mut item_collection, &url, "prev", prev, &method)?; 36 | } 37 | Ok(item_collection) 38 | } 39 | } 40 | 41 | fn add_link( 42 | item_collection: &mut ItemCollection, 43 | url: &Url, 44 | rel: &'static str, 45 | query: impl Serialize, 46 | method: &Method, 47 | ) -> Result<()> { 48 | match *method { 49 | Method::GET => { 50 | let mut url = url.clone(); 51 | let mut query = serde_urlencoded::to_string(query)?; 52 | if let Some(existing_query) = url.query() { 53 | query = format!("{}&{}", existing_query, query); 54 | } 55 | if !query.is_empty() { 56 | url.set_query(Some(&query)); 57 | } 58 | item_collection.links.push(Link::new(url, rel).geojson()); 59 | } 60 | Method::POST => todo!(), 61 | _ => unimplemented!(), // TODO make this an error 62 | } 63 | Ok(()) 64 | } 65 | 66 | #[cfg(test)] 67 | mod tests { 68 | use super::Page; 69 | use crate::assert_link; 70 | use http::Method; 71 | use stac_api::ItemCollection; 72 | use url::Url; 73 | 74 | #[test] 75 | fn into_item_collection_no_paging() { 76 | let page: Page<()> = Page { 77 | item_collection: ItemCollection::new(vec![]).unwrap(), 78 | next: None, 79 | prev: None, 80 | }; 81 | let item_collection = page 82 | .into_item_collection( 83 | &Url::parse("http://stac-api-backend.test/").unwrap(), 84 | &Method::GET, 85 | (), 86 | ) 87 | .unwrap(); 88 | assert_eq!(item_collection.links.len(), 1); 89 | assert_link!( 90 | item_collection, 91 | "self", 92 | "http://stac-api-backend.test/", 93 | "application/geo+json" 94 | ); 95 | } 96 | 97 | #[test] 98 | fn into_item_collection_next_get() { 99 | let page = Page { 100 | item_collection: ItemCollection::new(vec![]).unwrap(), 101 | next: Some([["skip", "1"], ["take", "1"]]), 102 | prev: None, 103 | }; 104 | let item_collection = page 105 | .into_item_collection( 106 | &Url::parse("http://stac-api-backend.test/items").unwrap(), 107 | &Method::GET, 108 | [["skip", "0"], ["take", "1"]], 109 | ) 110 | .unwrap(); 111 | assert_eq!(item_collection.links.len(), 2); 112 | assert_link!( 113 | item_collection, 114 | "self", 115 | "http://stac-api-backend.test/items?skip=0&take=1", 116 | "application/geo+json" 117 | ); 118 | assert_link!( 119 | item_collection, 120 | "next", 121 | "http://stac-api-backend.test/items?skip=1&take=1", 122 | "application/geo+json" 123 | ); 124 | } 125 | 126 | #[test] 127 | fn into_item_collection_prev_get() { 128 | let page = Page { 129 | item_collection: ItemCollection::new(vec![]).unwrap(), 130 | prev: Some([["skip", "1"], ["take", "1"]]), 131 | next: None, 132 | }; 133 | let item_collection = page 134 | .into_item_collection( 135 | &Url::parse("http://stac-api-backend.test/items").unwrap(), 136 | &Method::GET, 137 | [["skip", "2"], ["take", "1"]], 138 | ) 139 | .unwrap(); 140 | assert_eq!(item_collection.links.len(), 2); 141 | assert_link!( 142 | item_collection, 143 | "self", 144 | "http://stac-api-backend.test/items?skip=2&take=1", 145 | "application/geo+json" 146 | ); 147 | assert_link!( 148 | item_collection, 149 | "prev", 150 | "http://stac-api-backend.test/items?skip=1&take=1", 151 | "application/geo+json" 152 | ); 153 | } 154 | 155 | #[test] 156 | fn into_item_collection_next_get_with_params() { 157 | let page = Page { 158 | item_collection: ItemCollection::new(vec![]).unwrap(), 159 | next: Some([["skip", "1"], ["take", "1"]]), 160 | prev: None, 161 | }; 162 | let item_collection = page 163 | .into_item_collection( 164 | &Url::parse("http://stac-api-backend.test/items?limit=42").unwrap(), 165 | &Method::GET, 166 | [["skip", "0"], ["take", "1"]], 167 | ) 168 | .unwrap(); 169 | assert_eq!(item_collection.links.len(), 2); 170 | assert_link!( 171 | item_collection, 172 | "self", 173 | "http://stac-api-backend.test/items?limit=42&skip=0&take=1", 174 | "application/geo+json" 175 | ); 176 | assert_link!( 177 | item_collection, 178 | "next", 179 | "http://stac-api-backend.test/items?limit=42&skip=1&take=1", 180 | "application/geo+json" 181 | ); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /stac-api-backend/src/pgstac.rs: -------------------------------------------------------------------------------- 1 | //! STAC API backend for pgstac. 2 | 3 | use crate::{Backend, Items, Page}; 4 | use async_trait::async_trait; 5 | use bb8::Pool; 6 | use bb8_postgres::PostgresConnectionManager; 7 | use pgstac::Client; 8 | use serde::{Deserialize, Serialize}; 9 | use stac::{Collection, Item}; 10 | use stac_api::ItemCollection; 11 | use thiserror::Error; 12 | use tokio_postgres::tls::NoTls; 13 | 14 | /// The pgstac backend. 15 | #[derive(Clone, Debug)] 16 | pub struct PgstacBackend { 17 | pool: Pool>, // TODO allow tls 18 | } 19 | 20 | /// Crate-specific error enum. 21 | #[derive(Error, Debug)] 22 | pub enum Error { 23 | /// [bb8::RunError] 24 | #[error(transparent)] 25 | Bb8TokioPostgresRun(#[from] bb8::RunError), 26 | 27 | /// [pgstac::Error] 28 | #[error(transparent)] 29 | Pgstac(#[from] pgstac::Error), 30 | 31 | /// [stac_api::Error] 32 | #[error(transparent)] 33 | StacApi(#[from] stac_api::Error), 34 | 35 | /// [tokio_postgres::Error] 36 | #[error(transparent)] 37 | TokioPostgres(#[from] tokio_postgres::Error), 38 | } 39 | 40 | type Result = std::result::Result; 41 | 42 | /// Paging structure. 43 | #[derive(Default, Debug, Clone, Deserialize, Serialize)] 44 | pub struct Paging { 45 | /// The paging token. 46 | #[serde(skip_serializing_if = "Option::is_none")] 47 | pub token: Option, 48 | } 49 | 50 | impl PgstacBackend { 51 | /// Creates a new pgstac backend. 52 | pub async fn connect(config: &str) -> Result { 53 | let manager = PostgresConnectionManager::new_from_stringlike(config, NoTls)?; 54 | let pool = Pool::builder().build(manager).await?; 55 | Ok(PgstacBackend { pool }) 56 | } 57 | } 58 | 59 | #[async_trait] 60 | impl Backend for PgstacBackend { 61 | type Error = Error; 62 | type Paging = Paging; 63 | 64 | async fn collections(&self) -> Result> { 65 | let client = self.pool.get().await?; 66 | let client = Client::new(&*client); 67 | client.collections().await.map_err(Error::from) 68 | } 69 | 70 | async fn collection(&self, id: &str) -> Result> { 71 | let client = self.pool.get().await?; 72 | let client = Client::new(&*client); 73 | client.collection(id).await.map_err(Error::from) 74 | } 75 | 76 | async fn items(&self, id: &str, query: Items) -> Result>> { 77 | let client = self.pool.get().await?; 78 | let client = Client::new(&*client); 79 | let mut search = query.items.into_search(id); 80 | if let Some(token) = query.paging.token { 81 | let _ = search 82 | .additional_fields 83 | .insert("token".to_string(), token.into()); 84 | } 85 | let page = client.search(search).await?; 86 | if page.features.is_empty() { 87 | // TODO should we error if there's no collection? 88 | Ok(None) 89 | } else { 90 | let next = page.next_token().map(|token| Paging { token: Some(token) }); 91 | let prev = page.prev_token().map(|token| Paging { token: Some(token) }); 92 | let mut item_collection = ItemCollection::new(page.features)?; 93 | item_collection.context = Some(page.context); 94 | Ok(Some(Page { 95 | item_collection, 96 | next, 97 | prev, 98 | })) 99 | } 100 | } 101 | 102 | async fn item(&self, collection_id: &str, id: &str) -> Result> { 103 | let client = self.pool.get().await?; 104 | let client = Client::new(&*client); 105 | client.item(id, collection_id).await.map_err(Error::from) 106 | } 107 | 108 | async fn add_collection(&mut self, collection: Collection) -> Result> { 109 | let client = self.pool.get().await?; 110 | let client = Client::new(&*client); 111 | client.add_collection(collection).await?; 112 | Ok(None) // TODO check and retrieve the previous collection 113 | } 114 | 115 | async fn upsert_collection(&mut self, collection: Collection) -> Result> { 116 | let client = self.pool.get().await?; 117 | let client = Client::new(&*client); 118 | client.upsert_collection(collection).await?; 119 | Ok(None) // TODO check and retrieve the previous collection 120 | } 121 | 122 | async fn delete_collection(&mut self, id: &str) -> Result<()> { 123 | let client = self.pool.get().await?; 124 | let client = Client::new(&*client); 125 | client.delete_collection(id).await?; 126 | Ok(()) 127 | } 128 | 129 | async fn add_items(&mut self, items: Vec) -> Result<()> { 130 | let client = self.pool.get().await?; 131 | let client = Client::new(&*client); 132 | client.add_items(&items).await.map_err(Error::from) 133 | } 134 | 135 | async fn upsert_items(&mut self, items: Vec) -> Result<()> { 136 | let client = self.pool.get().await?; 137 | let client = Client::new(&*client); 138 | client.upsert_items(&items).await.map_err(Error::from) 139 | } 140 | 141 | async fn add_item(&mut self, item: Item) -> Result<()> { 142 | let client = self.pool.get().await?; 143 | let client = Client::new(&*client); 144 | client.add_item(item).await.map_err(Error::from) 145 | } 146 | } 147 | 148 | impl From for crate::Error { 149 | fn from(value: Error) -> Self { 150 | crate::Error::Backend(Box::new(value)) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /stac-server-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "stac-server-cli" 3 | version = "0.1.0" 4 | authors = ["Pete Gadomski "] 5 | edition = "2021" 6 | description = "Command-line interface for a STAC API server" 7 | homepage = "https://github.com/gadomski/stac-server-rs" 8 | repository = "https://github.com/gadomski/stac-server-rs" 9 | license = "MIT OR Apache-2.0" 10 | keywords = ["geospatial", "stac", "metadata", "geo", "raster"] 11 | categories = ["science", "data-structures"] 12 | 13 | [dependencies] 14 | aide = "0.13" 15 | axum = "0.7" 16 | clap = { version = "4", features = ["derive"] } 17 | serde = "1" 18 | stac = { version = "0.5" } 19 | stac-async = { version = "0.5" } 20 | stac-api-backend = { version = "0.1", path = "../stac-api-backend", features = [ 21 | "memory", 22 | "pgstac", 23 | ] } 24 | stac-server = { version = "0.1", path = "../stac-server" } 25 | thiserror = "1" 26 | tokio = { version = "1.23", features = ["macros", "rt-multi-thread"] } 27 | tokio-postgres = "0.7" 28 | toml = "0.8" 29 | 30 | [lib] 31 | path = "src/lib.rs" 32 | test = false 33 | doc = false 34 | doctest = false 35 | 36 | [[bin]] 37 | path = "src/main.rs" 38 | name = "stac-server" 39 | test = false 40 | doc = false 41 | doctest = false 42 | -------------------------------------------------------------------------------- /stac-server-cli/data: -------------------------------------------------------------------------------- 1 | ../data -------------------------------------------------------------------------------- /stac-server-cli/src/config.toml: -------------------------------------------------------------------------------- 1 | [server] 2 | addr = "127.0.0.1:7822" 3 | features = true 4 | 5 | [server.catalog] 6 | type = "Catalog" 7 | stac_version = "1.0.0" 8 | id = "stac-server-rs" 9 | description = "An example implementation of a STAC API using stac-server-rs" 10 | title = "stac-server-rs" 11 | links = [] 12 | -------------------------------------------------------------------------------- /stac-server-cli/src/lib.rs: -------------------------------------------------------------------------------- 1 | // TODO document 2 | 3 | use serde::Deserialize; 4 | use stac::Value; 5 | use stac_api_backend::Backend; 6 | use std::{path::Path, str::FromStr}; 7 | use thiserror::Error; 8 | use tokio::{ 9 | fs::File, 10 | io::{AsyncReadExt, BufReader}, 11 | task::JoinSet, 12 | }; 13 | 14 | pub async fn load_hrefs(backend: &mut B, hrefs: Vec) -> Result<()> 15 | where 16 | B: Backend, 17 | stac_api_backend::Error: From, 18 | { 19 | // TODO this could probably be its own method on a backend? 20 | 21 | let mut join_set: JoinSet> = JoinSet::new(); 22 | for href in hrefs { 23 | join_set.spawn(async move { stac_async::read(href).await.map_err(Error::from) }); 24 | } 25 | let mut item_vectors = Vec::new(); 26 | while let Some(result) = join_set.join_next().await { 27 | let value = result.unwrap()?; 28 | match value { 29 | Value::Catalog(_) => return Err(Error::Load(value)), 30 | Value::Collection(collection) => { 31 | backend 32 | .upsert_collection(collection) 33 | .await 34 | .map_err(stac_api_backend::Error::from)?; 35 | } 36 | Value::Item(item) => item_vectors.push(vec![item]), 37 | Value::ItemCollection(item_collection) => item_vectors.push(item_collection.items), 38 | } 39 | } 40 | for items in item_vectors { 41 | backend 42 | .add_items(items) 43 | .await 44 | .map_err(stac_api_backend::Error::from)?; 45 | } 46 | Ok(()) 47 | } 48 | 49 | #[derive(Debug, Error)] 50 | pub enum Error { 51 | #[error(transparent)] 52 | Io(#[from] std::io::Error), 53 | 54 | #[error("cannot load value")] 55 | Load(Value), 56 | 57 | #[error(transparent)] 58 | StacApiBackend(#[from] stac_api_backend::Error), 59 | 60 | #[error(transparent)] 61 | StacAsync(#[from] stac_async::Error), 62 | 63 | #[error(transparent)] 64 | TomlDe(#[from] toml::de::Error), 65 | } 66 | 67 | pub type Result = std::result::Result; 68 | 69 | #[derive(Debug, Deserialize)] 70 | pub struct Config { 71 | pub server: stac_server::Config, 72 | 73 | // TODO document how to pick a backend with a config file 74 | #[serde(default = "BackendConfig::default")] 75 | pub backend: BackendConfig, 76 | } 77 | 78 | #[derive(Debug, Deserialize)] 79 | pub enum BackendConfig { 80 | Memory, 81 | Pgstac(PgstacConfig), 82 | } 83 | 84 | #[derive(Debug, Deserialize)] 85 | pub struct PgstacConfig { 86 | pub config: String, 87 | } 88 | 89 | impl Config { 90 | pub async fn from_toml(path: impl AsRef) -> Result { 91 | let mut reader = File::open(path).await.map(BufReader::new)?; 92 | let mut string = String::new(); 93 | let _ = reader.read_to_string(&mut string).await?; 94 | string.parse() 95 | } 96 | } 97 | 98 | impl Default for Config { 99 | fn default() -> Self { 100 | let s = include_str!("config.toml"); 101 | s.parse().unwrap() 102 | } 103 | } 104 | 105 | impl FromStr for Config { 106 | type Err = Error; 107 | fn from_str(s: &str) -> Result { 108 | toml::from_str(&s).map_err(Error::from) 109 | } 110 | } 111 | 112 | impl BackendConfig { 113 | pub fn set_pgstac_config(&mut self, config: impl ToString) { 114 | *self = BackendConfig::Pgstac(PgstacConfig { 115 | config: config.to_string(), 116 | }) 117 | } 118 | } 119 | 120 | impl Default for BackendConfig { 121 | fn default() -> Self { 122 | BackendConfig::Memory 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /stac-server-cli/src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use stac_api_backend::{MemoryBackend, PgstacBackend}; 3 | use stac_server_cli::{BackendConfig, Config}; 4 | use std::path::PathBuf; 5 | 6 | /// Runs a STAC API server. 7 | #[derive(Debug, Parser)] 8 | struct Cli { 9 | /// The path to the server configuration. 10 | /// 11 | /// If not provided, a very simple default configuration 12 | /// (https://github.com/gadomski/stac-server-rs/blob/main/stac-server-cli/src/config.toml) 13 | /// will be used. 14 | #[arg(short, long)] 15 | config: Option, 16 | 17 | /// The address at which to serve the API, e.g. "127.0.0.1:7822". 18 | /// 19 | /// This will override any address configuration in the config file. 20 | #[arg(short, long)] 21 | addr: Option, 22 | 23 | /// The address of the pgstac database, e.g. "postgresql://username:password@localhost:5432/postgis". 24 | /// 25 | /// This will override any backend configuration in the config file. 26 | #[arg(short, long)] 27 | pgstac: Option, 28 | 29 | /// The hrefs of STAC collections and item collections to read and load into 30 | /// the backend when starting the server. 31 | hrefs: Vec, 32 | } 33 | 34 | #[tokio::main] 35 | async fn main() { 36 | // TODO simply this to a library call, so others can leverage the library to 37 | // add their own backends. 38 | 39 | let cli = Cli::parse(); 40 | let mut config = if let Some(config) = cli.config { 41 | Config::from_toml(config).await.unwrap() 42 | } else { 43 | Config::default() 44 | }; 45 | 46 | if let Some(addr) = &cli.addr { 47 | config.server.addr = addr.to_string(); 48 | } 49 | if let Some(pgstac) = &cli.pgstac { 50 | config.backend.set_pgstac_config(pgstac); 51 | } 52 | 53 | match config.backend { 54 | BackendConfig::Memory => { 55 | let mut backend = MemoryBackend::new(); 56 | stac_server_cli::load_hrefs(&mut backend, cli.hrefs) 57 | .await 58 | .unwrap(); 59 | println!("Serving on http://{}", config.server.addr); 60 | stac_server::serve(backend, config.server).await.unwrap() 61 | } 62 | BackendConfig::Pgstac(pgstac) => { 63 | let (_, _) = tokio_postgres::connect(&pgstac.config, tokio_postgres::NoTls) 64 | .await 65 | .unwrap(); 66 | let mut backend = PgstacBackend::connect(&pgstac.config).await.unwrap(); 67 | stac_server_cli::load_hrefs(&mut backend, cli.hrefs) 68 | .await 69 | .unwrap(); 70 | println!("Serving on http://{}", config.server.addr); 71 | stac_server::serve(backend, config.server).await.unwrap() 72 | } 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /stac-server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "stac-server" 3 | version = "0.1.0" 4 | authors = ["Pete Gadomski "] 5 | edition = "2021" 6 | description = "STAC API server" 7 | homepage = "https://github.com/gadomski/stac-server-rs" 8 | repository = "https://github.com/gadomski/stac-server-rs" 9 | license = "MIT OR Apache-2.0" 10 | keywords = ["geospatial", "stac", "metadata", "geo", "raster"] 11 | categories = ["science", "data-structures"] 12 | 13 | [dependencies] 14 | aide = { version = "0.13", features = ["axum"] } 15 | axum = "0.7" 16 | hyper = "1" 17 | serde = { version = "1", features = ["derive"] } 18 | serde_urlencoded = "0.7" 19 | stac = { version = "0.5", features = ["schemars"] } 20 | stac-api = { version = "0.3", features = ["schemars"] } 21 | stac-api-backend = { version = "0.1", path = "../stac-api-backend" } 22 | thiserror = "1" 23 | tokio = "1.23" 24 | url = "2.3" 25 | 26 | [dev-dependencies] 27 | futures-util = "0.3" 28 | geojson = "0.24" 29 | stac = { version = "0.5", features = ["schemars", "geo"] } 30 | stac-api-backend = { version = "0.1", path = "../stac-api-backend", features = [ 31 | "memory", 32 | "pgstac", 33 | ] } 34 | stac-async = "0.5" 35 | stac-validate = { version = "0.1" } 36 | tokio = { version = "1.23", features = ["rt", "macros"] } 37 | tokio-postgres = "0.7" 38 | tokio-test = "0.4" 39 | tower = "0.4" 40 | -------------------------------------------------------------------------------- /stac-server/data: -------------------------------------------------------------------------------- 1 | ../data -------------------------------------------------------------------------------- /stac-server/src/config.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use stac::Catalog; 3 | 4 | /// Server configuration. 5 | #[derive(Clone, Debug, Deserialize)] 6 | pub struct Config { 7 | /// The IP address of the server. 8 | pub addr: String, 9 | 10 | /// Should this server support features? 11 | /// 12 | /// Note that we don't allow just collections, because why. 13 | pub features: bool, 14 | 15 | /// The catalog that will serve as the landing page. 16 | pub catalog: Catalog, 17 | } 18 | 19 | impl Config { 20 | /// The root url for this config. 21 | /// 22 | /// # Examples 23 | /// 24 | /// ``` 25 | /// use stac_server::Config; 26 | /// let mut config = Config::default(); 27 | /// config.addr = "stac-server-rs.test/stac/v1".to_string(); 28 | /// assert_eq!(config.root_url(), "http://stac-server-rs.test/stac/v1"); 29 | /// ``` 30 | pub fn root_url(&self) -> String { 31 | // TODO enable https? Maybe? 32 | format!("http://{}", self.addr) 33 | } 34 | } 35 | 36 | impl Default for Config { 37 | fn default() -> Self { 38 | Config { 39 | addr: "127.0.0.1:7822".to_string(), 40 | features: true, 41 | catalog: Catalog::new( 42 | "stac-server-rs", 43 | "The default STAC API server from stac-server-rs", 44 | ), 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /stac-server/src/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | /// Crate-specific error enum. 4 | #[derive(Debug, Error)] 5 | pub enum Error { 6 | /// [std::net::AddrParseError] 7 | #[error(transparent)] 8 | AddrParse(#[from] std::net::AddrParseError), 9 | 10 | /// [hyper::Error] 11 | #[error(transparent)] 12 | Hyper(#[from] hyper::Error), 13 | 14 | /// [std::io::Error] 15 | #[error(transparent)] 16 | Io(#[from] std::io::Error), 17 | 18 | /// [serde_urlencoded::de::Error] 19 | #[error(transparent)] 20 | SerdeUrlencodedDe(#[from] serde_urlencoded::de::Error), 21 | 22 | /// [serde_urlencoded::ser::Error] 23 | #[error(transparent)] 24 | SerdeUrlencodedSer(#[from] serde_urlencoded::ser::Error), 25 | 26 | /// [stac_api::Error] 27 | #[error(transparent)] 28 | StacApi(#[from] stac_api::Error), 29 | 30 | /// [stac_api_backend::Error] 31 | #[error(transparent)] 32 | StacApiBackend(#[from] stac_api_backend::Error), 33 | 34 | /// [url::ParseError] 35 | #[error(transparent)] 36 | UrlParse(#[from] url::ParseError), 37 | } 38 | -------------------------------------------------------------------------------- /stac-server/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! STAC API server implementation using [axum](https://github.com/tokio-rs/axum). 2 | 3 | #![deny( 4 | elided_lifetimes_in_paths, 5 | explicit_outlives_requirements, 6 | keyword_idents, 7 | macro_use_extern_crate, 8 | meta_variable_misuse, 9 | missing_abi, 10 | missing_debug_implementations, 11 | missing_docs, 12 | non_ascii_idents, 13 | noop_method_call, 14 | pointer_structural_match, 15 | rust_2021_incompatible_closure_captures, 16 | rust_2021_incompatible_or_patterns, 17 | rust_2021_prefixes_incompatible_syntax, 18 | rust_2021_prelude_collisions, 19 | single_use_lifetimes, 20 | trivial_casts, 21 | trivial_numeric_casts, 22 | unreachable_pub, 23 | unsafe_code, 24 | unsafe_op_in_unsafe_fn, 25 | unused_crate_dependencies, 26 | unused_extern_crates, 27 | unused_import_braces, 28 | unused_lifetimes, 29 | unused_qualifications, 30 | unused_results 31 | )] 32 | 33 | mod config; 34 | mod error; 35 | mod router; 36 | 37 | use tokio::net::TcpListener; 38 | pub use {config::Config, error::Error, router::api}; 39 | 40 | /// Crate-specific result type. 41 | pub type Result = std::result::Result; 42 | 43 | /// Starts a server. 44 | /// 45 | /// # Examples 46 | /// 47 | /// ```no_run 48 | /// use stac_api_backend::MemoryBackend; 49 | /// use stac_server::Config; 50 | /// 51 | /// # tokio_test::block_on(async { 52 | /// // Runs forever 53 | /// stac_server::serve(MemoryBackend::new(), Config::default()).await.unwrap(); 54 | /// # }); 55 | /// ``` 56 | pub async fn serve(backend: B, config: Config) -> Result<()> 57 | where 58 | B: stac_api_backend::Backend, 59 | stac_api_backend::Error: From<::Error>, 60 | { 61 | let listener = TcpListener::bind(&config.addr).await?; 62 | let api = api(backend, config)?; 63 | axum::serve(listener, api).await.map_err(Error::from) 64 | } 65 | 66 | // Needed for integration tests. 67 | #[cfg(test)] 68 | use { 69 | futures_util as _, geojson as _, stac_async as _, stac_validate as _, tokio_postgres as _, 70 | tokio_test as _, 71 | }; 72 | -------------------------------------------------------------------------------- /stac-server/src/router.rs: -------------------------------------------------------------------------------- 1 | use crate::{Config, Error}; 2 | use aide::{ 3 | axum::{routing::get, ApiRouter, IntoApiResponse}, 4 | openapi::{Info, OpenApi}, 5 | }; 6 | use axum::{ 7 | extract::{Path, Query, State}, 8 | http::{header::CONTENT_TYPE, HeaderMap, StatusCode}, 9 | response::Html, 10 | Extension, Json, Router, 11 | }; 12 | use stac_api::{GetItems, Root}; 13 | use stac_api_backend::{Api, Backend, Items}; 14 | 15 | /// Creates a new STAC API router. 16 | /// 17 | /// # Examples 18 | /// 19 | /// ``` 20 | /// use stac::Catalog; 21 | /// use stac_api_backend::MemoryBackend; 22 | /// use stac_server::Config; 23 | /// 24 | /// let config = Config { 25 | /// addr: "http://localhost:7822".to_string(), 26 | /// features: true, 27 | /// catalog: Catalog::new("an-id", "A description"), 28 | /// }; 29 | /// let backend = MemoryBackend::new(); 30 | /// let api = stac_server::api(backend, config).unwrap(); 31 | /// ``` 32 | pub fn api(backend: B, config: Config) -> crate::Result 33 | where 34 | stac_api_backend::Error: From<::Error>, 35 | { 36 | // Need to build the OpenApi now so we can consume the catalog in the 37 | // Api::new call 38 | let mut open_api = build_openapi(&config.catalog.description); 39 | let root_url = config.root_url(); 40 | let api = Api::new(backend, config.catalog, &root_url)?.features(config.features); 41 | let mut router = ApiRouter::new() 42 | .api_route("/", get(root)) 43 | .api_route("/conformance", get(conformance)); 44 | if api.features { 45 | router = router 46 | .api_route("/collections", get(collections)) 47 | .api_route("/collections/:collection_id", get(collection)) 48 | .api_route("/collections/:collection_id/items", get(items)) 49 | .api_route("/collections/:collection_id/items/:item_id", get(item)); 50 | } else { 51 | router = router 52 | .api_route("/collections", get(not_implemented)) 53 | .api_route("/collections/:collection_id", get(not_implemented)) 54 | .api_route("/collections/:collection_id/items", get(not_implemented)) 55 | .api_route( 56 | "/collections/:collection_id/items/:item_id", 57 | get(not_implemented), 58 | ); 59 | } 60 | Ok(router 61 | .route("/api", get(service_desc)) 62 | .route("/api.html", get(service_doc)) 63 | .with_state(api) 64 | .finish_api(&mut open_api) 65 | .layer(Extension(open_api))) 66 | } 67 | 68 | async fn root(State(api): State>) -> Result, (StatusCode, String)> 69 | where 70 | stac_api_backend::Error: From<::Error>, 71 | { 72 | let root = api.root().await.map_err(internal_server_error)?; 73 | Ok(Json(root)) 74 | } 75 | 76 | async fn service_desc(Extension(api): Extension) -> impl IntoApiResponse { 77 | let mut headers = HeaderMap::new(); 78 | let _ = headers.insert( 79 | CONTENT_TYPE, 80 | "application/vnd.oai.openapi+json;version=3.1" 81 | .parse() 82 | .unwrap(), 83 | ); 84 | (headers, Json(api)) 85 | } 86 | 87 | async fn service_doc(State(api): State>) -> Html { 88 | Html(format!(" 89 | 90 | 91 | Redoc 92 | 93 | 94 | 95 | 96 | 97 | 100 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | ", api.url_builder.service_desc())) 113 | } 114 | 115 | async fn conformance(State(api): State>) -> impl IntoApiResponse 116 | where 117 | stac_api_backend::Error: From<::Error>, 118 | { 119 | Json(api.conformance()) 120 | } 121 | 122 | async fn collections(State(api): State>) -> impl IntoApiResponse 123 | where 124 | stac_api_backend::Error: From<::Error>, 125 | { 126 | api.collections() 127 | .await 128 | .map(Json) 129 | .map_err(internal_server_error) 130 | } 131 | 132 | async fn collection( 133 | State(api): State>, 134 | Path(collection_id): Path, 135 | ) -> impl IntoApiResponse 136 | where 137 | stac_api_backend::Error: From<::Error>, 138 | { 139 | if let Some(collection) = api 140 | .collection(&collection_id) 141 | .await 142 | .map_err(internal_server_error)? 143 | { 144 | return Ok(Json(collection)); 145 | } else { 146 | return Err(( 147 | StatusCode::NOT_FOUND, 148 | format!("no collection with id={}", collection_id), 149 | )); 150 | } 151 | } 152 | 153 | async fn items( 154 | State(api): State>, 155 | Path(collection_id): Path, 156 | Query(get_items): Query, 157 | ) -> impl IntoApiResponse 158 | where 159 | stac_api_backend::Error: From<::Error>, 160 | { 161 | match stac_api::Items::try_from(get_items) 162 | .map_err(Error::from) 163 | .and_then(|mut items| { 164 | // Need to go through `serde_urlencoded` because things come in as strings. 165 | let paging: B::Paging = serde_urlencoded::from_str(&serde_urlencoded::to_string( 166 | std::mem::take(&mut items.additional_fields), 167 | )?)?; 168 | Ok(Items { items, paging }) 169 | }) { 170 | Ok(items) => { 171 | if let Some(items) = api 172 | .items(&collection_id, items) 173 | .await 174 | .map_err(internal_server_error)? 175 | { 176 | let mut headers = HeaderMap::new(); 177 | let _ = headers.insert(CONTENT_TYPE, "application/geo+json".parse().unwrap()); 178 | return Ok((headers, Json(items))); 179 | } else { 180 | return Err(( 181 | StatusCode::NOT_FOUND, 182 | format!("no collection with id={}", collection_id), 183 | )); 184 | } 185 | } 186 | Err(err) => Err((StatusCode::BAD_REQUEST, format!("invalid query: {}", err))), 187 | } 188 | } 189 | 190 | async fn item( 191 | State(api): State>, 192 | Path((collection_id, item_id)): Path<(String, String)>, 193 | ) -> impl IntoApiResponse 194 | where 195 | stac_api_backend::Error: From<::Error>, 196 | { 197 | if let Some(item) = api 198 | .item(&collection_id, &item_id) 199 | .await 200 | .map_err(internal_server_error)? 201 | { 202 | let mut headers = HeaderMap::new(); 203 | let _ = headers.insert(CONTENT_TYPE, "application/geo+json".parse().unwrap()); 204 | return Ok((headers, Json(item))); 205 | } else { 206 | return Err(( 207 | StatusCode::NOT_FOUND, 208 | format!( 209 | "no item with id={} in collection={}", 210 | item_id, collection_id 211 | ), 212 | )); 213 | } 214 | } 215 | 216 | fn internal_server_error(err: stac_api_backend::Error) -> (StatusCode, String) { 217 | ( 218 | StatusCode::INTERNAL_SERVER_ERROR, 219 | format!("internal server error: {}", err), 220 | ) 221 | } 222 | 223 | async fn not_implemented() -> (StatusCode, String) { 224 | (StatusCode::NOT_IMPLEMENTED, "not implemented".to_string()) 225 | } 226 | 227 | fn build_openapi(description: impl ToString) -> OpenApi { 228 | OpenApi { 229 | info: Info { 230 | description: Some(description.to_string()), 231 | ..Info::default() 232 | }, 233 | ..OpenApi::default() 234 | } 235 | } 236 | 237 | #[cfg(test)] 238 | mod tests { 239 | use crate::Config; 240 | use axum::{ 241 | body::Body, 242 | http::{header::CONTENT_TYPE, Request, StatusCode}, 243 | }; 244 | use stac::{Catalog, Collection, Item}; 245 | use stac_api_backend::{Backend, MemoryBackend}; 246 | use tower::ServiceExt; 247 | 248 | fn test_config() -> Config { 249 | Config { 250 | addr: "http://localhost:7822".to_string(), 251 | features: true, 252 | catalog: Catalog::new("test-catalog", "A description"), 253 | } 254 | } 255 | 256 | #[tokio::test] 257 | async fn landing_page() { 258 | let api = super::api(MemoryBackend::new(), test_config()).unwrap(); 259 | let response = api 260 | .oneshot( 261 | Request::builder() 262 | .method("GET") 263 | .uri("/") 264 | .body(Body::empty()) 265 | .unwrap(), 266 | ) 267 | .await 268 | .unwrap(); 269 | assert_eq!(response.status(), StatusCode::OK); 270 | } 271 | 272 | #[tokio::test] 273 | async fn collections() { 274 | let api = super::api(MemoryBackend::new(), test_config()).unwrap(); 275 | let response = api 276 | .oneshot( 277 | Request::builder() 278 | .method("GET") 279 | .uri("/collections") 280 | .body(Body::empty()) 281 | .unwrap(), 282 | ) 283 | .await 284 | .unwrap(); 285 | assert_eq!(response.status(), StatusCode::OK); 286 | } 287 | 288 | #[tokio::test] 289 | async fn conformance() { 290 | let api = super::api(MemoryBackend::new(), test_config()).unwrap(); 291 | let response = api 292 | .oneshot( 293 | Request::builder() 294 | .method("GET") 295 | .uri("/conformance") 296 | .body(Body::empty()) 297 | .unwrap(), 298 | ) 299 | .await 300 | .unwrap(); 301 | assert_eq!(response.status(), StatusCode::OK); 302 | } 303 | 304 | #[tokio::test] 305 | async fn collection() { 306 | let mut backend = MemoryBackend::new(); 307 | let _ = backend 308 | .add_collection(Collection::new("an-id", "a description")) 309 | .await 310 | .unwrap(); 311 | let api = super::api(backend, test_config()).unwrap(); 312 | let response = api 313 | .oneshot( 314 | Request::builder() 315 | .method("GET") 316 | .uri("/collections/an-id") 317 | .body(Body::empty()) 318 | .unwrap(), 319 | ) 320 | .await 321 | .unwrap(); 322 | assert_eq!(response.status(), StatusCode::OK); 323 | } 324 | 325 | #[tokio::test] 326 | async fn items() { 327 | let mut backend = MemoryBackend::new(); 328 | let _ = backend 329 | .add_collection(Collection::new("an-id", "a description")) 330 | .await 331 | .unwrap(); 332 | let api = super::api(backend, test_config()).unwrap(); 333 | let response = api 334 | .oneshot( 335 | Request::builder() 336 | .method("GET") 337 | .uri("/collections/an-id/items") 338 | .body(Body::empty()) 339 | .unwrap(), 340 | ) 341 | .await 342 | .unwrap(); 343 | assert_eq!(response.status(), StatusCode::OK); 344 | assert_eq!( 345 | response.headers().get(CONTENT_TYPE).unwrap(), 346 | "application/geo+json" 347 | ); 348 | } 349 | 350 | #[tokio::test] 351 | async fn item() { 352 | let mut backend = MemoryBackend::new(); 353 | let _ = backend 354 | .add_collection(Collection::new("an-id", "a description")) 355 | .await 356 | .unwrap(); 357 | backend 358 | .add_items(vec![Item::new("item-id").collection("an-id")]) 359 | .await 360 | .unwrap(); 361 | let api = super::api(backend, test_config()).unwrap(); 362 | let response = api 363 | .oneshot( 364 | Request::builder() 365 | .method("GET") 366 | .uri("/collections/an-id/items/item-id") 367 | .body(Body::empty()) 368 | .unwrap(), 369 | ) 370 | .await 371 | .unwrap(); 372 | assert_eq!(response.status(), StatusCode::OK,); 373 | assert_eq!( 374 | response.headers().get(CONTENT_TYPE).unwrap(), 375 | "application/geo+json" 376 | ); 377 | } 378 | 379 | #[tokio::test] 380 | async fn no_features() { 381 | let mut config = test_config(); 382 | config.features = false; 383 | let api = super::api(MemoryBackend::new(), config).unwrap(); 384 | let response = api 385 | .clone() 386 | .oneshot( 387 | Request::builder() 388 | .method("GET") 389 | .uri("/collections") 390 | .body(Body::empty()) 391 | .unwrap(), 392 | ) 393 | .await 394 | .unwrap(); 395 | assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); 396 | let response = api 397 | .clone() 398 | .oneshot( 399 | Request::builder() 400 | .method("GET") 401 | .uri("/collections/foo") 402 | .body(Body::empty()) 403 | .unwrap(), 404 | ) 405 | .await 406 | .unwrap(); 407 | assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); 408 | let response = api 409 | .clone() 410 | .oneshot( 411 | Request::builder() 412 | .method("GET") 413 | .uri("/collections/foo/items") 414 | .body(Body::empty()) 415 | .unwrap(), 416 | ) 417 | .await 418 | .unwrap(); 419 | assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); 420 | let response = api 421 | .clone() 422 | .oneshot( 423 | Request::builder() 424 | .method("GET") 425 | .uri("/collections/foo/items/bar") 426 | .body(Body::empty()) 427 | .unwrap(), 428 | ) 429 | .await 430 | .unwrap(); 431 | assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); 432 | let response = api 433 | .clone() 434 | .oneshot( 435 | Request::builder() 436 | .method("GET") 437 | .uri("/") 438 | .body(Body::empty()) 439 | .unwrap(), 440 | ) 441 | .await 442 | .unwrap(); 443 | assert_eq!(response.status(), StatusCode::OK); 444 | } 445 | } 446 | -------------------------------------------------------------------------------- /stac-server/tests/client.rs: -------------------------------------------------------------------------------- 1 | use futures_util::stream::StreamExt; 2 | use geojson::{Geometry, Value}; 3 | use stac::{Catalog, Collection, Item}; 4 | use stac_api::Items; 5 | use stac_api_backend::{Backend, Error, MemoryBackend, PgstacBackend}; 6 | use stac_async::ApiClient; 7 | use stac_server::Config; 8 | use stac_validate::Validate; 9 | use tokio::net::TcpListener; 10 | 11 | #[tokio::test] 12 | async fn memory() { 13 | test(MemoryBackend::new()).await 14 | } 15 | 16 | #[tokio::test] 17 | #[ignore = "pgstac test skipped by default, because it requires external services"] 18 | async fn pgstac() { 19 | let config = "postgresql://username:password@localhost:5432/postgis"; 20 | let (_, _) = tokio_postgres::connect(config, tokio_postgres::NoTls) 21 | .await 22 | .unwrap(); 23 | test(PgstacBackend::connect(config).await.unwrap()).await 24 | } 25 | 26 | async fn test(mut backend: B) 27 | where 28 | B: Backend + 'static, 29 | Error: From<::Error>, 30 | ::Paging: Send + Sync, 31 | { 32 | if let Some(_) = backend.collection("collection-id").await.unwrap() { 33 | backend.delete_collection("collection-id").await.unwrap(); 34 | } 35 | backend 36 | .add_collection(Collection::new("collection-id", "A test collection")) 37 | .await 38 | .unwrap(); 39 | let mut items = Vec::new(); 40 | for i in 0..10 { 41 | let mut item = Item::new(format!("item-{}", i)).collection("collection-id"); 42 | item.properties.datetime = Some(format!("2023-07-{:02}T00:00:00Z", i + 1)); 43 | item.set_geometry(Geometry::new(Value::Polygon(vec![vec![ 44 | vec![-105.0, 40.0 + f64::from(i)], 45 | vec![-104.0, 40.0 + f64::from(i)], 46 | vec![-104.0, 41.0 + f64::from(i)], 47 | vec![-105.0, 41.0 + f64::from(i)], 48 | vec![-105.0, 40.0 + f64::from(i)], 49 | ]]))) 50 | .unwrap(); 51 | items.push(item); 52 | } 53 | backend.add_items(items).await.unwrap(); 54 | let config = Config { 55 | addr: "127.0.0.1:7822".to_string(), 56 | features: true, 57 | catalog: Catalog::new("a-catalog", "A test catalog"), 58 | }; 59 | 60 | let listener = TcpListener::bind(&config.addr).await.unwrap(); 61 | let api = stac_server::api(backend, config).unwrap(); 62 | let server = axum::serve(listener, api); 63 | tokio::spawn(async { server.await.unwrap() }); 64 | 65 | let client = ApiClient::new("http://127.0.0.1:7822").unwrap(); 66 | let collection = client.collection("collection-id").await.unwrap().unwrap(); 67 | collection.validate().unwrap(); 68 | assert_eq!(client.collection("not-an-id").await.unwrap(), None); 69 | 70 | let items: Vec<_> = client 71 | .items("collection-id", None) 72 | .await 73 | .unwrap() 74 | .map(|result| result.unwrap()) 75 | .collect() 76 | .await; 77 | assert_eq!(items.len(), 10); 78 | for item in items { 79 | let item = Item::try_from(item).unwrap(); 80 | item.validate().unwrap(); 81 | } 82 | 83 | let items: Vec<_> = client 84 | .items( 85 | "collection-id", 86 | Items { 87 | limit: Some(2), 88 | ..Default::default() 89 | }, 90 | ) 91 | .await 92 | .unwrap() 93 | .map(|result| result.unwrap()) 94 | .collect() 95 | .await; 96 | assert_eq!(items.len(), 10); 97 | 98 | let items: Vec<_> = client 99 | .items( 100 | "collection-id", 101 | Items { 102 | bbox: Some(vec![-110.0, 43.5, -100.0, 45.5]), 103 | ..Default::default() 104 | }, 105 | ) 106 | .await 107 | .unwrap() 108 | .map(|result| result.unwrap()) 109 | .collect() 110 | .await; 111 | assert_eq!(items.len(), 3); 112 | 113 | let items: Vec<_> = client 114 | .items( 115 | "collection-id", 116 | Items { 117 | datetime: Some("2023-07-02T00:00:00Z/2023-07-04T00:00:00Z".to_string()), 118 | ..Default::default() 119 | }, 120 | ) 121 | .await 122 | .unwrap() 123 | .map(|result| result.unwrap()) 124 | .collect() 125 | .await; 126 | assert_eq!(items.len(), 3); 127 | 128 | let items: Vec<_> = client 129 | .items( 130 | "collection-id", 131 | Items { 132 | bbox: Some(vec![-110.0, 43.5, -100.0, 45.5]), 133 | limit: Some(1), 134 | ..Default::default() 135 | }, 136 | ) 137 | .await 138 | .unwrap() 139 | .map(|result| result.unwrap()) 140 | .collect() 141 | .await; 142 | assert_eq!(items.len(), 3); 143 | } 144 | --------------------------------------------------------------------------------