├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── enhancements---feature-requests.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── ci.yml │ └── release-drafter.yml ├── .gitignore ├── .jabbarc ├── .mergify.yml ├── .nvmrc ├── .sbtopts ├── .scalafix.conf ├── .scalafmt.conf ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── build.sbt ├── docker-compose.yml ├── modules ├── client │ ├── js │ │ └── src │ │ │ ├── main │ │ │ └── scala │ │ │ │ └── com │ │ │ │ └── azavea │ │ │ │ └── stac4s │ │ │ │ └── api │ │ │ │ └── client │ │ │ │ ├── SearchFilters.scala │ │ │ │ ├── SttpStacClient.scala │ │ │ │ ├── package.scala │ │ │ │ └── util │ │ │ │ └── ClientCodecs.scala │ │ │ └── test │ │ │ └── scala │ │ │ └── com │ │ │ └── azavea │ │ │ └── stac4s │ │ │ └── api │ │ │ └── client │ │ │ └── SttpStacClientSpec.scala │ ├── jvm │ │ └── src │ │ │ ├── main │ │ │ └── scala │ │ │ │ └── com │ │ │ │ └── azavea │ │ │ │ └── stac4s │ │ │ │ └── api │ │ │ │ └── client │ │ │ │ ├── SearchFilters.scala │ │ │ │ ├── SttpStacClient.scala │ │ │ │ ├── package.scala │ │ │ │ └── util │ │ │ │ └── ClientCodecs.scala │ │ │ └── test │ │ │ └── scala │ │ │ └── com │ │ │ └── azavea │ │ │ └── stac4s │ │ │ └── api │ │ │ └── client │ │ │ └── SttpStacClientSpec.scala │ └── shared │ │ └── src │ │ ├── main │ │ ├── scala-2.12 │ │ │ └── com │ │ │ │ └── azavea │ │ │ │ └── stac4s │ │ │ │ └── api │ │ │ │ └── client │ │ │ │ └── ETagCodecs.scala │ │ ├── scala-2.13 │ │ │ └── com │ │ │ │ └── azavea │ │ │ │ └── stac4s │ │ │ │ └── api │ │ │ │ └── client │ │ │ │ └── ETagCodecs.scala │ │ └── scala │ │ │ └── com │ │ │ └── azavea │ │ │ └── stac4s │ │ │ └── api │ │ │ └── client │ │ │ ├── ETag.scala │ │ │ ├── Query.scala │ │ │ ├── StacClientF.scala │ │ │ ├── SttpStacClientF.scala │ │ │ └── util │ │ │ └── syntax │ │ │ └── package.scala │ │ └── test │ │ └── scala │ │ ├── com │ │ └── azavea │ │ │ └── stac4s │ │ │ └── api │ │ │ └── client │ │ │ ├── SttpEitherInstances.scala │ │ │ ├── SttpStacClientFSpec.scala │ │ │ └── SttpSyntax.scala │ │ └── org │ │ └── scalacheck │ │ └── resample │ │ └── package.scala ├── core-test │ ├── js │ │ └── src │ │ │ └── test │ │ │ └── scala │ │ │ └── com │ │ │ └── azavea │ │ │ └── stac4s │ │ │ ├── JsFPLawsSpec.scala │ │ │ └── JsSerDeSpec.scala │ ├── jvm │ │ └── src │ │ │ └── test │ │ │ └── scala │ │ │ └── com │ │ │ └── azavea │ │ │ └── stac4s │ │ │ ├── JvmFPLawsSpec.scala │ │ │ ├── JvmSerDeSpec.scala │ │ │ └── JvmSyntaxSpec.scala │ └── shared │ │ └── src │ │ └── test │ │ └── scala │ │ └── com │ │ └── azavea │ │ └── stac4s │ │ ├── BboxSpec.scala │ │ ├── SerDeSpec.scala │ │ └── SyntaxSpec.scala ├── core │ ├── js │ │ └── src │ │ │ └── main │ │ │ └── scala │ │ │ └── com │ │ │ └── azavea │ │ │ └── stac4s │ │ │ ├── Bbox.scala │ │ │ ├── StacCollection.scala │ │ │ ├── StacExtent.scala │ │ │ ├── StacItem.scala │ │ │ ├── extensions │ │ │ ├── CollectionExtension.scala │ │ │ └── layer │ │ │ │ └── StacLayer.scala │ │ │ ├── geometry │ │ │ └── Geometry.scala │ │ │ ├── jsTypes.scala │ │ │ ├── meta │ │ │ └── ForeignImplicits.scala │ │ │ └── syntax │ │ │ └── package.scala │ ├── jvm │ │ └── src │ │ │ └── main │ │ │ └── scala │ │ │ └── com │ │ │ └── azavea │ │ │ └── stac4s │ │ │ ├── Bbox.scala │ │ │ ├── StacCollection.scala │ │ │ ├── StacExtent.scala │ │ │ ├── StacItem.scala │ │ │ ├── SummaryValue.scala │ │ │ ├── extensions │ │ │ ├── CollectionExtension.scala │ │ │ ├── IntervalExtension.scala │ │ │ ├── layer │ │ │ │ └── StacLayer.scala │ │ │ └── periodic │ │ │ │ └── PeriodicExtent.scala │ │ │ ├── meta │ │ │ ├── ForeignImplicits.scala │ │ │ ├── GeoTrellisImplicits.scala │ │ │ ├── HasInstant.scala │ │ │ └── package.scala │ │ │ └── syntax │ │ │ └── package.scala │ └── shared │ │ └── src │ │ └── main │ │ └── scala │ │ └── com │ │ └── azavea │ │ └── stac4s │ │ ├── ItemCollection.scala │ │ ├── ItemDatetime.scala │ │ ├── ItemProperties.scala │ │ ├── ProductFieldNames.scala │ │ ├── SpdxId.scala │ │ ├── StacAsset.scala │ │ ├── StacAssetRole.scala │ │ ├── StacCatalog.scala │ │ ├── StacLicense.scala │ │ ├── StacLink.scala │ │ ├── StacLinkType.scala │ │ ├── StacMediaType.scala │ │ ├── StacProvider.scala │ │ ├── StacProviderRole.scala │ │ ├── Syntax.scala │ │ ├── TemporalExtent.scala │ │ ├── extensions │ │ ├── CatalogExtension.scala │ │ ├── ItemAssetExtension.scala │ │ ├── ItemCollectionExtension.scala │ │ ├── ItemExtension.scala │ │ ├── LinkExtension.scala │ │ ├── asset │ │ │ └── StacCollectionAsset.scala │ │ ├── eo │ │ │ ├── Band.scala │ │ │ ├── EOAssetExtension.scala │ │ │ ├── EOItemExtension.scala │ │ │ └── package.scala │ │ ├── label │ │ │ ├── LabelClass.scala │ │ │ ├── LabelClassClasses.scala │ │ │ ├── LabelClassName.scala │ │ │ ├── LabelCount.scala │ │ │ ├── LabelItemExtension.scala │ │ │ ├── LabelLinkExtension.scala │ │ │ ├── LabelMethod.scala │ │ │ ├── LabelOverview.scala │ │ │ ├── LabelProperties.scala │ │ │ ├── LabelStats.scala │ │ │ ├── LabelTask.scala │ │ │ └── LabelType.scala │ │ ├── layer │ │ │ ├── LayerItemExtension.scala │ │ │ └── StacLayerProperties.scala │ │ └── package.scala │ │ ├── meta │ │ └── ValidStacVersion.scala │ │ ├── package.scala │ │ └── types.scala └── testing │ ├── js │ └── src │ │ └── main │ │ └── scala │ │ └── JsInstances.scala │ ├── jvm │ └── src │ │ └── main │ │ └── scala │ │ └── JvmInstances.scala │ └── shared │ └── src │ └── main │ └── scala │ ├── TestInstances.scala │ └── testing.scala ├── project ├── Versions.scala ├── build.properties └── plugins.sbt └── sbt /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Expected behavior** 14 | A clear and concise description of what you expected to happen. 15 | 16 | **Additional context** 17 | Add any other context about the problem here. 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancements---feature-requests.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancements + Feature Requests 3 | about: Create an issue to log a possible improvement 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Improvement 11 | 12 | What is it that could be improved and how it could be improved. 13 | 14 | ### Notes + Context 15 | 16 | Any additional insights including possible approaches, user feedback, or historical context. 17 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | Brief description of what this PR does, and why it is needed. 4 | 5 | ### Checklist 6 | 7 | - [ ] New tests have been added or existing tests have been modified 8 | - [ ] Changelog updated (please use [`chan`](https://www.npmjs.com/package/@geut/chan)) 9 | 10 | ### Notes 11 | 12 | Optional. Ancillary topics, caveats, alternative strategies that didn't work out, anything else. 13 | 14 | Closes #XXX (if applicable) 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: '$NEXT_MINOR_VERSION' 2 | tag-template: 'v$NEXT_MINOR_VERSION' 3 | categories: 4 | - title: 'Added' 5 | labels: 6 | - 'feature' 7 | - title: 'Changed' 8 | labels: 9 | - 'enhancement' 10 | - 'dependency-update' 11 | - 'dependencies' 12 | - title: 'Fixed' 13 | labels: 14 | - 'fix' 15 | - 'bugfix' 16 | - 'bug' 17 | include-labels: 18 | - 'feature' 19 | - 'enhancement' 20 | - 'dependency-update' 21 | - 'dependencies' 22 | - 'fix' 23 | - 'bugfix' 24 | - 'bug' 25 | exclude-labels: 26 | - 'skip-changelog' 27 | - 'docs' 28 | - 'build' 29 | change-template: '- $TITLE [#$NUMBER](https://github.com/stac-utils/stac4s/pull/$NUMBER) (@$AUTHOR)' 30 | template: | 31 | $CHANGES 32 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | branches: ['**'] 5 | push: 6 | branches: ['**'] 7 | tags: [v*] 8 | jobs: 9 | build: 10 | name: Build and Test 11 | if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != 'stac-utils/stac4s' 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest] 15 | java: [11] 16 | distribution: [temurin] 17 | runs-on: ${{ matrix.os }} 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | - uses: coursier/cache-action@v6 23 | - uses: actions/setup-java@v4 24 | with: 25 | distribution: ${{ matrix.distribution }} 26 | java-version: ${{ matrix.java }} 27 | 28 | - name: Install sbt 29 | uses: sbt/setup-sbt@v1 30 | 31 | - name: Check formatting 32 | run: sbt ";scalafix --check; scalafmtCheck; scalafmtSbtCheck; scapegoat;" 33 | 34 | - name: Build project 35 | run: sbt +test 36 | 37 | publish: 38 | name: Publish Artifacts 39 | needs: [build] 40 | if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) 41 | strategy: 42 | matrix: 43 | os: [ubuntu-latest] 44 | java: [8] 45 | distribution: [temurin] 46 | runs-on: ${{ matrix.os }} 47 | steps: 48 | - uses: actions/checkout@v4 49 | with: 50 | fetch-depth: 0 51 | - uses: coursier/cache-action@v6 52 | - uses: actions/setup-java@v4 53 | with: 54 | distribution: ${{ matrix.distribution }} 55 | java-version: ${{ matrix.java }} 56 | 57 | - name: Install sbt 58 | uses: sbt/setup-sbt@v1 59 | 60 | - name: Release 61 | run: sbt ci-release 62 | env: 63 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 64 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 65 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 66 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 67 | if: ${{ env.SONATYPE_PASSWORD != '' && env.SONATYPE_USERNAME != '' }} 68 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, reopened, synchronize] 9 | 10 | jobs: 11 | update_release_draft: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: release-drafter/release-drafter@v6.1.0 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Sass Cache 2 | .sass-cache 3 | 4 | # css map 5 | .css.map 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | 11 | # C extensions 12 | *.so 13 | 14 | # PyInstaller 15 | # Usually these files are written by a python script from a template 16 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 17 | *.manifest 18 | *.spec 19 | 20 | # Installer logs 21 | pip-log.txt 22 | pip-delete-this-directory.txt 23 | 24 | # Unit test / coverage reports 25 | htmlcov/ 26 | .tox/ 27 | .coverage 28 | .cache 29 | nosetests.xml 30 | coverage.xml 31 | 32 | # Translations 33 | *.mo 34 | *.pot 35 | 36 | # Django stuff: 37 | *.log 38 | 39 | # Ansible 40 | deployment/ansible/roles/azavea.* 41 | *.retry 42 | 43 | # Vagrant 44 | .vagrant 45 | 46 | # NodeJS / Browserify stuff 47 | node_modules/ 48 | npm-debug.log 49 | 50 | # Emacs 51 | \#*# 52 | *~ 53 | .#* 54 | TAGS 55 | 56 | # Vim 57 | .*.swp 58 | 59 | # MacOS 60 | .DS_Store 61 | 62 | # Scala 63 | *.class 64 | *.log 65 | 66 | # sbt specific 67 | .cache 68 | .coursier-cache 69 | .history 70 | .lib/ 71 | dist/* 72 | target/ 73 | lib_managed/ 74 | src_managed/ 75 | project/boot/ 76 | project/plugins/project/ 77 | /project/.sbtboot 78 | /project/.boot/ 79 | /project/.ivy/ 80 | 81 | # Molecule 82 | .molecule/ 83 | *__pycache__* 84 | 85 | # Scala-IDE specific 86 | .scala_dependencies 87 | .worksheet 88 | .ensime/* 89 | .ensime 90 | .metals/* 91 | .metals 92 | .bsp/* 93 | .bsp 94 | /.ivy2/* 95 | .sbt/ 96 | metals.lock.db 97 | .metals/ 98 | .bloop/ 99 | project/ 100 | 101 | /.env 102 | /.envrc 103 | 104 | .node_modules 105 | dist/ 106 | .vscode 107 | 108 | /data 109 | .idea 110 | .ensime_cache 111 | /app-tasks/jars/rf-batch.jar 112 | /data 113 | 114 | .vscode 115 | 116 | # Patch files 117 | *.patch 118 | scratch/ 119 | 120 | stats.json 121 | app-lambda/opt/* 122 | /app-lambda/package.sh 123 | 124 | # js files from docusaurus 125 | node_modules/* 126 | website/build/* 127 | /project/metals.sbt 128 | -------------------------------------------------------------------------------- /.jabbarc: -------------------------------------------------------------------------------- 1 | adopt@1.8.0-232 -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: Automatic merge on approval 3 | conditions: 4 | - author=scala-steward 5 | - "status-success=ci/circleci: openjdk8-scala2.12" 6 | actions: 7 | merge: 8 | method: squash 9 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v14.15.0 2 | -------------------------------------------------------------------------------- /.sbtopts: -------------------------------------------------------------------------------- 1 | -J-Xmx2g 2 | -J-Xms64m 3 | -J-Xss2M 4 | -J-XX:+UseG1GC 5 | -J-XX:+UseStringDeduplication 6 | -J-XX:+UseCompressedOops 7 | -Dfile.encoding=UTF8 8 | -Djava.awt.headless=true 9 | -Dsun.io.serialization.extendedDebugInfo=true 10 | -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | rules = [ 2 | ProcedureSyntax, 3 | RemoveUnused, 4 | OrganizeImports 5 | ] 6 | 7 | OrganizeImports { 8 | coalesceToWildcardImportThreshold = 2147483647 # Int.MaxValue 9 | expandRelative = true 10 | groupExplicitlyImportedImplicitsSeparately = false 11 | groupedImports = Merge 12 | groups = ["com.azavea.stac4s", "*", "scala.", "re:javax?\\."] 13 | importSelectorsOrder = Ascii 14 | importsOrder = Ascii 15 | removeUnused = true 16 | } 17 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | runner.dialect = scala212 2 | version = 3.8.6 3 | align.preset = more // For pretty alignment. 4 | maxColumn = 120 5 | newlines.topLevelStatementBlankLines = [ 6 | { 7 | blanks { before = 1 } 8 | } 9 | ] 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 5 | 6 | ## [Unreleased] 7 | 8 | ## [0.9.0] - 2023-10-03 9 | ### Changed 10 | - Dependencies update and GitHub Actions Configuration [#607](https://github.com/stac-utils/stac4s/pull/607) (@pomadchin) 11 | - GeoTrellis 3.7.0, CE 3 [#602](https://github.com/stac-utils/stac4s/pull/602) (@echeipesh) 12 | 13 | ## [0.8.0] - 2022-03-30 14 | ### Changed 15 | - Pagination Improvements [#496](https://github.com/azavea/stac4s/pull/496) 16 | - Make SearchFilters pagination agnostic [#502](https://github.com/azavea/stac4s/pull/502) 17 | 18 | ## [0.7.2] - 2021-10-12 19 | ### Changed 20 | - Make pagination more generic and not Franklin specific [#413](https://github.com/azavea/stac4s/pull/413) 21 | 22 | ## [0.7.1] - 2021-09-23 23 | ### Changed 24 | - Expose client.search overload with an optional filter argument [#406](https://github.com/azavea/stac4s/pull/406) 25 | 26 | ## [0.7.0] - 2021-09-21 27 | ### Fixed 28 | - Allowed items to have both point in time and time range datetimes [#405](https://github.com/azavea/stac4s/pull/405) 29 | 30 | ## [0.6.2] - 2021-07-29 31 | ### Fixed 32 | - Corrected error accumulation for custom decoders [#373](https://github.com/azavea/stac4s/pull/373) 33 | 34 | ## [0.6.1] - 2021-07-13 35 | ### Changed 36 | - Add a correct content type to POST requests and SearchFilters adjustments [#359](https://github.com/azavea/stac4s/pull/359) 37 | 38 | ## [0.6.0] - 2021-07-01 39 | ### Added 40 | - Add STACClient itemUpdate, itemPatch and itemDelete methods [#346](https://github.com/azavea/stac4s/pull/346) 41 | 42 | ### Changed 43 | - Replace withPath with addPath to allow paths in the STAC API URI [#351](https://github.com/azavea/stac4s/pull/351) 44 | 45 | ## [0.5.0] - 2021-06-02 46 | ### Added 47 | - Add Scala 2.13 cross compilation [#310](https://github.com/azavea/stac4s/pull/310) 48 | - STAC Client pagination support [#327](https://github.com/azavea/stac4s/pull/327) 49 | - Brought compatibility up to 1.0.0 [#339](https://github.com/azavea/stac4s/pull/339) 50 | 51 | ### Changed 52 | - Make StacClient type alias a trait [#325](https://github.com/azavea/stac4s/pull/325) 53 | 54 | ## [0.4.0] - 2021-05-12 55 | ### Fixed 56 | - Item properties are no longer treated as a black box JsonObject [#309](https://github.com/azavea/stac4s/pull/309) 57 | 58 | ## [0.3.0] - 2021-05-07 59 | ### Changed 60 | - Update StacClient and SearchFilters JSON codecs [#305](https://github.com/azavea/stac4s/pull/305) 61 | 62 | ## [0.2.3] - 2021-05-04 63 | ### Fixed 64 | - Encoders make deliberate choices about dropping or not dropping nulls [#302](https://github.com/azavea/stac4s/pull/302) 65 | 66 | ## [0.2.2] - 2021-04-28 67 | ### Fixed 68 | - JVM StacCollections also have a type [#299](https://github.com/azavea/stac4s/pull/299) 69 | 70 | ## [0.2.1] - 2021-04-20 71 | ### Fixed 72 | - Bounded generators to prevent downstream test speed and memory issues [#290](https://github.com/azavea/stac4s/pull/290) 73 | 74 | ## [0.2.0] - 2021-04-19 75 | ### Added 76 | - Tested bbox union invariants [#266](https://github.com/azavea/stac4s/pull/266) 77 | - Modeled periodic extent and made intervals extensible [#276](https://github.com/azavea/stac4s/pull/276) 78 | - Updated models for compatibility with spec version 1.0.0-rc2 (breaking) [#283](https://github.com/azavea/stac4s/pull/283) 79 | 80 | ## [0.1.1] - 2021-03-12 81 | ### Fixed 82 | - Told circle only to publish tags that start with `v` [#190](https://github.com/azavea/stac4s/pull/190) 83 | - Review client specs and make them more deterministic [#212](https://github.com/azavea/stac4s/pull/212) 84 | 85 | ### Added 86 | - Added `StacLayer` and `StacLayerProperties` types [#252](https://github.com/azavea/stac4s/pull/252) 87 | - Bboxes can be unioned and form a semigroup [#259](https://github.com/azavea/stac4s/pull/259) 88 | 89 | ## [0.0.21] - 2021-01-08 90 | ### Fixed 91 | - Fix Client signatures [#210](https://github.com/azavea/stac4s/pull/210) 92 | 93 | ## [0.0.20] - 2021-01-04 94 | ### Added 95 | - Сlient module [#140](https://github.com/azavea/stac4s/pull/140) 96 | 97 | ## [0.0.19] - 2020-12-11 98 | ### Fixed 99 | - Repaired build.sbt configuration to get sonatype publication to cooperate [#186](https://github.com/azavea/stac4s/pull/186) 100 | 101 | ## [0.0.18] - 2020-11-23 102 | ### Added 103 | - Added cross-project configuration for Scala.js modules [#157](https://github.com/azavea/stac4s/pull/157) 104 | 105 | ## [0.0.17] - 2020-11-11 106 | ### Changed 107 | - SPDX license ids are captured by a specific enum rather than a refinement with validation [#172](https://github.com/azavea/stac4s/pull/172) 108 | 109 | ## [0.0.16] - 2020-09-30 110 | ### Changed 111 | - Remove joda time [#153](https://github.com/azavea/stac4s/pull/153) 112 | 113 | ## [0.0.15] - 2020-09-29 114 | ### Changed 115 | - All implicit imports from cats are moved to specific `cats.syntax.foo` imports [#146](https://github.com/azavea/stac4s/pull/146) 116 | - Time types are derived from `org.joda.time.Instant` instead of stock `java` time types [#152](https://github.com/azavea/stac4s/pull/152) 117 | 118 | ## [0.0.14] - 2020-08-06 119 | ### Changed 120 | - Generators for bboxes now always generate valid bboxes [#135](https://github.com/azavea/stac4s/pull/135) 121 | 122 | ## [0.0.13] - 2020-07-31 123 | ### Added 124 | - STAC 1.0.0-beta.1 support [#116](https://github.com/azavea/stac4s/pull/116) 125 | - STAC Layer extension spec [#126](https://github.com/azavea/stac4s/pull/126) 126 | 127 | ### Changed 128 | - Update `StacMediaType` for `geotiff` and `cog` in 1.0.0-beta.1 spec [#132](https://github.com/azavea/stac4s/pull/132) 129 | 130 | ### Removed 131 | - Remove STAC Catalof specs against manually created catalogs from tests [#126](https://github.com/azavea/stac4s/pull/126) 132 | 133 | ### Fixed 134 | - STAC Catalogs do not require the `stac_extensions` field [#127](https://github.com/azavea/stac4s/pull/127) 135 | 136 | ## [0.0.10] - 2020-06-17 137 | ### Added 138 | - Publication of `testing` module, which includes `scalacheck` generators for STAC base and extension types [#104](https://github.com/azavea/stac4s/pull/104) 139 | 140 | ### Changed 141 | - Receive GPG key while publishing artifacts [#101](https://github.com/azavea/stac4s/pull/101) 142 | 143 | ## [0.0.9] - 2020-06-02 144 | ### Fixed 145 | - Vendor enum (link types, media types, etc.) representations no longer prefix `vendor-` in serialization [#94](https://github.com/azavea/stac4s/pull/94) 146 | - Missing `derived_from` link type was included [#94](https://github.com/azavea/stac4s/pull/94) 147 | - Decoding collections from json does not require the `properties` field [#97](https://github.com/azavea/stac4s/pull/97) 148 | 149 | ## [0.0.8] - 2020-05-27 150 | ### Added 151 | - Created typeclasses for linking extensions to the items they extend [#85](https://github.com/azavea/stac4s/pull/85) 152 | - Created extension data model for EO Extension [#92](https://github.com/azavea/stac4s/pull/92) 153 | 154 | ### Changed 155 | - Reduce boilerplate in fieldnames derivation [#90](https://github.com/azavea/stac4s/issues/90) 156 | 157 | ### Fixed 158 | - Stopped generating `NaN`s as valid `Double` values [#91](https://github.com/azavea/stac4s/pull/91) 159 | 160 | ## [0.0.4] - 2020-03-25 161 | ### Added 162 | - Added ability to transform `Bbox` to `Extent` [#5](https://github.com/azavea/stac4s/pull/5) 163 | 164 | ### Changed 165 | - Updated to GeoTrellis 3.2 [#5](https://github.com/azavea/stac4s/pull/5) 166 | 167 | ### Removed 168 | - Removed `core` from package naming [#5](https://github.com/azavea/stac4s/pull/5) 169 | 170 | ## [0.0.3] - 2019-12-20 171 | ### Added 172 | - Added ability to transform `Bbox` to `Extent` [#5](https://github.com/azavea/stac4s/pull/5) 173 | 174 | ### Changed 175 | - Updated to GeoTrellis 3.2 [#5](https://github.com/azavea/stac4s/pull/5) 176 | 177 | ### Removed 178 | - Removed `core` from package naming [#5](https://github.com/azavea/stac4s/pull/5) 179 | 180 | [Unreleased]: https://github.com/azavea/stac4s/compare/v0.9.0...HEAD 181 | [0.9.0]: https://github.com/azavea/stac4s/compare/v0.8.0...v0.9.0 182 | [0.8.0]: https://github.com/azavea/stac4s/compare/v0.7.2...v0.8.0 183 | [0.7.2]: https://github.com/azavea/stac4s/compare/v0.7.1...v0.7.2 184 | [0.7.1]: https://github.com/azavea/stac4s/compare/v0.7.0...v0.7.1 185 | [0.7.0]: https://github.com/azavea/stac4s/compare/v0.6.2...v0.7.0 186 | [0.6.2]: https://github.com/azavea/stac4s/compare/v0.6.1...v0.6.2 187 | [0.6.1]: https://github.com/azavea/stac4s/compare/v0.6.0...v0.6.1 188 | [0.6.0]: https://github.com/azavea/stac4s/compare/v0.5.0...v0.6.0 189 | [0.5.0]: https://github.com/azavea/stac4s/compare/v0.4.0...v0.5.0 190 | [0.4.0]: https://github.com/azavea/stac4s/compare/v0.3.0...v0.4.0 191 | [0.3.0]: https://github.com/azavea/stac4s/compare/v0.2.3...v0.3.0 192 | [0.2.3]: https://github.com/azavea/stac4s/compare/v0.2.2...v0.2.3 193 | [0.2.2]: https://github.com/azavea/stac4s/compare/v0.2.1...v0.2.2 194 | [0.2.1]: https://github.com/azavea/stac4s/compare/v0.2.0...v0.2.1 195 | [0.2.0]: https://github.com/azavea/stac4s/compare/v0.1.1...v0.2.0 196 | [0.1.1]: https://github.com/azavea/stac4s/compare/v0.0.21...v0.1.1 197 | [0.0.21]: https://github.com/azavea/stac4s/compare/v0.0.20...v0.0.21 198 | [0.0.20]: https://github.com/azavea/stac4s/compare/v0.0.19...v0.0.20 199 | [0.0.19]: https://github.com/azavea/stac4s/compare/v0.0.18...v0.0.19 200 | [0.0.18]: https://github.com/azavea/stac4s/compare/v0.0.17...v0.0.18 201 | [0.0.17]: https://github.com/azavea/stac4s/compare/v0.0.16...v0.0.17 202 | [0.0.16]: https://github.com/azavea/stac4s/tree/0.0.16 203 | [0.0.15]: https://github.com/azavea/stac4s/tree/0.0.15 204 | [0.0.14]: https://github.com/azavea/stac4s/tree/0.0.14 205 | [0.0.13]: https://github.com/azavea/stac4s/tree/0.0.13 206 | [0.0.10]: https://github.com/azavea/stac4s/tree/0.0.10 207 | [0.0.9]: https://github.com/azavea/stac4s/tree/0.0.9 208 | [0.0.8]: https://github.com/azavea/stac4s/tree/0.0.8 209 | [0.0.4]: https://github.com/azavea/stac4s/tree/0.0.4 210 | [0.0.3]: https://github.com/azavea/stac4s/tree/0.0.3 211 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stac4s 2 | 3 | [![CI](https://github.com/stac-utils/stac4s/workflows/CI/badge.svg)](https://github.com/stac-utils/stac4s/actions) [![Maven Central](https://img.shields.io/maven-central/v/com.azavea.stac4s/client_2.12?color=blue)](http://search.maven.org/#search%7Cga%7C1%7com.azavea.stac4s) [![Snapshots](https://img.shields.io/nexus/s/https/oss.sonatype.org/com.azavea.stac4s/client_2.12.svg)](https://oss.sonatype.org/content/repositories/snapshots/com/azavea/stac4s/) 4 | 5 | [![Join the chat at https://gitter.im/azavea/stac4s](https://badges.gitter.im/azavea/stac4s.svg)](https://gitter.im/azavea/stac4s?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Scala Steward badge](https://img.shields.io/badge/Scala_Steward-helping-blue.svg?style=flat&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAQCAMAAAARSr4IAAAAVFBMVEUAAACHjojlOy5NWlrKzcYRKjGFjIbp293YycuLa3pYY2LSqql4f3pCUFTgSjNodYRmcXUsPD/NTTbjRS+2jomhgnzNc223cGvZS0HaSD0XLjbaSjElhIr+AAAAAXRSTlMAQObYZgAAAHlJREFUCNdNyosOwyAIhWHAQS1Vt7a77/3fcxxdmv0xwmckutAR1nkm4ggbyEcg/wWmlGLDAA3oL50xi6fk5ffZ3E2E3QfZDCcCN2YtbEWZt+Drc6u6rlqv7Uk0LdKqqr5rk2UCRXOk0vmQKGfc94nOJyQjouF9H/wCc9gECEYfONoAAAAASUVORK5CYII=)](https://scala-steward.org) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 6 | 7 | A scala project that provides types and basic functionality for working with [SpatioTemporal Asset Catalogs](https://stacspec.org). This library is the basis for projects like [Franklin](https://azavea.github.io/franklin/) and others. 8 | 9 | ### Usage 10 | 11 | The following STAC types are covered by this library: 12 | - 2d- and 3d- Bounding Boxes 13 | - Collection 14 | - Asset 15 | - Catalog 16 | - Temporal and Spatial Extents 17 | - Item 18 | - License 19 | - Link & Link Types 20 | - Relations 21 | - Providers & Roles 22 | - Media Types 23 | 24 | On its own this library does not provide much functionality; however, it can form a strong foundation for building catalogs and applications, especially when paired with libraries like [GeoTrellis Server](https://github.com/geotrellis/geotrellis-server) and [GeoTrellis](https://geotrellis.io). 25 | 26 | ### Contributing 27 | 28 | Contributions can be made via [pull requests](https://github.com/azavea/stac4s/pulls). You will need to fork the repository to your personal account, create a branch, update your fork, then make a pull request. Additionally, if you find a bug or have an idea for a feature/extension we would appreciate it if you opened an [issue](https://github.com/azavea/stac4s/issues) so we can work on a fix. 29 | 30 | ### Deployments, Releases, and Maintenance 31 | 32 | `master` signals the current unreleased, actively developed codebase. Each release will have an associated `git tag` is handled automatically via a CI job once a tag is pushed. Releases are handled automatically in CI. To produce a release: 33 | 34 | - make sure `master` is up-to-date: `git checkout master && git pull origin master` 35 | - rotate changelog entries into a section for the version you're releasing using `chan release X.Y.Z` 36 | - commit your changelog rotation: `git commit -am "Release X.Y.Z"` 37 | - make and push an annotated tag for your release and push `master`: 38 | 39 | ```bash 40 | $ git tag -s -a vX.Y.Z -m "Release version " 41 | $ git push origin --tags 42 | $ git push origin master 43 | ``` 44 | 45 | After you've pushed the tag, create a GitHub release using `chan gh-release X.Y.Z`. 46 | 47 | Active development and backports for a particular _minor_ version of `stac4s` will be tracked and maintained on a branch for that series (e.g. `series/0.1.x`, `series/0.2.x`, etc). Pull requests to backport a fix, feature, or other change should be made to that series' respective branch. 48 | 49 | Care will be taken to try and maintain backwards binary compatibility for all minor and bugfix releases. 50 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import xerial.sbt.Sonatype._ 2 | 3 | ThisBuild / versionScheme := Some("semver-spec") 4 | 5 | lazy val commonSettings = Seq( 6 | scalaVersion := "2.13.15", 7 | crossScalaVersions := List("2.13.15", "2.12.20"), 8 | Global / cancelable := true, 9 | scalafmtOnCompile := false, 10 | ThisBuild / scapegoatVersion := Versions.Scapegoat, 11 | scapegoatDisabledInspections := Seq("ObjectNames", "EmptyCaseClass"), 12 | addCompilerPlugin("org.typelevel" %% "kind-projector" % "0.13.3" cross CrossVersion.full), 13 | addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1"), 14 | addCompilerPlugin(scalafixSemanticdb), 15 | autoCompilerPlugins := true, 16 | externalResolvers := Seq(DefaultMavenRepository) ++ Resolver.sonatypeOssRepos("snapshots") ++ Seq( 17 | Resolver.typesafeIvyRepo("releases"), 18 | Resolver.bintrayRepo("azavea", "maven"), 19 | Resolver.bintrayRepo("azavea", "geotrellis"), 20 | "locationtech-releases" at "https://repo.locationtech.org/content/groups/releases", 21 | "locationtech-snapshots" at "https://repo.locationtech.org/content/groups/snapshots", 22 | Resolver.bintrayRepo("guizmaii", "maven"), 23 | Resolver.bintrayRepo("colisweb", "maven"), 24 | "jitpack".at("https://jitpack.io"), 25 | Resolver.file("local", file(Path.userHome.absolutePath + "/.ivy2/local"))( 26 | Resolver.ivyStylePatterns 27 | ) 28 | ) 29 | ) 30 | 31 | lazy val noPublishSettings = Seq( 32 | publish := {}, 33 | publishLocal := {}, 34 | publishArtifact := false 35 | ) 36 | 37 | lazy val publishSettings = Seq( 38 | organization := "com.azavea.stac4s", 39 | organizationName := "Azavea", 40 | organizationHomepage := Some(new URL("https://azavea.com/")), 41 | description := "stac4s is a scala library with primitives to build applications using the SpatioTemporal Asset Catalogs specification", 42 | Test / publishArtifact := false 43 | ) ++ sonatypeSettings 44 | 45 | lazy val sonatypeSettings = Seq( 46 | publishMavenStyle := true, 47 | sonatypeProfileName := "com.azavea", 48 | sonatypeProjectHosting := Some(GitHubHosting(user = "azavea", repository = "stac4s", email = "systems@azavea.com")), 49 | developers := List( 50 | Developer( 51 | id = "cbrown", 52 | name = "Christopher Brown", 53 | email = "cbrown@azavea.com", 54 | url = url("https://github.com/notthatbreezy") 55 | ), 56 | Developer( 57 | id = "jsantucci", 58 | name = "James Santucci", 59 | email = "jsantucci@azavea.com", 60 | url = url("https://github.com/jisantuc") 61 | ), 62 | Developer( 63 | id = "aaronxsu", 64 | name = "Aaron Su", 65 | email = "asu@azavea.com", 66 | url = url("https://github.com/aaronxsu") 67 | ), 68 | Developer( 69 | id = "pomadchin", 70 | name = "Grigory Pomadchin", 71 | email = "gpomadchin@azavea.com", 72 | url = url("https://github.com/pomadchin") 73 | ), 74 | Developer( 75 | id = "azavea", 76 | name = "Azavea Inc.", 77 | email = "systems@azavea.com", 78 | url = url("https://www.azavea.com") 79 | ) 80 | ), 81 | licenses := Seq("Apache-2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0.txt")) 82 | ) 83 | 84 | val jvmGeometryDependencies = Def.setting { 85 | Seq( 86 | "org.locationtech.jts" % "jts-core" % Versions.Jts, 87 | "org.locationtech.geotrellis" %% "geotrellis-vector" % Versions.GeoTrellis 88 | ) 89 | } 90 | 91 | val coreDependenciesJVM = Def.setting { 92 | Seq( 93 | "org.threeten" % "threeten-extra" % Versions.ThreeTenExtra, 94 | "io.circe" %% "circe-json-schema" % Versions.CirceJsonSchema 95 | ) ++ jvmGeometryDependencies.value 96 | } 97 | 98 | val testingDependenciesJVM = Def.setting { 99 | Seq( 100 | "org.locationtech.geotrellis" %% "geotrellis-vector" % Versions.GeoTrellis, 101 | "org.locationtech.jts" % "jts-core" % Versions.Jts, 102 | "org.threeten" % "threeten-extra" % Versions.ThreeTenExtra 103 | ) 104 | } 105 | 106 | val testRunnerDependenciesJVM = Seq( 107 | "io.circe" %% "circe-testing" % Versions.Circe % Test, 108 | "org.scalatest" %% "scalatest" % Versions.Scalatest % Test, 109 | "org.scalatestplus" %% "scalacheck-1-16" % Versions.ScalatestPlusScalacheck % Test 110 | ) 111 | 112 | lazy val root = project 113 | .in(file(".")) 114 | .settings(name := "stac4s") 115 | .settings(commonSettings) 116 | .settings(publishSettings) 117 | .settings(noPublishSettings) 118 | .aggregate(coreJS, coreJVM, testingJS, testingJVM, coreTestJS, coreTestJVM, clientJS, clientJVM) 119 | 120 | lazy val core = crossProject(JSPlatform, JVMPlatform) 121 | .in(file("modules/core")) 122 | .settings(commonSettings) 123 | .settings(publishSettings) 124 | .settings({ 125 | libraryDependencies ++= Seq( 126 | "com.beachape" %%% "enumeratum" % Versions.Enumeratum, 127 | "com.beachape" %%% "enumeratum-circe" % Versions.Enumeratum, 128 | "com.chuusai" %%% "shapeless" % Versions.Shapeless, 129 | "com.github.julien-truffaut" %%% "monocle-core" % Versions.Monocle, 130 | "com.github.julien-truffaut" %%% "monocle-macro" % Versions.Monocle, 131 | "eu.timepit" %%% "refined" % Versions.Refined, 132 | "io.circe" %%% "circe-core" % Versions.Circe, 133 | "io.circe" %%% "circe-generic" % Versions.Circe, 134 | "io.circe" %%% "circe-parser" % Versions.Circe, 135 | "io.circe" %%% "circe-refined" % Versions.CirceRefined, 136 | "org.typelevel" %%% "cats-core" % Versions.Cats, 137 | "org.typelevel" %%% "cats-kernel" % Versions.Cats 138 | ) 139 | }) 140 | .jvmSettings(libraryDependencies ++= coreDependenciesJVM.value) 141 | .jsSettings(libraryDependencies += "io.github.cquiroz" %%% "scala-java-time" % Versions.ScalaJavaTime) 142 | 143 | lazy val coreJVM = core.jvm 144 | lazy val coreJS = core.js 145 | 146 | lazy val testing = crossProject(JSPlatform, JVMPlatform) 147 | .in(file("modules/testing")) 148 | .dependsOn(core) 149 | .settings(commonSettings) 150 | .settings(publishSettings) 151 | .settings( 152 | libraryDependencies ++= Seq( 153 | "com.beachape" %%% "enumeratum" % Versions.Enumeratum, 154 | "com.beachape" %%% "enumeratum-scalacheck" % Versions.Enumeratum, 155 | "com.chuusai" %%% "shapeless" % Versions.Shapeless, 156 | "eu.timepit" %%% "refined-scalacheck" % Versions.Refined, 157 | "eu.timepit" %%% "refined" % Versions.Refined, 158 | "io.chrisdavenport" %%% "cats-scalacheck" % Versions.ScalacheckCats, 159 | "io.circe" %%% "circe-core" % Versions.Circe, 160 | "io.circe" %%% "circe-literal" % Versions.Circe, 161 | "org.scalacheck" %%% "scalacheck" % Versions.Scalacheck, 162 | "org.typelevel" %%% "cats-core" % Versions.Cats 163 | ) 164 | ) 165 | .jvmSettings(libraryDependencies ++= testingDependenciesJVM.value) 166 | .jsSettings(libraryDependencies += "io.github.cquiroz" %%% "scala-java-time" % Versions.ScalaJavaTime % Test) 167 | 168 | lazy val testingJVM = testing.jvm 169 | lazy val testingJS = testing.js 170 | 171 | lazy val coreTest = crossProject(JSPlatform, JVMPlatform) 172 | .in(file("modules/core-test")) 173 | .dependsOn(testing % Test) 174 | .settings(commonSettings) 175 | .settings(noPublishSettings) 176 | .settings( 177 | libraryDependencies ++= Seq( 178 | "io.circe" %%% "circe-testing" % Versions.Circe % Test, 179 | "org.scalatest" %%% "scalatest" % Versions.Scalatest % Test, 180 | "org.scalatestplus" %%% "scalacheck-1-16" % Versions.ScalatestPlusScalacheck % Test, 181 | "org.typelevel" %%% "discipline-scalatest" % Versions.DisciplineScalatest % Test 182 | ) 183 | ) 184 | .jsSettings(libraryDependencies += "io.github.cquiroz" %%% "scala-java-time" % Versions.ScalaJavaTime % Test) 185 | 186 | lazy val coreTestJVM = coreTest.jvm 187 | lazy val coreTestJS = coreTest.js 188 | lazy val coreTestRef = LocalProject("modules/core-test") 189 | 190 | lazy val client = crossProject(JSPlatform, JVMPlatform) 191 | .in(file("modules/client")) 192 | .dependsOn(core, testing % Test) 193 | .settings(commonSettings) 194 | .settings(publishSettings) 195 | .settings( 196 | libraryDependencies ++= Seq( 197 | "io.circe" %%% "circe-core" % Versions.Circe, 198 | "io.circe" %%% "circe-generic" % Versions.Circe, 199 | "io.circe" %%% "circe-refined" % Versions.CirceRefined, 200 | "com.chuusai" %%% "shapeless" % Versions.Shapeless, 201 | "eu.timepit" %%% "refined" % Versions.Refined, 202 | "org.typelevel" %%% "cats-core" % Versions.Cats, 203 | "com.softwaremill.sttp.client3" %%% "core" % Versions.Sttp, 204 | "com.softwaremill.sttp.client3" %%% "circe" % Versions.Sttp, 205 | "com.softwaremill.sttp.client3" %%% "json-common" % Versions.Sttp, 206 | "com.softwaremill.sttp.model" %%% "core" % Versions.SttpModel, 207 | "com.softwaremill.sttp.shared" %%% "core" % Versions.SttpShared, 208 | "co.fs2" %%% "fs2-core" % Versions.Fs2, 209 | "org.scalatest" %%% "scalatest" % Versions.Scalatest % Test 210 | ) 211 | ) 212 | .jsSettings(libraryDependencies += "io.github.cquiroz" %%% "scala-java-time" % Versions.ScalaJavaTime) 213 | .jvmSettings(libraryDependencies ++= jvmGeometryDependencies.value) 214 | 215 | lazy val clientJVM = client.jvm 216 | lazy val clientJS = client.js 217 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.3' 2 | services: 3 | database: 4 | image: quay.io/azavea/postgis:2.3-postgres9.6-slim 5 | environment: 6 | - POSTGRES_USER=stac4s 7 | - POSTGRES_PASSWORD=stac4s 8 | - POSTGRES_DB=stac4s 9 | ports: 10 | - "5432:5432" 11 | healthcheck: 12 | test: ["CMD", "pg_isready", "-U", "stac4s"] 13 | interval: 3s 14 | timeout: 3s 15 | retries: 3 16 | start_period: 5s 17 | -------------------------------------------------------------------------------- /modules/client/js/src/main/scala/com/azavea/stac4s/api/client/SearchFilters.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api.client 2 | 3 | import com.azavea.stac4s.api.client.util.ClientCodecs 4 | import com.azavea.stac4s.geometry.Geometry 5 | import com.azavea.stac4s.{Bbox, TemporalExtent, productFieldNames} 6 | 7 | import cats.syntax.option._ 8 | import eu.timepit.refined.types.numeric.NonNegInt 9 | import io.circe._ 10 | import io.circe.refined._ 11 | import io.circe.syntax._ 12 | 13 | case class SearchFilters( 14 | bbox: Option[Bbox] = None, 15 | datetime: Option[TemporalExtent] = None, 16 | intersects: Option[Geometry] = None, 17 | collections: List[String] = Nil, 18 | items: List[String] = Nil, 19 | limit: Option[NonNegInt] = None, 20 | query: Map[String, List[Query]] = Map.empty, 21 | // according to the STAC Spec, any fields can be used to represent pagination 22 | // for more details see https://github.com/radiantearth/stac-api-spec/tree/v1.0.0-rc.1/item-search#pagination 23 | paginationBody: JsonObject = JsonObject.empty 24 | ) 25 | 26 | object SearchFilters extends ClientCodecs { 27 | val searchFilterFields = productFieldNames[SearchFilters] 28 | 29 | implicit val searchFiltersDecoder: Decoder[SearchFilters] = { c => 30 | for { 31 | bbox <- c.get[Option[Bbox]]("bbox") 32 | datetime <- c.get[Option[TemporalExtent]]("datetime") 33 | intersects <- c.get[Option[Geometry]]("intersects") 34 | collectionsOption <- c.get[Option[List[String]]]("collections") 35 | itemsOption <- c.get[Option[List[String]]]("ids") 36 | limit <- c.get[Option[NonNegInt]]("limit") 37 | query <- c.get[Option[Map[String, List[Query]]]]("query") 38 | document <- c.value.as[JsonObject] 39 | } yield { 40 | SearchFilters( 41 | bbox, 42 | datetime, 43 | intersects, 44 | collectionsOption getOrElse Nil, 45 | itemsOption getOrElse Nil, 46 | limit, 47 | query getOrElse Map.empty, 48 | document.filter { case (k, _) => !searchFilterFields.contains(k) } 49 | ) 50 | } 51 | } 52 | 53 | implicit val searchFiltersEncoder: Encoder[SearchFilters] = { filters => 54 | val fieldsEncoder = Encoder.forProduct7( 55 | "bbox", 56 | "datetime", 57 | "intersects", 58 | "collections", 59 | "ids", 60 | "limit", 61 | "query" 62 | ) { filters: SearchFilters => 63 | ( 64 | filters.bbox, 65 | filters.datetime, 66 | filters.intersects, 67 | filters.collections.some.filter(_.nonEmpty), 68 | filters.items.some.filter(_.nonEmpty), 69 | filters.limit, 70 | filters.query.some.filter(_.nonEmpty) 71 | ) 72 | } 73 | 74 | fieldsEncoder(filters).deepMerge(filters.paginationBody.asJson) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /modules/client/js/src/main/scala/com/azavea/stac4s/api/client/SttpStacClient.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api.client 2 | 3 | import cats.MonadThrow 4 | import sttp.client3.SttpBackend 5 | import sttp.model.Uri 6 | 7 | object SttpStacClient { 8 | 9 | def apply[F[_]: MonadThrow](client: SttpBackend[F, Any], baseUri: Uri): SttpStacClient[F] = 10 | SttpStacClientF[F, SearchFilters](client, baseUri) 11 | } 12 | -------------------------------------------------------------------------------- /modules/client/js/src/main/scala/com/azavea/stac4s/api/client/package.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api 2 | 3 | package object client { 4 | type SttpStacClient[F[_]] = SttpStacClientF[F, SearchFilters] 5 | type StacClient[F[_]] = StacClientF[F, SearchFilters] 6 | type StreamingStacClientFS2[F[_]] = StreamingStacClientF[F, fs2.Stream[F, *], SearchFilters] 7 | type StreamingStacClient[F[_], G[_]] = StreamingStacClientF[F, G, SearchFilters] 8 | } 9 | -------------------------------------------------------------------------------- /modules/client/js/src/main/scala/com/azavea/stac4s/api/client/util/ClientCodecs.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api.client.util 2 | 3 | import com.azavea.stac4s.TemporalExtent 4 | 5 | import cats.syntax.apply._ 6 | import cats.syntax.either._ 7 | import io.circe.{Decoder, Encoder} 8 | 9 | import java.time.Instant 10 | 11 | trait ClientCodecs { 12 | 13 | // TemporalExtent STAC API compatible serialization 14 | // Ported from https://github.com/azavea/franklin/ 15 | private def stringToInstant(s: String): Either[Throwable, Instant] = 16 | Either.catchNonFatal(Instant.parse(s)) 17 | 18 | private def temporalExtentToString(te: TemporalExtent): String = 19 | te match { 20 | case TemporalExtent(Some(start), Some(end)) if start != end => s"${start.toString}/${end.toString}" 21 | case TemporalExtent(Some(start), Some(end)) if start == end => s"${start.toString}" 22 | case TemporalExtent(Some(start), _) => s"${start.toString}/.." 23 | case TemporalExtent(_, Some(end)) => s"../${end.toString}" 24 | case _ => "../.." 25 | } 26 | 27 | private def temporalExtentFromString(str: String): Either[String, TemporalExtent] = { 28 | str.split("/").toList match { 29 | case ".." :: endString :: _ => 30 | val parsedEnd = stringToInstant(endString) 31 | parsedEnd match { 32 | case Left(_) => s"Could not decode instant: $str".asLeft 33 | case Right(end: Instant) => TemporalExtent(None, end).asRight 34 | } 35 | case startString :: ".." :: _ => 36 | val parsedStart = stringToInstant(startString) 37 | parsedStart match { 38 | case Left(_) => s"Could not decode instant: $str".asLeft 39 | case Right(start: Instant) => TemporalExtent(start, None).asRight 40 | } 41 | case startString :: endString :: _ => 42 | val parsedStart = stringToInstant(startString) 43 | val parsedEnd = stringToInstant(endString) 44 | (parsedStart, parsedEnd).tupled match { 45 | case Left(_) => s"Could not decode instant: $str".asLeft 46 | case Right((start: Instant, end: Instant)) => TemporalExtent(start, end).asRight 47 | } 48 | case _ => 49 | Either.catchNonFatal(Instant.parse(str)) match { 50 | case Left(_) => s"Could not decode instant: $str".asLeft 51 | case Right(t: Instant) => TemporalExtent(t, t).asRight 52 | } 53 | } 54 | } 55 | 56 | implicit lazy val encoderTemporalExtent: Encoder[TemporalExtent] = 57 | Encoder.encodeString.contramap[TemporalExtent](temporalExtentToString) 58 | 59 | implicit lazy val decoderTemporalExtent: Decoder[TemporalExtent] = 60 | Decoder.decodeString.emap(temporalExtentFromString) 61 | 62 | } 63 | -------------------------------------------------------------------------------- /modules/client/js/src/test/scala/com/azavea/stac4s/api/client/SttpStacClientSpec.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api.client 2 | 3 | import com.azavea.stac4s.testing.JsInstances 4 | 5 | import sttp.client3.UriContext 6 | 7 | class SttpStacClientSpec extends SttpStacClientFSpec[SearchFilters] with JsInstances { 8 | lazy val client = SttpStacClient(backend, uri"http://localhost:9090") 9 | } 10 | -------------------------------------------------------------------------------- /modules/client/jvm/src/main/scala/com/azavea/stac4s/api/client/SearchFilters.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api.client 2 | 3 | import com.azavea.stac4s.api.client.util.ClientCodecs 4 | import com.azavea.stac4s.{Bbox, TemporalExtent, productFieldNames} 5 | 6 | import cats.syntax.option._ 7 | import eu.timepit.refined.types.numeric.NonNegInt 8 | import geotrellis.vector.{io => _, _} 9 | import io.circe._ 10 | import io.circe.refined._ 11 | import io.circe.syntax._ 12 | 13 | case class SearchFilters( 14 | bbox: Option[Bbox] = None, 15 | datetime: Option[TemporalExtent] = None, 16 | intersects: Option[Geometry] = None, 17 | collections: List[String] = Nil, 18 | items: List[String] = Nil, 19 | limit: Option[NonNegInt] = None, 20 | query: Map[String, List[Query]] = Map.empty, 21 | // according to the STAC Spec, any fields can be used to represent pagination 22 | // for more details see https://github.com/radiantearth/stac-api-spec/tree/v1.0.0-rc.1/item-search#pagination 23 | paginationBody: JsonObject = JsonObject.empty 24 | ) 25 | 26 | object SearchFilters extends ClientCodecs { 27 | val searchFilterFields = productFieldNames[SearchFilters] 28 | 29 | implicit val searchFiltersDecoder: Decoder[SearchFilters] = { c => 30 | for { 31 | bbox <- c.get[Option[Bbox]]("bbox") 32 | datetime <- c.get[Option[TemporalExtent]]("datetime") 33 | intersects <- c.get[Option[Geometry]]("intersects") 34 | collectionsOption <- c.get[Option[List[String]]]("collections") 35 | itemsOption <- c.get[Option[List[String]]]("ids") 36 | limit <- c.get[Option[NonNegInt]]("limit") 37 | query <- c.get[Option[Map[String, List[Query]]]]("query") 38 | document <- c.value.as[JsonObject] 39 | } yield { 40 | SearchFilters( 41 | bbox, 42 | datetime, 43 | intersects, 44 | collectionsOption getOrElse Nil, 45 | itemsOption getOrElse Nil, 46 | limit, 47 | query getOrElse Map.empty, 48 | document.filter { case (k, _) => !searchFilterFields.contains(k) } 49 | ) 50 | } 51 | } 52 | 53 | implicit val searchFiltersEncoder: Encoder[SearchFilters] = { filters => 54 | val fieldsEncoder = Encoder.forProduct7( 55 | "bbox", 56 | "datetime", 57 | "intersects", 58 | "collections", 59 | "ids", 60 | "limit", 61 | "query" 62 | ) { filters: SearchFilters => 63 | ( 64 | filters.bbox, 65 | filters.datetime, 66 | filters.intersects, 67 | filters.collections.some.filter(_.nonEmpty), 68 | filters.items.some.filter(_.nonEmpty), 69 | filters.limit, 70 | filters.query.some.filter(_.nonEmpty) 71 | ) 72 | } 73 | 74 | fieldsEncoder(filters).deepMerge(filters.paginationBody.asJson) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /modules/client/jvm/src/main/scala/com/azavea/stac4s/api/client/SttpStacClient.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api.client 2 | 3 | import cats.MonadThrow 4 | import sttp.client3.SttpBackend 5 | import sttp.model.Uri 6 | 7 | object SttpStacClient { 8 | 9 | def apply[F[_]: MonadThrow](client: SttpBackend[F, Any], baseUri: Uri): SttpStacClient[F] = 10 | SttpStacClientF[F, SearchFilters](client, baseUri) 11 | } 12 | -------------------------------------------------------------------------------- /modules/client/jvm/src/main/scala/com/azavea/stac4s/api/client/package.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api 2 | 3 | package object client { 4 | type SttpStacClient[F[_]] = SttpStacClientF[F, SearchFilters] 5 | type StacClient[F[_]] = StacClientF[F, SearchFilters] 6 | type StreamingStacClientFS2[F[_]] = StreamingStacClientF[F, fs2.Stream[F, *], SearchFilters] 7 | type StreamingStacClient[F[_], G[_]] = StreamingStacClientF[F, G, SearchFilters] 8 | } 9 | -------------------------------------------------------------------------------- /modules/client/jvm/src/main/scala/com/azavea/stac4s/api/client/util/ClientCodecs.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api.client.util 2 | 3 | import com.azavea.stac4s.TemporalExtent 4 | 5 | import cats.syntax.apply._ 6 | import cats.syntax.either._ 7 | import io.circe.{Decoder, Encoder} 8 | 9 | import java.time.Instant 10 | 11 | trait ClientCodecs { 12 | 13 | // TemporalExtent STAC API compatible serialization 14 | // Ported from https://github.com/azavea/franklin/ 15 | private def stringToInstant(s: String): Either[Throwable, Instant] = 16 | Either.catchNonFatal(Instant.parse(s)) 17 | 18 | private def temporalExtentToString(te: TemporalExtent): String = 19 | te match { 20 | case TemporalExtent(Some(start), Some(end)) if start != end => s"${start.toString}/${end.toString}" 21 | case TemporalExtent(Some(start), Some(end)) if start == end => s"${start.toString}" 22 | case TemporalExtent(Some(start), _) => s"${start.toString}/.." 23 | case TemporalExtent(_, Some(end)) => s"../${end.toString}" 24 | case _ => "../.." 25 | } 26 | 27 | private def temporalExtentFromString(str: String): Either[String, TemporalExtent] = { 28 | str.split("/").toList match { 29 | case ".." :: endString :: _ => 30 | val parsedEnd = stringToInstant(endString) 31 | parsedEnd match { 32 | case Left(_) => s"Could not decode instant: $str".asLeft 33 | case Right(end: Instant) => TemporalExtent(None, end).asRight 34 | } 35 | case startString :: ".." :: _ => 36 | val parsedStart = stringToInstant(startString) 37 | parsedStart match { 38 | case Left(_) => s"Could not decode instant: $str".asLeft 39 | case Right(start: Instant) => TemporalExtent(start, None).asRight 40 | } 41 | case startString :: endString :: _ => 42 | val parsedStart = stringToInstant(startString) 43 | val parsedEnd = stringToInstant(endString) 44 | (parsedStart, parsedEnd).tupled match { 45 | case Left(_) => s"Could not decode instant: $str".asLeft 46 | case Right((start: Instant, end: Instant)) => TemporalExtent(start, end).asRight 47 | } 48 | case _ => 49 | Either.catchNonFatal(Instant.parse(str)) match { 50 | case Left(_) => s"Could not decode instant: $str".asLeft 51 | case Right(t: Instant) => TemporalExtent(t, t).asRight 52 | } 53 | } 54 | } 55 | 56 | implicit lazy val encoderTemporalExtent: Encoder[TemporalExtent] = 57 | Encoder.encodeString.contramap[TemporalExtent](temporalExtentToString) 58 | 59 | implicit lazy val decoderTemporalExtent: Decoder[TemporalExtent] = 60 | Decoder.decodeString.emap(temporalExtentFromString) 61 | 62 | } 63 | -------------------------------------------------------------------------------- /modules/client/jvm/src/test/scala/com/azavea/stac4s/api/client/SttpStacClientSpec.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api.client 2 | 3 | import com.azavea.stac4s.testing.JvmInstances 4 | 5 | import sttp.client3.UriContext 6 | 7 | class SttpStacClientSpec extends SttpStacClientFSpec[SearchFilters] with JvmInstances { 8 | lazy val client = SttpStacClient(backend, uri"http://localhost:9090") 9 | } 10 | -------------------------------------------------------------------------------- /modules/client/shared/src/main/scala-2.12/com/azavea/stac4s/api/client/ETagCodecs.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api.client 2 | 3 | import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} 4 | import io.circe.refined._ 5 | import io.circe.{Decoder, Encoder} 6 | 7 | trait ETagCodecs { 8 | implicit def encoderETag[T: Encoder]: Encoder[ETag[T]] = deriveEncoder 9 | implicit def decoderETag[T: Decoder]: Decoder[ETag[T]] = deriveDecoder 10 | } 11 | -------------------------------------------------------------------------------- /modules/client/shared/src/main/scala-2.13/com/azavea/stac4s/api/client/ETagCodecs.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api.client 2 | 3 | import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} 4 | import io.circe.refined._ 5 | import io.circe.{Decoder, Encoder} 6 | 7 | import scala.annotation.unused 8 | 9 | trait ETagCodecs { 10 | implicit def encoderETag[T](implicit @unused e: Encoder[T]): Encoder[ETag[T]] = deriveEncoder 11 | implicit def decoderETag[T](implicit @unused d: Decoder[T]): Decoder[ETag[T]] = deriveDecoder 12 | } 13 | -------------------------------------------------------------------------------- /modules/client/shared/src/main/scala/com/azavea/stac4s/api/client/ETag.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api.client 2 | 3 | import eu.timepit.refined.types.string.NonEmptyString 4 | 5 | case class ETag[T](entity: T, tag: Option[NonEmptyString]) 6 | 7 | object ETag extends ETagCodecs 8 | -------------------------------------------------------------------------------- /modules/client/shared/src/main/scala/com/azavea/stac4s/api/client/Query.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api.client 2 | 3 | import cats.data.NonEmptyVector 4 | import cats.implicits._ 5 | import eu.timepit.refined.types.string.NonEmptyString 6 | import io.circe._ 7 | import io.circe.refined._ 8 | import io.circe.syntax._ 9 | 10 | /** https://github.com/azavea/franklin/blob/286c5c755585cf743eae5bd176609d8c125ad2b9/application/src/main/scala/com/azavea/franklin/datamodel/Query.scala 11 | */ 12 | sealed abstract class Query 13 | 14 | case class Equals(value: Json) extends Query 15 | case class NotEqualTo(value: Json) extends Query 16 | case class GreaterThan(floor: Json) extends Query 17 | case class GreaterThanEqual(floor: Json) extends Query 18 | case class LessThan(ceiling: Json) extends Query 19 | case class LessThanEqual(ceiling: Json) extends Query 20 | case class StartsWith(prefix: NonEmptyString) extends Query 21 | case class EndsWith(postfix: NonEmptyString) extends Query 22 | case class Contains(substring: NonEmptyString) extends Query 23 | case class In(values: NonEmptyVector[Json]) extends Query 24 | case class Superset(values: NonEmptyVector[Json]) extends Query 25 | 26 | object Query { 27 | 28 | private def fromString(s: String, f: NonEmptyString => Query): Option[Query] = 29 | NonEmptyString.from(s).toOption.map(f) 30 | 31 | private def fromStringOrNum(js: Json, f: Json => Query): Option[Query] = 32 | js.asNumber map { _ => 33 | f(js) 34 | } orElse { js.asString map { _ => f(js) } } 35 | 36 | private def errMessage(operator: String, json: Json): String = 37 | s"Cannot construct `$operator` query with $json" 38 | 39 | def queriesFromMap(unparsed: Map[String, Json]): Either[String, List[Query]] = 40 | unparsed.toList traverse { 41 | case (_ @ "eq", json) => Right(Equals(json)) 42 | case (_ @ "neq", json) => Right(NotEqualTo(json)) 43 | case (op @ "lt", json) => 44 | Either.fromOption(fromStringOrNum(json, LessThan.apply), errMessage(op, json)) 45 | case (op @ "lte", json) => 46 | Either.fromOption( 47 | fromStringOrNum(json, LessThanEqual.apply), 48 | errMessage(op, json) 49 | ) 50 | case (op @ "gt", json) => 51 | Either.fromOption( 52 | fromStringOrNum(json, GreaterThan.apply), 53 | errMessage(op, json) 54 | ) 55 | case (op @ "gte", json) => 56 | Either.fromOption( 57 | fromStringOrNum(json, GreaterThanEqual.apply), 58 | errMessage(op, json) 59 | ) 60 | case (op @ "startsWith", json) => 61 | Either.fromOption( 62 | json.asString flatMap { fromString(_, StartsWith.apply) }, 63 | errMessage(op, json) 64 | ) 65 | case (op @ "endsWith", json) => 66 | Either.fromOption( 67 | json.asString flatMap { fromString(_, EndsWith.apply) }, 68 | errMessage(op, json) 69 | ) 70 | case (op @ "contains", json) => 71 | Either.fromOption( 72 | json.asString flatMap { fromString(_, Contains.apply) }, 73 | errMessage(op, json) 74 | ) 75 | case (op @ "in", json) => 76 | Either.fromOption( 77 | json.asArray flatMap { _.toNev } map { vec => In(vec) }, 78 | errMessage(op, json) 79 | ) 80 | case (op @ "superset", json) => 81 | Either.fromOption( 82 | json.asArray flatMap { _.toNev } map { vec => Superset(vec) }, 83 | errMessage(op, json) 84 | ) 85 | case (k, _) => Left(s"$k is not a valid operator") 86 | } 87 | 88 | implicit val encQuery: Encoder[List[Query]] = { queries => 89 | Map( 90 | queries map { 91 | case Equals(value) => "eq" -> value.asJson 92 | case NotEqualTo(value) => "neq" -> value.asJson 93 | case GreaterThan(floor) => "gt" -> floor.asJson 94 | case GreaterThanEqual(floor) => "gte" -> floor.asJson 95 | case LessThan(ceiling) => "lt" -> ceiling.asJson 96 | case LessThanEqual(ceiling) => "lte" -> ceiling.asJson 97 | case StartsWith(prefix) => "startsWith" -> prefix.asJson 98 | case EndsWith(postfix) => "endsWith" -> postfix.asJson 99 | case Contains(substring) => "contains" -> substring.asJson 100 | case In(values) => "in" -> values.asJson 101 | case Superset(values) => "superset" -> values.asJson 102 | }: _* 103 | ).asJson 104 | } 105 | 106 | implicit val decQueries: Decoder[List[Query]] = Decoder.decodeJsonObject.emap { jsonObj => 107 | queriesFromMap(jsonObj.toMap) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /modules/client/shared/src/main/scala/com/azavea/stac4s/api/client/StacClientF.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api.client 2 | 3 | import com.azavea.stac4s.{StacCollection, StacItem} 4 | 5 | import eu.timepit.refined.types.string.NonEmptyString 6 | import io.circe.Json 7 | 8 | trait StacClientF[F[_], S] { 9 | def collection(collectionId: NonEmptyString): F[StacCollection] 10 | def collectionCreate(collection: StacCollection): F[StacCollection] 11 | def item(collectionId: NonEmptyString, itemId: NonEmptyString): F[ETag[StacItem]] 12 | def itemCreate(collectionId: NonEmptyString, item: StacItem): F[ETag[StacItem]] 13 | def itemUpdate(collectionId: NonEmptyString, item: ETag[StacItem]): F[ETag[StacItem]] 14 | def itemPatch(collectionId: NonEmptyString, itemId: NonEmptyString, patch: ETag[Json]): F[ETag[StacItem]] 15 | def itemDelete(collectionId: NonEmptyString, itemId: NonEmptyString): F[Either[String, String]] 16 | } 17 | 18 | trait StreamingStacClientF[F[_], G[_], S] extends StacClientF[F, S] { 19 | def collections: G[StacCollection] 20 | def search: G[StacItem] 21 | def search(filter: S): G[StacItem] 22 | def search(filter: Option[S]): G[StacItem] 23 | def items(collectionId: NonEmptyString): G[StacItem] 24 | } 25 | -------------------------------------------------------------------------------- /modules/client/shared/src/main/scala/com/azavea/stac4s/api/client/SttpStacClientF.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api.client 2 | 3 | import com.azavea.stac4s.api.client.util.syntax._ 4 | import com.azavea.stac4s.{StacCollection, StacItem, StacLink, StacLinkType} 5 | 6 | import cats.MonadThrow 7 | import cats.syntax.apply._ 8 | import cats.syntax.either._ 9 | import cats.syntax.flatMap._ 10 | import cats.syntax.functor._ 11 | import cats.syntax.nested._ 12 | import cats.syntax.option._ 13 | import eu.timepit.refined.types.string.NonEmptyString 14 | import fs2.Stream 15 | import io.circe.syntax._ 16 | import io.circe.{Encoder, Json, JsonObject} 17 | import sttp.client3.circe.asJson 18 | import sttp.client3.{Response, SttpBackend, UriContext, basicRequest} 19 | import sttp.model.{MediaType, Uri} 20 | 21 | case class SttpStacClientF[F[_]: MonadThrow, S: Encoder]( 22 | client: SttpBackend[F, Any], 23 | baseUri: Uri 24 | ) extends StreamingStacClientF[F, Stream[F, *], S] { 25 | import SttpStacClientF._ 26 | 27 | def search: Stream[F, StacItem] = search(None) 28 | 29 | def search(filter: S): Stream[F, StacItem] = search(filter.some) 30 | 31 | def search(filter: Option[S]): Stream[F, StacItem] = { 32 | // the initial filter may contain the paginationBody that is used for the initial query 33 | val initialBody = filter.map(_.asJson.deepDropNullValues).getOrElse(JsonObject.empty.asJson) 34 | Stream 35 | .unfoldLoopEval((baseUri.addPath("search"), initialBody)) { case (link, request) => 36 | client 37 | .send( 38 | basicRequest 39 | .post(link) 40 | .contentType(MediaType.ApplicationJson) 41 | .body(request.noSpaces) 42 | .response(asJson[Json]) 43 | ) 44 | .flatMap { response => 45 | val items = response.stacItems 46 | val next = response.nextPage(request) 47 | (items, next).tupled 48 | } 49 | } 50 | .flatMap(Stream.emits) 51 | } 52 | 53 | def collections: Stream[F, StacCollection] = 54 | Stream 55 | .unfoldLoopEval(baseUri.addPath("collections")) { link => 56 | client 57 | .send(basicRequest.get(link).response(asJson[Json])) 58 | .flatMap { response => 59 | val items = response.stacCollections 60 | val nextLink = response.nextLink 61 | (items, nextLink).tupled 62 | } 63 | } 64 | .flatMap(Stream.emits) 65 | 66 | def collection(collectionId: NonEmptyString): F[StacCollection] = 67 | client 68 | .send( 69 | basicRequest 70 | .get(baseUri.addPath("collections", collectionId.value)) 71 | .response(asJson[StacCollection]) 72 | ) 73 | .flatMap(_.body.liftTo[F]) 74 | 75 | def collectionCreate(collection: StacCollection): F[StacCollection] = 76 | client 77 | .send( 78 | basicRequest 79 | .post(baseUri.addPath("collections")) 80 | .contentType(MediaType.ApplicationJson) 81 | .body(collection.asJson.noSpaces) 82 | .response(asJson[StacCollection]) 83 | ) 84 | .flatMap(_.body.liftTo[F]) 85 | 86 | def items(collectionId: NonEmptyString): Stream[F, StacItem] = 87 | Stream 88 | .unfoldLoopEval(baseUri.addPath("collections", collectionId.value, "items")) { link => 89 | client 90 | .send(basicRequest.get(link).response(asJson[Json])) 91 | .flatMap { response => 92 | val items = response.stacItems 93 | val nextLink = response.nextLink 94 | (items, nextLink).tupled 95 | } 96 | } 97 | .flatMap(Stream.emits) 98 | 99 | def item(collectionId: NonEmptyString, itemId: NonEmptyString): F[ETag[StacItem]] = 100 | client 101 | .send( 102 | basicRequest 103 | .get(baseUri.addPath("collections", collectionId.value, "items", itemId.value)) 104 | .response(asJson[StacItem]) 105 | ) 106 | .flatMap(_.bodyETag.liftTo[F]) 107 | 108 | def itemCreate(collectionId: NonEmptyString, item: StacItem): F[ETag[StacItem]] = 109 | client 110 | .send( 111 | basicRequest 112 | .post(baseUri.addPath("collections", collectionId.value, "items")) 113 | .contentType(MediaType.ApplicationJson) 114 | .body(item.asJson.noSpaces) 115 | .response(asJson[StacItem]) 116 | ) 117 | .flatMap(_.bodyETag.liftTo[F]) 118 | 119 | def itemUpdate(collectionId: NonEmptyString, item: ETag[StacItem]): F[ETag[StacItem]] = 120 | client 121 | .send( 122 | basicRequest 123 | .put(baseUri.addPath("collections", collectionId.value, "items", item.entity.id)) 124 | .headerIfMatch(item.tag) 125 | .body(item.entity.asJson.noSpaces) 126 | .response(asJson[StacItem]) 127 | ) 128 | .flatMap(_.bodyETag.liftTo[F]) 129 | 130 | def itemPatch(collectionId: NonEmptyString, itemId: NonEmptyString, patch: ETag[Json]): F[ETag[StacItem]] = 131 | client 132 | .send( 133 | basicRequest 134 | .patch(baseUri.addPath("collections", collectionId.value, "items", itemId.value)) 135 | .headerIfMatch(patch.tag) 136 | .body(patch.entity.noSpaces) 137 | .response(asJson[StacItem]) 138 | ) 139 | .flatMap(_.bodyETag.liftTo[F]) 140 | 141 | def itemDelete(collectionId: NonEmptyString, itemId: NonEmptyString): F[Either[String, String]] = 142 | client 143 | .send(basicRequest.delete(baseUri.addPath("collections", collectionId.value, "items", itemId.value))) 144 | .map(_.body) 145 | } 146 | 147 | object SttpStacClientF { 148 | 149 | implicit class ResponseEitherJsonOps[E <: Exception](val self: Response[Either[E, Json]]) extends AnyVal { 150 | 151 | /** Get the next page Uri from the retrieved Json body and the next pagination body. */ 152 | def nextPage[F[_]: MonadThrow]: F[Option[(Uri, Option[Json])]] = 153 | self.body 154 | .flatMap { 155 | _.hcursor 156 | .downField("links") 157 | .as[Option[List[StacLink]]] 158 | .map(_.flatMap(_.collectFirst { 159 | case l if l.rel == StacLinkType.Next => 160 | // The STAC API server may return the next page token as a part of the extensionFields, 161 | // it is just a string that should be used in the next page request body. 162 | // Some STAC API implementations (i.e. Franklin) 163 | // encode pagination into the next page Uri (put it into the l.href): 164 | // in this case, the pagination token is always set to None and only Uri is used for the pagination purposes. 165 | 166 | // to make the case described above more generic, we can take the entire body 167 | // and pass it forward by merging with the body (SearchFilters in a form of Json) 168 | // with the paginationBody 169 | // see https://github.com/azavea/stac4s/pull/496 and https://github.com/azavea/stac4s/pull/502 for details 170 | val paginationBody: Option[Json] = l.extensionFields("body").map(_.deepDropNullValues) 171 | 172 | uri"${l.href}" -> paginationBody 173 | })) 174 | } 175 | .liftTo[F] 176 | 177 | /** Get the next page Uri and the next page Json request body (that has a correctly set next page token). */ 178 | def nextPage[F[_]: MonadThrow](filter: Json): F[Option[(Uri, Json)]] = 179 | nextPage.nested.map { case (uri, body) => (uri, filter.setPaginationBody(body)) }.value 180 | 181 | /** Get the next page Uri and drop the next page token / body. Useful for get requests with no POST pagination 182 | * support. 183 | */ 184 | def nextLink[F[_]: MonadThrow]: F[Option[Uri]] = nextPage.nested.map(_._1).value 185 | 186 | /** Decode List of StacItem from the retrieved Json body. */ 187 | def stacItems[F[_]: MonadThrow]: F[List[StacItem]] = 188 | self.body.flatMap(_.hcursor.downField("features").as[List[StacItem]]).liftTo[F] 189 | 190 | /** Decode List of StacCollection from the retrieved Json body. */ 191 | def stacCollections[F[_]: MonadThrow]: F[List[StacCollection]] = 192 | self.body.flatMap(_.hcursor.downField("collections").as[List[StacCollection]]).liftTo[F] 193 | } 194 | 195 | implicit class JsonOps(val self: Json) extends AnyVal { 196 | 197 | def setPaginationBody(body: Option[Json]): Json = { 198 | val selfNotNull = self.deepDropNullValues 199 | val bodyNotNull = body.map(_.deepDropNullValues).getOrElse(JsonObject.empty.asJson) 200 | 201 | val filter = selfNotNull.deepMerge(bodyNotNull) 202 | 203 | // bbox and intersection can't be present at the same time 204 | if (filter.hcursor.downField("bbox").succeeded && filter.hcursor.downField("intersects").succeeded) { 205 | // let's see which field is present in the nextPageBody 206 | val field = 207 | if (bodyNotNull.hcursor.downField("bbox").succeeded && bodyNotNull.hcursor.downField("intersects").failed) 208 | filter.hcursor.downField("intersects") 209 | else 210 | filter.hcursor.downField("bbox") 211 | 212 | field.delete.top.getOrElse(filter) 213 | } else filter 214 | } 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /modules/client/shared/src/main/scala/com/azavea/stac4s/api/client/util/syntax/package.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api.client.util 2 | 3 | import com.azavea.stac4s.api.client.ETag 4 | 5 | import eu.timepit.refined.types.string.NonEmptyString 6 | import sttp.client3.{RequestT, Response} 7 | import sttp.model.HeaderNames 8 | 9 | package object syntax { 10 | 11 | implicit class RequestTOps[U[_], T, -R](val self: RequestT[U, T, R]) extends AnyVal { 12 | def header(k: String, v: Option[String]): RequestT[U, T, R] = v.fold(self)(self.header(k, _)) 13 | 14 | @SuppressWarnings(Array("UnusedMethodParameter")) 15 | def header(k: String, v: Option[NonEmptyString])(implicit d: DummyImplicit): RequestT[U, T, R] = 16 | v.fold(self)(e => self.header(k, e.value)) 17 | 18 | def headerIfMatch(v: Option[NonEmptyString]): RequestT[U, T, R] = header(HeaderNames.IfMatch, v) 19 | def headerETag(v: Option[NonEmptyString]): RequestT[U, T, R] = header(HeaderNames.Etag, v) 20 | } 21 | 22 | implicit class ResponseOps[T](val self: Response[T]) extends AnyVal { 23 | def headerETag: Option[NonEmptyString] = self.header(HeaderNames.Etag).flatMap(NonEmptyString.from(_).toOption) 24 | } 25 | 26 | implicit class ResponseEitherOps[E, T](val self: Response[Either[E, T]]) extends AnyVal { 27 | def bodyETag: Either[E, ETag[T]] = self.body.map(ETag(_, self.headerETag)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /modules/client/shared/src/test/scala/com/azavea/stac4s/api/client/SttpEitherInstances.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api.client 2 | 3 | import cats.effect.Sync 4 | import cats.effect.kernel.{CancelScope, Poll} 5 | import cats.syntax.apply._ 6 | import cats.syntax.either._ 7 | 8 | import scala.concurrent.duration.{DurationInt, FiniteDuration} 9 | 10 | trait SttpEitherInstances { 11 | 12 | private[this] val IdPoll = new Poll[Either[Throwable, *]] { 13 | def apply[A](fa: Either[Throwable, A]): Either[Throwable, A] = fa 14 | } 15 | 16 | /** [[Sync]] instance defined for Either[Throwable, *]. It is required (sadly) to derive [[fs2.Stream.Compiler]] which 17 | * is necessary for the [[fs2.Stream.compile]] function. 18 | */ 19 | implicit val eitherSync: Sync[Either[Throwable, *]] = new Sync[Either[Throwable, *]] { 20 | lazy val me = cats.instances.either.catsStdInstancesForEither[Throwable] 21 | 22 | def flatMap[A, B](fa: Either[Throwable, A])(f: A => Either[Throwable, B]): Either[Throwable, B] = 23 | fa.flatMap(f) 24 | 25 | def tailRecM[A, B](a: A)(f: A => Either[Throwable, Either[A, B]]): Either[Throwable, B] = 26 | me.tailRecM(a)(f) 27 | 28 | def raiseError[A](e: Throwable): Either[Throwable, A] = me.raiseError(e) 29 | 30 | def handleErrorWith[A](fa: Either[Throwable, A])(f: Throwable => Either[Throwable, A]): Either[Throwable, A] = 31 | me.handleErrorWith(fa)(f) 32 | 33 | def pure[A](x: A): Either[Throwable, A] = me.pure(x) 34 | 35 | def suspend[A](hint: Sync.Type)(thunk: => A): Either[Throwable, A] = thunk.asRight 36 | 37 | def monotonic: Either[Throwable, FiniteDuration] = 1.second.asRight 38 | 39 | def realTime: Either[Throwable, FiniteDuration] = 1.second.asRight 40 | 41 | def rootCancelScope: CancelScope = CancelScope.Uncancelable 42 | 43 | def forceR[A, B](fa: Either[Throwable, A])(fb: Either[Throwable, B]): Either[Throwable, B] = 44 | fa.productR(fb) 45 | 46 | def uncancelable[A](body: Poll[Either[Throwable, *]] => Either[Throwable, A]): Either[Throwable, A] = 47 | body(IdPoll) 48 | 49 | def canceled: Either[Throwable, Unit] = ().asRight 50 | 51 | def onCancel[A](fa: Either[Throwable, A], fin: Either[Throwable, Unit]): Either[Throwable, A] = fa 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /modules/client/shared/src/test/scala/com/azavea/stac4s/api/client/SttpStacClientFSpec.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api.client 2 | 3 | import com.azavea.stac4s.{ItemCollection, StacCollection, StacItem} 4 | 5 | import cats.syntax.either._ 6 | import eu.timepit.refined.types.all.NonEmptyString 7 | import io.circe.syntax._ 8 | import io.circe.{JsonObject, parser} 9 | import org.scalacheck.Arbitrary 10 | import org.scalacheck.resample._ 11 | import org.scalatest.BeforeAndAfterAll 12 | import org.scalatest.funspec.AnyFunSpec 13 | import org.scalatest.matchers.should.Matchers 14 | import sttp.client3.testing.SttpBackendStub 15 | import sttp.client3.{Response, StringBody} 16 | import sttp.model.Method 17 | import sttp.monad.EitherMonad 18 | 19 | trait SttpStacClientFSpec[S] 20 | extends AnyFunSpec 21 | with Matchers 22 | with BeforeAndAfterAll 23 | with SttpEitherInstances 24 | with SttpSyntax { 25 | 26 | def arbCollectionShort: Arbitrary[StacCollection] 27 | def arbItemCollectionShort: Arbitrary[ItemCollection] 28 | def arbItemShort: Arbitrary[StacItem] 29 | 30 | def client: SttpStacClientF[Either[Throwable, *], S] 31 | 32 | /** We use the default synchronous Either backend to use the same tests set for the Scala JS backend. */ 33 | lazy val backend = 34 | SttpBackendStub(EitherMonad) 35 | .whenRequestMatches(_.uri.path == Seq("search")) 36 | .thenRespondF { _ => Response.json(arbItemCollectionShort.arbitrary.resample().asJson) } 37 | .whenRequestMatches { 38 | case req if req.method == Method.GET => req.uri.path == Seq("collections") 39 | case _ => false 40 | } 41 | .thenRespondF { _ => 42 | Response.json(JsonObject("collections" -> arbCollectionShort.arbitrary.sample.toList.asJson).asJson) 43 | } 44 | .whenRequestMatches { 45 | case req if req.method == Method.GET => req.uri.path == Seq("collections", "collection_id") 46 | case _ => false 47 | } 48 | .thenRespondF { _ => Response.item(arbCollectionShort.arbitrary.resample()) } 49 | .whenRequestMatches { 50 | case req if req.method == Method.GET => req.uri.path == Seq("collections", "collection_id", "items") 51 | case _ => false 52 | } 53 | .thenRespondF { _ => Response.json(arbItemCollectionShort.arbitrary.sample.asJson) } 54 | .whenRequestMatches { 55 | case req if req.method == Method.GET => req.uri.path == Seq("collections", "collection_id", "items", "item_id") 56 | case _ => false 57 | } 58 | .thenRespondF { _ => Response.item(arbItemShort.arbitrary.resample()) } 59 | .whenRequestMatches { 60 | case req if req.method == Method.PUT => req.uri.path == Seq("collections", "collection_id", "items", "item_id") 61 | case _ => false 62 | } 63 | .thenRespondF { req => 64 | req.body match { 65 | case sb: StringBody => Response.item(parser.parse(sb.s).flatMap(_.as[StacItem]).valueOr(throw _)) 66 | case _ => Response.item(arbItemShort.arbitrary.resample()) 67 | } 68 | } 69 | .whenRequestMatches { 70 | case req if req.method == Method.PATCH => 71 | req.uri.path == Seq("collections", "collection_id", "items", "item_id") 72 | case _ => false 73 | } 74 | .thenRespondF { _ => Response.item(arbItemShort.arbitrary.resample()) } 75 | .whenRequestMatches { 76 | case req if req.method == Method.DELETE => 77 | req.uri.path == Seq("collections", "collection_id", "items", "item_id") 78 | case _ => false 79 | } 80 | .thenRespondF { _ => Response.empty } 81 | .whenRequestMatches { 82 | case req if req.method == Method.POST => req.uri.path == Seq("collections", "collection_id", "items") 83 | case _ => false 84 | } 85 | .thenRespondF { req => 86 | req.body match { 87 | case sb: StringBody => Response.item(parser.parse(sb.s).flatMap(_.as[StacItem]).valueOr(throw _)) 88 | case _ => Response.item(arbItemShort.arbitrary.resample()) 89 | } 90 | } 91 | .whenRequestMatches { 92 | case req if req.method == Method.POST => req.uri.path == Seq("collections") 93 | case _ => false 94 | } 95 | .thenRespondF { req => 96 | req.body match { 97 | case sb: StringBody => Response.item(parser.parse(sb.s).flatMap(_.as[StacCollection]).valueOr(throw _)) 98 | case _ => Response.item(arbCollectionShort.arbitrary.resample()) 99 | } 100 | 101 | } 102 | 103 | describe("SttpStacClientSpec") { 104 | val collectionId = NonEmptyString.unsafeFrom("collection_id") 105 | val itemId = NonEmptyString.unsafeFrom("item_id") 106 | 107 | it("search") { 108 | client.search.compile.toList 109 | .valueOr(throw _) 110 | } 111 | 112 | it("collections") { 113 | client.collections.compile.toList 114 | .valueOr(throw _) 115 | .map(_.id should not be empty) 116 | } 117 | 118 | it("collection") { 119 | client 120 | .collection(collectionId) 121 | .valueOr(throw _) 122 | .id should not be empty 123 | } 124 | 125 | it("collectionCreate") { 126 | client 127 | .collectionCreate(arbCollectionShort.arbitrary.resample()) 128 | .valueOr(throw _) 129 | .id should not be empty 130 | } 131 | 132 | it("items") { 133 | client 134 | .items(collectionId) 135 | .compile 136 | .toList 137 | .valueOr(throw _) 138 | .map(_.id should not be empty) 139 | } 140 | 141 | it("item") { 142 | client 143 | .item(collectionId, itemId) 144 | .valueOr(throw _) 145 | .entity 146 | .id should not be empty 147 | } 148 | 149 | it("itemCreate") { 150 | val item = arbItemShort.arbitrary.resample().etag 151 | client 152 | .itemCreate(collectionId, item.entity) 153 | .valueOr(throw _) should be(item) 154 | } 155 | 156 | it("itemUpdate") { 157 | val item = arbItemShort.arbitrary.resample().copy(id = "item_id").etag 158 | client 159 | .itemUpdate(collectionId, item) 160 | .valueOr(throw _) should be(item) 161 | } 162 | 163 | it("itemPatch") { 164 | val patch = JsonObject("properties" -> Map("key" -> "value").asJson).asJson.etag 165 | client 166 | .itemPatch(collectionId, itemId, patch) 167 | .valueOr(throw _) 168 | .entity 169 | .id should not be empty 170 | } 171 | 172 | it("itemDelete") { 173 | client 174 | .itemDelete(collectionId, itemId) 175 | .valueOr(throw _) 176 | .valueOr(str => throw new Exception(str)) should be(empty) 177 | } 178 | } 179 | 180 | override def afterAll(): Unit = backend.close().valueOr(throw _) 181 | } 182 | -------------------------------------------------------------------------------- /modules/client/shared/src/test/scala/com/azavea/stac4s/api/client/SttpSyntax.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.api.client 2 | 3 | import cats.syntax.either._ 4 | import cats.syntax.option._ 5 | import eu.timepit.refined.types.string.NonEmptyString 6 | import io.circe.Json 7 | import sttp.client3.Response 8 | import sttp.model.{Header, HeaderNames, StatusCode} 9 | 10 | trait SttpSyntax { 11 | 12 | implicit class ResponseOps(self: Response.type) { 13 | 14 | def empty: Either[Nothing, Response[Either[Nothing, String]]] = Response.ok("".asRight).asRight 15 | 16 | def item[T](item: T): Either[Nothing, Response[Either[Nothing, T]]] = 17 | Response(item.asRight, StatusCode.Ok, "OK", Header(HeaderNames.Etag, item.##.toString) :: Nil).asRight 18 | 19 | def json(json: Json): Either[Nothing, Response[Either[Nothing, Json]]] = 20 | Response.ok(json.asRight).asRight 21 | } 22 | 23 | implicit class EtagOps[T](val self: T) { 24 | def etag: ETag[T] = ETag(self, NonEmptyString.unsafeFrom(self.##.toString).some) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /modules/client/shared/src/test/scala/org/scalacheck/resample/package.scala: -------------------------------------------------------------------------------- 1 | package org.scalacheck 2 | 3 | import scala.annotation.tailrec 4 | 5 | package object resample { 6 | 7 | /** https://github.com/typelevel/scalacheck/issues/650#issuecomment-625384900 */ 8 | implicit class GenOps[A](val g: Gen[A]) extends AnyVal { 9 | 10 | def resample(retries: Int = 1000): A = { 11 | @tailrec 12 | def loop(tries: Int): A = 13 | if (tries >= retries) sys.error("Generator failed to produce a non-empty result.") 14 | else 15 | g.sample match { 16 | case Some(a) => a 17 | case None => loop(tries + 1) 18 | } 19 | loop(0) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /modules/core-test/js/src/test/scala/com/azavea/stac4s/JsFPLawsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import com.azavea.stac4s.testing.TestInstances 4 | 5 | import cats.kernel.laws.discipline.SemigroupTests 6 | import org.scalatest.funsuite.AnyFunSuite 7 | import org.scalatest.matchers.must.Matchers 8 | import org.scalatestplus.scalacheck.Checkers 9 | import org.typelevel.discipline.scalatest.FunSuiteDiscipline 10 | 11 | class JsFPLawsSpec extends AnyFunSuite with FunSuiteDiscipline with Checkers with Matchers with TestInstances { 12 | checkAll("Semigroup.Bbox", SemigroupTests[Bbox].semigroup) 13 | } 14 | -------------------------------------------------------------------------------- /modules/core-test/js/src/test/scala/com/azavea/stac4s/JsSerDeSpec.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import com.azavea.stac4s.geometry.Geometry 4 | import com.azavea.stac4s.testing.JsInstances._ 5 | 6 | import io.circe.syntax._ 7 | import io.circe.testing.{ArbitraryInstances, CodecTests} 8 | import org.scalatest.funsuite.AnyFunSuite 9 | import org.scalatest.matchers.must.Matchers 10 | import org.scalatestplus.scalacheck.Checkers 11 | import org.typelevel.discipline.scalatest.FunSuiteDiscipline 12 | 13 | import java.time.Instant 14 | 15 | class JsSerDeSpec extends AnyFunSuite with FunSuiteDiscipline with Checkers with Matchers with ArbitraryInstances { 16 | checkAll("Codec.ItemCollection", CodecTests[ItemCollection].unserializableCodec) 17 | checkAll("Codec.StacItem", CodecTests[StacItem].unserializableCodec) 18 | checkAll("Codec.Geometry", CodecTests[Geometry].unserializableCodec) 19 | 20 | /** Ensure that the datetime field is present but null for time ranges 21 | * 22 | * Specification: 23 | * https://github.com/radiantearth/stac-spec/blob/v1.0.0-rc.4/item-spec/common-metadata.md#date-and-time-range 24 | */ 25 | test("Encoded time ranges print null datetime") { 26 | val tr = TimeRange( 27 | Instant.parse("2021-01-01T00:00:00Z"), 28 | Instant.parse("2022-01-01T00:00:00Z") 29 | ) 30 | 31 | val js = tr.asJson 32 | 33 | js.as[Map[String, Option[Instant]]] 34 | .fold( 35 | _ => fail(s"Encoded value was not decodable as a map of strings to optional instants: ${js.noSpaces}"), 36 | m => m.get("datetime") must equal(Some(Option.empty[Instant])) 37 | ) 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /modules/core-test/jvm/src/test/scala/com/azavea/stac4s/JvmFPLawsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import com.azavea.stac4s.testing.TestInstances 4 | 5 | import cats.kernel.laws.discipline.SemigroupTests 6 | import org.scalatest.funsuite.AnyFunSuite 7 | import org.scalatest.matchers.must.Matchers 8 | import org.scalatestplus.scalacheck.Checkers 9 | import org.typelevel.discipline.scalatest.FunSuiteDiscipline 10 | 11 | class JvmFPLawsSpec extends AnyFunSuite with FunSuiteDiscipline with Checkers with Matchers with TestInstances { 12 | checkAll("Semigroup.Bbox", SemigroupTests[Bbox].semigroup) 13 | } 14 | -------------------------------------------------------------------------------- /modules/core-test/jvm/src/test/scala/com/azavea/stac4s/JvmSerDeSpec.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import com.azavea.stac4s.extensions.layer.StacLayer 4 | import com.azavea.stac4s.extensions.periodic.PeriodicExtent 5 | import com.azavea.stac4s.meta._ 6 | import com.azavea.stac4s.testing.JvmInstances._ 7 | 8 | import geotrellis.vector.Geometry 9 | import io.circe.syntax._ 10 | import io.circe.testing.{ArbitraryInstances, CodecTests} 11 | import org.scalatest.funsuite.AnyFunSuite 12 | import org.scalatest.matchers.must.Matchers 13 | import org.scalatestplus.scalacheck.Checkers 14 | import org.threeten.extra.PeriodDuration 15 | import org.typelevel.discipline.scalatest.FunSuiteDiscipline 16 | 17 | import java.time.Instant 18 | 19 | class JvmSerDeSpec extends AnyFunSuite with FunSuiteDiscipline with Checkers with Matchers with ArbitraryInstances { 20 | checkAll("Codec.ItemCollection", CodecTests[ItemCollection].unserializableCodec) 21 | checkAll("Codec.StacItem", CodecTests[StacItem].unserializableCodec) 22 | checkAll("Codec.Geometry", CodecTests[Geometry].unserializableCodec) 23 | checkAll("Codec.Instant", CodecTests[Instant].unserializableCodec) 24 | checkAll("Codec.StacCollection", CodecTests[StacCollection].unserializableCodec) 25 | checkAll("Codec.SummaryValue", CodecTests[SummaryValue].unserializableCodec) 26 | checkAll("Codec.Interval", CodecTests[Interval].unserializableCodec) 27 | checkAll("Codec.StacExtent", CodecTests[StacExtent].unserializableCodec) 28 | checkAll("Codec.TemporalExtent", CodecTests[TemporalExtent].unserializableCodec) 29 | checkAll("Codec.StacLayer", CodecTests[StacLayer].unserializableCodec) 30 | checkAll("Codec.PeriodDuration", CodecTests[PeriodDuration].unserializableCodec) 31 | checkAll("Codec.PeriodicExtent", CodecTests[PeriodicExtent].unserializableCodec) 32 | 33 | /** Ensure that the datetime field is present but null for time ranges 34 | * 35 | * Specification: 36 | * https://github.com/radiantearth/stac-spec/blob/v1.0.0-rc.4/item-spec/common-metadata.md#date-and-time-range 37 | */ 38 | test("Encoded time ranges print null datetime") { 39 | val tr = TimeRange( 40 | Instant.parse("2021-01-01T00:00:00Z"), 41 | Instant.parse("2022-01-01T00:00:00Z") 42 | ) 43 | 44 | val js = tr.asJson 45 | 46 | js.as[Map[String, Option[Instant]]] 47 | .fold( 48 | _ => fail(s"Encoded value was not decodable as a map of strings to optional instants: ${js.noSpaces}"), 49 | m => m.get("datetime") must equal(Some(Option.empty[Instant])) 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /modules/core-test/jvm/src/test/scala/com/azavea/stac4s/JvmSyntaxSpec.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import com.azavea.stac4s.extensions._ 4 | import com.azavea.stac4s.extensions.label._ 5 | import com.azavea.stac4s.syntax._ 6 | import com.azavea.stac4s.testing.JvmInstances._ 7 | import com.azavea.stac4s.testing.TestInstances._ 8 | 9 | import cats.syntax.validated._ 10 | import org.scalatest.funsuite.AnyFunSuite 11 | import org.scalatest.matchers.should.Matchers 12 | import org.scalatestplus.scalacheck.Checkers 13 | 14 | class JvmSyntaxSpec extends AnyFunSuite with Checkers with Matchers { 15 | 16 | test("item syntax results in the same values as typeclass summoner to extend") { 17 | check { (item: StacItem, labelExtension: LabelItemExtension) => 18 | item.addExtensionFields(labelExtension) == ItemExtension[LabelItemExtension] 19 | .addExtensionFields(item, labelExtension) 20 | } 21 | } 22 | 23 | test("item syntax results in the same values as typeclass summoner to parse") { 24 | check { (item: StacItem, labelExtension: LabelItemExtension) => 25 | item.addExtensionFields(labelExtension).getExtensionFields[LabelItemExtension] == 26 | labelExtension.valid 27 | } 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /modules/core-test/shared/src/test/scala/com/azavea/stac4s/BboxSpec.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import com.azavea.stac4s.testing.TestInstances._ 4 | 5 | import org.scalatest.funsuite.AnyFunSuite 6 | import org.scalatest.matchers.should.Matchers 7 | import org.scalatestplus.scalacheck.Checkers 8 | 9 | class BboxSpec extends AnyFunSuite with Checkers with Matchers { 10 | 11 | test("union with left consituent and union is union") { 12 | check { (bbox1: Bbox, bbox2: Bbox) => 13 | bbox1.union(bbox2).union(bbox1) == bbox1.union(bbox2) 14 | } 15 | } 16 | 17 | test("union with right constituent and union is union") { 18 | check { (bbox1: Bbox, bbox2: Bbox) => 19 | bbox1.union(bbox2).union(bbox2) == bbox1.union(bbox2) 20 | } 21 | } 22 | 23 | test("union with self is union") { 24 | check { (bbox1: Bbox, bbox2: Bbox) => 25 | { 26 | val unioned = bbox1.union(bbox2) 27 | unioned.union(unioned) == unioned 28 | } 29 | } 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /modules/core-test/shared/src/test/scala/com/azavea/stac4s/SerDeSpec.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import com.azavea.stac4s.extensions.asset._ 4 | import com.azavea.stac4s.extensions.eo._ 5 | import com.azavea.stac4s.extensions.label._ 6 | import com.azavea.stac4s.extensions.layer._ 7 | import com.azavea.stac4s.meta.ForeignImplicits._ 8 | import com.azavea.stac4s.testing.TestInstances._ 9 | import com.azavea.stac4s.types._ 10 | 11 | import io.circe.Decoder 12 | import io.circe.parser._ 13 | import io.circe.syntax._ 14 | import io.circe.testing.{ArbitraryInstances, CodecTests} 15 | import org.scalatest.Assertion 16 | import org.scalatest.funsuite.AnyFunSuite 17 | import org.scalatest.matchers.should.Matchers 18 | import org.scalatestplus.scalacheck.Checkers 19 | import org.typelevel.discipline.scalatest.FunSuiteDiscipline 20 | 21 | import java.time.{Instant, OffsetDateTime} 22 | 23 | class SerDeSpec extends AnyFunSuite with FunSuiteDiscipline with Checkers with Matchers with ArbitraryInstances { 24 | 25 | // core 26 | checkAll("Codec.Bbox", CodecTests[Bbox].unserializableCodec) 27 | checkAll("Codec.SPDX", CodecTests[SPDX].unserializableCodec) 28 | checkAll("Codec.StacAssetRole", CodecTests[StacAssetRole].unserializableCodec) 29 | checkAll("Codec.StacCatalog", CodecTests[StacCatalog].unserializableCodec) 30 | checkAll("Codec.StacCollectionAsset", CodecTests[StacCollectionAsset].unserializableCodec) 31 | checkAll("Codec.StacAsset", CodecTests[StacAsset].unserializableCodec) 32 | checkAll("Codec.StacLinkType", CodecTests[StacLinkType].unserializableCodec) 33 | checkAll("Codec.StacMediaType", CodecTests[StacMediaType].unserializableCodec) 34 | checkAll("Codec.StacProviderRole", CodecTests[StacProviderRole].unserializableCodec) 35 | checkAll("Codec.ThreeDimBbox", CodecTests[ThreeDimBbox].unserializableCodec) 36 | checkAll("Codec.TwoDimBbox", CodecTests[TwoDimBbox].unserializableCodec) 37 | checkAll("Codec.ItemDatetime", CodecTests[ItemDatetime].unserializableCodec) 38 | checkAll("Codec.ItemProperties", CodecTests[ItemProperties].unserializableCodec) 39 | 40 | // extensions 41 | 42 | // label extension 43 | checkAll("Codec.LabelClass", CodecTests[LabelClass].unserializableCodec) 44 | checkAll("Codec.LabelClassClasses", CodecTests[LabelClassClasses].unserializableCodec) 45 | checkAll("Codec.LabelClassName", CodecTests[LabelClassName].unserializableCodec) 46 | checkAll("Codec.LabelCount", CodecTests[LabelCount].unserializableCodec) 47 | checkAll("Codec.LabelExtensionProperties", CodecTests[LabelItemExtension].unserializableCodec) 48 | checkAll("Codec.LabelMethod", CodecTests[LabelMethod].unserializableCodec) 49 | checkAll("Codec.LabelOverview", CodecTests[LabelOverview].unserializableCodec) 50 | checkAll("Codec.LabelProperties", CodecTests[LabelProperties].unserializableCodec) 51 | checkAll("Codec.LabelStats", CodecTests[LabelStats].unserializableCodec) 52 | checkAll("Codec.LabelTask", CodecTests[LabelTask].unserializableCodec) 53 | checkAll("Codec.LabelType", CodecTests[LabelType].unserializableCodec) 54 | 55 | // Layer extension 56 | checkAll("Codec.LayerProperties", CodecTests[LayerItemExtension].unserializableCodec) 57 | checkAll("Codec.StacLayerProperties", CodecTests[StacLayerProperties].unserializableCodec) 58 | 59 | // eo extension 60 | checkAll("Codec.EOBand", CodecTests[Band].unserializableCodec) 61 | checkAll("Codec.EOItemExtension", CodecTests[EOItemExtension].unserializableCodec) 62 | checkAll("Codec.EOAssetExtension", CodecTests[EOAssetExtension].unserializableCodec) 63 | 64 | // unit tests 65 | test("ignore optional fields") { 66 | val link = 67 | decode[StacLink]("""{"href":"s3://foo/item.json","rel":"item"}""") 68 | link map { _.extensionFields } shouldBe Right(().asJsonObject) 69 | } 70 | 71 | // timezone parsing unit tests 72 | private def getTimeDecodeTest(timestring: String): Assertion = 73 | timestring.asJson.as[Instant] shouldBe Right(OffsetDateTime.parse(timestring, RFC3339formatter).toInstant) 74 | 75 | private def accumulatingDecodeTest[T: Decoder]: Assertion = 76 | decodeAccumulating[T]("{}").fold( 77 | errs => { 78 | errs.size should be > 1 79 | }, 80 | _ => fail("Decoding succeeded but should not have") 81 | ) 82 | 83 | test("Instant decodes timestrings with +0x:00 timezones") { 84 | getTimeDecodeTest("2018-01-01T00:00:00+05:00") 85 | } 86 | 87 | test("Instant decodes timestrings with -0x:00 timezones") { 88 | getTimeDecodeTest("2018-01-01T00:00:00-09:00") 89 | } 90 | 91 | test("Instant decodes timestrings with 0000 format timezone") { 92 | getTimeDecodeTest("2018-01-01T00:00:00+0000") 93 | } 94 | 95 | test("Instant decodes timestrings with +00 format timezone") { 96 | getTimeDecodeTest("2018-01-01T00:00:00+00") 97 | } 98 | 99 | test("Instant decodes timestrings with -00 format timezone") { 100 | getTimeDecodeTest("2018-01-01T00:00:00-00") 101 | } 102 | 103 | test("Instant decodes timestring with Z format timezone") { 104 | getTimeDecodeTest("2020-04-03T11:32:26Z") 105 | } 106 | 107 | test("Instant decodes timestring with 1e3 Z format timezone") { 108 | getTimeDecodeTest("2018-04-03T11:32:26.553Z") 109 | } 110 | 111 | test("Instant decodes timestring with 1e9 Z format timezone") { 112 | getTimeDecodeTest("2018-04-03T11:32:26.553955473Z") 113 | } 114 | 115 | test("Collections accumulate errors") { 116 | accumulatingDecodeTest[StacCollection] 117 | } 118 | 119 | test("Items accumulate errors") { 120 | accumulatingDecodeTest[StacItem] 121 | } 122 | 123 | test("Catalogs accumulate errors") { 124 | accumulatingDecodeTest[StacCatalog] 125 | } 126 | 127 | test("Links accumulate errors") { 128 | accumulatingDecodeTest[StacLink] 129 | } 130 | 131 | test("Assets accumulate errors") { 132 | // StacAssets actually only have one required field, so an empty json object only 133 | // decodes with one error even with accumulation 134 | decodeAccumulating[StacAsset]("""{"href": 1234, "title": 1234}""") 135 | .fold(errs => errs.size shouldBe 2, _ => fail("Decoding should not have succeeded")) 136 | } 137 | 138 | test("Extents accumulate errors") { 139 | accumulatingDecodeTest[StacExtent] 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /modules/core-test/shared/src/test/scala/com/azavea/stac4s/SyntaxSpec.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import com.azavea.stac4s.extensions._ 4 | import com.azavea.stac4s.extensions.eo._ 5 | import com.azavea.stac4s.extensions.label._ 6 | import com.azavea.stac4s.syntax._ 7 | import com.azavea.stac4s.testing.TestInstances._ 8 | 9 | import cats.syntax.validated._ 10 | import org.scalatest.funsuite.AnyFunSuite 11 | import org.scalatest.matchers.should.Matchers 12 | import org.scalatestplus.scalacheck.Checkers 13 | 14 | class SyntaxSpec extends AnyFunSuite with Checkers with Matchers { 15 | 16 | test("link syntax results in the same values as typeclass summoner to extend") { 17 | check { (stacLink: StacLink, labelLinkExtension: LabelLinkExtension) => 18 | stacLink.addExtensionFields(labelLinkExtension) == LinkExtension[LabelLinkExtension] 19 | .addExtensionFields(stacLink, labelLinkExtension) 20 | } 21 | } 22 | 23 | test("link syntax results in the same values as typeclass summoner to parse") { 24 | check { (stacLink: StacLink, labelLinkExtension: LabelLinkExtension) => 25 | stacLink.addExtensionFields(labelLinkExtension).getExtensionFields[LabelLinkExtension] == labelLinkExtension.valid 26 | } 27 | } 28 | 29 | test("asset syntax results in the same values as typeclass summoner to extend") { 30 | check { (StacAsset: StacAsset, eoAssetExtension: EOAssetExtension) => 31 | StacAsset.addExtensionFields(eoAssetExtension) == StacAssetExtension[EOAssetExtension] 32 | .addExtensionFields(StacAsset, eoAssetExtension) 33 | } 34 | } 35 | 36 | test("asset syntax results in the same values as typeclass summoner to parse") { 37 | check { (StacAsset: StacAsset, eoAssetExtension: EOAssetExtension) => 38 | StacAsset.addExtensionFields(eoAssetExtension).getExtensionFields[EOAssetExtension] == eoAssetExtension.valid 39 | } 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /modules/core/js/src/main/scala/com/azavea/stac4s/Bbox.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import cats.Eq 4 | import cats.kernel.Semigroup 5 | import cats.syntax.either._ 6 | import cats.syntax.functor._ 7 | import io.circe._ 8 | import io.circe.syntax._ 9 | 10 | sealed trait Bbox { 11 | val xmin: Double 12 | val ymin: Double 13 | val xmax: Double 14 | val ymax: Double 15 | val toList: List[Double] 16 | 17 | def union(other: Bbox): Bbox 18 | } 19 | 20 | final case class TwoDimBbox(xmin: Double, ymin: Double, xmax: Double, ymax: Double) extends Bbox { 21 | val toList = List(xmin, ymin, xmax, ymax) 22 | 23 | def union(other: Bbox): Bbox = other match { 24 | case TwoDimBbox(otherXmin, otherYmin, otherXmax, otherYmax) => 25 | TwoDimBbox(otherXmin min xmin, otherYmin min ymin, otherXmax max xmax, otherYmax max ymax) 26 | case ThreeDimBbox(otherXmin, otherYmin, zmin, otherXmax, otherYmax, zmax) => 27 | ThreeDimBbox(otherXmin min xmin, otherYmin min ymin, zmin, otherXmax max xmax, otherYmax max ymax, zmax) 28 | } 29 | } 30 | 31 | final case class ThreeDimBbox( 32 | xmin: Double, 33 | ymin: Double, 34 | zmin: Double, 35 | xmax: Double, 36 | ymax: Double, 37 | zmax: Double 38 | ) extends Bbox { 39 | val toList = List(xmin, ymin, zmin, xmax, ymax, zmax) 40 | 41 | def union(other: Bbox): Bbox = other match { 42 | case TwoDimBbox(otherXmin, otherYmin, otherXmax, otherYmax) => 43 | ThreeDimBbox(otherXmin min xmin, otherYmin min ymin, zmin, otherXmax max xmax, otherYmax max ymax, zmax) 44 | case ThreeDimBbox(otherXmin, otherYmin, otherZmin, otherXmax, otherYmax, otherZmax) => 45 | ThreeDimBbox( 46 | otherXmin min xmin, 47 | otherYmin min ymin, 48 | otherZmin min zmin, 49 | otherXmax max xmax, 50 | otherYmax max ymax, 51 | otherZmax max zmax 52 | ) 53 | } 54 | } 55 | 56 | object TwoDimBbox { 57 | 58 | implicit val eqTwoDimBbox: Eq[TwoDimBbox] = Eq.fromUniversalEquals 59 | 60 | implicit val decoderTwoDBox: Decoder[TwoDimBbox] = 61 | Decoder.decodeList[Double].emap { 62 | case twodim if twodim.length == 4 => 63 | Either.right(TwoDimBbox(twodim(0), twodim(1), twodim(2), twodim(3))) 64 | case other => 65 | Either.left( 66 | s"Incorrect number of values for 2d box - found ${other.length}, expected 4" 67 | ) 68 | } 69 | 70 | implicit val encoderTwoDimBbox: Encoder[TwoDimBbox] = _.toList.asJson 71 | } 72 | 73 | object ThreeDimBbox { 74 | 75 | implicit val eqThreeDimBbox: Eq[ThreeDimBbox] = Eq.fromUniversalEquals 76 | 77 | implicit val decoderThreeDimBox: Decoder[ThreeDimBbox] = 78 | Decoder.decodeList[Double].emap { 79 | case threeDim if threeDim.length == 6 => 80 | Either.right( 81 | ThreeDimBbox( 82 | threeDim(0), 83 | threeDim(1), 84 | threeDim(2), 85 | threeDim(3), 86 | threeDim(4), 87 | threeDim(5) 88 | ) 89 | ) 90 | case other => 91 | Either.left( 92 | s"Incorrect number of values for 2d box - found ${other.length}, expected 4" 93 | ) 94 | } 95 | 96 | implicit val encoderThreeDimBbox: Encoder[ThreeDimBbox] = _.toList.asJson 97 | } 98 | 99 | object Bbox { 100 | 101 | implicit val encoderBbox: Encoder[Bbox] = { 102 | case two: TwoDimBbox => two.asJson 103 | case three: ThreeDimBbox => three.asJson 104 | } 105 | 106 | implicit val decoderBbox: Decoder[Bbox] = Decoder[TwoDimBbox].widen or Decoder[ThreeDimBbox].widen 107 | 108 | implicit val eqBbox: Eq[Bbox] = Eq.fromUniversalEquals 109 | 110 | implicit val semigroupBbox: Semigroup[Bbox] = Semigroup.instance(_ union _) 111 | 112 | } 113 | -------------------------------------------------------------------------------- /modules/core/js/src/main/scala/com/azavea/stac4s/StacCollection.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import com.azavea.stac4s.types.CollectionType 4 | 5 | import cats.Eq 6 | import cats.syntax.apply._ 7 | import cats.syntax.either._ 8 | import io.circe._ 9 | import io.circe.refined._ 10 | import io.circe.syntax._ 11 | 12 | final case class StacCollection( 13 | _type: CollectionType, 14 | stacVersion: String, 15 | stacExtensions: List[String], 16 | id: String, 17 | title: Option[String], 18 | description: String, 19 | keywords: List[String], 20 | license: StacLicense, 21 | providers: List[StacProvider], 22 | extent: StacExtent, 23 | summaries: JsonObject, 24 | properties: JsonObject, 25 | links: List[StacLink], 26 | assets: Option[Map[String, StacAsset]], 27 | extensionFields: JsonObject = ().asJsonObject 28 | ) 29 | 30 | object StacCollection { 31 | val collectionFields = productFieldNames[StacCollection] 32 | 33 | implicit val eqStacCollection: Eq[StacCollection] = Eq.fromUniversalEquals 34 | 35 | implicit val encoderStacCollection: Encoder[StacCollection] = { collection => 36 | val baseEncoder: Encoder[StacCollection] = Encoder.forProduct14( 37 | "type", 38 | "stac_version", 39 | "stac_extensions", 40 | "id", 41 | "title", 42 | "description", 43 | "keywords", 44 | "license", 45 | "providers", 46 | "extent", 47 | "summaries", 48 | "properties", 49 | "links", 50 | "assets" 51 | )(collection => 52 | ( 53 | collection._type, 54 | collection.stacVersion, 55 | collection.stacExtensions, 56 | collection.id, 57 | collection.title, 58 | collection.description, 59 | collection.keywords, 60 | collection.license, 61 | collection.providers, 62 | collection.extent, 63 | collection.summaries, 64 | collection.properties, 65 | collection.links, 66 | collection.assets 67 | ) 68 | ) 69 | 70 | baseEncoder(collection).deepMerge(collection.extensionFields.asJson).dropNullValues 71 | } 72 | 73 | implicit val decoderStacCollection: Decoder[StacCollection] = new Decoder[StacCollection] { 74 | 75 | override def decodeAccumulating(c: HCursor) = ( 76 | c.get[CollectionType]("type").toValidatedNel, 77 | c.get[String]("stac_version").toValidatedNel, 78 | c.get[Option[List[String]]]("stac_extensions").toValidatedNel, 79 | c.get[String]("id").toValidatedNel, 80 | c.get[Option[String]]("title").toValidatedNel, 81 | c.get[String]("description").toValidatedNel, 82 | c.get[Option[List[String]]]("keywords").toValidatedNel, 83 | c.get[StacLicense]("license").toValidatedNel, 84 | c.get[Option[List[StacProvider]]]("providers").toValidatedNel, 85 | c.get[StacExtent]("extent").toValidatedNel, 86 | c.get[Option[JsonObject]]("summaries").toValidatedNel, 87 | c.get[Option[JsonObject]]("properties").toValidatedNel, 88 | c.get[List[StacLink]]("links").toValidatedNel, 89 | c.get[Option[Map[String, StacAsset]]]("assets").toValidatedNel, 90 | c.value.as[JsonObject].toValidatedNel 91 | ).mapN( 92 | ( 93 | _type: CollectionType, 94 | stacVersion: String, 95 | stacExtensions: Option[List[String]], 96 | id: String, 97 | title: Option[String], 98 | description: String, 99 | keywords: Option[List[String]], 100 | license: StacLicense, 101 | providers: Option[List[StacProvider]], 102 | extent: StacExtent, 103 | summaries: Option[JsonObject], 104 | properties: Option[JsonObject], 105 | links: List[StacLink], 106 | assets: Option[Map[String, StacAsset]], 107 | extensionFields: JsonObject 108 | ) => 109 | StacCollection( 110 | _type, 111 | stacVersion, 112 | stacExtensions getOrElse Nil, 113 | id, 114 | title, 115 | description, 116 | keywords getOrElse List.empty, 117 | license, 118 | providers getOrElse List.empty, 119 | extent, 120 | summaries getOrElse JsonObject.fromMap(Map.empty), 121 | properties getOrElse JsonObject.fromMap(Map.empty), 122 | links, 123 | assets, 124 | extensionFields.filter({ case (k, _) => 125 | !collectionFields.contains(k) 126 | }) 127 | ) 128 | ) 129 | 130 | def apply(c: HCursor) = decodeAccumulating(c).toEither.leftMap(_.head) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /modules/core/js/src/main/scala/com/azavea/stac4s/StacExtent.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import cats.Eq 4 | import io.circe._ 5 | import io.circe.generic.semiauto._ 6 | 7 | final case class SpatialExtent(bbox: List[Bbox]) 8 | 9 | object SpatialExtent { 10 | implicit val encSpatialExtent: Encoder[SpatialExtent] = deriveEncoder 11 | implicit val decSpatialExtent: Decoder[SpatialExtent] = deriveDecoder 12 | } 13 | 14 | final case class Interval(interval: List[TemporalExtent]) 15 | 16 | object Interval { 17 | implicit val encInterval: Encoder[Interval] = deriveEncoder 18 | implicit val decInterval: Decoder[Interval] = deriveDecoder 19 | } 20 | 21 | final case class StacExtent( 22 | spatial: SpatialExtent, 23 | temporal: Interval 24 | ) 25 | 26 | object StacExtent { 27 | implicit val eqStacExtent: Eq[StacExtent] = Eq.fromUniversalEquals 28 | implicit val encStacExtent: Encoder[StacExtent] = deriveEncoder 29 | implicit val decStacExtent: Decoder[StacExtent] = deriveDecoder 30 | } 31 | -------------------------------------------------------------------------------- /modules/core/js/src/main/scala/com/azavea/stac4s/StacItem.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import com.azavea.stac4s.geometry.Geometry 4 | 5 | import cats.Eq 6 | import io.circe._ 7 | import monocle.Lens 8 | import monocle.macros.GenLens 9 | 10 | final case class StacItem( 11 | id: String, 12 | stacVersion: String, 13 | stacExtensions: List[String], 14 | _type: String = "Feature", 15 | geometry: Geometry, 16 | bbox: TwoDimBbox, 17 | links: List[StacLink], 18 | assets: Map[String, StacAsset], 19 | collection: Option[String], 20 | properties: ItemProperties 21 | ) { 22 | 23 | val cogUri: Option[String] = assets 24 | .filter(_._2._type.contains(`image/cog`)) 25 | .values 26 | .headOption map { _.href } 27 | } 28 | 29 | object StacItem { 30 | 31 | val propertiesExtension: Lens[StacItem, JsonObject] = GenLens[StacItem](_.properties.extensionFields) 32 | 33 | implicit val eqStacItem: Eq[StacItem] = Eq.fromUniversalEquals 34 | 35 | implicit val encStacItem: Encoder[StacItem] = Encoder 36 | .forProduct10( 37 | "id", 38 | "stac_version", 39 | "stac_extensions", 40 | "type", 41 | "geometry", 42 | "bbox", 43 | "links", 44 | "assets", 45 | "collection", 46 | "properties" 47 | )((item: StacItem) => 48 | ( 49 | item.id, 50 | item.stacVersion, 51 | item.stacExtensions, 52 | item._type, 53 | item.geometry, 54 | item.bbox, 55 | item.links, 56 | item.assets, 57 | item.collection, 58 | item.properties 59 | ) 60 | ) 61 | .mapJson(_.dropNullValues) 62 | 63 | implicit val decStacItem: Decoder[StacItem] = Decoder.forProduct10( 64 | "id", 65 | "stac_version", 66 | "stac_extensions", 67 | "type", 68 | "geometry", 69 | "bbox", 70 | "links", 71 | "assets", 72 | "collection", 73 | "properties" 74 | )( 75 | ( 76 | id: String, 77 | stacVersion: String, 78 | stacExtensions: Option[List[String]], 79 | _type: String, 80 | geometry: Geometry, 81 | bbox: TwoDimBbox, 82 | links: List[StacLink], 83 | assets: Map[String, StacAsset], 84 | collection: Option[String], 85 | properties: ItemProperties 86 | ) => { 87 | StacItem( 88 | id, 89 | stacVersion, 90 | stacExtensions getOrElse List.empty, 91 | _type, 92 | geometry, 93 | bbox, 94 | links, 95 | assets, 96 | collection, 97 | properties 98 | ) 99 | } 100 | ) 101 | 102 | } 103 | -------------------------------------------------------------------------------- /modules/core/js/src/main/scala/com/azavea/stac4s/extensions/CollectionExtension.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions 2 | 3 | import com.azavea.stac4s.StacCollection 4 | 5 | import io.circe.syntax._ 6 | import io.circe.{Decoder, Encoder} 7 | 8 | trait CollectionExtension[T] { 9 | def getExtensionFields(collection: StacCollection): ExtensionResult[T] 10 | 11 | def addExtensionFields(collection: StacCollection, extensionFields: T): StacCollection 12 | } 13 | 14 | object CollectionExtension { 15 | def apply[T](implicit ev: CollectionExtension[T]): CollectionExtension[T] = ev 16 | 17 | def instance[T](implicit decoder: Decoder[T], objectEncoder: Encoder.AsObject[T]) = 18 | new CollectionExtension[T] { 19 | 20 | def getExtensionFields(collection: StacCollection): ExtensionResult[T] = 21 | decoder.decodeAccumulating(collection.extensionFields.asJson.hcursor) 22 | 23 | def addExtensionFields(collection: StacCollection, extensionFields: T): StacCollection = 24 | collection.copy(extensionFields = 25 | collection.extensionFields.deepMerge(objectEncoder.encodeObject(extensionFields)) 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /modules/core/js/src/main/scala/com/azavea/stac4s/extensions/layer/StacLayer.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.layer 2 | 3 | import com.azavea.stac4s.geometry.Geometry 4 | import com.azavea.stac4s.{Bbox, StacLink} 5 | 6 | import cats.kernel.Eq 7 | import eu.timepit.refined.types.string 8 | import io.circe.refined._ 9 | import io.circe.{Decoder, Encoder} 10 | 11 | final case class StacLayer( 12 | id: string.NonEmptyString, 13 | bbox: Bbox, 14 | geometry: Geometry, 15 | properties: StacLayerProperties, 16 | links: List[StacLink], 17 | _type: String = "Feature" 18 | ) 19 | 20 | object StacLayer { 21 | implicit val eqStacLayer: Eq[StacLayer] = Eq.fromUniversalEquals 22 | 23 | implicit val encStacLayer: Encoder[StacLayer] = Encoder.forProduct6( 24 | "id", 25 | "bbox", 26 | "geometry", 27 | "properties", 28 | "links", 29 | "type" 30 | )(layer => (layer.id, layer.bbox, layer.geometry, layer.properties, layer.links, layer._type)) 31 | 32 | implicit val decStacLayer: Decoder[StacLayer] = Decoder.forProduct6( 33 | "id", 34 | "bbox", 35 | "geometry", 36 | "properties", 37 | "links", 38 | "type" 39 | )(StacLayer.apply) 40 | } 41 | -------------------------------------------------------------------------------- /modules/core/js/src/main/scala/com/azavea/stac4s/geometry/Geometry.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.geometry 2 | 3 | import cats.Eq 4 | import cats.syntax.either._ 5 | import cats.syntax.eq._ 6 | import cats.syntax.traverse._ 7 | import io.circe._ 8 | import io.circe.syntax._ 9 | 10 | sealed abstract class Geometry 11 | 12 | object Geometry { 13 | 14 | case class Point2d private (x: Double, y: Double) extends Geometry { 15 | def asCoordinateArray: List[Double] = List(x, y) 16 | } 17 | 18 | case class Polygon private (coords: List[Point2d]) extends Geometry { 19 | def asCoordinateArray: List[List[List[Double]]] = List(coords.map(_.asCoordinateArray)) 20 | } 21 | 22 | case class MultiPolygon private (polys: List[Polygon]) extends Geometry { 23 | def asCoordinateArray: List[List[List[List[Double]]]] = polys.map(_.asCoordinateArray) 24 | } 25 | 26 | private def point2dFromArray(arr: List[Double]): Either[String, Point2d] = arr match { 27 | case longitude :: latitude :: Nil => Right(Point2d(longitude, latitude)) 28 | case _ => Left("Point can only be constructed from exactly two coordinates") 29 | } 30 | 31 | private def polygonFromArray(arr: List[List[List[Double]]]): Either[String, Polygon] = 32 | arr.flatten traverse { point2dFromArray } flatMap { points => 33 | (points.headOption, points.lastOption) match { 34 | case (Some(h), Some(t)) if h === t && points.size >= 4 => 35 | Either.right[String, Polygon](Polygon(points)) 36 | case _ => 37 | Either.left( 38 | "Polyons must have at least four points with the first equal to the last" 39 | ) 40 | } 41 | } 42 | 43 | private def multiPolygonFromArray(arr: List[List[List[List[Double]]]]): Either[String, MultiPolygon] = 44 | arr traverse { polygonFromArray } map { MultiPolygon } 45 | 46 | implicit val eqPoint2d: Eq[Point2d] = Eq.fromUniversalEquals 47 | implicit val eqPolygon: Eq[Polygon] = Eq.fromUniversalEquals 48 | implicit val eqMultiPolygon: Eq[MultiPolygon] = Eq.fromUniversalEquals 49 | 50 | implicit val eqGeometry: Eq[Geometry] = Eq.fromUniversalEquals 51 | 52 | implicit val encPoint2d: Encoder[Point2d] = { point2d => 53 | Map( 54 | "type" -> "Point".asJson, 55 | "coordinates" -> List(point2d.x, point2d.y).asJson 56 | ).asJson 57 | } 58 | 59 | // for now, I'm ignoring polygons with holes 60 | // however polygons still store coordinates as List[List[List[Double]]] 61 | implicit val encPolygon: Encoder[Polygon] = { polygon => 62 | Map( 63 | "type" -> "Polygon".asJson, 64 | "coordinates" -> polygon.asCoordinateArray.asJson 65 | ).asJson 66 | } 67 | 68 | // for now, I'm ignoring multi polygons with holes 69 | // however multipolygons still store coordinates as List[List[List[List[Double]]]] 70 | implicit val encMultiPolygon: Encoder[MultiPolygon] = { mpolygon => 71 | Map( 72 | "type" -> "MultiPolygon".asJson, 73 | "coordinates" -> mpolygon.asCoordinateArray.asJson 74 | ).asJson 75 | } 76 | 77 | implicit val decPoint2d: Decoder[Point2d] = { c => 78 | c.get[List[Double]]("coordinates") match { 79 | case Right(arr) => point2dFromArray(arr).leftMap(DecodingFailure(_, c.history)) 80 | case Left(err) => Left(err) // re-wrap to get the correct RHS 81 | } 82 | } 83 | 84 | implicit val decPolygon: Decoder[Polygon] = { c => 85 | c.get[List[List[List[Double]]]]("coordinates") match { 86 | case Right(arr) => 87 | polygonFromArray(arr).leftMap(DecodingFailure(_, c.history)) 88 | case Left(err) => Left(err) // re-wrap to get the correct RHS 89 | } 90 | } 91 | 92 | implicit val decMultiPolygon: Decoder[MultiPolygon] = { c => 93 | c.get[List[List[List[List[Double]]]]]("coordinates") flatMap { arr => 94 | multiPolygonFromArray(arr).leftMap(DecodingFailure(_, c.history)) 95 | } 96 | } 97 | 98 | implicit val encGeometry: Encoder[Geometry] = { 99 | case mp @ MultiPolygon(_) => mp.asJson 100 | case p @ Polygon(_) => p.asJson 101 | case p2d @ Point2d(_, _) => p2d.asJson 102 | } 103 | 104 | implicit val decGeometry: Decoder[Geometry] = { c => 105 | for { 106 | geomType <- c.get[String]("type") 107 | result <- geomType.toLowerCase match { 108 | case "polygon" => Decoder[Polygon].decodeJson(c.value) 109 | case "point" => Decoder[Point2d].decodeJson(c.value) 110 | case "multipolygon" => Decoder[MultiPolygon].decodeJson(c.value) 111 | case _ => Left(DecodingFailure(s"Unrecognized geometry: $geomType", c.history)) 112 | } 113 | } yield result 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /modules/core/js/src/main/scala/com/azavea/stac4s/jsTypes.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import eu.timepit.refined.W 4 | import eu.timepit.refined.api.Refined 5 | import eu.timepit.refined.generic._ 6 | 7 | package object jsTypes { 8 | 9 | type CatalogType = String Refined Equal[W.`"Catalog"`.T] 10 | type CollectionType = String Refined Equal[W.`"Collection"`.T] 11 | } 12 | -------------------------------------------------------------------------------- /modules/core/js/src/main/scala/com/azavea/stac4s/meta/ForeignImplicits.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.meta 2 | 3 | import cats.Eq 4 | import cats.syntax.either._ 5 | import io.circe._ 6 | import io.circe.parser.decode 7 | 8 | import scala.util.Try 9 | 10 | import java.time.format.{DateTimeFormatter, DateTimeFormatterBuilder} 11 | import java.time.{Instant, OffsetDateTime} 12 | 13 | trait ForeignImplicits { 14 | 15 | // circe codecs 16 | // A more flexible alternative to DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS[xxx][xx][X]") 17 | // https://tools.ietf.org/html/rfc3339 18 | // Warning: This formatter is good only for parsing 19 | val RFC3339formatter = 20 | new DateTimeFormatterBuilder() 21 | .append(DateTimeFormatter.ISO_LOCAL_DATE_TIME) 22 | .optionalStart() 23 | .appendOffset("+HH:MM", "+00:00") 24 | .optionalEnd() 25 | .optionalStart() 26 | .appendOffset("+HHMM", "+0000") 27 | .optionalEnd() 28 | .optionalStart() 29 | .appendOffset("+HH", "Z") 30 | .optionalEnd() 31 | .toFormatter() 32 | 33 | implicit val eqInstant: Eq[Instant] = Eq.fromUniversalEquals 34 | 35 | implicit val encodeInstant: Encoder[Instant] = Encoder[String].contramap(_.toString) 36 | 37 | implicit val decodeInstant: Decoder[Instant] = 38 | Decoder[String].emap(s => 39 | Either 40 | .fromTry(Try(OffsetDateTime.parse(s, RFC3339formatter).toInstant)) 41 | .leftMap(_ => s"$s was not a valid string format") 42 | ) 43 | 44 | implicit val decTimeRange: Decoder[(Option[Instant], Option[Instant])] = Decoder[String] map { str => 45 | val components = str.replace("[", "").replace("]", "").split(",") map { 46 | _.trim 47 | } 48 | components match { 49 | case parts if parts.length == 2 => 50 | val start = parts.head 51 | val end = parts.drop(1).head 52 | (decode[Instant](start).toOption, decode[Instant](end).toOption) 53 | case parts if parts.length > 2 => 54 | val message = s"Too many elements for temporal extent: $parts" 55 | throw new ParsingFailure(message, new Exception(message)) 56 | case parts if parts.length < 2 => 57 | val message = s"Too few elements for temporal extent: $parts" 58 | throw new ParsingFailure(message, new Exception(message)) 59 | case x => throw new scala.MatchError(x) 60 | } 61 | } 62 | 63 | } 64 | 65 | object ForeignImplicits extends ForeignImplicits {} 66 | -------------------------------------------------------------------------------- /modules/core/js/src/main/scala/com/azavea/stac4s/syntax/package.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import com.azavea.stac4s.extensions._ 4 | 5 | package object syntax extends Syntax { 6 | 7 | implicit class stacCollectionExtensions(collection: StacCollection) { 8 | 9 | def getExtensionFields[T](implicit ev: CollectionExtension[T]): ExtensionResult[T] = 10 | ev.getExtensionFields(collection) 11 | 12 | def addExtensionFields[T](properties: T)(implicit ev: CollectionExtension[T]): StacCollection = 13 | ev.addExtensionFields(collection, properties) 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /modules/core/jvm/src/main/scala/com/azavea/stac4s/Bbox.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import cats.Eq 4 | import cats.kernel.Semigroup 5 | import cats.syntax.either._ 6 | import cats.syntax.functor._ 7 | import geotrellis.vector.{Extent, ExtentRangeError} 8 | import io.circe._ 9 | import io.circe.syntax._ 10 | 11 | sealed trait Bbox { 12 | val xmin: Double 13 | val ymin: Double 14 | val xmax: Double 15 | val ymax: Double 16 | val toList: List[Double] 17 | 18 | val toExtent: Either[String, Extent] = 19 | try { 20 | Either.right(Extent(xmin, ymin, xmax, ymax)) 21 | } catch { 22 | case e: ExtentRangeError => Either.left(e.toString) 23 | } 24 | 25 | def union(other: Bbox): Bbox 26 | } 27 | 28 | final case class TwoDimBbox(xmin: Double, ymin: Double, xmax: Double, ymax: Double) extends Bbox { 29 | val toList = List(xmin, ymin, xmax, ymax) 30 | 31 | def union(other: Bbox): Bbox = other match { 32 | case TwoDimBbox(otherXmin, otherYmin, otherXmax, otherYmax) => 33 | TwoDimBbox(otherXmin min xmin, otherYmin min ymin, otherXmax max xmax, otherYmax max ymax) 34 | case ThreeDimBbox(otherXmin, otherYmin, zmin, otherXmax, otherYmax, zmax) => 35 | ThreeDimBbox(otherXmin min xmin, otherYmin min ymin, zmin, otherXmax max xmax, otherYmax max ymax, zmax) 36 | } 37 | } 38 | 39 | final case class ThreeDimBbox( 40 | xmin: Double, 41 | ymin: Double, 42 | zmin: Double, 43 | xmax: Double, 44 | ymax: Double, 45 | zmax: Double 46 | ) extends Bbox { 47 | val toList = List(xmin, ymin, zmin, xmax, ymax, zmax) 48 | 49 | def union(other: Bbox): Bbox = other match { 50 | case TwoDimBbox(otherXmin, otherYmin, otherXmax, otherYmax) => 51 | ThreeDimBbox(otherXmin min xmin, otherYmin min ymin, zmin, otherXmax max xmax, otherYmax max ymax, zmax) 52 | case ThreeDimBbox(otherXmin, otherYmin, otherZmin, otherXmax, otherYmax, otherZmax) => 53 | ThreeDimBbox( 54 | otherXmin min xmin, 55 | otherYmin min ymin, 56 | otherZmin min zmin, 57 | otherXmax max xmax, 58 | otherYmax max ymax, 59 | otherZmax max zmax 60 | ) 61 | } 62 | } 63 | 64 | object TwoDimBbox { 65 | 66 | implicit val eqTwoDimBbox: Eq[TwoDimBbox] = Eq.fromUniversalEquals 67 | 68 | implicit val decoderTwoDBox: Decoder[TwoDimBbox] = 69 | Decoder.decodeList[Double].emap { 70 | case twodim if twodim.length == 4 => 71 | Either.right(TwoDimBbox(twodim(0), twodim(1), twodim(2), twodim(3))) 72 | case other => 73 | Either.left( 74 | s"Incorrect number of values for 2d box - found ${other.length}, expected 4" 75 | ) 76 | } 77 | 78 | implicit val encoderTwoDimBbox: Encoder[TwoDimBbox] = _.toList.asJson 79 | } 80 | 81 | object ThreeDimBbox { 82 | 83 | implicit val eqThreeDimBbox: Eq[ThreeDimBbox] = Eq.fromUniversalEquals 84 | 85 | implicit val decoderThreeDimBox: Decoder[ThreeDimBbox] = 86 | Decoder.decodeList[Double].emap { 87 | case threeDim if threeDim.length == 6 => 88 | Either.right( 89 | ThreeDimBbox( 90 | threeDim(0), 91 | threeDim(1), 92 | threeDim(2), 93 | threeDim(3), 94 | threeDim(4), 95 | threeDim(5) 96 | ) 97 | ) 98 | case other => 99 | Either.left( 100 | s"Incorrect number of values for 2d box - found ${other.length}, expected 4" 101 | ) 102 | } 103 | 104 | implicit val encoderThreeDimBbox: Encoder[ThreeDimBbox] = _.toList.asJson 105 | } 106 | 107 | object Bbox { 108 | 109 | implicit val encoderBbox: Encoder[Bbox] = { 110 | case two: TwoDimBbox => two.asJson 111 | case three: ThreeDimBbox => three.asJson 112 | } 113 | 114 | implicit val decoderBbox: Decoder[Bbox] = Decoder[TwoDimBbox].widen or Decoder[ThreeDimBbox].widen 115 | 116 | implicit val eqBbox: Eq[Bbox] = Eq.fromUniversalEquals 117 | 118 | implicit val semigroupBbox: Semigroup[Bbox] = Semigroup.instance((_ union _)) 119 | 120 | } 121 | -------------------------------------------------------------------------------- /modules/core/jvm/src/main/scala/com/azavea/stac4s/StacCollection.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import com.azavea.stac4s.types.CollectionType 4 | 5 | import cats.Eq 6 | import cats.syntax.apply._ 7 | import cats.syntax.either._ 8 | import eu.timepit.refined.types.string 9 | import io.circe._ 10 | import io.circe.refined._ 11 | import io.circe.syntax._ 12 | 13 | final case class StacCollection( 14 | _type: CollectionType, 15 | stacVersion: String, 16 | stacExtensions: List[String], 17 | id: String, 18 | title: Option[String], 19 | description: String, 20 | keywords: List[String], 21 | license: StacLicense, 22 | providers: List[StacProvider], 23 | extent: StacExtent, 24 | summaries: Map[string.NonEmptyString, SummaryValue], 25 | properties: JsonObject, 26 | links: List[StacLink], 27 | assets: Option[Map[String, StacAsset]], 28 | extensionFields: JsonObject = ().asJsonObject 29 | ) 30 | 31 | object StacCollection { 32 | val collectionFields = productFieldNames[StacCollection] 33 | 34 | implicit val eqStacCollection: Eq[StacCollection] = Eq.fromUniversalEquals 35 | 36 | implicit val encoderStacCollection: Encoder[StacCollection] = { collection => 37 | val baseEncoder: Encoder[StacCollection] = Encoder.forProduct14( 38 | "type", 39 | "stac_version", 40 | "stac_extensions", 41 | "id", 42 | "title", 43 | "description", 44 | "keywords", 45 | "license", 46 | "providers", 47 | "extent", 48 | "summaries", 49 | "properties", 50 | "links", 51 | "assets" 52 | )(collection => 53 | ( 54 | collection._type, 55 | collection.stacVersion, 56 | collection.stacExtensions, 57 | collection.id, 58 | collection.title, 59 | collection.description, 60 | collection.keywords, 61 | collection.license, 62 | collection.providers, 63 | collection.extent, 64 | collection.summaries, 65 | collection.properties, 66 | collection.links, 67 | collection.assets 68 | ) 69 | ) 70 | 71 | baseEncoder(collection).deepMerge(collection.extensionFields.asJson).dropNullValues 72 | } 73 | 74 | implicit val decoderStacCollection: Decoder[StacCollection] = new Decoder[StacCollection] { 75 | 76 | override def decodeAccumulating(c: HCursor) = { 77 | ( 78 | c.get[CollectionType]("type").toValidatedNel, 79 | c.get[String]("stac_version").toValidatedNel, 80 | c.get[Option[List[String]]]("stac_extensions").toValidatedNel, 81 | c.get[String]("id").toValidatedNel, 82 | c.get[Option[String]]("title").toValidatedNel, 83 | c.get[String]("description").toValidatedNel, 84 | c.get[Option[List[String]]]("keywords").toValidatedNel, 85 | c.get[StacLicense]("license").toValidatedNel, 86 | c.get[Option[List[StacProvider]]]("providers").toValidatedNel, 87 | c.get[StacExtent]("extent").toValidatedNel, 88 | c.get[Option[Map[string.NonEmptyString, SummaryValue]]]("summaries").toValidatedNel, 89 | c.get[Option[JsonObject]]("properties").toValidatedNel, 90 | c.get[List[StacLink]]("links").toValidatedNel, 91 | c.get[Option[Map[String, StacAsset]]]("assets").toValidatedNel, 92 | c.value.as[JsonObject].toValidatedNel 93 | ).mapN( 94 | ( 95 | _type: CollectionType, 96 | stacVersion: String, 97 | stacExtensions: Option[List[String]], 98 | id: String, 99 | title: Option[String], 100 | description: String, 101 | keywords: Option[List[String]], 102 | license: StacLicense, 103 | providers: Option[List[StacProvider]], 104 | extent: StacExtent, 105 | summaries: Option[Map[string.NonEmptyString, SummaryValue]], 106 | properties: Option[JsonObject], 107 | links: List[StacLink], 108 | assets: Option[Map[String, StacAsset]], 109 | extensionFields: JsonObject 110 | ) => 111 | StacCollection( 112 | _type, 113 | stacVersion, 114 | stacExtensions getOrElse Nil, 115 | id, 116 | title, 117 | description, 118 | keywords getOrElse List.empty, 119 | license, 120 | providers getOrElse List.empty, 121 | extent, 122 | summaries getOrElse Map.empty, 123 | properties getOrElse JsonObject.fromMap(Map.empty), 124 | links, 125 | assets, 126 | extensionFields.filter({ case (k, _) => 127 | !collectionFields.contains(k) 128 | }) 129 | ) 130 | ) 131 | } 132 | 133 | def apply(c: HCursor) = decodeAccumulating(c).toEither.leftMap(_.head) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /modules/core/jvm/src/main/scala/com/azavea/stac4s/StacExtent.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import cats.Eq 4 | import cats.syntax.apply._ 5 | import io.circe._ 6 | import io.circe.generic.semiauto._ 7 | import io.circe.syntax._ 8 | 9 | final case class SpatialExtent(bbox: List[Bbox]) 10 | 11 | object SpatialExtent { 12 | implicit val encSpatialExtent: Encoder[SpatialExtent] = deriveEncoder 13 | implicit val decSpatialExtent: Decoder[SpatialExtent] = deriveDecoder 14 | } 15 | 16 | final case class Interval(interval: List[TemporalExtent], extensionFields: JsonObject = JsonObject.empty) 17 | 18 | object Interval { 19 | val intervalFields = productFieldNames[Interval] 20 | 21 | implicit val encInterval: Encoder[Interval] = { interval: Interval => 22 | val baseEncInterval: Encoder[Interval] = Encoder.forProduct1("interval")({ interval: Interval => 23 | interval.interval 24 | }) 25 | baseEncInterval(interval).deepMerge(interval.extensionFields.asJson) 26 | } 27 | 28 | implicit val decInterval: Decoder[Interval] = { c: HCursor => 29 | ( 30 | c.get[List[TemporalExtent]]("interval"), 31 | c.value.as[JsonObject] 32 | ) mapN { (interval: List[TemporalExtent], document: JsonObject) => 33 | Interval( 34 | interval, 35 | document.filter({ case (k, _) => 36 | !intervalFields.contains(k) 37 | }) 38 | ) 39 | } 40 | 41 | } 42 | 43 | implicit val eqInterval: Eq[Interval] = Eq.fromUniversalEquals 44 | } 45 | 46 | final case class StacExtent( 47 | spatial: SpatialExtent, 48 | temporal: Interval 49 | ) 50 | 51 | object StacExtent { 52 | implicit val eqStacExtent: Eq[StacExtent] = Eq.fromUniversalEquals 53 | implicit val encStacExtent: Encoder[StacExtent] = deriveEncoder 54 | implicit val decStacExtent: Decoder[StacExtent] = deriveDecoder 55 | } 56 | -------------------------------------------------------------------------------- /modules/core/jvm/src/main/scala/com/azavea/stac4s/StacItem.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import com.azavea.stac4s.meta._ 4 | 5 | import cats.Eq 6 | import geotrellis.vector.Geometry 7 | import io.circe._ 8 | import monocle.Lens 9 | import monocle.macros.GenLens 10 | 11 | final case class StacItem( 12 | id: String, 13 | stacVersion: String, 14 | stacExtensions: List[String], 15 | _type: String = "Feature", 16 | geometry: Geometry, 17 | bbox: TwoDimBbox, 18 | links: List[StacLink], 19 | assets: Map[String, StacAsset], 20 | collection: Option[String], 21 | properties: ItemProperties 22 | ) { 23 | 24 | val cogUri: Option[String] = assets 25 | .filter(_._2._type.contains(`image/cog`)) 26 | .values 27 | .headOption map { _.href } 28 | } 29 | 30 | object StacItem { 31 | 32 | val propertiesExtension: Lens[StacItem, JsonObject] = GenLens[StacItem](_.properties.extensionFields) 33 | 34 | implicit val eqStacItem: Eq[StacItem] = Eq.fromUniversalEquals 35 | 36 | implicit val encStacItem: Encoder[StacItem] = Encoder 37 | .forProduct10( 38 | "id", 39 | "stac_version", 40 | "stac_extensions", 41 | "type", 42 | "geometry", 43 | "bbox", 44 | "links", 45 | "assets", 46 | "collection", 47 | "properties" 48 | )((item: StacItem) => 49 | ( 50 | item.id, 51 | item.stacVersion, 52 | item.stacExtensions, 53 | item._type, 54 | item.geometry, 55 | item.bbox, 56 | item.links, 57 | item.assets, 58 | item.collection, 59 | item.properties 60 | ) 61 | ) 62 | .mapJson(_.dropNullValues) 63 | 64 | implicit val decStacItem: Decoder[StacItem] = Decoder.forProduct10( 65 | "id", 66 | "stac_version", 67 | "stac_extensions", 68 | "type", 69 | "geometry", 70 | "bbox", 71 | "links", 72 | "assets", 73 | "collection", 74 | "properties" 75 | )( 76 | ( 77 | id: String, 78 | stacVersion: String, 79 | stacExtensions: Option[List[String]], 80 | _type: String, 81 | geometry: Geometry, 82 | bbox: TwoDimBbox, 83 | links: List[StacLink], 84 | assets: Map[String, StacAsset], 85 | collection: Option[String], 86 | properties: ItemProperties 87 | ) => { 88 | StacItem( 89 | id, 90 | stacVersion, 91 | stacExtensions getOrElse List.empty, 92 | _type, 93 | geometry, 94 | bbox, 95 | links, 96 | assets, 97 | collection, 98 | properties 99 | ) 100 | } 101 | ) 102 | 103 | } 104 | -------------------------------------------------------------------------------- /modules/core/jvm/src/main/scala/com/azavea/stac4s/SummaryValue.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import cats.kernel.Eq 4 | import cats.syntax.all._ 5 | import eu.timepit.refined.types.string 6 | import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} 7 | import io.circe.refined._ 8 | import io.circe.schema.Schema 9 | import io.circe.{Decoder, DecodingFailure, Encoder, HCursor, Json} 10 | 11 | import scala.util.Try 12 | 13 | sealed abstract class SummaryValue 14 | 15 | case class StringRangeSummary( 16 | minimum: string.NonEmptyString, 17 | maximum: string.NonEmptyString 18 | ) extends SummaryValue 19 | 20 | case class NumericRangeSummary( 21 | minimum: Double, 22 | maximum: Double 23 | ) extends SummaryValue 24 | 25 | // This constructor isn't exported because not all JSON is valid JSON Schema 26 | case class SchemaSummary private[stac4s] ( 27 | underlying: Json 28 | ) extends SummaryValue 29 | 30 | object SummaryValue { 31 | 32 | implicit val eqSummaryValue: Eq[SummaryValue] = Eq.fromUniversalEquals 33 | 34 | val decStringRangeSummary: Decoder[StringRangeSummary] = deriveDecoder 35 | val encStringRangeSummary: Encoder[StringRangeSummary] = deriveEncoder 36 | 37 | val decNumericRangeSummary: Decoder[NumericRangeSummary] = deriveDecoder 38 | val encNumericRangeSummary: Encoder[NumericRangeSummary] = deriveEncoder 39 | 40 | // treat this decoder as a smart constructor, since we can't 41 | // decode or encode the schema directly 42 | val decSchemaSummary: Decoder[SchemaSummary] = { c: HCursor => 43 | Either 44 | .fromTry(Try { 45 | Schema.load(c.value) 46 | }) 47 | .leftMap(t => DecodingFailure(t.getMessage, c.history)) 48 | .map(_ => SchemaSummary(c.value)) 49 | } 50 | 51 | val encSchemaSummary: Encoder[SchemaSummary] = { _.underlying } 52 | 53 | // more horrible type inference 🙄, so had to annotate the first one 54 | implicit val decSummaryValue: Decoder[SummaryValue] = 55 | decStringRangeSummary.widen[SummaryValue] or decNumericRangeSummary.widen or decSchemaSummary.widen 56 | 57 | implicit val encSummaryValue: Encoder[SummaryValue] = { 58 | case summ: StringRangeSummary => 59 | encStringRangeSummary(summ) 60 | case summ: NumericRangeSummary => 61 | encNumericRangeSummary(summ) 62 | case summ: SchemaSummary => 63 | encSchemaSummary(summ) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /modules/core/jvm/src/main/scala/com/azavea/stac4s/extensions/CollectionExtension.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions 2 | 3 | import com.azavea.stac4s.StacCollection 4 | 5 | import io.circe.syntax._ 6 | import io.circe.{Decoder, Encoder} 7 | 8 | trait CollectionExtension[T] { 9 | def getExtensionFields(collection: StacCollection): ExtensionResult[T] 10 | 11 | def addExtensionFields(collection: StacCollection, extensionFields: T): StacCollection 12 | } 13 | 14 | object CollectionExtension { 15 | def apply[T](implicit ev: CollectionExtension[T]): CollectionExtension[T] = ev 16 | 17 | def instance[T](implicit decoder: Decoder[T], objectEncoder: Encoder.AsObject[T]) = 18 | new CollectionExtension[T] { 19 | 20 | def getExtensionFields(collection: StacCollection): ExtensionResult[T] = 21 | decoder.decodeAccumulating(collection.extensionFields.asJson.hcursor) 22 | 23 | def addExtensionFields(collection: StacCollection, extensionFields: T): StacCollection = 24 | collection.copy(extensionFields = 25 | collection.extensionFields.deepMerge(objectEncoder.encodeObject(extensionFields)) 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /modules/core/jvm/src/main/scala/com/azavea/stac4s/extensions/IntervalExtension.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions 2 | 3 | import com.azavea.stac4s.Interval 4 | 5 | import io.circe.syntax._ 6 | import io.circe.{Decoder, Encoder} 7 | 8 | trait IntervalExtension[T] { 9 | def getExtensionFields(interval: Interval): ExtensionResult[T] 10 | def addExtensionFields(interval: Interval, extensionFields: T): Interval 11 | } 12 | 13 | object IntervalExtension { 14 | def apply[T](implicit ev: IntervalExtension[T]): IntervalExtension[T] = ev 15 | 16 | def instance[T](implicit decoder: Decoder[T], objectEncoder: Encoder.AsObject[T]) = 17 | new IntervalExtension[T] { 18 | 19 | def getExtensionFields(interval: Interval): ExtensionResult[T] = 20 | decoder.decodeAccumulating(interval.extensionFields.asJson.hcursor) 21 | 22 | def addExtensionFields(interval: Interval, extensionFields: T): Interval = 23 | interval.copy(extensionFields = interval.extensionFields.deepMerge(objectEncoder.encodeObject(extensionFields))) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /modules/core/jvm/src/main/scala/com/azavea/stac4s/extensions/layer/StacLayer.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.layer 2 | 3 | import com.azavea.stac4s.meta._ 4 | import com.azavea.stac4s.{Bbox, StacLink} 5 | 6 | import cats.kernel.Eq 7 | import eu.timepit.refined.types.string 8 | import geotrellis.vector.Geometry 9 | import io.circe.refined._ 10 | import io.circe.{Decoder, Encoder} 11 | 12 | final case class StacLayer( 13 | id: string.NonEmptyString, 14 | bbox: Bbox, 15 | geometry: Geometry, 16 | properties: StacLayerProperties, 17 | links: List[StacLink], 18 | _type: String = "Feature" 19 | ) 20 | 21 | object StacLayer { 22 | implicit val eqStacLayer: Eq[StacLayer] = Eq.fromUniversalEquals 23 | 24 | implicit val encStacLayer: Encoder[StacLayer] = Encoder.forProduct6( 25 | "id", 26 | "bbox", 27 | "geometry", 28 | "properties", 29 | "links", 30 | "type" 31 | )(layer => (layer.id, layer.bbox, layer.geometry, layer.properties, layer.links, layer._type)) 32 | 33 | implicit val decStacLayer: Decoder[StacLayer] = Decoder.forProduct6( 34 | "id", 35 | "bbox", 36 | "geometry", 37 | "properties", 38 | "links", 39 | "type" 40 | )(StacLayer.apply) 41 | } 42 | -------------------------------------------------------------------------------- /modules/core/jvm/src/main/scala/com/azavea/stac4s/extensions/periodic/PeriodicExtent.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.periodic 2 | 3 | import com.azavea.stac4s.extensions.IntervalExtension 4 | import com.azavea.stac4s.meta._ 5 | 6 | import cats.kernel.Eq 7 | import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} 8 | import io.circe.{Decoder, Encoder} 9 | import org.threeten.extra.PeriodDuration 10 | 11 | final case class PeriodicExtent( 12 | period: PeriodDuration 13 | ) 14 | 15 | object PeriodicExtent { 16 | implicit val decPeriodicExtent: Decoder[PeriodicExtent] = deriveDecoder 17 | implicit val encPeriodicExtentObject: Encoder.AsObject[PeriodicExtent] = deriveEncoder 18 | implicit val eqPeriodicExtent: Eq[PeriodicExtent] = Eq.fromUniversalEquals 19 | 20 | implicit val intervalExtension: IntervalExtension[PeriodicExtent] = IntervalExtension.instance[PeriodicExtent] 21 | } 22 | -------------------------------------------------------------------------------- /modules/core/jvm/src/main/scala/com/azavea/stac4s/meta/ForeignImplicits.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.meta 2 | 3 | import cats.Eq 4 | import cats.syntax.either._ 5 | import io.circe._ 6 | import io.circe.parser.decode 7 | import org.threeten.extra.PeriodDuration 8 | 9 | import scala.util.Try 10 | 11 | import java.time.format.{DateTimeFormatter, DateTimeFormatterBuilder} 12 | import java.time.{Instant, OffsetDateTime} 13 | 14 | trait ForeignImplicits { 15 | 16 | // circe codecs 17 | // A more flexible alternative to DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS[xxx][xx][X]") 18 | // https://tools.ietf.org/html/rfc3339 19 | // Warning: This formatter is good only for parsing 20 | val RFC3339formatter = 21 | new DateTimeFormatterBuilder() 22 | .append(DateTimeFormatter.ISO_LOCAL_DATE_TIME) 23 | .optionalStart() 24 | .appendOffset("+HH:MM", "+00:00") 25 | .optionalEnd() 26 | .optionalStart() 27 | .appendOffset("+HHMM", "+0000") 28 | .optionalEnd() 29 | .optionalStart() 30 | .appendOffset("+HH", "Z") 31 | .optionalEnd() 32 | .toFormatter() 33 | 34 | implicit val eqInstant: Eq[Instant] = Eq.fromUniversalEquals 35 | 36 | implicit val encodeInstant: Encoder[Instant] = Encoder[String].contramap(_.toString) 37 | 38 | implicit val decodeInstant: Decoder[Instant] = 39 | Decoder[String].emap(s => 40 | Either 41 | .fromTry(Try(OffsetDateTime.parse(s, RFC3339formatter).toInstant)) 42 | .leftMap(_ => s"$s was not a valid string format") 43 | ) 44 | 45 | implicit val decTimeRange: Decoder[(Option[Instant], Option[Instant])] = Decoder[String] map { str => 46 | val components = str.replace("[", "").replace("]", "").split(",") map { 47 | _.trim 48 | } 49 | components match { 50 | case parts if parts.length == 2 => 51 | val start = parts.head 52 | val end = parts.drop(1).head 53 | (decode[Instant](start).toOption, decode[Instant](end).toOption) 54 | case parts if parts.length > 2 => 55 | val message = s"Too many elements for temporal extent: $parts" 56 | throw new ParsingFailure(message, new Exception(message)) 57 | case parts if parts.length < 2 => 58 | val message = s"Too few elements for temporal extent: $parts" 59 | throw new ParsingFailure(message, new Exception(message)) 60 | case x => throw new scala.MatchError(x) 61 | } 62 | } 63 | 64 | implicit val decPeriodDuration: Decoder[PeriodDuration] = 65 | Decoder[String].emap(s => 66 | Either.fromTry(Try(PeriodDuration.parse(s))).leftMap(_ => s"$s was not a valid period duration format") 67 | ) 68 | implicit val encPeriodDuration: Encoder[PeriodDuration] = Encoder[String].contramap(_.toString) 69 | implicit val eqPeriodDuration: Eq[PeriodDuration] = Eq.fromUniversalEquals 70 | 71 | } 72 | 73 | object ForeignImplicits extends ForeignImplicits {} 74 | -------------------------------------------------------------------------------- /modules/core/jvm/src/main/scala/com/azavea/stac4s/meta/GeoTrellisImplicits.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.meta 2 | 3 | import cats.Eq 4 | import geotrellis.vector.Geometry 5 | import geotrellis.vector.io.json.GeometryFormats 6 | 7 | trait GeoTrellisImplicits extends GeometryFormats { 8 | implicit val eqGeometry: Eq[Geometry] = Eq.fromUniversalEquals 9 | } 10 | -------------------------------------------------------------------------------- /modules/core/jvm/src/main/scala/com/azavea/stac4s/meta/HasInstant.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.meta 2 | 3 | import eu.timepit.refined.api.Validate 4 | 5 | import java.time.Instant 6 | 7 | final case class HasInstant() 8 | 9 | object HasInstant { 10 | 11 | implicit def hasInstant: Validate.Plain[Option[Instant], HasInstant] = 12 | Validate.fromPredicate( 13 | { 14 | case None => false 15 | case _ => true 16 | }, 17 | t => s"Value Is None: $t", 18 | HasInstant() 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /modules/core/jvm/src/main/scala/com/azavea/stac4s/meta/package.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | package object meta extends GeoTrellisImplicits with ForeignImplicits {} 4 | -------------------------------------------------------------------------------- /modules/core/jvm/src/main/scala/com/azavea/stac4s/syntax/package.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import com.azavea.stac4s.extensions._ 4 | 5 | package object syntax extends Syntax { 6 | 7 | implicit class stacCollectionExtensions(collection: StacCollection) { 8 | 9 | def getExtensionFields[T](implicit ev: CollectionExtension[T]): ExtensionResult[T] = 10 | ev.getExtensionFields(collection) 11 | 12 | def addExtensionFields[T](properties: T)(implicit ev: CollectionExtension[T]): StacCollection = 13 | ev.addExtensionFields(collection, properties) 14 | } 15 | 16 | implicit class intervalExtensions(interval: Interval) { 17 | 18 | def getExtensionFields[T](implicit ev: IntervalExtension[T]): ExtensionResult[T] = 19 | ev.getExtensionFields(interval) 20 | 21 | def addExtensionFields[T](properties: T)(implicit ev: IntervalExtension[T]): Interval = 22 | ev.addExtensionFields(interval, properties) 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/ItemCollection.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import cats.Eq 4 | import cats.syntax.apply._ 5 | import io.circe._ 6 | import io.circe.refined._ 7 | import io.circe.syntax._ 8 | 9 | final case class ItemCollection( 10 | _type: String = "FeatureCollection", 11 | stacVersion: StacVersion, 12 | stacExtensions: List[String], 13 | features: List[StacItem], 14 | links: List[StacLink], 15 | extensionFields: JsonObject = ().asJsonObject 16 | ) 17 | 18 | object ItemCollection { 19 | 20 | val itemCollectionFields = productFieldNames[ItemCollection] 21 | 22 | implicit val eqItemCollection: Eq[ItemCollection] = Eq.fromUniversalEquals 23 | 24 | implicit val encItemCollection: Encoder[ItemCollection] = new Encoder[ItemCollection] { 25 | 26 | def apply(collection: ItemCollection): Json = { 27 | val baseEncoder: Encoder[ItemCollection] = Encoder.forProduct5( 28 | "type", 29 | "stac_version", 30 | "stac_extensions", 31 | "features", 32 | "links" 33 | )(itemCollection => 34 | ( 35 | itemCollection._type, 36 | itemCollection.stacVersion, 37 | itemCollection.stacExtensions, 38 | itemCollection.features, 39 | itemCollection.links 40 | ) 41 | ) 42 | 43 | baseEncoder(collection).deepMerge(collection.extensionFields.asJson).dropNullValues 44 | } 45 | } 46 | 47 | implicit val decItemCollection: Decoder[ItemCollection] = { c: HCursor => 48 | ( 49 | c.get[String]("type"), 50 | c.get[StacVersion]("stac_version"), 51 | c.get[Option[List[String]]]("stac_extensions"), 52 | c.get[List[StacItem]]("features"), 53 | c.get[Option[List[StacLink]]]("links"), 54 | c.value.as[JsonObject] 55 | ).mapN( 56 | ( 57 | _type: String, 58 | stacVersion: StacVersion, 59 | extensions: Option[List[String]], 60 | features: List[StacItem], 61 | links: Option[List[StacLink]], 62 | document: JsonObject 63 | ) => 64 | ItemCollection( 65 | _type, 66 | stacVersion, 67 | extensions getOrElse Nil, 68 | features, 69 | links getOrElse Nil, 70 | document.filter({ case (k, _) => 71 | !itemCollectionFields.contains(k) 72 | }) 73 | ) 74 | ) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/ItemDatetime.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import cats.syntax.apply._ 4 | import io.circe.{Decoder, Encoder, HCursor} 5 | 6 | import java.time.Instant 7 | 8 | case class PointInTime(when: Instant) 9 | 10 | case class TimeRange(start: Instant, end: Instant) 11 | 12 | object TimeRange { 13 | 14 | implicit val decTimeRange: Decoder[TimeRange] = { cursor: HCursor => 15 | (cursor.get[Instant]("start_datetime"), cursor.get[Instant]("end_datetime")) mapN { TimeRange.apply } 16 | } 17 | 18 | implicit val encTimeRange: Encoder[TimeRange] = 19 | Encoder.forProduct3("datetime", "start_datetime", "end_datetime")(range => 20 | (Option.empty[Instant], range.start, range.end) 21 | ) 22 | } 23 | 24 | object PointInTime { 25 | 26 | implicit val decPointInTime: Decoder[PointInTime] = { cursor: HCursor => 27 | cursor.get[Instant]("datetime") map { PointInTime.apply } 28 | } 29 | implicit val encPointInTime: Encoder[PointInTime] = Encoder.forProduct1("datetime")(_.when) 30 | } 31 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/ItemProperties.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import com.azavea.stac4s.types._ 4 | 5 | import cats.data.NonEmptyList 6 | import cats.kernel.Eq 7 | import cats.syntax.apply._ 8 | import eu.timepit.refined.types.string 9 | import io.circe.refined._ 10 | import io.circe.syntax._ 11 | import io.circe.{Decoder, Encoder, HCursor, JsonObject} 12 | 13 | import java.time.Instant 14 | 15 | case class ItemProperties( 16 | datetime: ItemDatetime, 17 | title: Option[string.NonEmptyString] = None, 18 | description: Option[string.NonEmptyString] = None, 19 | created: Option[Instant] = None, 20 | updated: Option[Instant] = None, 21 | license: Option[StacLicense] = None, 22 | providers: Option[NonEmptyList[StacProvider]] = None, 23 | platform: Option[string.NonEmptyString] = None, 24 | instruments: Option[NonEmptyList[string.NonEmptyString]] = None, 25 | constellation: Option[string.NonEmptyString] = None, 26 | mission: Option[string.NonEmptyString] = None, 27 | gsd: Option[Double] = None, 28 | extensionFields: JsonObject = JsonObject.empty 29 | ) 30 | 31 | object ItemProperties { 32 | 33 | implicit val eqItemProperties: Eq[ItemProperties] = Eq.fromUniversalEquals 34 | 35 | val itemPropertiesFields = productFieldNames[ItemProperties] ++ List("datetime", "start_datetime", "end_datetime") 36 | 37 | implicit val encItemProperties: Encoder[ItemProperties] = { properties => 38 | val baseEncoder: Encoder[ItemProperties] = Encoder 39 | .forProduct11( 40 | "title", 41 | "description", 42 | "created", 43 | "updated", 44 | "license", 45 | "providers", 46 | "platform", 47 | "instruments", 48 | "constellation", 49 | "mission", 50 | "gsd" 51 | )((props: ItemProperties) => 52 | ( 53 | props.title, 54 | props.description, 55 | props.created, 56 | props.updated, 57 | props.license, 58 | props.providers, 59 | props.platform, 60 | props.instruments, 61 | props.constellation, 62 | props.mission, 63 | props.gsd 64 | ) 65 | ) 66 | baseEncoder(properties).dropNullValues 67 | .deepMerge(properties.datetime.asJson) 68 | .deepMerge(properties.extensionFields.asJson) 69 | } 70 | 71 | implicit val decItemProperties: Decoder[ItemProperties] = { cursor: HCursor => 72 | ( 73 | cursor.as[ItemDatetime], 74 | cursor.get[Option[string.NonEmptyString]]("title"), 75 | cursor.get[Option[string.NonEmptyString]]("description"), 76 | cursor.get[Option[Instant]]("created"), 77 | cursor.get[Option[Instant]]("updated"), 78 | cursor.get[Option[StacLicense]]("license"), 79 | cursor.get[Option[NonEmptyList[StacProvider]]]("providers"), 80 | cursor.get[Option[string.NonEmptyString]]("platform"), 81 | cursor.get[Option[NonEmptyList[string.NonEmptyString]]]("instruments"), 82 | cursor.get[Option[string.NonEmptyString]]("constellation"), 83 | cursor.get[Option[string.NonEmptyString]]("mission"), 84 | cursor.get[Option[Double]]("gsd"), 85 | cursor.value.as[JsonObject] 86 | ) mapN { 87 | case ( 88 | dt, 89 | title, 90 | description, 91 | created, 92 | updated, 93 | license, 94 | providers, 95 | platform, 96 | instruments, 97 | constellation, 98 | mission, 99 | gsd, 100 | fields 101 | ) => 102 | ItemProperties( 103 | dt, 104 | title, 105 | description, 106 | created, 107 | updated, 108 | license, 109 | providers, 110 | platform, 111 | instruments, 112 | constellation, 113 | mission, 114 | gsd, 115 | fields.filter({ case (k, _) => 116 | !itemPropertiesFields.contains(k) 117 | }) 118 | ) 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/ProductFieldNames.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import shapeless.ops.hlist.ToTraversable 4 | import shapeless.ops.record.Keys 5 | import shapeless.{HList, LabelledGeneric} 6 | 7 | import scala.annotation.nowarn 8 | 9 | trait ProductFieldNames[T] { 10 | def get: Set[String] 11 | } 12 | 13 | object ProductFieldNames { 14 | def apply[T](implicit ev: ProductFieldNames[T]): ProductFieldNames[T] = ev 15 | 16 | @SuppressWarnings(Array("all")) 17 | implicit def fromLabelledGeneric[T, L <: HList, K <: HList](implicit 18 | @nowarn lg: LabelledGeneric.Aux[T, L], 19 | keys: Keys.Aux[L, K], 20 | toList: ToTraversable.Aux[K, List, Symbol] 21 | ): ProductFieldNames[T] = 22 | new ProductFieldNames[T] { 23 | def get: Set[String] = keys().toList.flatMap(field => substituteFieldName(field.name)).toSet 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/StacAsset.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import cats.Eq 4 | import cats.syntax.apply._ 5 | import cats.syntax.either._ 6 | import io.circe._ 7 | import io.circe.syntax._ 8 | 9 | final case class StacAsset( 10 | href: String, 11 | title: Option[String], 12 | description: Option[String], 13 | roles: Set[StacAssetRole], 14 | _type: Option[StacMediaType], 15 | extensionFields: JsonObject = ().asJsonObject 16 | ) 17 | 18 | object StacAsset { 19 | val assetFields = productFieldNames[StacAsset] 20 | 21 | implicit val eqStacAsset: Eq[StacAsset] = Eq.fromUniversalEquals 22 | 23 | implicit val encStacAsset: Encoder[StacAsset] = { asset => 24 | val baseEncoder: Encoder[StacAsset] = 25 | Encoder.forProduct5("href", "title", "description", "roles", "type")(asset => 26 | (asset.href, asset.title, asset.description, asset.roles, asset._type) 27 | ) 28 | baseEncoder(asset).deepMerge(asset.extensionFields.asJson).dropNullValues 29 | } 30 | 31 | implicit val decStacAsset: Decoder[StacAsset] = new Decoder[StacAsset] { 32 | 33 | override def decodeAccumulating(c: HCursor) = ( 34 | c.get[String]("href").toValidatedNel, 35 | c.get[Option[String]]("title").toValidatedNel, 36 | c.get[Option[String]]("description").toValidatedNel, 37 | c.get[Option[Set[StacAssetRole]]]("roles").toValidatedNel, 38 | c.get[Option[StacMediaType]]("type").toValidatedNel, 39 | c.value.as[JsonObject].toValidatedNel 40 | ).mapN( 41 | ( 42 | href: String, 43 | title: Option[String], 44 | description: Option[String], 45 | roles: Option[Set[StacAssetRole]], 46 | mediaType: Option[StacMediaType], 47 | document: JsonObject 48 | ) => 49 | StacAsset( 50 | href, 51 | title, 52 | description, 53 | roles getOrElse Set.empty, 54 | mediaType, 55 | document.filter({ case (k, _) => 56 | !assetFields.contains(k) 57 | }) 58 | ) 59 | ) 60 | 61 | def apply(c: HCursor) = decodeAccumulating(c).toEither.leftMap(_.head) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/StacAssetRole.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import cats.Eq 4 | import cats.syntax.either._ 5 | import cats.syntax.invariant._ 6 | import io.circe.{Decoder, Encoder} 7 | 8 | sealed abstract class StacAssetRole(val repr: String) { 9 | override def toString = repr 10 | } 11 | 12 | object StacAssetRole { 13 | 14 | implicit def eqStacAssetRole: Eq[StacAssetRole] = 15 | Eq[String].imap(fromString _)(_.repr) 16 | 17 | case object Thumbnail extends StacAssetRole("thumbnail") 18 | case object Overview extends StacAssetRole("overview") 19 | case object Data extends StacAssetRole("data") 20 | case object Metadata extends StacAssetRole("metadata") 21 | final case class VendorAsset(s: String) extends StacAssetRole(s) 22 | 23 | private def fromString(s: String): StacAssetRole = s.toLowerCase match { 24 | case "thumbnail" => Thumbnail 25 | case "overview" => Overview 26 | case "data" => Data 27 | case "metadata" => Metadata 28 | case _ => VendorAsset(s) 29 | } 30 | 31 | implicit val encStacAssetRole: Encoder[StacAssetRole] = 32 | Encoder.encodeString.contramap[StacAssetRole](_.toString) 33 | 34 | implicit val decStacAssetRole: Decoder[StacAssetRole] = 35 | Decoder.decodeString.emap { str => Either.catchNonFatal(fromString(str)).leftMap(_ => "StacAssetRole") } 36 | } 37 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/StacCatalog.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import com.azavea.stac4s.types.CatalogType 4 | 5 | import cats.Eq 6 | import cats.syntax.apply._ 7 | import cats.syntax.either._ 8 | import io.circe._ 9 | import io.circe.refined._ 10 | import io.circe.syntax._ 11 | 12 | final case class StacCatalog( 13 | _type: CatalogType, 14 | stacVersion: String, 15 | stacExtensions: List[String], 16 | id: String, 17 | title: Option[String], 18 | description: String, 19 | links: List[StacLink], 20 | extensionFields: JsonObject = ().asJsonObject 21 | ) 22 | 23 | object StacCatalog { 24 | 25 | val catalogFields = productFieldNames[StacCatalog] 26 | 27 | implicit val eqStacCatalog: Eq[StacCatalog] = Eq.fromUniversalEquals 28 | 29 | implicit val encCatalog: Encoder[StacCatalog] = { catalog => 30 | val baseEncoder: Encoder[StacCatalog] = 31 | Encoder.forProduct7( 32 | "type", 33 | "stac_version", 34 | "stac_extensions", 35 | "id", 36 | "title", 37 | "description", 38 | "links" 39 | )(catalog => 40 | ( 41 | catalog._type, 42 | catalog.stacVersion, 43 | catalog.stacExtensions, 44 | catalog.id, 45 | catalog.title, 46 | catalog.description, 47 | catalog.links 48 | ) 49 | ) 50 | 51 | baseEncoder(catalog).deepMerge(catalog.extensionFields.asJson).dropNullValues 52 | } 53 | 54 | implicit val decCatalog: Decoder[StacCatalog] = new Decoder[StacCatalog] { 55 | 56 | override def decodeAccumulating(c: HCursor) = { 57 | ( 58 | c.get[CatalogType]("type").toValidatedNel, 59 | c.get[String]("stac_version").toValidatedNel, 60 | c.get[Option[List[String]]]("stac_extensions").toValidatedNel, 61 | c.get[String]("id").toValidatedNel, 62 | c.get[Option[String]]("title").toValidatedNel, 63 | c.get[String]("description").toValidatedNel, 64 | c.get[List[StacLink]]("links").toValidatedNel, 65 | c.value.as[JsonObject].toValidatedNel 66 | ).mapN( 67 | ( 68 | catalogType: CatalogType, 69 | version: String, 70 | extensions: Option[List[String]], 71 | id: String, 72 | title: Option[String], 73 | description: String, 74 | links: List[StacLink], 75 | document: JsonObject 76 | ) => 77 | StacCatalog.apply( 78 | catalogType, 79 | version, 80 | extensions getOrElse Nil, 81 | id, 82 | title, 83 | description, 84 | links, 85 | document.filter({ case (k, _) => 86 | !catalogFields.contains(k) 87 | }) 88 | ) 89 | ) 90 | } 91 | 92 | def apply(c: HCursor) = decodeAccumulating(c).toEither.leftMap(_.head) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/StacLicense.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import cats.Eq 4 | import cats.syntax.either._ 5 | import cats.syntax.functor._ 6 | import io.circe._ 7 | import io.circe.syntax._ 8 | 9 | sealed trait StacLicense { 10 | val name: String 11 | } 12 | 13 | final case class Proprietary() extends StacLicense { 14 | val name: String = "proprietary" 15 | } 16 | 17 | final case class SPDX(spdxId: SpdxId) extends StacLicense { 18 | val name = spdxId.toString 19 | } 20 | 21 | object SPDX { 22 | implicit val eqSpdx: Eq[SPDX] = Eq.fromUniversalEquals 23 | } 24 | 25 | object StacLicense { 26 | 27 | implicit val encoderSpdxLicense: Encoder[SPDX] = 28 | Encoder.encodeString.contramap(_.name) 29 | 30 | implicit val encoderProprietary: Encoder[Proprietary] = 31 | Encoder.encodeString.contramap(_.name) 32 | 33 | implicit val encoderStacLicense: Encoder[StacLicense] = Encoder.instance { 34 | case spdx: SPDX => spdx.asJson 35 | case proprietary: Proprietary => proprietary.asJson 36 | } 37 | 38 | implicit val decodeSpdx: Decoder[SPDX] = 39 | Decoder[SpdxId] map { SPDX.apply } 40 | 41 | implicit val decodeProprietary: Decoder[Proprietary] = 42 | Decoder.decodeString.emap { 43 | case "proprietary" => Either.right(Proprietary()) 44 | case s => Either.left(s"Unknown Proprietary License: $s") 45 | } 46 | 47 | implicit val decodeStacLicense: Decoder[StacLicense] = 48 | Decoder[SPDX].widen or Decoder[Proprietary].widen 49 | 50 | } 51 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/StacLink.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import cats.syntax.apply._ 4 | import cats.syntax.either._ 5 | import io.circe._ 6 | import io.circe.syntax._ 7 | 8 | final case class StacLink( 9 | href: String, 10 | rel: StacLinkType, 11 | _type: Option[StacMediaType], 12 | title: Option[String], 13 | extensionFields: JsonObject = ().asJsonObject 14 | ) 15 | 16 | object StacLink { 17 | val linkFields = productFieldNames[StacLink] 18 | 19 | implicit val encStacLink: Encoder[StacLink] = { link => 20 | val baseEncoder = Encoder 21 | .forProduct4( 22 | "href", 23 | "rel", 24 | "type", 25 | "title" 26 | )((link: StacLink) => (link.href, link.rel, link._type, link.title)) 27 | 28 | baseEncoder(link).deepMerge(link.extensionFields.asJson).dropNullValues 29 | } 30 | 31 | implicit val decStacLink: Decoder[StacLink] = new Decoder[StacLink] { 32 | 33 | override def decodeAccumulating(c: HCursor) = { 34 | ( 35 | c.downField("href").as[String].toValidatedNel, 36 | c.downField("rel").as[StacLinkType].toValidatedNel, 37 | c.get[Option[StacMediaType]]("type").toValidatedNel, 38 | c.get[Option[String]]("title").toValidatedNel, 39 | c.value.as[JsonObject].toValidatedNel 40 | ).mapN( 41 | ( 42 | href: String, 43 | rel: StacLinkType, 44 | _type: Option[StacMediaType], 45 | title: Option[String], 46 | document: JsonObject 47 | ) => 48 | StacLink( 49 | href, 50 | rel, 51 | _type, 52 | title, 53 | document.filter({ case (k, _) => 54 | !linkFields.contains(k) 55 | }) 56 | ) 57 | ) 58 | } 59 | 60 | def apply(c: HCursor) = decodeAccumulating(c).toEither.leftMap(_.head) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/StacLinkType.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import cats.Eq 4 | import cats.syntax.either._ 5 | import cats.syntax.invariant._ 6 | import io.circe._ 7 | 8 | sealed abstract class StacLinkType(val repr: String) { 9 | override def toString = repr 10 | } 11 | 12 | object StacLinkType { 13 | 14 | implicit def eqStacLinkType: Eq[StacLinkType] = 15 | Eq[String].imap(fromString)(_.repr) 16 | 17 | case object Self extends StacLinkType("self") 18 | case object StacRoot extends StacLinkType("root") 19 | case object Parent extends StacLinkType("parent") 20 | case object Child extends StacLinkType("child") 21 | case object Item extends StacLinkType("item") 22 | case object Items extends StacLinkType("items") 23 | case object Source extends StacLinkType("source") 24 | case object Collection extends StacLinkType("collection") 25 | case object License extends StacLinkType("license") 26 | case object Alternate extends StacLinkType("alternate") 27 | case object DescribedBy extends StacLinkType("describedBy") 28 | case object Next extends StacLinkType("next") 29 | case object Prev extends StacLinkType("prev") 30 | case object ServiceDesc extends StacLinkType("service-desc") 31 | case object ServiceDoc extends StacLinkType("service-doc") 32 | case object Conformance extends StacLinkType("conformance") 33 | case object Data extends StacLinkType("data") 34 | case object LatestVersion extends StacLinkType("latest-version") 35 | case object PredecessorVersion extends StacLinkType("predecessor-version") 36 | case object SuccessorVersion extends StacLinkType("successor-version") 37 | case object DerivedFrom extends StacLinkType("derived_from") 38 | case object Via extends StacLinkType("via") 39 | case object Canonical extends StacLinkType("canonical") 40 | final case class VendorLinkType(underlying: String) extends StacLinkType(underlying) 41 | 42 | private def fromString(s: String): StacLinkType = s.toLowerCase match { 43 | case "self" => Self 44 | case "root" => StacRoot 45 | case "parent" => Parent 46 | case "child" => Child 47 | case "item" => Item 48 | case "items" => Items 49 | case "alternate" => Alternate 50 | case "collection" => Collection 51 | case "describedby" => DescribedBy 52 | case "next" => Next 53 | case "license" => License 54 | case "prev" => Prev 55 | case "service-desc" => ServiceDesc 56 | case "service-doc" => ServiceDoc 57 | case "conformance" => Conformance 58 | case "data" => Data 59 | case "source" => Source 60 | case "latest-version" => LatestVersion 61 | case "predecessor-version" => PredecessorVersion 62 | case "successor-version" => SuccessorVersion 63 | case "derived_from" => DerivedFrom 64 | case "via" => Via 65 | case "canonical" => Canonical 66 | case _ => VendorLinkType(s) 67 | } 68 | 69 | implicit val encStacLinkType: Encoder[StacLinkType] = 70 | Encoder.encodeString.contramap[StacLinkType](_.toString) 71 | 72 | implicit val decStacLinkType: Decoder[StacLinkType] = 73 | Decoder.decodeString.emap { str => Either.catchNonFatal(fromString(str)).leftMap(_ => "StacLinkType") } 74 | } 75 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/StacMediaType.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import cats.Eq 4 | import cats.syntax.either._ 5 | import cats.syntax.invariant._ 6 | import io.circe._ 7 | 8 | sealed abstract class StacMediaType(val repr: String) { 9 | override def toString: String = repr 10 | } 11 | 12 | object StacMediaType { 13 | 14 | implicit def eqStacMediaType: Eq[StacMediaType] = 15 | Eq[String].imap(fromString)(_.repr) 16 | 17 | private def fromString(s: String): StacMediaType = s match { 18 | case "image/tiff; application=geotiff" => `image/geotiff` 19 | case "image/tiff; application=geotiff; profile=cloud-optimized" => `image/cog` 20 | case "image/jp2" => `image/jp2` 21 | case "image/png" => `image/png` 22 | case "image/jpeg" => `image/jpeg` 23 | case "text/xml" => `text/xml` 24 | case "text/html" => `text/html` 25 | case "application/xml" => `application/xml` 26 | case "application/json" => `application/json` 27 | case "text/plain" => `text/plain` 28 | case "application/geo+json" => `application/geo+json` 29 | case "application/geopackage+sqlite3" => `application/geopackage+sqlite3` 30 | case "application/x-hdf5" => `application/x-hdf5` 31 | case "application/x-hdf" => `application/x-hdf` 32 | case _ => VendorMediaType(s) 33 | } 34 | 35 | implicit val encMediaType: Encoder[StacMediaType] = 36 | Encoder.encodeString.contramap[StacMediaType](_.toString) 37 | 38 | implicit val decMediaType: Decoder[StacMediaType] = 39 | Decoder.decodeString.emap { str => Either.catchNonFatal(fromString(str)).leftMap(_ => "StacLinkType") } 40 | } 41 | 42 | case object `image/geotiff` extends StacMediaType("image/tiff; application=geotiff") 43 | case object `image/cog` extends StacMediaType("image/tiff; application=geotiff; profile=cloud-optimized") 44 | case object `image/jp2` extends StacMediaType("image/jp2") 45 | case object `image/png` extends StacMediaType("image/png") 46 | case object `image/jpeg` extends StacMediaType("image/jpeg") 47 | case object `text/xml` extends StacMediaType("text/xml") 48 | case object `text/html` extends StacMediaType("text/html") 49 | case object `application/xml` extends StacMediaType("application/xml") 50 | case object `application/json` extends StacMediaType("application/json") 51 | case object `text/plain` extends StacMediaType("text/plain") 52 | case object `application/geo+json` extends StacMediaType("application/geo+json") 53 | case object `application/geopackage+sqlite3` extends StacMediaType("application/geopackage+sqlite3") 54 | case object `application/x-hdf5` extends StacMediaType("application/x-hdf5") 55 | case object `application/x-hdf` extends StacMediaType("application/x-hdf") 56 | final case class VendorMediaType(underlying: String) extends StacMediaType(underlying) 57 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/StacProvider.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import io.circe._ 4 | import io.circe.generic.semiauto._ 5 | 6 | final case class StacProvider( 7 | name: String, 8 | description: Option[String], 9 | roles: List[StacProviderRole], 10 | url: Option[String] 11 | ) 12 | 13 | object StacProvider { 14 | implicit val encStacProvider: Encoder[StacProvider] = deriveEncoder[StacProvider].mapJson(_.dropNullValues) 15 | implicit val decStacProvider: Decoder[StacProvider] = deriveDecoder 16 | } 17 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/StacProviderRole.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import cats.Eq 4 | import cats.syntax.either._ 5 | import cats.syntax.invariant._ 6 | import io.circe._ 7 | 8 | sealed abstract class StacProviderRole(val repr: String) { 9 | override def toString: String = repr 10 | } 11 | 12 | object StacProviderRole { 13 | 14 | implicit def eqStacProviderRole: Eq[StacProviderRole] = Eq[String].imap(fromString)(_.repr) 15 | 16 | def fromString(s: String): StacProviderRole = s.toLowerCase match { 17 | case "licensor" => Licensor 18 | case "producer" => Producer 19 | case "processor" => Processor 20 | case "host" => Host 21 | } 22 | 23 | implicit val encProviderRole: Encoder[StacProviderRole] = 24 | Encoder.encodeString.contramap[StacProviderRole](_.toString) 25 | 26 | implicit val decProviderRole: Decoder[StacProviderRole] = 27 | Decoder.decodeString.emap { str => Either.catchNonFatal(fromString(str)).leftMap(_ => "StacProviderRole") } 28 | } 29 | 30 | case object Licensor extends StacProviderRole("licensor") 31 | case object Producer extends StacProviderRole("producer") 32 | case object Processor extends StacProviderRole("processor") 33 | case object Host extends StacProviderRole("host") 34 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/Syntax.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import com.azavea.stac4s.extensions._ 4 | 5 | trait Syntax { 6 | 7 | implicit class stacItemExtensions(item: StacItem) { 8 | 9 | def getExtensionFields[T](implicit ev: ItemExtension[T]): ExtensionResult[T] = 10 | ev.getExtensionFields(item) 11 | 12 | def addExtensionFields[T](properties: T)(implicit ev: ItemExtension[T]): StacItem = 13 | ev.addExtensionFields(item, properties) 14 | } 15 | 16 | implicit class stacCatalogExtensions(catalog: StacCatalog) { 17 | 18 | def getExtensionFields[T](implicit ev: CatalogExtension[T]): ExtensionResult[T] = 19 | ev.getExtensionFields(catalog) 20 | 21 | def addExtensionFields[T](properties: T)(implicit ev: CatalogExtension[T]): StacCatalog = 22 | ev.addExtensionFields(catalog, properties) 23 | } 24 | 25 | implicit class StacAssetExtensions(StacAsset: StacAsset) { 26 | 27 | def getExtensionFields[T](implicit ev: StacAssetExtension[T]): ExtensionResult[T] = 28 | ev.getExtensionFields(StacAsset) 29 | 30 | def addExtensionFields[T](properties: T)(implicit ev: StacAssetExtension[T]): StacAsset = 31 | ev.addExtensionFields(StacAsset, properties) 32 | } 33 | 34 | implicit class stacLinkExtensions(link: StacLink) { 35 | 36 | def getExtensionFields[T](implicit ev: LinkExtension[T]): ExtensionResult[T] = 37 | ev.getExtensionFields(link) 38 | 39 | def addExtensionFields[T](properties: T)(implicit ev: LinkExtension[T]): StacLink = 40 | ev.addExtensionFields(link, properties) 41 | } 42 | 43 | implicit class stacItemCollectionExtensions(itemCollection: ItemCollection) { 44 | 45 | def getExtensionFields[T](implicit ev: ItemCollectionExtension[T]): ExtensionResult[T] = 46 | ev.getExtensionFields(itemCollection) 47 | 48 | def addExtensionFields[T](properties: T)(implicit ev: ItemCollectionExtension[T]): ItemCollection = 49 | ev.addExtensionFields(itemCollection, properties) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/TemporalExtent.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import cats.kernel.Eq 4 | import io.circe.{Decoder, DecodingFailure, Encoder, HCursor} 5 | 6 | import java.time.Instant 7 | 8 | case class TemporalExtent(start: Option[Instant], end: Option[Instant]) 9 | 10 | object TemporalExtent { 11 | 12 | def apply(start: Instant, end: Option[Instant]): TemporalExtent = 13 | TemporalExtent(Some(start), end) 14 | 15 | def apply(start: Option[Instant], end: Instant): TemporalExtent = 16 | TemporalExtent(start, Some(end)) 17 | 18 | def apply(start: Instant, end: Instant): TemporalExtent = 19 | TemporalExtent(Some(start), Some(end)) 20 | 21 | implicit val eqTemporalExtent: Eq[TemporalExtent] = Eq.fromUniversalEquals 22 | 23 | implicit val decTemporalExtent: Decoder[TemporalExtent] = { c: HCursor => 24 | c.value.as[List[Option[Instant]]] flatMap { 25 | case h :: t :: Nil => Right(TemporalExtent(h, t)) 26 | case _ => Left(DecodingFailure("Temporal extents must have exactly two elements", c.history)) 27 | } 28 | } 29 | 30 | implicit val encTemporalExtent: Encoder[TemporalExtent] = 31 | Encoder[List[Option[Instant]]].contramap(ext => List(ext.start, ext.end)) 32 | } 33 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/CatalogExtension.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions 2 | 3 | import com.azavea.stac4s.StacCatalog 4 | 5 | import io.circe.syntax._ 6 | import io.circe.{Decoder, Encoder} 7 | 8 | trait CatalogExtension[T] { 9 | def getExtensionFields(catalog: StacCatalog): ExtensionResult[T] 10 | def addExtensionFields(catalog: StacCatalog, extensionFields: T): StacCatalog 11 | } 12 | 13 | object CatalogExtension { 14 | def apply[T](implicit ev: CatalogExtension[T]): CatalogExtension[T] = ev 15 | 16 | def instance[T](implicit decoder: Decoder[T], objectEncoder: Encoder.AsObject[T]) = 17 | new CatalogExtension[T] { 18 | 19 | def getExtensionFields(catalog: StacCatalog): ExtensionResult[T] = 20 | decoder.decodeAccumulating(catalog.extensionFields.asJson.hcursor) 21 | 22 | def addExtensionFields(catalog: StacCatalog, extensionFields: T): StacCatalog = 23 | catalog.copy(extensionFields = catalog.extensionFields.deepMerge(objectEncoder.encodeObject(extensionFields))) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/ItemAssetExtension.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions 2 | 3 | import com.azavea.stac4s.StacAsset 4 | 5 | import io.circe.syntax._ 6 | import io.circe.{Decoder, Encoder} 7 | 8 | trait StacAssetExtension[T] { 9 | def getExtensionFields(asset: StacAsset): ExtensionResult[T] 10 | def addExtensionFields(asset: StacAsset, extensionFields: T): StacAsset 11 | } 12 | 13 | object StacAssetExtension { 14 | def apply[T](implicit ev: StacAssetExtension[T]): StacAssetExtension[T] = ev 15 | 16 | def instance[T](implicit decoder: Decoder[T], objectEncoder: Encoder.AsObject[T]) = 17 | new StacAssetExtension[T] { 18 | 19 | def getExtensionFields(asset: StacAsset): ExtensionResult[T] = 20 | decoder.decodeAccumulating(asset.extensionFields.asJson.hcursor) 21 | 22 | def addExtensionFields(asset: StacAsset, extensionFields: T): StacAsset = 23 | asset.copy(extensionFields = asset.extensionFields.deepMerge(objectEncoder.encodeObject(extensionFields))) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/ItemCollectionExtension.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions 2 | 3 | import com.azavea.stac4s._ 4 | 5 | import io.circe.syntax._ 6 | import io.circe.{Decoder, Encoder} 7 | 8 | trait ItemCollectionExtension[T] { 9 | def getExtensionFields(itemCollection: ItemCollection): ExtensionResult[T] 10 | def addExtensionFields(itemCollection: ItemCollection, properties: T): ItemCollection 11 | } 12 | 13 | object ItemExtensionCollection { 14 | def apply[T](implicit ev: ItemCollectionExtension[T]): ItemCollectionExtension[T] = ev 15 | 16 | def instance[T](implicit decoder: Decoder[T], objectEncoder: Encoder.AsObject[T]): ItemCollectionExtension[T] = 17 | new ItemCollectionExtension[T] { 18 | 19 | def getExtensionFields(itemCollection: ItemCollection): ExtensionResult[T] = 20 | decoder.decodeAccumulating( 21 | itemCollection.extensionFields.asJson.hcursor 22 | ) 23 | 24 | def addExtensionFields(itemCollection: ItemCollection, extensionProperties: T): ItemCollection = 25 | itemCollection.copy(extensionFields = 26 | itemCollection.extensionFields.deepMerge(objectEncoder.encodeObject(extensionProperties)) 27 | ) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/ItemExtension.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions 2 | 3 | import com.azavea.stac4s.StacItem 4 | 5 | import io.circe._ 6 | import io.circe.syntax._ 7 | 8 | // typeclass trait for anything that is an extension of item properties 9 | trait ItemExtension[T] { 10 | def getExtensionFields(item: StacItem): ExtensionResult[T] 11 | def addExtensionFields(item: StacItem, properties: T): StacItem 12 | } 13 | 14 | object ItemExtension { 15 | // summoner 16 | def apply[T](implicit ev: ItemExtension[T]): ItemExtension[T] = ev 17 | 18 | // constructor for anything with a `Decoder` and an `Encoder.AsObject` 19 | def instance[T](implicit decoder: Decoder[T], objectEncoder: Encoder.AsObject[T]): ItemExtension[T] = 20 | new ItemExtension[T] { 21 | 22 | def getExtensionFields(item: StacItem): ExtensionResult[T] = 23 | decoder.decodeAccumulating( 24 | item.properties.extensionFields.asJson.hcursor 25 | ) 26 | 27 | def addExtensionFields(item: StacItem, extensionProperties: T) = 28 | StacItem.propertiesExtension.modify(_.deepMerge(objectEncoder.encodeObject(extensionProperties)))(item) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/LinkExtension.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions 2 | 3 | import com.azavea.stac4s.StacLink 4 | 5 | import io.circe.syntax._ 6 | import io.circe.{Decoder, Encoder} 7 | 8 | trait LinkExtension[T] { 9 | def getExtensionFields(link: StacLink): ExtensionResult[T] 10 | 11 | def addExtensionFields(link: StacLink, extensionFields: T): StacLink 12 | } 13 | 14 | object LinkExtension { 15 | def apply[T](implicit ev: LinkExtension[T]): LinkExtension[T] = ev 16 | 17 | def instance[T](implicit decoder: Decoder[T], objectEncoder: Encoder.AsObject[T]) = 18 | new LinkExtension[T] { 19 | 20 | def getExtensionFields(link: StacLink): ExtensionResult[T] = 21 | decoder.decodeAccumulating(link.extensionFields.asJson.hcursor) 22 | 23 | def addExtensionFields(link: StacLink, extensionFields: T): StacLink = 24 | link.copy(extensionFields = link.extensionFields.deepMerge(objectEncoder.encodeObject(extensionFields))) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/asset/StacCollectionAsset.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.asset 2 | 3 | import com.azavea.stac4s._ 4 | 5 | import cats.Eq 6 | import io.circe._ 7 | 8 | final case class StacCollectionAsset( 9 | title: String, 10 | description: Option[String], 11 | roles: List[StacAssetRole], 12 | _type: StacMediaType 13 | ) 14 | 15 | object StacCollectionAsset { 16 | 17 | implicit val eqStacCollectionAsset: Eq[StacCollectionAsset] = Eq.fromUniversalEquals 18 | 19 | implicit val encStacCollectionAsset: Encoder[StacCollectionAsset] = 20 | Encoder.forProduct4("title", "description", "roles", "type")(asset => 21 | (asset.title, asset.description, asset.roles, asset._type) 22 | ) 23 | 24 | implicit val decStacCollectionAsset: Decoder[StacCollectionAsset] = 25 | Decoder.forProduct4("title", "description", "roles", "type")( 26 | StacCollectionAsset.apply 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/eo/Band.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.eo 2 | 3 | import cats.Eq 4 | import eu.timepit.refined.types.numeric.PosDouble 5 | import eu.timepit.refined.types.string.NonEmptyString 6 | import io.circe._ 7 | import io.circe.refined._ 8 | 9 | case class Band( 10 | name: NonEmptyString, 11 | commonName: Option[NonEmptyString], 12 | description: Option[NonEmptyString], 13 | centerWavelength: Option[PosDouble], 14 | fullWidthHalfMax: Option[PosDouble] 15 | ) 16 | 17 | object Band { 18 | 19 | implicit val eqBand: Eq[Band] = Eq.fromUniversalEquals 20 | 21 | implicit val encBand: Encoder[Band] = Encoder 22 | .forProduct5( 23 | "name", 24 | "common_name", 25 | "description", 26 | "center_wavelength", 27 | "full_width_half_max" 28 | )((band: Band) => (band.name, band.commonName, band.description, band.centerWavelength, band.fullWidthHalfMax)) 29 | .mapJson(_.dropNullValues) 30 | 31 | implicit val decBand: Decoder[Band] = Decoder.forProduct5( 32 | "name", 33 | "common_name", 34 | "description", 35 | "center_wavelength", 36 | "full_width_half_max" 37 | )(Band.apply) 38 | } 39 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/eo/EOAssetExtension.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.eo 2 | 3 | import com.azavea.stac4s.extensions.StacAssetExtension 4 | 5 | import cats.Eq 6 | import cats.data.NonEmptyList 7 | import io.circe._ 8 | import io.circe.syntax._ 9 | 10 | case class EOAssetExtension( 11 | bands: NonEmptyList[Band] 12 | ) 13 | 14 | object EOAssetExtension { 15 | 16 | implicit val eqEOAssetExtension: Eq[EOAssetExtension] = Eq.fromUniversalEquals 17 | 18 | implicit val encEOAssetExtension: Encoder.AsObject[EOAssetExtension] = 19 | Encoder.AsObject[Map[String, Json]].contramapObject(assetExt => Map("eo:bands" -> assetExt.bands.asJson)) 20 | 21 | implicit val decEOAssetExtension: Decoder[EOAssetExtension] = Decoder.forProduct1("eo:bands")(EOAssetExtension.apply) 22 | 23 | implicit val assetExtension: StacAssetExtension[EOAssetExtension] = StacAssetExtension.instance 24 | } 25 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/eo/EOItemExtension.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.eo 2 | 3 | import com.azavea.stac4s.extensions.ItemExtension 4 | 5 | import cats.Eq 6 | import cats.data.NonEmptyList 7 | import io.circe._ 8 | import io.circe.refined._ 9 | import io.circe.syntax._ 10 | 11 | case class EOItemExtension( 12 | bands: NonEmptyList[Band], 13 | cloudCover: Option[Percentage] 14 | ) 15 | 16 | object EOItemExtension { 17 | 18 | implicit val eq: Eq[EOItemExtension] = Eq.fromUniversalEquals 19 | 20 | implicit val encEOItemExtension: Encoder.AsObject[EOItemExtension] = Encoder 21 | .AsObject[Map[String, Json]] 22 | .contramapObject((band: EOItemExtension) => 23 | Map("eo:bands" -> band.bands.asJson, "eo:cloud_cover" -> band.cloudCover.asJson) 24 | ) 25 | .mapJsonObject(_.filter({ case (_, jValue) => 26 | !jValue.isNull 27 | })) 28 | 29 | implicit val decEOItemExtension: Decoder[EOItemExtension] = Decoder.forProduct2( 30 | "eo:bands", 31 | "eo:cloud_cover" 32 | )(EOItemExtension.apply) 33 | 34 | implicit val itemExtension: ItemExtension[EOItemExtension] = ItemExtension.instance 35 | } 36 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/eo/package.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions 2 | 3 | import eu.timepit.refined._ 4 | import eu.timepit.refined.api._ 5 | import eu.timepit.refined.numeric._ 6 | 7 | package object eo { 8 | type Percentage = Double Refined Interval.Closed[W.`0D`.T, W.`100D`.T] 9 | object Percentage extends RefinedTypeOps[Percentage, Double] 10 | } 11 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/label/LabelClass.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.label 2 | 3 | import cats.Eq 4 | import io.circe.{Decoder, Encoder} 5 | 6 | case class LabelClass( 7 | name: LabelClassName, 8 | classes: LabelClassClasses 9 | ) 10 | 11 | object LabelClass { 12 | 13 | implicit val eqLabelClass: Eq[LabelClass] = Eq.fromUniversalEquals 14 | 15 | implicit val decLabelClass: Decoder[LabelClass] = Decoder.forProduct2( 16 | "name", 17 | "classes" 18 | )(LabelClass.apply) 19 | 20 | implicit val encLabelClass: Encoder[LabelClass] = Encoder.forProduct2( 21 | "name", 22 | "classes" 23 | )(labelClass => (labelClass.name, labelClass.classes)) 24 | } 25 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/label/LabelClassClasses.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.label 2 | 3 | import cats.Eq 4 | import cats.data.NonEmptyList 5 | import cats.syntax.functor._ 6 | import io.circe._ 7 | import io.circe.syntax._ 8 | 9 | sealed abstract class LabelClassClasses 10 | 11 | object LabelClassClasses { 12 | // why non-empty? empty lists don't round trip, since we don't know whether 13 | // they're empty lists of names or numbers. That's bad for tests, and also 14 | // it doesn't really make any sense to be vectors labeled without classes, 15 | // so I think the stricter requirement over the STAC spec is fine in this case 16 | // https://github.com/radiantearth/stac-spec/tree/master/extensions/label#class-object 17 | case class NamedLabelClasses(names: NonEmptyList[String]) extends LabelClassClasses 18 | case class NumberedLabelClasses(indices: NonEmptyList[Int]) extends LabelClassClasses 19 | 20 | implicit val encNamedLabelClasses: Encoder[NamedLabelClasses] = 21 | Encoder[NonEmptyList[String]].contramap(_.names) 22 | 23 | implicit val decNamedLabelClasses: Decoder[NamedLabelClasses] = 24 | Decoder[NonEmptyList[String]].map(NamedLabelClasses.apply) 25 | 26 | implicit val encNumberedLabelClasses: Encoder[NumberedLabelClasses] = 27 | Encoder[NonEmptyList[Int]].contramap(_.indices) 28 | 29 | implicit val decNumberedLabelClasses: Decoder[NumberedLabelClasses] = 30 | Decoder[NonEmptyList[Int]].map(NumberedLabelClasses.apply) 31 | 32 | implicit val encLabelClassClasses: Encoder[LabelClassClasses] = new Encoder[LabelClassClasses] { 33 | 34 | def apply(t: LabelClassClasses): Json = t match { 35 | case named: NamedLabelClasses => named.asJson 36 | case numbered: NumberedLabelClasses => numbered.asJson 37 | } 38 | } 39 | 40 | implicit val eqLabelClassClasses: Eq[LabelClassClasses] = Eq.fromUniversalEquals 41 | 42 | implicit val decLabelClassClassels: Decoder[LabelClassClasses] = Decoder[NumberedLabelClasses].widen or 43 | Decoder[NamedLabelClasses].widen 44 | } 45 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/label/LabelClassName.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.label 2 | 3 | import cats.Eq 4 | import cats.syntax.invariant._ 5 | import io.circe.{Decoder, Encoder} 6 | 7 | sealed abstract class LabelClassName(repr: String) { 8 | override def toString: String = repr 9 | } 10 | 11 | object LabelClassName { 12 | case object Raster extends LabelClassName("raster") 13 | case class VectorName(s: String) extends LabelClassName(s) 14 | 15 | def fromOption(o: Option[String]): LabelClassName = o match { 16 | case Some(s) => VectorName(s) 17 | case None => Raster 18 | } 19 | 20 | def toOption(name: LabelClassName) = name match { 21 | case Raster => None 22 | case VectorName(s) => Some(s) 23 | } 24 | 25 | implicit val eqLabelClassName: Eq[LabelClassName] = Eq[Option[String]].imap(fromOption)(toOption) 26 | 27 | implicit val encLabelClassName: Encoder[LabelClassName] = Encoder[Option[String]].contramap(toOption) 28 | 29 | implicit val decLabelClassName: Decoder[LabelClassName] = Decoder[Option[String]].map(fromOption) 30 | } 31 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/label/LabelCount.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.label 2 | 3 | import cats.Eq 4 | import io.circe.{Decoder, Encoder} 5 | 6 | case class LabelCount( 7 | name: String, 8 | count: Int 9 | ) 10 | 11 | object LabelCount { 12 | 13 | implicit val eqLabelCount: Eq[LabelCount] = Eq.fromUniversalEquals 14 | 15 | implicit val decLabelCount: Decoder[LabelCount] = Decoder.forProduct2( 16 | "name", 17 | "count" 18 | )(LabelCount.apply) 19 | 20 | implicit val encLabelCount: Encoder[LabelCount] = Encoder.forProduct2( 21 | "name", 22 | "count" 23 | )(labelCount => (labelCount.name, labelCount.count)) 24 | } 25 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/label/LabelItemExtension.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.label 2 | 3 | import com.azavea.stac4s.extensions.ItemExtension 4 | 5 | import cats.Eq 6 | import cats.syntax.apply._ 7 | import io.circe.syntax._ 8 | import io.circe.{Decoder, Encoder, HCursor, Json} 9 | 10 | case class LabelItemExtension( 11 | properties: LabelProperties, 12 | classes: List[LabelClass], 13 | description: String, 14 | _type: LabelType, 15 | tasks: List[LabelTask], 16 | methods: List[LabelMethod], 17 | overviews: List[LabelOverview] 18 | ) 19 | 20 | object LabelItemExtension { 21 | 22 | implicit val encLabelExtensionPropertiesObject: Encoder.AsObject[LabelItemExtension] = Encoder 23 | .AsObject[Map[String, Json]] 24 | .contramapObject((properties: LabelItemExtension) => 25 | Map( 26 | "label:properties" -> properties.properties.asJson, 27 | "label:classes" -> properties.classes.asJson, 28 | "label:description" -> properties.description.asJson, 29 | "label:type" -> properties._type.asJson, 30 | "label:tasks" -> properties.tasks.asJson, 31 | "label:methods" -> properties.methods.asJson, 32 | "label:overviews" -> properties.overviews.asJson 33 | ) 34 | ) 35 | 36 | implicit val decLabelExtensionProperties: Decoder[LabelItemExtension] = new Decoder[LabelItemExtension] { 37 | 38 | def apply(c: HCursor) = 39 | ( 40 | c.downField("label:properties").as[LabelProperties], 41 | c.downField("label:classes").as[List[LabelClass]], 42 | c.downField("label:description").as[String], 43 | c.downField("label:type").as[LabelType], 44 | c.downField("label:tasks").as[Option[List[LabelTask]]], 45 | c.downField("label:methods").as[Option[List[LabelMethod]]], 46 | c.downField("label:overviews").as[Option[List[LabelOverview]]] 47 | ).mapN( 48 | ( 49 | properties: LabelProperties, 50 | classes: List[LabelClass], 51 | description: String, 52 | _type: LabelType, 53 | tasks: Option[List[LabelTask]], 54 | methods: Option[List[LabelMethod]], 55 | overviews: Option[List[LabelOverview]] 56 | ) => 57 | LabelItemExtension( 58 | properties, 59 | classes, 60 | description, 61 | _type, 62 | tasks getOrElse Nil, 63 | methods getOrElse Nil, 64 | overviews getOrElse Nil 65 | ) 66 | ) 67 | } 68 | 69 | implicit val eqLabelExtensionProperties: Eq[LabelItemExtension] = Eq.fromUniversalEquals 70 | 71 | implicit val itemExtensionLabelProperties: ItemExtension[LabelItemExtension] = ItemExtension.instance 72 | } 73 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/label/LabelLinkExtension.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.label 2 | 3 | import com.azavea.stac4s.extensions.LinkExtension 4 | 5 | import cats.data.NonEmptyList 6 | import io.circe.syntax._ 7 | import io.circe.{Decoder, Encoder, Json} 8 | 9 | case class LabelLinkExtension(assets: NonEmptyList[String]) 10 | 11 | object LabelLinkExtension { 12 | 13 | implicit val encLabelLinkExtensionObject: Encoder.AsObject[LabelLinkExtension] = Encoder 14 | .AsObject[Map[String, Json]] 15 | .contramapObject((extensionFields: LabelLinkExtension) => Map("label:assets" -> extensionFields.assets.asJson)) 16 | 17 | implicit val decLabelLinkExtension: Decoder[LabelLinkExtension] = 18 | Decoder.forProduct1("label:assets")(LabelLinkExtension.apply) 19 | 20 | implicit val linkExtension: LinkExtension[LabelLinkExtension] = LinkExtension.instance 21 | } 22 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/label/LabelMethod.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.label 2 | 3 | import cats.Eq 4 | import cats.syntax.invariant._ 5 | import io.circe.{Decoder, Encoder} 6 | 7 | sealed abstract class LabelMethod(val repr: String) { 8 | override def toString: String = repr 9 | } 10 | 11 | object LabelMethod { 12 | case object Manual extends LabelMethod("manual") 13 | case object Automatic extends LabelMethod("automatic") 14 | case class VendorMethod(methodName: String) extends LabelMethod(methodName) 15 | 16 | implicit def eqLabelMethod: Eq[LabelMethod] = Eq[String].imap(fromString _)(_.repr) 17 | 18 | implicit val decLabelMethod: Decoder[LabelMethod] = Decoder[String].map(fromString _) 19 | implicit val encLabelMethod: Encoder[LabelMethod] = Encoder[String].contramap(_.repr) 20 | 21 | def fromString(s: String): LabelMethod = s.toLowerCase match { 22 | case "manual" => Manual 23 | case "automatic" => Automatic 24 | case _ => VendorMethod(s) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/label/LabelOverview.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.label 2 | 3 | import cats.Eq 4 | import cats.syntax.apply._ 5 | import io.circe.{Decoder, Encoder, HCursor} 6 | 7 | case class LabelOverview( 8 | propertyKey: String, 9 | counts: List[LabelCount], 10 | statistics: List[LabelStats] 11 | ) 12 | 13 | object LabelOverview { 14 | 15 | implicit val eqLabelOverview: Eq[LabelOverview] = Eq.fromUniversalEquals 16 | 17 | implicit val decLabelOverview: Decoder[LabelOverview] = new Decoder[LabelOverview] { 18 | 19 | def apply(c: HCursor) = 20 | ( 21 | c.downField("property_key").as[String], 22 | c.downField("counts").as[Option[List[LabelCount]]], 23 | c.downField("statistics").as[Option[List[LabelStats]]] 24 | ).mapN((key: String, counts: Option[List[LabelCount]], statistics: Option[List[LabelStats]]) => 25 | LabelOverview( 26 | key, 27 | counts getOrElse Nil, 28 | statistics getOrElse Nil 29 | ) 30 | ) 31 | } 32 | 33 | implicit val encLabelOverview: Encoder[LabelOverview] = Encoder.forProduct3( 34 | "property_key", 35 | "counts", 36 | "statistics" 37 | )(labelOverview => (labelOverview.propertyKey, labelOverview.counts, labelOverview.statistics)) 38 | } 39 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/label/LabelProperties.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.label 2 | 3 | import cats.Eq 4 | import cats.syntax.invariant._ 5 | import io.circe.{Decoder, Encoder} 6 | 7 | sealed abstract class LabelProperties(val repr: String) { 8 | override def toString: String = repr 9 | } 10 | 11 | object LabelProperties { 12 | case class VectorLabelProperties(fields: List[String]) extends LabelProperties("vector") 13 | case object RasterLabelProperties extends LabelProperties("raster") 14 | 15 | def fromOption(o: Option[List[String]]): LabelProperties = o match { 16 | case Some(fields) => VectorLabelProperties(fields) 17 | case None => RasterLabelProperties 18 | } 19 | 20 | def toOption(props: LabelProperties): Option[List[String]] = props match { 21 | case VectorLabelProperties(fields) => Some(fields) 22 | case RasterLabelProperties => None 23 | } 24 | 25 | implicit val eqLabelProperties: Eq[LabelProperties] = Eq[Option[List[String]]].imap(fromOption)(toOption) 26 | 27 | implicit val decLabelProperties: Decoder[LabelProperties] = Decoder[Option[List[String]]] map { 28 | fromOption 29 | } 30 | 31 | implicit val encLabelProperties: Encoder[LabelProperties] = Encoder[Option[List[String]]].contramap(toOption) 32 | } 33 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/label/LabelStats.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.label 2 | 3 | import cats.Eq 4 | import io.circe.{Decoder, Encoder} 5 | 6 | case class LabelStats( 7 | name: String, 8 | value: Double 9 | ) 10 | 11 | object LabelStats { 12 | 13 | implicit val eqLabelStats: Eq[LabelStats] = Eq.fromUniversalEquals 14 | 15 | implicit val decLabelStats: Decoder[LabelStats] = Decoder.forProduct2("name", "value")( 16 | LabelStats.apply 17 | ) 18 | 19 | implicit val encLabelStats: Encoder[LabelStats] = 20 | Encoder.forProduct2("name", "value")(labelStats => (labelStats.name, labelStats.value)) 21 | } 22 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/label/LabelTask.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.label 2 | 3 | import cats.Eq 4 | import cats.syntax.invariant._ 5 | import io.circe.{Decoder, Encoder} 6 | 7 | sealed abstract class LabelTask(val repr: String) { 8 | override def toString = repr 9 | } 10 | 11 | object LabelTask { 12 | 13 | case object Regression extends LabelTask("regression") 14 | case object Segmentation extends LabelTask("segmentation") 15 | case object Detection extends LabelTask("detection") 16 | case object Classification extends LabelTask("classification") 17 | case class VendorTask(taskName: String) extends LabelTask(taskName) 18 | 19 | implicit def eqLabelTask: Eq[LabelTask] = Eq[String].imap(fromString _)(_.repr) 20 | 21 | implicit val decLabelTask: Decoder[LabelTask] = Decoder[String].map(fromString _) 22 | implicit val encLabelTask: Encoder[LabelTask] = Encoder[String].contramap(_.repr) 23 | 24 | def fromString(s: String): LabelTask = s.toLowerCase match { 25 | case "regression" => Regression 26 | case "segmentation" => Segmentation 27 | case "detection" => Detection 28 | case "classification" => Classification 29 | case _ => VendorTask(s) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/label/LabelType.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.label 2 | 3 | import cats.Eq 4 | import io.circe.{Decoder, Encoder} 5 | 6 | sealed abstract class LabelType(val repr: String) { 7 | override def toString: String = repr 8 | } 9 | 10 | object LabelType { 11 | case object Vector extends LabelType("vector") 12 | case object Raster extends LabelType("raster") 13 | 14 | def fromStringE(s: String): Either[String, LabelType] = s.toLowerCase match { 15 | case "vector" => Right(Vector) 16 | case "raster" => Right(Raster) 17 | case str => Left(s"$str is not a valid label type. Should be raster or vector") 18 | } 19 | 20 | // There's no invariant functor with strings here because only two strings map to 21 | // label types 22 | implicit val eqLabelType: Eq[LabelType] = Eq.fromUniversalEquals 23 | implicit val encLabelType: Encoder[LabelType] = Encoder[String].contramap(_.repr) 24 | implicit val decLabelType: Decoder[LabelType] = Decoder[String].emap(fromStringE _) 25 | } 26 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/layer/LayerItemExtension.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.layer 2 | 3 | import com.azavea.stac4s.extensions.ItemExtension 4 | 5 | import cats.Eq 6 | import cats.data.NonEmptyList 7 | import eu.timepit.refined.types.string.NonEmptyString 8 | import io.circe.refined._ 9 | import io.circe.syntax._ 10 | import io.circe.{Decoder, Encoder} 11 | 12 | case class LayerItemExtension(ids: NonEmptyList[NonEmptyString]) 13 | 14 | object LayerItemExtension { 15 | implicit val eqLayerProperties: Eq[LayerItemExtension] = Eq.fromUniversalEquals 16 | 17 | implicit val encLayerProperties: Encoder.AsObject[LayerItemExtension] = 18 | Encoder.AsObject.instance[LayerItemExtension] { o => Map("layer:ids" -> o.ids.asJson).asJsonObject } 19 | 20 | implicit val decLayerProperties: Decoder[LayerItemExtension] = 21 | Decoder.forProduct1("layer:ids")(LayerItemExtension.apply) 22 | 23 | implicit val itemExtension: ItemExtension[LayerItemExtension] = ItemExtension.instance 24 | } 25 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/layer/StacLayerProperties.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.extensions.layer 2 | 3 | import cats.kernel.Eq 4 | import io.circe.{Decoder, Encoder} 5 | 6 | import java.time.Instant 7 | 8 | final case class StacLayerProperties( 9 | startDatetime: Instant, 10 | endDatetime: Instant 11 | ) 12 | 13 | object StacLayerProperties { 14 | 15 | implicit val eqLayerProperties: Eq[StacLayerProperties] = Eq.fromUniversalEquals 16 | 17 | implicit val decLayerProperties: Decoder[StacLayerProperties] = Decoder.forProduct2( 18 | "start_datetime", 19 | "end_datetime" 20 | )(StacLayerProperties.apply) 21 | 22 | implicit val encLayerProperties: Encoder[StacLayerProperties] = Encoder.forProduct2( 23 | "start_datetime", 24 | "end_datetime" 25 | )(props => (props.startDatetime, props.endDatetime)) 26 | } 27 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/extensions/package.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import cats.data.ValidatedNel 4 | import io.circe.Error 5 | 6 | package object extensions { 7 | 8 | // convenience type not to have to write ValidatedNel in a few places / 9 | // to expose a nicer API to users (a la MAML: 10 | // https://github.com/geotrellis/maml/blob/713c6a0c54646d1972855bf5a1f0efddd108f95d/shared/src/main/scala/error/package.scala#L8) 11 | type ExtensionResult[T] = ValidatedNel[Error, T] 12 | 13 | } 14 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/meta/ValidStacVersion.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.meta 2 | 3 | import eu.timepit.refined.api.Validate 4 | 5 | final case class ValidStacVersion() 6 | 7 | object ValidStacVersion { 8 | 9 | val stacVersions = List( 10 | "1.0.0", 11 | "1.0.0-rc4" 12 | ) 13 | 14 | implicit def validStacVersion: Validate.Plain[String, ValidStacVersion] = 15 | Validate.fromPredicate( 16 | (s: String) => stacVersions.contains(s), 17 | t => s"Invalid STAC Version: $t", 18 | ValidStacVersion() 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/package.scala: -------------------------------------------------------------------------------- 1 | package com.azavea 2 | 3 | import com.azavea.stac4s.meta.ValidStacVersion 4 | 5 | import eu.timepit.refined.api.{Refined, RefinedTypeOps} 6 | 7 | package object stac4s { 8 | 9 | type StacVersion = String Refined ValidStacVersion 10 | object StacVersion extends RefinedTypeOps[StacVersion, String] 11 | 12 | def substituteFieldName(fieldName: String): Option[String] = fieldName match { 13 | case "_type" => Some("type") 14 | case "stacVersion" => Some("stac_version") 15 | case "stacExtensions" => Some("stac_extensions") 16 | case "extensionFields" => None 17 | case s => Some(s) 18 | } 19 | 20 | def productFieldNames[T: ProductFieldNames]: Set[String] = ProductFieldNames[T].get 21 | } 22 | -------------------------------------------------------------------------------- /modules/core/shared/src/main/scala/com/azavea/stac4s/types.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import cats.data.Ior 4 | import cats.kernel.Eq 5 | import eu.timepit.refined.W 6 | import eu.timepit.refined.api.Refined 7 | import eu.timepit.refined.generic._ 8 | import io.circe.syntax._ 9 | import io.circe.{Decoder, DecodingFailure, Encoder, HCursor} 10 | 11 | package object types { 12 | 13 | type CatalogType = String Refined Equal[W.`"Catalog"`.T] 14 | type CollectionType = String Refined Equal[W.`"Collection"`.T] 15 | 16 | type ItemDatetime = Ior[PointInTime, TimeRange] 17 | 18 | implicit val encItemDateTime: Encoder[ItemDatetime] = { 19 | case Ior.Left(pit @ PointInTime(_)) => pit.asJson 20 | case Ior.Right(tr @ TimeRange(_, _)) => tr.asJson 21 | case Ior.Both(pit @ PointInTime(_), tr @ TimeRange(_, _)) => 22 | // order is important here! the time range encoder also writes a `null` value to the 23 | // datetime field, which overwrites what the point-in-time encoder wants to write. 24 | val out = tr.asJson.deepMerge(pit.asJson) 25 | out 26 | } 27 | 28 | implicit val decItemDateTime: Decoder[ItemDatetime] = { c: HCursor => 29 | (c.as[PointInTime], c.as[TimeRange]) match { 30 | case (Right(pit), Right(tr)) => Right(Ior.Both(pit, tr)) 31 | case (_, Right(tr)) => Right(Ior.Right(tr)) 32 | case (Right(pit), _) => Right(Ior.Left(pit)) 33 | case (Left(err1), Left(err2)) => 34 | (err1, err2) match { 35 | case (DecodingFailure(decFailure1, h1), DecodingFailure(decFailure2, h2)) => 36 | Left(DecodingFailure(s"${decFailure1}. ${decFailure2}", h1 ++ h2)) 37 | // since they're decoding the same cursor, if one of the errors is a ParsingFailure instead 38 | // of a decoding failure, they _both_ should be, so we just need the first one 39 | case _ => 40 | Left(err1) 41 | } 42 | } 43 | } 44 | 45 | implicit val eqItemDatetime: Eq[ItemDatetime] = Eq.fromUniversalEquals 46 | } 47 | -------------------------------------------------------------------------------- /modules/testing/js/src/main/scala/JsInstances.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s.testing 2 | 3 | import com.azavea.stac4s.extensions.layer.StacLayer 4 | import com.azavea.stac4s.geometry.Geometry.{MultiPolygon, Point2d, Polygon} 5 | import com.azavea.stac4s.geometry._ 6 | import com.azavea.stac4s.types.CollectionType 7 | import com.azavea.stac4s.{ 8 | Bbox, 9 | Interval, 10 | ItemCollection, 11 | Proprietary, 12 | SpatialExtent, 13 | StacAsset, 14 | StacCollection, 15 | StacExtent, 16 | StacItem, 17 | StacLink, 18 | StacVersion, 19 | TemporalExtent 20 | } 21 | 22 | import cats.syntax.apply._ 23 | import cats.syntax.option._ 24 | import eu.timepit.refined.scalacheck.GenericInstances 25 | import io.circe.JsonObject 26 | import io.circe.syntax._ 27 | import org.scalacheck.cats.implicits._ 28 | import org.scalacheck.{Arbitrary, Gen} 29 | 30 | import java.time.Instant 31 | 32 | trait JsInstances extends GenericInstances { 33 | 34 | private[testing] def finiteDoubleGen: Gen[Double] = Arbitrary.arbitrary[Double].filterNot(_.isNaN) 35 | 36 | private[testing] def point2dGen: Gen[Point2d] = (finiteDoubleGen, finiteDoubleGen).mapN(Point2d.apply) 37 | 38 | private[testing] def temporalExtentGen: Gen[TemporalExtent] = 39 | (Gen.const(Instant.now), Gen.const(Instant.now)).tupled 40 | .map { case (start, end) => TemporalExtent(start, end) } 41 | 42 | private[testing] def stacExtentGen: Gen[StacExtent] = 43 | ( 44 | TestInstances.bboxGen, 45 | temporalExtentGen 46 | ).mapN((bbox: Bbox, interval: TemporalExtent) => StacExtent(SpatialExtent(List(bbox)), Interval(List(interval)))) 47 | 48 | /** We know for sure that we have five points, so there's no risk in calling .head */ 49 | @SuppressWarnings(Array("TraversableHead", "UnsafeTraversableMethods")) private[testing] def polygonGen 50 | : Gen[Polygon] = 51 | Gen.listOfN(5, point2dGen).map(points => Polygon(points :+ points.head)) 52 | private[testing] def multipolygonGen: Gen[MultiPolygon] = Gen.listOfN(3, polygonGen).map(MultiPolygon.apply) 53 | 54 | private[testing] def geometryGen: Gen[Geometry] = Gen.oneOf( 55 | point2dGen, 56 | polygonGen, 57 | multipolygonGen 58 | ) 59 | 60 | private[testing] def stacItemGen: Gen[StacItem] = 61 | ( 62 | nonEmptyStringGen, 63 | Gen.const("1.0.0"), 64 | Gen.const(List.empty[String]), 65 | Gen.const("Feature"), 66 | geometryGen, 67 | TestInstances.twoDimBboxGen, 68 | nonEmptyListGen(TestInstances.stacLinkGen) map { _.toList }, 69 | TestInstances.assetMapGen, 70 | Gen.option(nonEmptyStringGen), 71 | TestInstances.itemPropertiesGen 72 | ).mapN(StacItem.apply) 73 | 74 | private[testing] def stacItemShortGen: Gen[StacItem] = 75 | ( 76 | nonEmptyStringGen, 77 | Gen.const("1.0.0"), 78 | Gen.const(List.empty[String]), 79 | Gen.const("Feature"), 80 | geometryGen, 81 | TestInstances.twoDimBboxGen, 82 | Gen.const(Nil), 83 | Gen.const(Map.empty[String, StacAsset]), 84 | Gen.option(nonEmptyStringGen), 85 | TestInstances.itemPropertiesGen 86 | ).mapN(StacItem.apply) 87 | 88 | private[testing] def itemCollectionGen: Gen[ItemCollection] = 89 | ( 90 | Gen.const("FeatureCollection"), 91 | Gen.const(StacVersion.unsafeFrom("1.0.0")), 92 | Gen.const(Nil), 93 | Gen.listOf[StacItem](stacItemGen), 94 | Gen.listOf[StacLink](TestInstances.stacLinkGen), 95 | Gen.const(().asJsonObject) 96 | ).mapN(ItemCollection.apply) 97 | 98 | private[testing] def itemCollectionShortGen: Gen[ItemCollection] = 99 | ( 100 | Gen.const("FeatureCollection"), 101 | Gen.const(StacVersion.unsafeFrom("1.0.0")), 102 | Gen.const(Nil), 103 | Gen.listOfN[StacItem](2, stacItemGen), 104 | Gen.const(Nil), 105 | Gen.const(().asJsonObject) 106 | ).mapN(ItemCollection.apply) 107 | 108 | private[testing] def stacCollectionShortGen: Gen[StacCollection] = 109 | ( 110 | Arbitrary.arbitrary[CollectionType], 111 | Gen.const("1.0.0"), 112 | Gen.const(Nil), 113 | nonEmptyStringGen, 114 | nonEmptyStringGen.map(_.some), 115 | nonEmptyStringGen, 116 | Gen.const(Nil), 117 | Gen.const(Proprietary()), 118 | Gen.const(Nil), 119 | stacExtentGen, 120 | Gen.const(JsonObject.empty), 121 | Gen.const(JsonObject.empty), 122 | Gen.const(Nil), 123 | Gen.option(TestInstances.assetMapGen), 124 | Gen.const(().asJsonObject) 125 | ).mapN(StacCollection.apply) 126 | 127 | private[testing] def stacLayerGen: Gen[StacLayer] = ( 128 | nonEmptyAlphaRefinedStringGen, 129 | TestInstances.bboxGen, 130 | geometryGen, 131 | TestInstances.stacLayerPropertiesGen, 132 | Gen.listOfN(8, TestInstances.stacLinkGen), 133 | Gen.const("Feature") 134 | ).mapN( 135 | StacLayer.apply 136 | ) 137 | 138 | implicit val arbItem: Arbitrary[StacItem] = Arbitrary { stacItemGen } 139 | 140 | val arbItemShort: Arbitrary[StacItem] = Arbitrary { stacItemShortGen } 141 | 142 | implicit val arbItemCollection: Arbitrary[ItemCollection] = Arbitrary { 143 | itemCollectionGen 144 | } 145 | 146 | val arbItemCollectionShort: Arbitrary[ItemCollection] = Arbitrary { 147 | itemCollectionShortGen 148 | } 149 | 150 | implicit val arbGeometry: Arbitrary[Geometry] = Arbitrary { geometryGen } 151 | 152 | val arbCollectionShort: Arbitrary[StacCollection] = Arbitrary { stacCollectionShortGen } 153 | 154 | implicit val arbStacLayer: Arbitrary[StacLayer] = Arbitrary { 155 | stacLayerGen 156 | } 157 | 158 | } 159 | 160 | object JsInstances extends JsInstances {} 161 | -------------------------------------------------------------------------------- /modules/testing/shared/src/main/scala/testing.scala: -------------------------------------------------------------------------------- 1 | package com.azavea.stac4s 2 | 3 | import cats.data.NonEmptyList 4 | import eu.timepit.refined.types.string.NonEmptyString 5 | import org.scalacheck.Arbitrary.arbitrary 6 | import org.scalacheck.Gen 7 | 8 | import java.time.Instant 9 | 10 | package object testing { 11 | 12 | def nonEmptyStringGen: Gen[String] = 13 | Gen.listOfN(30, Gen.alphaChar) map { _.mkString } 14 | 15 | def nonEmptyAlphaRefinedStringGen: Gen[NonEmptyString] = 16 | nonEmptyStringGen map NonEmptyString.unsafeFrom 17 | 18 | def possiblyEmptyListGen[T](g: Gen[T]) = 19 | Gen.choose(0, 10) flatMap { count => Gen.listOfN(count, g) } 20 | 21 | def possiblyEmptyMapGen[T, U](g: Gen[(T, U)]) = 22 | Gen.choose(0, 10) flatMap { count => Gen.mapOfN(count, g) } 23 | 24 | def nonEmptyListGen[T](g: Gen[T]): Gen[NonEmptyList[T]] = 25 | Gen.nonEmptyListOf(g) map { NonEmptyList.fromListUnsafe } 26 | 27 | def instantGen: Gen[Instant] = arbitrary[Int] map { x => Instant.now.plusMillis(x.toLong) } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /project/Versions.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | import sbt.Keys._ 3 | 4 | object Versions { 5 | 6 | private def ver(for212: String, for213: String) = Def.setting { 7 | CrossVersion.partialVersion(scalaVersion.value) match { 8 | case Some((2, 12)) => for212 9 | case Some((2, 13)) => for213 10 | case _ => sys.error("not good") 11 | } 12 | } 13 | 14 | val Cats = "2.12.0" 15 | val Circe = "0.14.10" 16 | val CirceRefined = "0.15.1" 17 | val CirceJsonSchema = "0.2.0" 18 | val DisciplineScalatest = "2.3.0" 19 | val Enumeratum = "1.7.5" 20 | val GeoTrellis = "3.7.1" 21 | val Jts = "1.20.0" 22 | val Monocle = "2.1.0" 23 | val Refined = "0.11.3" 24 | val ScalacheckCats = "0.3.2" 25 | val Scalacheck = "1.18.1" 26 | val ScalatestPlusScalacheck = "3.2.14.0" 27 | val Scalatest = "3.2.19" 28 | val Scapegoat = "3.1.3" 29 | val Shapeless = "2.3.12" 30 | val Sttp = "3.10.2" 31 | val SttpModel = "1.7.11" 32 | val SttpShared = "1.4.2" 33 | val Fs2 = "3.11.0" 34 | val ThreeTenExtra = "1.8.0" 35 | val ScalaJavaTime = "2.6.0" 36 | } 37 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.10.7 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addDependencyTreePlugin 2 | addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.9.3") 3 | addSbtPlugin("com.github.cb372" % "sbt-explicit-dependencies" % "0.3.1") 4 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") 5 | addSbtPlugin("com.sksamuel.scapegoat" %% "sbt-scapegoat" % "1.2.9") 6 | addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.13.0") 7 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1") 8 | addSbtPlugin("org.typelevel" % "sbt-tpolecat" % "0.5.2") 9 | addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.6.4") 10 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.18.1") 11 | addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2") 12 | --------------------------------------------------------------------------------