Repository: stac-utils/stac-rs (language: python)

Issue:
Remove GDAL support
It's sort of a one off, and pretty much everything we're doing is already done by other software.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml
deleted file mode 100644
index 27bee698..00000000
--- a/.github/workflows/api.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-name: Api
-
-on:
-  pull_request:
-    paths:
-      - api/**
-  push:
-    paths:
-      - api/**
-    branches:
-      - main
-  workflow_dispatch:
-
-defaults:
-  run:
-    working-directory: api
-
-concurrency:
-  group: ${{ github.workflow }}-${{ github.ref }}
-  cancel-in-progress: true
-
-env:
-  CARGO_TERM_COLOR: always
-  CARGO_TERM_VERBOSE: true
-
-jobs:
-  test-api:
-    strategy:
-      matrix: 
-        os:
-          - ubuntu-latest
-          - macos-latest
-          - windows-latest
-    runs-on: ${{ matrix.os }}
-    steps:
-      - uses: actions/checkout@v4
-      - name: Set up Rust cache
-        uses: Swatinem/rust-cache@v2
-      - name: Test
-        run: cargo test --all-features
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 24d26be7..e330cd9d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,90 +15,67 @@ env:
   CARGO_TERM_VERBOSE: true
 
 jobs:
-  test:
-    runs-on: ubuntu-latest
+  test-core:
+    name: Test stac
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        os:
+          - ubuntu-latest
+          - macos-latest
+          - windows-latest
     steps:
       - uses: actions/checkout@v4
       - uses: Swatinem/rust-cache@v2
-      - name: Install GDAL
-        run: |
-          sudo apt-get update
-          sudo apt-get install libgdal-dev
       - name: Test
-        run: cargo test --lib
-  lint:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v4
-      - uses: Swatinem/rust-cache@v2
-      - name: Install GDAL
-        run: |
-          sudo apt-get update
-          sudo apt-get install libgdal-dev
-      - name: Fmt
-        run: cargo fmt
-      - name: Clippy
-        run: cargo clippy --all --all-features
-  check:
+        run: cargo test -p stac --all-features
+  test-api:
+    name: Test stac-api
     runs-on: ubuntu-latest
-    strategy:
-      matrix:
-        chunk:
-          - 1
-          - 2
-          - 3
     steps:
       - uses: actions/checkout@v4
       - uses: Swatinem/rust-cache@v2
-      - name: Install GDAL
-        run: |
-          sudo apt-get update
-          sudo apt-get install libgdal-dev
-      - name: Install check-all-features
-        run: cargo install cargo-all-features
-      - name: Check
-        run: cargo check-all-features --n-chunks 3 --chunk ${{ matrix.chunk }}
-  check-nightly:
+      - name: Test
+        run: cargo test -p stac-api --all-features
+  test-cli:
+    name: Test stac-cli
     runs-on: ubuntu-latest
+    defaults:
+      run:
+        working-directory: cli
     steps:
       - uses: actions/checkout@v4
-      - uses: dtolnay/rust-toolchain@nightly
       - uses: Swatinem/rust-cache@v2
-      - name: Install GDAL
-        run: |
-          sudo apt-get update
-          sudo apt-get install libgdal-dev
-      - name: Check
-        run: cargo check --all --all-features
-  doc:
+      - uses: actions/setup-python@v5
+        with:
+          python-version: 3.x
+      - uses: astral-sh/setup-uv@v3
+        with:
+          enable-cache: true
+      - name: Test
+        run: cargo test -F duckdb/bundled --all-features
+      - name: Install
+        run: uv sync
+      - name: Smoke test
+        run: uv run stacrs --version
+  test-duckdb:
+    name: Test stac-duckdb
     runs-on: ubuntu-latest
-    env:
-      RUSTDOCFLAGS: -Dwarnings
     steps:
       - uses: actions/checkout@v4
       - uses: Swatinem/rust-cache@v2
-      - name: Install GDAL
-        run: |
-          sudo apt-get update
-          sudo apt-get install libgdal-dev
-      - name: Doc
-        run: cargo doc --all-features
-  validate-stac-server:
+      - name: Test
+        run: cargo test -p stac-duckdb -F duckdb/bundled
+  test-extensions:
+    name: Test stac-extensions
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v4
       - uses: Swatinem/rust-cache@v2
-      - uses: actions/setup-python@v5
-        with:
-          python-version: "3.10"
-      - uses: astral-sh/setup-uv@v3
-        with:
-          enable-cache: true
-      - name: Install Python dependencies
-        run: uv sync --extra stac-api-validator
-      - name: Validate
-        run: uv run scripts/validate-stac-server
-  validate-stac-server-pgstac:
+      - name: Test
+        run: cargo test -p stac-extensions
+  test-pgstac:
+    name: Test pgstac
     runs-on: ubuntu-latest
     services:
       pgstac:
@@ -121,11 +98,99 @@ jobs:
       - uses: astral-sh/setup-uv@v3
         with:
           enable-cache: true
+      - name: Test
+        run: cargo test -p pgstac
       - name: Install Python dependencies
         run: uv sync --extra stac-api-validator
       - name: Validate
         run: uv run scripts/validate-stac-server --pgstac
+  test-python:
+    name: Test stacrs (python)
+    runs-on: ubuntu-latest
+    defaults:
+      run:
+        working-directory: python
+    steps:
+      - uses: actions/checkout@v4
+      - uses: Swatinem/rust-cache@v2
+      - uses: actions/setup-python@v5
+        with:
+          python-version: 3.x
+      - uses: astral-sh/setup-uv@v3
+        with:
+          enable-cache: true
+      - name: Install
+        run: uv sync
+      - name: Check
+        run: uv run ruff check && uv run ruff format --check && uv run mypy .
+      - name: Test
+        run: uv run pytest
+  test-server:
+    name: Test stac-server
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: Swatinem/rust-cache@v2
+      - uses: actions/setup-python@v5
+        with:
+          python-version: "3.x"
+      - uses: astral-sh/setup-uv@v3
+        with:
+          enable-cache: true
+      - name: Test
+        run: cargo test -p stac-server --all-features
+      - name: Install Python dependencies
+        run: uv sync --extra stac-api-validator
+      - name: Validate
+        run: uv run scripts/validate-stac-server
+  lint:
+    name: Lint
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: Swatinem/rust-cache@v2
+      - name: Fmt
+        run: cargo fmt
+      - name: Clippy
+        run: cargo clippy --all --all-features
+  check-feature-combinations:
+    name: Check feature combinations
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        chunk:
+          - 1
+          - 2
+          - 3
+          - 4
+    steps:
+      - uses: actions/checkout@v4
+      - uses: Swatinem/rust-cache@v2
+      - name: Install check-all-features
+        run: cargo install cargo-all-features
+      - name: Check
+        run: cargo check-all-features --n-chunks 4 --chunk ${{ matrix.chunk }}
+  check-nightly:
+    name: Check (nightly)
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@nightly
+      - uses: Swatinem/rust-cache@v2
+      - name: Check
+        run: cargo check --workspace --all-features
+  doc:
+    name: Docs
+    runs-on: ubuntu-latest
+    env:
+      RUSTDOCFLAGS: -Dwarnings
+    steps:
+      - uses: actions/checkout@v4
+      - uses: Swatinem/rust-cache@v2
+      - name: Doc
+        run: cargo doc --workspace --all-features
   validate-stac-geoparquet:
+    name: Validate stac-geoparquet
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v4
diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml
deleted file mode 100644
index 2aaa9566..00000000
--- a/.github/workflows/cli.yml
+++ /dev/null
@@ -1,57 +0,0 @@
-name: Cli
-
-on:
-  pull_request:
-    paths:
-      - cli/**
-  push:
-    branches:
-      - main
-    paths:
-      - cli/**
-  workflow_dispatch:
-
-defaults:
-  run:
-    working-directory: cli
-
-concurrency:
-  group: ${{ github.workflow }}-${{ github.ref }}
-  cancel-in-progress: true
-
-env:
-  CARGO_TERM_COLOR: always
-  CARGO_TERM_VERBOSE: true
-
-jobs:
-  test-cli:
-    strategy:
-      matrix: 
-        os:
-          - macos-latest
-          - windows-latest
-    runs-on: ${{ matrix.os }}
-    steps:
-      - uses: actions/checkout@v4
-      - name: Set up Rust cache
-        uses: Swatinem/rust-cache@v2
-      - name: Test
-        run: cargo test --no-default-features -F pgstac -F python
-  test-cli-with-gdal:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v4
-      - uses: Swatinem/rust-cache@v2
-      - name: Install GDAL
-        run: |
-          sudo apt-get update
-          sudo apt-get install libgdal-dev
-      - name: Test
-        run: cargo test --no-default-features -F gdal
-  test-cli-with-duckdb:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v4
-      - uses: Swatinem/rust-cache@v2
-      - name: Test
-        run: cargo test --no-default-features -F duckdb -F duckdb/bundled
diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml
deleted file mode 100644
index c1c35983..00000000
--- a/.github/workflows/core.yml
+++ /dev/null
@@ -1,49 +0,0 @@
-name: Core
-
-on:
-  pull_request:
-    paths:
-      - core/**
-  push:
-    branches:
-      - main
-    paths:
-      - core/**
-  workflow_dispatch:
-defaults:
-  run:
-    working-directory: core
-
-concurrency:
-  group: ${{ github.workflow }}-${{ github.ref }}
-  cancel-in-progress: true
-
-env:
-  CARGO_TERM_COLOR: always
-  CARGO_TERM_VERBOSE: true
-
-jobs:
-  test-core:
-    strategy:
-      matrix: 
-        os:
-          - macos-latest
-          - windows-latest
-    runs-on: ${{ matrix.os }}
-    steps:
-      - uses: actions/checkout@v4
-      - name: Set up Rust cache
-        uses: Swatinem/rust-cache@v2
-      - name: Test
-        run: cargo test -F geo -F geoparquet-compression -F reqwest -F object-store-all -F validate-blocking
-  test-core-with-gdal:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v4
-      - uses: Swatinem/rust-cache@v2
-      - name: Install GDAL
-        run: |
-          sudo apt-get update
-          sudo apt-get install libgdal-dev
-      - name: Test
-        run: cargo test --all-features
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 97e6b269..e4f8b783 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -30,5 +30,5 @@ jobs:
         run: uv sync --extra docs && uv sync --no-dev --inexact --project stacrs
       - name: Deploy
         run: |
-          VERSION=$(git describe --tags --match="v*" --abbrev=0)
+          VERSION=$(git describe --tags --match="python-v*" --abbrev=0)
           uv run mike deploy $VERSION latest --update-aliases --push
diff --git a/.github/workflows/duckdb.yml b/.github/workflows/duckdb.yml
deleted file mode 100644
index ec8ac8ab..00000000
--- a/.github/workflows/duckdb.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-name: Duckdb
-
-on:
-  pull_request:
-    paths:
-      - duckdb/**
-  push:
-    branches:
-      - main
-    paths:
-      - duckdb/**
-  workflow_dispatch:
-
-defaults:
-  run:
-    working-directory: duckdb
-
-concurrency:
-  group: ${{ github.workflow }}-${{ github.ref }}
-  cancel-in-progress: true
-
-env:
-  CARGO_TERM_COLOR: always
-  CARGO_TERM_VERBOSE: true
-
-jobs:
-  test-duckdb:
-    strategy:
-      matrix: 
-        os:
-          - ubuntu-latest
-          - macos-latest
-          - windows-latest
-    runs-on: ${{ matrix.os }}
-    steps:
-      - uses: actions/checkout@v4
-      - name: Set up Rust cache
-        uses: Swatinem/rust-cache@v2
-      - name: Test
-        run: cargo test -F duckdb/bundled
diff --git a/.github/workflows/pgstac.yml b/.github/workflows/pgstac.yml
deleted file mode 100644
index c4455106..00000000
--- a/.github/workflows/pgstac.yml
+++ /dev/null
@@ -1,46 +0,0 @@
-name: Pgstac
-
-on:
-  pull_request:
-    paths:
-      - pgstac/**
-  push:
-    branches:
-      - main
-    paths:
-      - pgstac/**
-  workflow_dispatch:
-
-defaults:
-  run:
-    working-directory: pgstac
-
-concurrency:
-  group: ${{ github.workflow }}-${{ github.ref }}
-  cancel-in-progress: true
-
-env:
-  CARGO_TERM_COLOR: always
-  CARGO_TERM_VERBOSE: true
-
-jobs:
-  test-pgstac:
-    runs-on: ubuntu-latest
-    services:
-      pgstac:
-        image: ghcr.io/stac-utils/pgstac:v0.8.6
-        env:
-          POSTGRES_USER: username
-          POSTGRES_PASSWORD: password
-          POSTGRES_DB: postgis
-          PGUSER: username
-          PGPASSWORD: password
-          PGDATABASE: postgis
-        ports:
-          - 5432:5432
-    steps:
-      - uses: actions/checkout@v4
-      - name: Set up Rust cache
-        uses: Swatinem/rust-cache@v2
-      - name: Test
-        run: cargo test
diff --git a/.github/workflows/python-cli.yml b/.github/workflows/python-cli.yml
index 8a6678b1..d65f945e 100644
--- a/.github/workflows/python-cli.yml
+++ b/.github/workflows/python-cli.yml
@@ -3,10 +3,7 @@ name: Python CLI
 on:
   push:
     tags:
-      - 'stac-cli-*'
-  pull_request:
-    paths:
-      - cli/**
+      - "stac-cli-*"
   workflow_dispatch:
 
 permissions:
@@ -17,22 +14,6 @@ concurrency:
   cancel-in-progress: true
 
 jobs:
-  test-stacrs-cli:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v4
-      - uses: Swatinem/rust-cache@v2
-      - uses: actions/setup-python@v5
-        with:
-          python-version: 3.x
-      - name: Install dev requirements
-        run: pip install maturin
-      - name: Build
-        run: maturin build --manifest-path cli/Cargo.toml --out dist
-      - name: Install stacrs-cli
-        run: pip install stacrs-cli --find-links dist --no-index
-      - name: Smoke test
-        run: stacrs --version
   linux:
     runs-on: ${{ matrix.platform.runner }}
     strategy:
@@ -62,7 +43,7 @@ jobs:
         with:
           target: ${{ matrix.platform.target }}
           args: --release --out dist --find-interpreter --manifest-path cli/Cargo.toml
-          sccache: 'true'
+          sccache: "true"
       - name: Upload wheels
         uses: actions/upload-artifact@v4
         with:
@@ -122,7 +103,7 @@ jobs:
         with:
           target: ${{ matrix.platform.target }}
           args: --release --out dist --find-interpreter --manifest-path cli/Cargo.toml
-          sccache: 'true'
+          sccache: "true"
       - name: Upload wheels
         uses: actions/upload-artifact@v4
         with:
@@ -150,7 +131,7 @@ jobs:
         with:
           target: ${{ matrix.platform.target }}
           args: --release --out dist --find-interpreter --manifest-path cli/Cargo.toml
-          sccache: 'true'
+          sccache: "true"
       - name: Upload wheels
         uses: actions/upload-artifact@v4
         with:
@@ -178,13 +159,11 @@ jobs:
     name: Release
     runs-on: ubuntu-latest
     needs:
-      - test-stacrs-cli
       - linux
       # - musllinux
       - windows
       - macos
       - sdist
-    if: startsWith(github.ref, 'refs/tags/stac-cli-')
     environment:
       name: pypi
       url: https://pypi.org/p/stacrs-cli
diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml
index a91258a7..0d7f51c9 100644
--- a/.github/workflows/python.yml
+++ b/.github/workflows/python.yml
@@ -4,9 +4,6 @@ on:
   push:
     tags:
       - "python-*"
-  pull_request:
-    paths:
-      - python/**
   workflow_dispatch:
 
 permissions:
@@ -17,29 +14,8 @@ concurrency:
   cancel-in-progress: true
 
 jobs:
-  test-stacrs:
-    runs-on: ubuntu-latest
-    defaults:
-      run:
-        working-directory: python
-    steps:
-      - uses: actions/checkout@v4
-      - uses: Swatinem/rust-cache@v2
-      - uses: actions/setup-python@v5
-        with:
-          python-version: 3.x
-      - uses: astral-sh/setup-uv@v3
-        with:
-          enable-cache: true
-      - name: Install
-        run: uv sync
-      - name: Check
-        run: uv run ruff check && uv run ruff format --check && uv run mypy .
-      - name: Test
-        run: uv run pytest
   linux:
     runs-on: ${{ matrix.platform.runner }}
-    if: startsWith(github.ref, 'refs/tags/python-')
     strategy:
       matrix:
         platform:
@@ -107,7 +83,6 @@ jobs:
 
   windows:
     runs-on: ${{ matrix.platform.runner }}
-    if: startsWith(github.ref, 'refs/tags/python-')
     strategy:
       matrix:
         platform:
@@ -137,7 +112,6 @@ jobs:
 
   macos:
     runs-on: ${{ matrix.platform.runner }}
-    if: startsWith(github.ref, 'refs/tags/python-')
     strategy:
       matrix:
         platform:
@@ -185,13 +159,11 @@ jobs:
     name: Release
     runs-on: ubuntu-latest
     needs:
-      - test-stacrs
       - linux
       # - musllinux
       - windows
       - macos
       - sdist
-    if: startsWith(github.ref, 'refs/tags/python-')
     environment:
       name: pypi
       url: https://pypi.org/p/stacrs
diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml
deleted file mode 100644
index c19feca6..00000000
--- a/.github/workflows/server.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-name: Server
-
-on:
-  pull_request:
-    paths:
-      - server/**
-  push:
-    branches:
-      - main
-    paths:
-      - server/**
-  workflow_dispatch:
-
-defaults:
-  run:
-    working-directory: server
-
-concurrency:
-  group: ${{ github.workflow }}-${{ github.ref }}
-  cancel-in-progress: true
-
-env:
-  CARGO_TERM_COLOR: always
-  CARGO_TERM_VERBOSE: true
-
-jobs:
-  test-server:
-    strategy:
-      matrix: 
-        os:
-          - ubuntu-latest
-          - macos-latest
-          - windows-latest
-    runs-on: ${{ matrix.os }}
-    steps:
-      - uses: actions/checkout@v4
-      - name: Set up Rust cache
-        uses: Swatinem/rust-cache@v2
-      - name: Test
-        run: cargo test -F axum -F pgstac
diff --git a/Cargo.toml b/Cargo.toml
index 98dda548..9a78ba39 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,7 +1,25 @@
 [workspace]
 resolver = "2"
-members = ["api", "cli", "core", "duckdb", "pgstac", "python", "server"]
-default-members = ["api", "cli", "core", "server"]
+members = [
+    "api",
+    "cli",
+    "core",
+    "duckdb",
+    "extensions",
+    "pgstac",
+    "python",
+    "server",
+]
+default-members = ["api", "cli", "core", "extensions"]
+
+[workspace.package]
+authors = ["Pete Gadomski <pete.gadomski@gmail.com>"]
+edition = "2021"
+homepage = "https://stac-utils.github.io/stac-rs"
+repository = "https://github.com/stac-utils/stac-rs"
+license = "MIT OR Apache-2.0"
+categories = ["science", "data-structures"]
+rust-version = "1.75"
 
 [workspace.dependencies]
 arrow = "52.2"
@@ -21,8 +39,6 @@ clap = "4.5"
 duckdb = "=1.0.0"
 fluent-uri = "0.3.1"
 futures = "0.3.31"
-gdal = "0.17.1"
-gdal-sys = "0.10.0"
 geo = "0.28.0"
 geo-types = "0.7.13"
 geoarrow = "0.3.0"
diff --git a/api/Cargo.toml b/api/Cargo.toml
index 35102194..3db5ee2d 100644
--- a/api/Cargo.toml
+++ b/api/Cargo.toml
@@ -1,14 +1,15 @@
 [package]
 name = "stac-api"
-version = "0.6.0"
-authors = ["Pete Gadomski <pete.gadomski@gmail.com>"]
-edition = "2021"
 description = "Rust library for the SpatioTemporal Asset Catalog (STAC) API specification"
-homepage = "https://github.com/stac-utils/stac-rs"
-repository = "https://github.com/stac-utils/stac-rs"
-license = "MIT OR Apache-2.0"
-keywords = ["geospatial", "stac", "metadata", "geo", "raster"]
+version = "0.6.0"
+keywords = ["geospatial", "stac", "metadata", "geo", "api"]
 categories = ["science", "data-structures", "web-programming"]
+authors.workspace = true
+edition.workspace = true
+homepage.workspace = true
+repository.workspace = true
+license.workspace = true
+rust-version.workspace = true
 
 [features]
 client = [
@@ -22,25 +23,25 @@ geo = ["dep:geo", "stac/geo"]
 
 [dependencies]
 async-stream = { workspace = true, optional = true }
-chrono = { workspace = true }
+chrono.workspace = true
 futures = { workspace = true, optional = true }
 http = { workspace = true, optional = true }
 reqwest = { workspace = true, features = ["json"], optional = true }
 geo = { workspace = true, optional = true }
-geojson = { workspace = true }
-serde = { workspace = true }
-serde_json = { workspace = true }
-serde_urlencoded = { workspace = true }
-stac = { workspace = true }
-thiserror = { workspace = true }
+geojson.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+serde_urlencoded.workspace = true
+stac.workspace = true
+thiserror.workspace = true
 tokio = { workspace = true, optional = true }
-url = { workspace = true }
+url.workspace = true
 
 [dev-dependencies]
-geojson = { workspace = true }
-mockito = { workspace = true }
+geojson.workspace = true
+mockito.workspace = true
 tokio = { workspace = true, features = ["rt", "macros"] }
-tokio-test = { workspace = true }
+tokio-test.workspace = true
 
 [package.metadata.docs.rs]
 all-features = true
diff --git a/cli/Cargo.toml b/cli/Cargo.toml
index 06c191e5..18ead7d8 100644
--- a/cli/Cargo.toml
+++ b/cli/Cargo.toml
@@ -1,33 +1,32 @@
 [package]
 name = "stac-cli"
-version = "0.4.0"
-edition = "2021"
 description = "Command line interface for stac-rs"
-documentation = "https://docs.rs/stac-cli"
-readme = "README.md"
-repository = "https://github.com/stac-utils/stac-rs"
-license = "MIT OR Apache-2.0"
+version = "0.4.0"
 keywords = ["geospatial", "stac", "metadata", "geo", "raster"]
-categories = ["science", "data-structures"]
-rust-version = "1.75"
+authors.workspace = true
+edition.workspace = true
+homepage.workspace = true
+repository.workspace = true
+license.workspace = true
+categories.workspace = true
+rust-version.workspace = true
 
 [features]
-default = ["gdal", "pgstac"]
+default = ["pgstac"]
 duckdb = ["dep:stac-duckdb", "dep:duckdb"]
-gdal = ["stac/gdal"]
 pgstac = ["stac-server/pgstac", "dep:pgstac"]
 python = ["dep:pyo3", "pgstac"]
 
 [dependencies]
-axum = { workspace = true }
+axum.workspace = true
 clap = { workspace = true, features = ["derive"] }
 duckdb = { workspace = true, optional = true } # We have this dependency only to allow us to bundle it
-object_store = { workspace = true }
+object_store.workspace = true
 pgstac = { workspace = true, optional = true }
 pyo3 = { workspace = true, optional = true }
-reqwest = { workspace = true }
-serde = { workspace = true }
-serde_json = { workspace = true }
+reqwest.workspace = true
+serde.workspace = true
+serde_json.workspace = true
 stac = { workspace = true, features = [
     "geoparquet-compression",
     "object-store-all",
@@ -37,21 +36,21 @@ stac = { workspace = true, features = [
 stac-api = { workspace = true, features = ["client"] }
 stac-duckdb = { workspace = true, optional = true }
 stac-server = { workspace = true, features = ["axum"] }
-thiserror = { workspace = true }
+thiserror.workspace = true
 tokio = { workspace = true, features = [
     "macros",
     "io-std",
     "rt-multi-thread",
     "fs",
 ] }
-tokio-stream = { workspace = true }
-tracing = { workspace = true }
-tracing-subscriber = { workspace = true }
-url = { workspace = true }
+tokio-stream.workspace = true
+tracing.workspace = true
+tracing-subscriber.workspace = true
+url.workspace = true
 
 [dev-dependencies]
-assert_cmd = { workspace = true }
-tokio-test = { workspace = true }
+assert_cmd.workspace = true
+tokio-test.workspace = true
 
 [lib]
 crate-type = ["lib", "cdylib"]
diff --git a/cli/README.md b/cli/README.md
index 111348e0..dc01063d 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -10,17 +10,14 @@ Command Line Interface (CLI) for [STAC](https://stacspec.org/), named `stacrs`.
 
 ## Installation
 
-```sh
-cargo install stac-cli
+```shell
+python -m pip install stacrs-cli
 ```
 
 Or:
 
-```shell
-# NOTE: The version from PyPI does not include GDAL or DuckDB support. If you
-# need to use these features, install via `cargo install` (GDAL is enabled by
-# default) or `cargo install -F duckdb` (DuckDB is not).
-python -m pip install stacrs-cli
+```sh
+cargo install stac-cli
 ```
 
 Then:
@@ -44,15 +41,14 @@ Use the `--help` flag to see all available options for the CLI and the subcomman
 
 ## Features
 
-This crate has five features, three of them on by default:
+This crate has features:
 
 - `duckdb`: experimental support for querying [stac-geoparquet](https://github.com/stac-utils/stac-geoparquet) files using [DuckDB](https://duckdb.org/)
-- `gdal`: read geospatial data from rasters (enabled by default)
 - `geoparquet`: read and write [stac-geoparquet](https://github.com/stac-utils/stac-geoparquet) (enabled by default)
 - `pgstac`: enable a [pgstac](https://github.com/stac-utils/pgstac) backend for `stacrs serve` (enabled by default)
 - `python`: create an entrypoint that can be called from Python (used to enable `python -m pip install stacrs-cli`)
 
-If you don't want to use GDAL or any of the other default features:
+If you don't want to use any of the default features:
 
 ```shell
 cargo install stac-cli --no-default-features
diff --git a/cli/src/args/item.rs b/cli/src/args/item.rs
index 510d3a4b..e344dd2e 100644
--- a/cli/src/args/item.rs
+++ b/cli/src/args/item.rs
@@ -22,11 +22,6 @@ pub(crate) struct Args {
     #[arg(short, long = "role", default_values_t = ["data".to_string()])]
     pub(crate) roles: Vec<String>,
 
-    /// Don't use GDAL to add geospatial metadata to the item
-    #[cfg(feature = "gdal")]
-    #[arg(long)]
-    pub(crate) disable_gdal: bool,
-
     /// Allow assets to have relative hrefs
     #[arg(long)]
     pub(crate) allow_relative_hrefs: bool,
@@ -48,10 +43,6 @@ impl Run for Args {
             })
             .unwrap_or_else(|| "default".to_string());
         let mut builder = Builder::new(id).canonicalize_paths(!self.allow_relative_hrefs);
-        #[cfg(feature = "gdal")]
-        {
-            builder = builder.enable_gdal(!self.disable_gdal);
-        }
         if let Some(href) = href {
             let mut asset = Asset::new(href);
             asset.roles = self.roles;
diff --git a/cli/src/args/items.rs b/cli/src/args/items.rs
index 1e12661d..460adc92 100644
--- a/cli/src/args/items.rs
+++ b/cli/src/args/items.rs
@@ -20,11 +20,6 @@ pub(crate) struct Args {
     #[arg(short, long = "role", default_values_t = ["data".to_string()])]
     roles: Vec<String>,
 
-    /// Don't use GDAL to add geospatial metadata to the item
-    #[cfg(feature = "gdal")]
-    #[arg(long)]
-    disable_gdal: bool,
-
     /// Allow assets to have relative hrefs
     #[arg(long)]
     allow_relative_hrefs: bool,
@@ -42,8 +37,6 @@ impl Run for Args {
                 outfile: None,
                 key: self.key.clone(),
                 roles: self.roles.clone(),
-                #[cfg(feature = "gdal")]
-                disable_gdal: self.disable_gdal,
                 allow_relative_hrefs: self.allow_relative_hrefs,
             };
             let _ = join_set.spawn(async move { args.run(input, sender).await });
diff --git a/core/Cargo.toml b/core/Cargo.toml
index 536f299b..437218d6 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -1,17 +1,17 @@
 [package]
 name = "stac"
-version = "0.10.2"
-authors = ["Pete Gadomski <pete.gadomski@gmail.com>"]
-edition = "2021"
 description = "Rust library for the SpatioTemporal Asset Catalog (STAC) specification"
-homepage = "https://github.com/stac-utils/stac-rs"
-repository = "https://github.com/stac-utils/stac-rs"
-license = "MIT OR Apache-2.0"
-keywords = ["geospatial", "stac", "metadata", "geo", "raster"]
-categories = ["science", "data-structures"]
+version = "0.10.2"
+keywords = ["geospatial", "stac", "metadata", "geo"]
+authors.workspace = true
+categories.workspace = true
+edition.workspace = true
+homepage.workspace = true
+license.workspace = true
+repository.workspace = true
+rust-version.workspace = true
 
 [features]
-gdal = ["dep:gdal", "dep:gdal-sys"]
 geo = ["dep:geo"]
 geoarrow = [
     "dep:geoarrow",
@@ -52,35 +52,33 @@ arrow-array = { workspace = true, optional = true }
 arrow-cast = { workspace = true, optional = true }
 arrow-json = { workspace = true, optional = true }
 arrow-schema = { workspace = true, optional = true }
-bytes = { workspace = true }
+bytes.workspace = true
 chrono = { workspace = true, features = ["serde"] }
 fluent-uri = { workspace = true, optional = true }
-gdal = { workspace = true, optional = true }
-gdal-sys = { workspace = true, optional = true }
 geo = { workspace = true, optional = true }
 geo-types = { workspace = true, optional = true }
 geoarrow = { workspace = true, optional = true }
-geojson = { workspace = true }
+geojson.workspace = true
 jsonschema = { workspace = true, optional = true }
-log = { workspace = true }
-mime = { workspace = true }
+log.workspace = true
+mime.workspace = true
 object_store = { workspace = true, optional = true }
 parquet = { workspace = true, optional = true }
 reqwest = { workspace = true, features = ["json", "blocking"], optional = true }
 serde = { workspace = true, features = ["derive"] }
 serde_json = { workspace = true, features = ["preserve_order"] }
-thiserror = { workspace = true }
+thiserror.workspace = true
 tokio = { workspace = true, optional = true }
-tracing = { workspace = true }
-url = { workspace = true }
+tracing.workspace = true
+url.workspace = true
 
 [dev-dependencies]
-assert-json-diff = { workspace = true }
-bytes = { workspace = true }
-rstest = { workspace = true }
-tempfile = { workspace = true }
+assert-json-diff.workspace = true
+bytes.workspace = true
+rstest.workspace = true
+tempfile.workspace = true
 tokio = { workspace = true, features = ["macros"] }
-tokio-test = { workspace = true }
+tokio-test.workspace = true
 
 [[test]]
 name = "examples"
@@ -103,4 +101,6 @@ denylist = [
     "object-store-azure",
     "object-store-gcp",
     "object-store-http",
+    "reqwest-rustls",
+    "validate-blocking",
 ]
diff --git a/core/build.rs b/core/build.rs
deleted file mode 100644
index 8f97a89b..00000000
--- a/core/build.rs
+++ /dev/null
@@ -1,29 +0,0 @@
-fn main() {
-    #[cfg(feature = "gdal")]
-    {
-        use std::str::FromStr;
-
-        // https://blog.rust-lang.org/2024/05/06/check-cfg.html
-        println!("cargo::rustc-check-cfg=cfg(gdal_has_int8)");
-        println!("cargo::rustc-check-cfg=cfg(gdal_has_int64)");
-        println!("cargo::rustc-check-cfg=cfg(gdal_has_uint64)");
-
-        let gdal_version_string = std::env::var("DEP_GDAL_VERSION_NUMBER").unwrap();
-        let gdal_version = i64::from_str(&gdal_version_string)
-            .expect("Could not convert gdal version string into number.");
-        let major = gdal_version / 1000000;
-        let minor = (gdal_version - major * 1000000) / 10000;
-        let patch = (gdal_version - major * 1000000 - minor * 10000) / 100;
-
-        if major != 3 {
-            panic!("This crate requires a GDAL version = 3. Found {major}.{minor}.{patch}");
-        }
-        if minor >= 5 {
-            println!("cargo:rustc-cfg=gdal_has_int64");
-            println!("cargo:rustc-cfg=gdal_has_uint64");
-        }
-        if minor >= 7 {
-            println!("cargo:rustc-cfg=gdal_has_int8");
-        }
-    }
-}
diff --git a/core/src/catalog.rs b/core/src/catalog.rs
index c7966586..01e50e44 100644
--- a/core/src/catalog.rs
+++ b/core/src/catalog.rs
@@ -1,4 +1,4 @@
-use crate::{Error, Extensions, Fields, Href, Link, Links, Migrate, Result, Version, STAC_VERSION};
+use crate::{Error, Fields, Href, Link, Links, Migrate, Result, Version, STAC_VERSION};
 use serde::{Deserialize, Serialize};
 use serde_json::{Map, Value};
 
@@ -135,15 +135,6 @@ impl Fields for Catalog {
     }
 }
 
-impl Extensions for Catalog {
-    fn extensions(&self) -> &Vec<String> {
-        &self.extensions
-    }
-    fn extensions_mut(&mut self) -> &mut Vec<String> {
-        &mut self.extensions
-    }
-}
-
 fn deserialize_type<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
 where
     D: serde::de::Deserializer<'de>,
diff --git a/core/src/collection.rs b/core/src/collection.rs
index 60ced25a..6b5d78e1 100644
--- a/core/src/collection.rs
+++ b/core/src/collection.rs
@@ -1,6 +1,6 @@
 use crate::{
-    Asset, Assets, Bbox, Error, Extensions, Fields, Href, Item, ItemAsset, Link, Links, Migrate,
-    Result, Version, STAC_VERSION,
+    Asset, Assets, Bbox, Error, Fields, Href, Item, ItemAsset, Link, Links, Migrate, Result,
+    Version, STAC_VERSION,
 };
 use chrono::{DateTime, Utc};
 use serde::{Deserialize, Serialize};
@@ -400,15 +400,6 @@ impl Fields for Collection {
     }
 }
 
-impl Extensions for Collection {
-    fn extensions(&self) -> &Vec<String> {
-        &self.extensions
-    }
-    fn extensions_mut(&mut self) -> &mut Vec<String> {
-        &mut self.extensions
-    }
-}
-
 impl TryFrom<Collection> for Map<String, Value> {
     type Error = Error;
     fn try_from(collection: Collection) -> Result<Self> {
diff --git a/core/src/error.rs b/core/src/error.rs
index ac7dbcc5..f4e72c72 100644
--- a/core/src/error.rs
+++ b/core/src/error.rs
@@ -22,11 +22,6 @@ pub enum Error {
     #[error(transparent)]
     ChronoParse(#[from] chrono::ParseError),
 
-    /// [gdal::errors::GdalError]
-    #[cfg(feature = "gdal")]
-    #[error(transparent)]
-    GdalError(#[from] gdal::errors::GdalError),
-
     /// A required feature is not enabled.
     #[error("{0} is not enabled")]
     FeatureNotEnabled(&'static str),
diff --git a/core/src/extensions/projection.rs b/core/src/extensions/projection.rs
deleted file mode 100644
index 23026bfb..00000000
--- a/core/src/extensions/projection.rs
+++ /dev/null
@@ -1,184 +0,0 @@
-//! The Projection extension.
-
-use super::Extension;
-use geojson::Geometry;
-use serde::{Deserialize, Serialize};
-use serde_json::{Map, Value};
-
-/// The projection extension fields.
-#[derive(Debug, Serialize, Deserialize, Default, PartialEq, Clone)]
-pub struct Projection {
-    /// EPSG code of the datasource
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub code: Option<String>,
-
-    /// WKT2 string representing the Coordinate Reference System (CRS) that the
-    /// proj:geometry and proj:bbox fields represent
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub wkt2: Option<String>,
-
-    /// PROJJSON object representing the Coordinate Reference System (CRS) that
-    /// the proj:geometry and proj:bbox fields represent
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub projjson: Option<Map<String, Value>>,
-
-    /// Defines the footprint of this Item.
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub geometry: Option<Geometry>,
-
-    /// Bounding box of the Item in the asset CRS in 2 or 3 dimensions.
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub bbox: Option<Vec<f64>>,
-
-    /// Coordinates representing the centroid of the Item (in lat/long)
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub centroid: Option<Centroid>,
-
-    /// Number of pixels in Y and X directions for the default grid
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub shape: Option<Vec<usize>>,
-
-    /// The affine transformation coefficients for the default grid
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub transform: Option<Vec<f64>>,
-}
-
-/// This object represents the centroid of the Item Geometry.
-#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
-pub struct Centroid {
-    /// The latitude of the centroid.
-    pub lat: f64,
-
-    /// The longitude of the centroid.
-    pub lon: f64,
-}
-
-impl Projection {
-    /// Returns this projection's bounds in WGS84.
-    ///
-    /// Requires one of the crs fields to be set (code, wkt2, or projjson) as well as a bbox.
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// use stac::extensions::Projection;
-    /// let projection = Projection {
-    ///     code: Some("EPSG:32621".to_string()),
-    ///     bbox: Some(vec![
-    ///         373185.0,
-    ///         8019284.949381611,
-    ///         639014.9492102272,
-    ///         8286015.0
-    ///     ]),
-    ///     ..Default::default()
-    /// };
-    /// let bounds = projection.wgs84_bounds().unwrap().unwrap();
-    /// ```
-    #[cfg(feature = "gdal")]
-    pub fn wgs84_bounds(&self) -> crate::Result<Option<crate::Bbox>> {
-        use gdal::spatial_ref::{AxisMappingStrategy, CoordTransform, SpatialRef};
-
-        if let Some(bbox) = self.bbox.as_ref() {
-            if bbox.len() != 4 {
-                return Ok(None);
-            }
-            if let Some(spatial_ref) = self.spatial_ref()? {
-                let mut wgs84 = SpatialRef::from_epsg(4326)?;
-                // Ensure we're lon then lat
-                wgs84.set_axis_mapping_strategy(AxisMappingStrategy::TraditionalGisOrder);
-                let coord_transform = CoordTransform::new(&spatial_ref, &wgs84)?;
-                let bounds =
-                    coord_transform.transform_bounds(&[bbox[0], bbox[1], bbox[2], bbox[3]], 21)?;
-                let round = |n: f64| (n * 10_000_000.).round() / 10_000_000.;
-                Ok(Some(crate::Bbox::new(
-                    round(bounds[0]),
-                    round(bounds[1]),
-                    round(bounds[2]),
-                    round(bounds[3]),
-                )))
-            } else {
-                Ok(None)
-            }
-        } else {
-            Ok(None)
-        }
-    }
-
-    #[cfg(feature = "gdal")]
-    fn spatial_ref(&self) -> crate::Result<Option<gdal::spatial_ref::SpatialRef>> {
-        use crate::Error;
-        use gdal::spatial_ref::SpatialRef;
-
-        if let Some(code) = self.code.as_deref() {
-            SpatialRef::from_definition(code)
-                .map(Some)
-                .map_err(Error::from)
-        } else if let Some(wkt) = self.wkt2.as_ref() {
-            SpatialRef::from_wkt(wkt).map(Some).map_err(Error::from)
-        } else if let Some(projjson) = self.projjson.clone() {
-            SpatialRef::from_definition(&serde_json::to_string(&Value::Object(projjson))?)
-                .map(Some)
-                .map_err(Error::from)
-        } else {
-            Ok(None)
-        }
-    }
-
-    /// Returns true if this projection structure is empty.
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// use stac::extensions::Projection;
-    ///
-    /// let projection = Projection::default();
-    /// assert!(projection.is_empty());
-    /// ```
-    pub fn is_empty(&self) -> bool {
-        serde_json::to_value(self)
-            .map(|v| v == Value::Object(Default::default()))
-            .unwrap_or(true)
-    }
-}
-
-impl Extension for Projection {
-    const IDENTIFIER: &'static str =
-        "https://stac-extensions.github.io/projection/v2.0.0/schema.json";
-    const PREFIX: &'static str = "proj";
-}
-
-#[cfg(test)]
-mod tests {
-    use super::Projection;
-    use crate::{Fields, Item};
-
-    #[cfg(feature = "gdal")]
-    #[test]
-    fn axis_order() {
-        let projection = Projection {
-            code: Some("EPSG:32621".to_string()),
-            bbox: Some(vec![
-                373185.0,
-                8019284.949381611,
-                639014.9492102272,
-                8286015.0,
-            ]),
-            ..Default::default()
-        };
-        let bounds = projection.wgs84_bounds().unwrap().unwrap();
-        assert!(
-            (bounds.xmin() - -61.2876244).abs() < 0.1,
-            "{}",
-            bounds.xmin()
-        );
-        assert!((bounds.ymin() - 72.229798).abs() < 0.1);
-    }
-
-    #[test]
-    fn example() {
-        let item: Item =
-            crate::read("examples/extensions-collection/proj-example/proj-example.json").unwrap();
-        let projection = item.extension::<Projection>().unwrap();
-        assert_eq!(projection.code.unwrap(), "EPSG:32614");
-    }
-}
diff --git a/core/src/fields.rs b/core/src/fields.rs
index 1b521ab8..aa8f82c9 100644
--- a/core/src/fields.rs
+++ b/core/src/fields.rs
@@ -1,4 +1,4 @@
-use crate::{Error, Extension, Result};
+use crate::{Error, Result};
 use serde::{de::DeserializeOwned, Serialize};
 use serde_json::{json, Map, Value};
 
@@ -79,9 +79,11 @@ pub trait Fields {
     /// # Examples
     ///
     /// ```
-    /// use stac::{Fields, Item, extensions::Projection};
+    /// use stac::{Fields, Item};
+    /// use serde_json::Value;
+    ///
     /// let item: Item = stac::read("examples/extensions-collection/proj-example/proj-example.json").unwrap();
-    /// let projection: Projection = item.fields_with_prefix("proj").unwrap();  // Prefer `Extensions::extension`
+    /// let projection: Value = item.fields_with_prefix("proj").unwrap();
     /// ```
     fn fields_with_prefix<D: DeserializeOwned>(&self, prefix: &str) -> Result<D> {
         let mut map = Map::new();
@@ -99,10 +101,12 @@ pub trait Fields {
     /// # Examples
     ///
     /// ```
-    /// use stac::{Fields, Item, extensions::Projection};
-    /// let projection = Projection { code: Some("EPSG:4326".to_string()), ..Default::default() };
+    /// use stac::{Fields, Item};
+    /// use serde_json::json;
+    ///
+    /// let projection = json!({ "code": "EPSG:4326" });
     /// let mut item = Item::new("an-id");
-    /// item.set_fields_with_prefix("proj", projection);  // Prefer `Extensions::set_extension`
+    /// item.set_fields_with_prefix("proj", projection);
     /// ```
     fn set_fields_with_prefix<S: Serialize>(&mut self, prefix: &str, value: S) -> Result<()> {
         let value = serde_json::to_value(value)?;
@@ -121,62 +125,14 @@ pub trait Fields {
     /// # Examples
     ///
     /// ```
-    /// use stac::{Fields, Item, extensions::Projection};
-    /// let projection = Projection { code: Some("EPSG:4326".to_string()), ..Default::default() };
-    /// let mut item = Item::new("an-id");
-    /// item.remove_fields_with_prefix("proj");  // Prefer `Fields::remove_extension`
+    /// use stac::{Fields, Item};
+    ///
+    /// let mut item: Item = stac::read("examples/extensions-collection/proj-example/proj-example.json").unwrap();
+    /// item.remove_fields_with_prefix("proj");
     /// ```
     fn remove_fields_with_prefix(&mut self, prefix: &str) {
         let prefix = format!("{}:", prefix);
         self.fields_mut()
             .retain(|key, _| !(key.starts_with(&prefix) && key.len() > prefix.len()));
     }
-
-    /// Gets an extension's data.
-    ///
-    /// Returns `Ok(None)` if the object doesn't have the given extension.
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// use stac::{Item, Fields, extensions::Projection};
-    /// let item: Item = stac::read("examples/extensions-collection/proj-example/proj-example.json").unwrap();
-    /// let projection: Projection = item.extension().unwrap();
-    /// assert_eq!(projection.code.unwrap(), "EPSG:32614");
-    /// ```
-    fn extension<E: Extension>(&self) -> Result<E> {
-        self.fields_with_prefix(E::PREFIX)
-    }
-
-    /// Sets an extension's data into this object.
-    ///
-    /// This will remove any previous fields from this extension
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// use stac::{Item, Fields, extensions::Projection};
-    /// let mut item = Item::new("an-id");
-    /// let projection = Projection { code: Some("EPSG:4326".to_string()), ..Default::default() };
-    /// item.set_extension(projection).unwrap();
-    /// ```
-    fn set_extension<E: Extension>(&mut self, extension: E) -> Result<()> {
-        self.remove_extension::<E>();
-        self.set_fields_with_prefix(E::PREFIX, extension)
-    }
-
-    /// Removes all of the extension's fields from this object.
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// use stac::{Item, extensions::{Projection, Extensions}};
-    /// let mut item: Item = stac::read("examples/extensions-collection/proj-example/proj-example.json").unwrap();
-    /// assert!(item.has_extension::<Projection>());
-    /// item.remove_extension::<Projection>();
-    /// assert!(!item.has_extension::<Projection>());
-    /// ```
-    fn remove_extension<E: Extension>(&mut self) {
-        self.remove_fields_with_prefix(E::PREFIX);
-    }
 }
diff --git a/core/src/gdal.rs b/core/src/gdal.rs
deleted file mode 100644
index d116a72c..00000000
--- a/core/src/gdal.rs
+++ /dev/null
@@ -1,243 +0,0 @@
-//! Functions that leverage [GDAL](https://gdal.org/).
-
-use crate::{
-    extensions::{
-        projection::Centroid,
-        raster::{Band, Raster, Statistics},
-        Projection,
-    },
-    Asset, Bbox, Extensions, Fields, Item, Result,
-};
-use gdal::{
-    spatial_ref::{CoordTransform, SpatialRef},
-    Dataset,
-};
-
-/// Update an [Item] using GDAL.
-///
-/// Adds things like the [Raster] and [Projection] extensions.
-///
-/// # Examples
-///
-/// ```
-/// use stac::{Asset, Item, Extensions, extensions::Raster};
-/// let mut item = Item::new("an-id");
-/// item.assets.insert("data".to_string(), Asset::new("assets/dataset.tif"));
-/// stac::gdal::update_item(&mut item, false, true).unwrap();
-/// assert!(item.has_extension::<Raster>());
-/// ```
-pub fn update_item(
-    item: &mut Item,
-    force_statistics: bool,
-    is_approx_statistics_ok: bool,
-) -> Result<()> {
-    log::debug!(
-        "updating item={} with force_statistics={} and is_approx_statistics_ok={}",
-        item.id,
-        force_statistics,
-        is_approx_statistics_ok
-    );
-    gdal::config::set_error_handler(|err, code, msg| log::warn!("{:?} ({}): {}", err, code, msg));
-    let mut has_raster = false;
-    let mut has_projection = false;
-    let mut projections = Vec::new();
-    let mut bbox = Bbox::new(180., 90., -180., 90.); // Intentionally invalid bbox so the first update always takes
-    for asset in item.assets.values_mut() {
-        update_asset(asset, force_statistics, is_approx_statistics_ok)?;
-        let projection = asset.extension::<Projection>()?;
-        if !projection.is_empty() {
-            has_projection = true;
-            if let Some(asset_bounds) = projection.wgs84_bounds()? {
-                bbox.update(asset_bounds);
-            }
-            projections.push(projection);
-        }
-        if !has_raster && !asset.extension::<Raster>()?.is_empty() {
-            has_raster = true;
-        }
-    }
-    if bbox.is_valid() {
-        item.geometry = Some(bbox.to_geometry());
-        item.bbox = Some(bbox);
-    }
-    if has_projection {
-        if !projections.is_empty()
-            && projections
-                .iter()
-                .all(|projection| *projection == projections[0])
-        {
-            Extensions::set_extension(item, projections[0].clone())?;
-            for asset in item.assets.values_mut() {
-                asset.remove_extension::<Projection>();
-            }
-        } else {
-            item.add_extension::<Projection>();
-        }
-    }
-    if has_raster {
-        item.add_extension::<Raster>();
-    }
-    Ok(())
-}
-
-fn update_asset(
-    asset: &mut Asset,
-    force_statistics: bool,
-    is_approx_statistics_ok: bool,
-) -> Result<()> {
-    let dataset = Dataset::open(&asset.href)?;
-    let mut spatial_resolution = None;
-    let mut projection = Projection::default();
-    let (width, height) = dataset.raster_size();
-    projection.shape = Some(vec![height, width]);
-    if let Ok(geo_transform) = dataset.geo_transform() {
-        projection.transform = Some(vec![
-            geo_transform[1],
-            geo_transform[2],
-            geo_transform[0],
-            geo_transform[4],
-            geo_transform[5],
-            geo_transform[3],
-        ]);
-        spatial_resolution = Some((geo_transform[1].abs() + geo_transform[5].abs()) / 2.0);
-        let (width, height) = dataset.raster_size();
-        let width = width as f64;
-        let height = height as f64;
-        let x0 = geo_transform[0];
-        let x1 = geo_transform[0] + width * geo_transform[1];
-        let x2 = geo_transform[0] + width * geo_transform[1] + height * geo_transform[2];
-        let x3 = geo_transform[0] + height * geo_transform[2];
-        let xmin = x0.min(x1).min(x2).min(x3);
-        let xmax = x0.max(x1).max(x2).max(x3);
-        let y0 = geo_transform[3];
-        let y1 = geo_transform[3] + width * geo_transform[4];
-        let y2 = geo_transform[3] + width * geo_transform[4] + height * geo_transform[5];
-        let y3 = geo_transform[3] + height * geo_transform[5];
-        let ymin = y0.min(y1).min(y2).min(y3);
-        let ymax = y0.max(y1).max(y2).max(y3);
-        projection.bbox = Some(vec![xmin, ymin, xmax, ymax]);
-    }
-    if let Ok(spatial_ref) = dataset.spatial_ref() {
-        if spatial_ref
-            .auth_name()
-            .ok()
-            .map(|auth_name| auth_name == "EPSG")
-            .unwrap_or_default()
-        {
-            projection.code = spatial_ref
-                .auth_code()
-                .map(|code| format!("EPSG:{}", code))
-                .ok();
-        }
-        // FIXME Don't know how to get WKT2 out of the gdal crate yet.
-        if projection.code.is_none() {
-            projection.projjson = spatial_ref
-                .to_projjson()
-                .ok()
-                .and_then(|s| serde_json::from_str(&s).ok());
-        }
-        if let Some(bbox) = projection.bbox.as_ref() {
-            let mut x = [(bbox[0] + bbox[2]) / 2.];
-            let mut y = [(bbox[1] + bbox[3]) / 2.];
-            let coord_transform = CoordTransform::new(&spatial_ref, &SpatialRef::from_epsg(4326)?)?;
-            coord_transform.transform_coords(&mut x, &mut y, &mut [])?;
-            let round = |n: f64| (n * 10_000_000.).round() / 10_000_000.;
-            projection.centroid = Some(Centroid {
-                lat: round(x[0]),
-                lon: round(y[0]),
-            });
-        }
-    }
-    let count = dataset.raster_count();
-    let mut bands = Vec::with_capacity(count);
-    for i in 1..=count {
-        let band = dataset.rasterband(i)?;
-        bands.push(Band {
-            nodata: band.no_data_value(),
-            data_type: Some(band.band_type().into()),
-            spatial_resolution,
-            scale: band.scale(),
-            offset: band.offset(),
-            unit: Some(band.unit()).filter(|s| !s.is_empty()),
-            statistics: band
-                .get_statistics(force_statistics, is_approx_statistics_ok)?
-                .map(Statistics::from),
-            ..Default::default()
-        })
-    }
-    asset.set_extension(projection)?;
-    asset.set_extension(Raster { bands })?;
-    Ok(())
-}
-
-#[cfg(test)]
-mod tests {
-    use crate::{
-        extensions::{projection::Centroid, raster::DataType, Projection, Raster},
-        item::Builder,
-        Extensions, Fields,
-    };
-
-    #[test]
-    fn raster_data_type() {
-        let mut item = Builder::new("an-id")
-            .asset("data", "assets/dataset.tif")
-            .build()
-            .unwrap();
-        super::update_item(&mut item, false, true).unwrap();
-        assert!(item.has_extension::<Raster>());
-        let raster: Raster = item.assets.get("data").unwrap().extension().unwrap();
-        assert_eq!(
-            *raster.bands[0].data_type.as_ref().unwrap(),
-            DataType::UInt16
-        )
-    }
-
-    #[test]
-    fn raster_spatial_resolution() {
-        let mut item = Builder::new("an-id")
-            .asset("data", "assets/dataset_geo.tif")
-            .build()
-            .unwrap();
-        super::update_item(&mut item, false, true).unwrap();
-        let raster: Raster = item.assets.get("data").unwrap().extension().unwrap();
-        assert_eq!(
-            raster.bands[0].spatial_resolution.unwrap(),
-            100.01126757344893
-        );
-    }
-
-    #[test]
-    fn projection() {
-        let mut item = Builder::new("an-id")
-            .asset("data", "assets/dataset_geo.tif")
-            .build()
-            .unwrap();
-        super::update_item(&mut item, false, true).unwrap();
-        let projection: Projection = item.extension().unwrap();
-        assert_eq!(projection.code.unwrap(), "EPSG:32621");
-        assert_eq!(
-            projection.bbox.unwrap(),
-            vec![373185.0, 8019284.949381611, 639014.9492102272, 8286015.0]
-        );
-        assert_eq!(projection.shape.unwrap(), vec![2667, 2658]);
-        assert_eq!(
-            projection.transform.unwrap(),
-            vec![
-                100.01126757344893,
-                0.0,
-                373185.0,
-                0.0,
-                -100.01126757344893,
-                8286015.0,
-            ]
-        );
-        assert_eq!(
-            projection.centroid.unwrap(),
-            Centroid {
-                lon: -56.8079473,
-                lat: 73.4675736,
-            }
-        )
-    }
-}
diff --git a/core/src/item.rs b/core/src/item.rs
index d35489eb..0e56687a 100644
--- a/core/src/item.rs
+++ b/core/src/item.rs
@@ -1,8 +1,7 @@
 //! STAC Items.
 
 use crate::{
-    Asset, Assets, Bbox, Error, Extensions, Fields, Href, Link, Links, Migrate, Result, Version,
-    STAC_VERSION,
+    Asset, Assets, Bbox, Error, Fields, Href, Link, Links, Migrate, Result, Version, STAC_VERSION,
 };
 use chrono::{DateTime, FixedOffset, Utc};
 use geojson::{feature::Id, Feature, Geometry};
@@ -233,11 +232,6 @@ pub struct Builder {
     id: String,
     canonicalize_paths: bool,
     assets: HashMap<String, Asset>,
-    enable_gdal: bool,
-    #[cfg(feature = "gdal")]
-    force_statistics: bool, // TODO add builder method
-    #[cfg(feature = "gdal")]
-    is_approx_statistics_ok: bool, // TODO add builder method
 }
 
 impl Builder {
@@ -254,11 +248,6 @@ impl Builder {
             id: id.to_string(),
             canonicalize_paths: true,
             assets: HashMap::new(),
-            enable_gdal: cfg!(feature = "gdal"),
-            #[cfg(feature = "gdal")]
-            force_statistics: false,
-            #[cfg(feature = "gdal")]
-            is_approx_statistics_ok: true,
         }
     }
 
@@ -290,22 +279,6 @@ impl Builder {
         self
     }
 
-    /// Enable or disable GDAL processing of asset files.
-    ///
-    /// If this crate is _not_ compiled with the `gdal` flag and this value is
-    /// `true`, an error will be thrown.
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// use stac::item::Builder;
-    /// let builder = Builder::new("an-id").enable_gdal(false);
-    /// ```
-    pub fn enable_gdal(mut self, enable_gdal: bool) -> Builder {
-        self.enable_gdal = enable_gdal;
-        self
-    }
-
     /// Builds an [Item] from this builder.
     ///
     /// # Examples
@@ -327,16 +300,6 @@ impl Builder {
             }
             let _ = item.assets.insert(key, asset);
         }
-        if self.enable_gdal {
-            #[cfg(feature = "gdal")]
-            crate::gdal::update_item(
-                &mut item,
-                self.force_statistics,
-                self.is_approx_statistics_ok,
-            )?;
-            #[cfg(not(feature = "gdal"))]
-            return Err(Error::FeatureNotEnabled("gdal"));
-        }
         Ok(item)
     }
 }
@@ -652,15 +615,6 @@ impl Fields for Item {
     }
 }
 
-impl Extensions for Item {
-    fn extensions(&self) -> &Vec<String> {
-        &self.extensions
-    }
-    fn extensions_mut(&mut self) -> &mut Vec<String> {
-        &mut self.extensions
-    }
-}
-
 impl TryFrom<Item> for Map<String, Value> {
     type Error = Error;
     fn try_from(item: Item) -> Result<Self> {
@@ -750,10 +704,7 @@ impl Migrate for Item {}
 #[cfg(test)]
 mod tests {
     use super::{Builder, FlatItem, Item};
-    use crate::{
-        extensions::{Projection, Raster},
-        Asset, Extensions, STAC_VERSION,
-    };
+    use crate::{Asset, STAC_VERSION};
     use geojson::{feature::Id, Feature};
     use serde_json::Value;
 
@@ -917,19 +868,6 @@ mod tests {
         assert_eq!(asset.roles, vec!["data"]);
     }
 
-    #[test]
-    fn builder_uses_gdal() {
-        let item = Builder::new("an-id")
-            .asset("data", "assets/dataset.tif")
-            .build()
-            .unwrap();
-        if cfg!(feature = "gdal") {
-            assert!(item.has_extension::<Raster>());
-        } else {
-            assert!(!item.has_extension::<Raster>());
-        }
-    }
-
     #[test]
     fn try_from_geojson_feature() {
         let mut feature = Feature {
@@ -974,7 +912,6 @@ mod tests {
     #[test]
     fn flat_item_without_geometry() {
         let mut item = Item::new("an-item");
-        item.add_extension::<Projection>();
         item.bbox = Some(vec![-105., 42., -105., -42.].try_into().unwrap());
         let mut value = serde_json::to_value(item).unwrap();
         let _ = value.as_object_mut().unwrap().remove("geometry").unwrap();
diff --git a/core/src/lib.rs b/core/src/lib.rs
index cf9dc942..b523ba71 100644
--- a/core/src/lib.rs
+++ b/core/src/lib.rs
@@ -103,7 +103,6 @@
 //!
 //! # Features
 //!
-//! - `gdal`: read raster assets, see [gdal]
 //! - `geo`: add some geo-enabled methods, see [geo]
 //! - `geoarrow`: read and write [geoarrow](https://geoarrow.org/), see [geoarrow]
 //! - `geoparquet`: read and write [geoparquet](https://geoparquet.org/), see [geoparquet]
@@ -156,11 +155,8 @@ mod collection;
 mod data_type;
 pub mod datetime;
 mod error;
-pub mod extensions;
 mod fields;
 mod format;
-#[cfg(feature = "gdal")]
-pub mod gdal;
 #[cfg(feature = "geo")]
 pub mod geo;
 #[cfg(feature = "geoarrow")]
@@ -194,7 +190,6 @@ pub use {
     collection::{Collection, Extent, Provider, SpatialExtent, TemporalExtent, COLLECTION_TYPE},
     data_type::DataType,
     error::Error,
-    extensions::{Extension, Extensions},
     fields::Fields,
     format::Format,
     geoparquet::{FromGeoparquet, IntoGeoparquet},
@@ -473,10 +468,6 @@ mod readme {
     external_doc_test!(include_str!("../README.md"));
 }
 
-// We only use gdal-sys for the build script.
-#[cfg(feature = "gdal")]
-use gdal_sys as _;
-
 // For now, we only use tracing in the validate module.
 #[cfg(not(feature = "validate"))]
 use tracing as _;
diff --git a/docs/cli/index.md b/docs/cli/index.md
index 58a7c6c2..9165fccd 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -17,13 +17,6 @@ If you have Python, use `pip`:
 python -m pip install stacrs-cli
 ```
 
-!!! Note
-<!-- markdownlint-disable-next-line MD046 -->
-The PyPI version of the CLI does not contain bindings to GDAL. This
-shouldn't be a problem for most users, but if you're using `stacrs item
-    image.tiff` to generate new STAC items from a raster, you'll need to install
-from `cargo`.
-
 The CLI is called **stacrs**:
 
 ```shell
diff --git a/duckdb/Cargo.toml b/duckdb/Cargo.toml
index 0bfd4538..44e2e7cf 100644
--- a/duckdb/Cargo.toml
+++ b/duckdb/Cargo.toml
@@ -1,25 +1,26 @@
 [package]
 name = "stac-duckdb"
-version = "0.0.2"
-authors = ["Pete Gadomski <pete.gadomski@gmail.com>"]
-edition = "2021"
 description = "Experimental client for querying stac-geoparquet using DuckDB"
-homepage = "https://github.com/stac-utils/stac-rs"
-repository = "https://github.com/stac-utils/stac-rs"
-license = "MIT OR Apache-2.0"
+version = "0.0.2"
 keywords = ["geospatial", "stac", "metadata", "geo", "raster"]
-categories = ["science", "data-structures"]
+authors.workspace = true
+edition.workspace = true
+homepage.workspace = true
+repository.workspace = true
+license.workspace = true
+categories.workspace = true
+rust-version.workspace = true
 
 [dependencies]
-arrow = { workspace = true }
-duckdb = { workspace = true }
-libduckdb-sys = { workspace = true }
-geoarrow = { workspace = true }
-parquet = { workspace = true }
+arrow.workspace = true
+duckdb.workspace = true
+libduckdb-sys.workspace = true
+geoarrow.workspace = true
+parquet.workspace = true
 stac = { workspace = true, features = ["geoarrow"] }
-stac-api = { workspace = true }
-thiserror = { workspace = true }
+stac-api.workspace = true
+thiserror.workspace = true
 
 [dev-dependencies]
 duckdb-test = { path = "duckdb-test" }
-geo = { workspace = true }
+geo.workspace = true
diff --git a/extensions/Cargo.toml b/extensions/Cargo.toml
new file mode 100644
index 00000000..82e27d11
--- /dev/null
+++ b/extensions/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "stac-extensions"
+description = "Manage STAC extensions (https://stac-extensions.github.io/)"
+version = "0.1.0"
+keywords = ["geospatial", "stac", "extensions"]
+authors.workspace = true
+edition.workspace = true
+homepage.workspace = true
+repository.workspace = true
+license.workspace = true
+categories.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+geojson.workspace = true
+stac.workspace = true
+serde.workspace = true
+serde_json.workspace = true
diff --git a/core/data/auth/collection.json b/extensions/data/auth/collection.json
similarity index 100%
rename from core/data/auth/collection.json
rename to extensions/data/auth/collection.json
diff --git a/core/data/auth/item.json b/extensions/data/auth/item.json
similarity index 100%
rename from core/data/auth/item.json
rename to extensions/data/auth/item.json
diff --git a/core/data/eo/item.json b/extensions/data/eo/item.json
similarity index 100%
rename from core/data/eo/item.json
rename to extensions/data/eo/item.json
diff --git a/extensions/examples b/extensions/examples
new file mode 120000
index 00000000..dc8ca857
--- /dev/null
+++ b/extensions/examples
@@ -0,0 +1,1 @@
+../spec-examples/v1.1.0
\ No newline at end of file
diff --git a/core/src/extensions/authentication.rs b/extensions/src/authentication.rs
similarity index 93%
rename from core/src/extensions/authentication.rs
rename to extensions/src/authentication.rs
index 8ed4d350..ac59cb99 100644
--- a/core/src/extensions/authentication.rs
+++ b/extensions/src/authentication.rs
@@ -1,6 +1,6 @@
 //! Provides a standard set of fields to describe authentication and
 //! authorization schemes, flows, and scopes required to access
-//! [Assets](crate::Asset) and [Links](crate::Link) that align with the [OpenAPI
+//! [Assets](stac::Asset) and [Links](stac::Link) that align with the [OpenAPI
 //! security spec](https://swagger.io/docs/specification/authentication/).
 
 use crate::Extension;
@@ -12,12 +12,12 @@ use std::collections::HashMap;
 #[derive(Debug, Serialize, Deserialize)]
 pub struct Authentication {
     /// A property that contains all of the [scheme definitions](Scheme) used by
-    /// [Assets](crate::Asset) and [Links](crate::Link) in the STAC [Item](crate::Item) or [Collection](crate::Collection).
+    /// [Assets](stac::Asset) and [Links](stac::Link) in the STAC [Item](crate::Item) or [Collection](crate::Collection).
     #[serde(default, skip_serializing_if = "HashMap::is_empty")]
     pub schemes: HashMap<String, Scheme>,
 
-    /// A property that specifies which schemes may be used to access an [Asset](crate::Asset)
-    /// or [Link](crate::Link).
+    /// A property that specifies which schemes may be used to access an [Asset](stac::Asset)
+    /// or [Link](stac::Link).
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     pub refs: Vec<String>,
 }
@@ -167,12 +167,12 @@ impl Extension for Authentication {
 #[cfg(test)]
 mod tests {
     use super::{Authentication, In, Scheme};
-    use crate::{Collection, Fields, Item};
+    use crate::{Collection, Extensions, Item};
     use serde_json::json;
 
     #[test]
     fn collection() {
-        let collection: Collection = crate::read("data/auth/collection.json").unwrap();
+        let collection: Collection = stac::read("data/auth/collection.json").unwrap();
         let authentication: Authentication = collection.extension().unwrap();
         let oauth = authentication.schemes.get("oauth").unwrap();
         let _ = oauth.flows.get("authorizationCode").unwrap();
@@ -180,7 +180,7 @@ mod tests {
 
     #[test]
     fn item() {
-        let collection: Item = crate::read("data/auth/item.json").unwrap();
+        let collection: Item = stac::read("data/auth/item.json").unwrap();
         let authentication: Authentication = collection.extension().unwrap();
         let _ = authentication.schemes.get("none").unwrap();
     }
diff --git a/core/src/extensions/electro_optical.rs b/extensions/src/electro_optical.rs
similarity index 95%
rename from core/src/extensions/electro_optical.rs
rename to extensions/src/electro_optical.rs
index 10918713..4341efc5 100644
--- a/core/src/extensions/electro_optical.rs
+++ b/extensions/src/electro_optical.rs
@@ -29,7 +29,7 @@ pub struct ElectroOptical {
 
 /// [Spectral
 /// bands](https://www.sciencedirect.com/topics/earth-and-planetary-sciences/spectral-band)
-/// in an [Asset](crate::Asset).
+/// in an [Asset](stac::Asset).
 #[derive(Debug, Serialize, Deserialize)]
 pub struct Band {
     /// The name of the band (e.g., "B01", "B8", "band2", "red").
@@ -72,11 +72,11 @@ impl Extension for ElectroOptical {
 #[cfg(test)]
 mod tests {
     use super::ElectroOptical;
-    use crate::{Fields, Item};
+    use crate::{Extensions, Item};
 
     #[test]
     fn item() {
-        let item: Item = crate::read("data/eo/item.json").unwrap();
+        let item: Item = stac::read("data/eo/item.json").unwrap();
         let _: ElectroOptical = item.extension().unwrap();
     }
 }
diff --git a/core/src/extensions/mod.rs b/extensions/src/lib.rs
similarity index 77%
rename from core/src/extensions/mod.rs
rename to extensions/src/lib.rs
index 608493eb..926bc1d6 100644
--- a/core/src/extensions/mod.rs
+++ b/extensions/src/lib.rs
@@ -21,12 +21,12 @@
 //!
 //! ## Usage
 //!
-//! [Item](crate::Item), [Collection](crate::Collection),
-//! [Catalog](crate::Catalog) all implement the [Extensions] trait, which
-//! provides methods to get, set, and remove extension information:
+//! [Item], [Collection], and [Catalog] all implement the [Extensions] trait,
+//! which provides methods to get, set, and remove extension information:
 //!
 //! ```
-//! use stac::{Item, Extensions, Fields, extensions::{Projection, projection::Centroid}};
+//! use stac::Item;
+//! use stac_extensions::{Extensions, Projection, projection::Centroid};
 //! let mut item: Item = stac::read("examples/extensions-collection/proj-example/proj-example.json").unwrap();
 //! assert!(item.has_extension::<Projection>());
 //!
@@ -48,8 +48,8 @@ pub mod electro_optical;
 pub mod projection;
 pub mod raster;
 
-use crate::{Fields, Result};
 use serde::{de::DeserializeOwned, Serialize};
+use stac::{Catalog, Collection, Fields, Item, Result};
 pub use {projection::Projection, raster::Raster};
 
 /// A trait implemented by extensions.
@@ -68,7 +68,7 @@ pub trait Extension: Serialize + DeserializeOwned {
     /// # Examples
     ///
     /// ```
-    /// use stac::extensions::{Raster, Extension};
+    /// use stac_extensions::{Raster, Extension};
     /// assert_eq!(Raster::identifier_prefix(), "https://stac-extensions.github.io/raster/");
     /// ```
     fn identifier_prefix() -> &'static str {
@@ -87,7 +87,9 @@ pub trait Extensions: Fields {
     /// # Examples
     ///
     /// ```
-    /// use stac::{Extensions, Item};
+    /// use stac::Item;
+    /// use stac_extensions::Extensions;
+    ///
     /// let item = Item::new("an-id");
     /// assert!(item.extensions().is_empty());
     /// ```
@@ -98,7 +100,9 @@ pub trait Extensions: Fields {
     /// # Examples
     ///
     /// ```
-    /// use stac::{Extensions, Item};
+    /// use stac::Item;
+    /// use stac_extensions::Extensions;
+    ///
     /// let mut item = Item::new("an-id");
     /// item.extensions_mut().push("https://stac-extensions.github.io/raster/v1.1.0/schema.json".to_string());
     /// ```
@@ -109,7 +113,9 @@ pub trait Extensions: Fields {
     /// # Examples
     ///
     /// ```
-    /// use stac::{Item, extensions::{Projection, Extensions}};
+    /// use stac::Item;
+    /// use stac_extensions::{Projection, Extensions};
+    ///
     /// let mut item = Item::new("an-id");
     /// assert!(!item.has_extension::<Projection>());
     /// let projection = Projection { code: Some("EPSG:4326".to_string()), ..Default::default() };
@@ -122,12 +128,30 @@ pub trait Extensions: Fields {
             .any(|extension| extension.starts_with(E::identifier_prefix()))
     }
 
+    /// Returns an extension's data.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use stac::Item;
+    /// use stac_extensions::{Projection, Extensions};
+    ///
+    /// let item: Item = stac::read("examples/extensions-collection/proj-example/proj-example.json").unwrap();
+    /// let projection: Projection = item.extension().unwrap();
+    /// assert_eq!(projection.code.unwrap(), "EPSG:32614");
+    /// ```
+    fn extension<E: Extension>(&self) -> Result<E> {
+        self.fields_with_prefix(E::PREFIX)
+    }
+
     /// Adds an extension's identifier to this object.
     ///
     /// # Examples
     ///
     /// ```
-    /// use stac::{Item, extensions::{Projection, Extensions}};
+    /// use stac::Item;
+    /// use stac_extensions::{Projection, Extensions};
+    ///
     /// let mut item = Item::new("an-id");
     /// item.add_extension::<Projection>();
     /// ```
@@ -143,7 +167,9 @@ pub trait Extensions: Fields {
     /// # Examples
     ///
     /// ```
-    /// use stac::{Item, extensions::{Projection, Extensions}};
+    /// use stac::Item;
+    /// use stac_extensions::{Projection, Extensions};
+    ///
     /// let mut item = Item::new("an-id");
     /// let projection = Projection { code: Some("EPSG:4326".to_string()), ..Default::default() };
     /// item.set_extension(projection).unwrap();
@@ -151,7 +177,8 @@ pub trait Extensions: Fields {
     fn set_extension<E: Extension>(&mut self, extension: E) -> Result<()> {
         self.extensions_mut().push(E::IDENTIFIER.to_string());
         self.extensions_mut().dedup();
-        Fields::set_extension(self, extension)
+        self.remove_fields_with_prefix(E::PREFIX);
+        self.set_fields_with_prefix(E::PREFIX, extension)
     }
 
     /// Removes this extension and all of its fields from this object.
@@ -159,30 +186,43 @@ pub trait Extensions: Fields {
     /// # Examples
     ///
     /// ```
-    /// use stac::{Item, extensions::{Projection, Extensions}};
+    /// use stac::Item;
+    /// use stac_extensions::{Projection, Extensions};
+    ///
     /// let mut item: Item = stac::read("examples/extensions-collection/proj-example/proj-example.json").unwrap();
     /// assert!(item.has_extension::<Projection>());
     /// item.remove_extension::<Projection>();
     /// assert!(!item.has_extension::<Projection>());
     /// ```
     fn remove_extension<E: Extension>(&mut self) {
-        Fields::remove_extension::<E>(self);
+        self.remove_fields_with_prefix(E::PREFIX);
         self.extensions_mut()
             .retain(|extension| !extension.starts_with(E::identifier_prefix()))
     }
 }
 
+macro_rules! impl_extensions {
+    ($name:ident) => {
+        impl Extensions for $name {
+            fn extensions(&self) -> &Vec<String> {
+                &self.extensions
+            }
+            fn extensions_mut(&mut self) -> &mut Vec<String> {
+                &mut self.extensions
+            }
+        }
+    };
+}
+
+impl_extensions!(Item);
+impl_extensions!(Catalog);
+impl_extensions!(Collection);
+
 #[cfg(test)]
 mod tests {
-    use super::Extensions;
-    use crate::{
-        extensions::{
-            raster::{Band, Raster},
-            Projection,
-        },
-        Asset, Extension, Item,
-    };
+    use crate::{raster::Raster, Extension, Extensions, Projection};
     use serde_json::json;
+    use stac::Item;
 
     #[test]
     fn identifer_prefix() {
@@ -196,19 +236,6 @@ mod tests {
         );
     }
 
-    #[test]
-    fn set_extension_on_asset() {
-        use crate::Fields;
-
-        let mut asset = Asset::new("a/href.tif");
-        let mut band = Band::default();
-        band.unit = Some("parsecs".to_string());
-        let raster = Raster { bands: vec![band] };
-        asset.set_extension(raster).unwrap();
-        let mut item = Item::new("an-id");
-        let _ = item.assets.insert("data".to_string(), asset);
-    }
-
     #[test]
     fn remove_extension() {
         let mut item = Item::new("an-id");
diff --git a/extensions/src/projection.rs b/extensions/src/projection.rs
new file mode 100644
index 00000000..3bb194e1
--- /dev/null
+++ b/extensions/src/projection.rs
@@ -0,0 +1,92 @@
+//! The Projection extension.
+
+use super::Extension;
+use geojson::Geometry;
+use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value};
+
+/// The projection extension fields.
+#[derive(Debug, Serialize, Deserialize, Default, PartialEq, Clone)]
+pub struct Projection {
+    /// EPSG code of the datasource
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub code: Option<String>,
+
+    /// WKT2 string representing the Coordinate Reference System (CRS) that the
+    /// proj:geometry and proj:bbox fields represent
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub wkt2: Option<String>,
+
+    /// PROJJSON object representing the Coordinate Reference System (CRS) that
+    /// the proj:geometry and proj:bbox fields represent
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub projjson: Option<Map<String, Value>>,
+
+    /// Defines the footprint of this Item.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub geometry: Option<Geometry>,
+
+    /// Bounding box of the Item in the asset CRS in 2 or 3 dimensions.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub bbox: Option<Vec<f64>>,
+
+    /// Coordinates representing the centroid of the Item (in lat/long)
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub centroid: Option<Centroid>,
+
+    /// Number of pixels in Y and X directions for the default grid
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub shape: Option<Vec<usize>>,
+
+    /// The affine transformation coefficients for the default grid
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub transform: Option<Vec<f64>>,
+}
+
+/// This object represents the centroid of the Item Geometry.
+#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
+pub struct Centroid {
+    /// The latitude of the centroid.
+    pub lat: f64,
+
+    /// The longitude of the centroid.
+    pub lon: f64,
+}
+
+impl Projection {
+    /// Returns true if this projection structure is empty.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use stac_extensions::Projection;
+    ///
+    /// let projection = Projection::default();
+    /// assert!(projection.is_empty());
+    /// ```
+    pub fn is_empty(&self) -> bool {
+        serde_json::to_value(self)
+            .map(|v| v == Value::Object(Default::default()))
+            .unwrap_or(true)
+    }
+}
+
+impl Extension for Projection {
+    const IDENTIFIER: &'static str =
+        "https://stac-extensions.github.io/projection/v2.0.0/schema.json";
+    const PREFIX: &'static str = "proj";
+}
+
+#[cfg(test)]
+mod tests {
+    use super::Projection;
+    use crate::{Extensions, Item};
+
+    #[test]
+    fn example() {
+        let item: Item =
+            stac::read("examples/extensions-collection/proj-example/proj-example.json").unwrap();
+        let projection = item.extension::<Projection>().unwrap();
+        assert_eq!(projection.code.unwrap(), "EPSG:32614");
+    }
+}
diff --git a/core/src/extensions/raster.rs b/extensions/src/raster.rs
similarity index 76%
rename from core/src/extensions/raster.rs
rename to extensions/src/raster.rs
index e6cf6907..b598b537 100644
--- a/core/src/extensions/raster.rs
+++ b/extensions/src/raster.rs
@@ -10,8 +10,8 @@
 //! in the asset (values statistics, value interpretation, transforms).
 
 use super::Extension;
-pub use crate::{DataType, Statistics};
 use serde::{Deserialize, Serialize};
+pub use stac::{DataType, Statistics};
 
 /// The raster extension.
 #[derive(Debug, Serialize, Deserialize, Default)]
@@ -125,49 +125,12 @@ impl Raster {
     /// # Examples
     ///
     /// ```
-    /// use stac::extensions::Raster;
+    /// use stac_extensions::Raster;
     ///
-    /// let projection = Raster::default();
-    /// assert!(projection.is_empty());
+    /// let raster = Raster::default();
+    /// assert!(raster.is_empty());
     /// ```
     pub fn is_empty(&self) -> bool {
         self.bands.is_empty()
     }
 }
-
-#[cfg(feature = "gdal")]
-impl From<gdal::raster::GdalDataType> for DataType {
-    fn from(value: gdal::raster::GdalDataType) -> Self {
-        use gdal::raster::GdalDataType;
-
-        match value {
-            GdalDataType::Unknown => DataType::Other,
-            #[cfg(gdal_has_int8)]
-            GdalDataType::Int8 => DataType::Int8,
-            GdalDataType::Int16 => DataType::Int16,
-            GdalDataType::Int32 => DataType::Int32,
-            #[cfg(gdal_has_int64)]
-            GdalDataType::Int64 => DataType::Int64,
-            GdalDataType::UInt8 => DataType::UInt8,
-            GdalDataType::UInt16 => DataType::UInt16,
-            GdalDataType::UInt32 => DataType::UInt32,
-            #[cfg(gdal_has_uint64)]
-            GdalDataType::UInt64 => DataType::UInt64,
-            GdalDataType::Float32 => DataType::Float32,
-            GdalDataType::Float64 => DataType::Float64,
-        }
-    }
-}
-
-#[cfg(feature = "gdal")]
-impl From<gdal::raster::StatisticsAll> for Statistics {
-    fn from(value: gdal::raster::StatisticsAll) -> Self {
-        Statistics {
-            minimum: Some(value.min),
-            maximum: Some(value.max),
-            mean: Some(value.mean),
-            stddev: Some(value.std_dev),
-            valid_percent: None,
-        }
-    }
-}
diff --git a/pgstac/Cargo.toml b/pgstac/Cargo.toml
index b374b4ae..8b899585 100644
--- a/pgstac/Cargo.toml
+++ b/pgstac/Cargo.toml
@@ -1,26 +1,27 @@
 [package]
 name = "pgstac"
-version = "0.2.1"
-authors = ["Pete Gadomski <pete.gadomski@gmail.com>"]
-edition = "2021"
 description = "Rust interface for pgstac"
-homepage = "https://github.com/stac-utils/stac-rs"
-repository = "https://github.com/stac-utils/stac-rs"
-license = "MIT OR Apache-2.0"
+version = "0.2.1"
 keywords = ["geospatial", "stac", "metadata", "raster", "database"]
 categories = ["database", "data-structures", "science"]
+authors.workspace = true
+edition.workspace = true
+homepage.workspace = true
+repository.workspace = true
+license.workspace = true
+rust-version.workspace = true
 
 [features]
 tls = ["dep:rustls", "dep:tokio-postgres-rustls", "dep:webpki-roots"]
 
 [dependencies]
-geojson = { workspace = true }
+geojson.workspace = true
 rustls = { workspace = true, features = ["ring", "std"], optional = true }
-serde = { workspace = true }
-serde_json = { workspace = true }
-stac = { workspace = true }
-stac-api = { workspace = true }
-thiserror = { workspace = true }
+serde.workspace = true
+serde_json.workspace = true
+stac.workspace = true
+stac-api.workspace = true
+thiserror.workspace = true
 tokio-postgres = { workspace = true, features = ["with-serde_json-1"] }
 tokio-postgres-rustls = { workspace = true, optional = true }
 webpki-roots = { workspace = true, optional = true }
@@ -28,7 +29,7 @@ webpki-roots = { workspace = true, optional = true }
 [dev-dependencies]
 pgstac-test = { path = "pgstac-test" }
 tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
-tokio-test = { workspace = true }
+tokio-test.workspace = true
 
 [package.metadata.docs.rs]
 all-features = true
diff --git a/pyproject.toml b/pyproject.toml
index 127709df..5c934282 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,7 +25,7 @@ stac-api-validator = ["setuptools>=75.1.0", "stac-api-validator>=0.6.3"]
 
 
 [tool.uv.sources]
-stacrs = { workspace = true }
+stacrs.workspace = true
 
 
 [tool.uv.workspace]
diff --git a/python/Cargo.toml b/python/Cargo.toml
index ff57356a..004e38c9 100644
--- a/python/Cargo.toml
+++ b/python/Cargo.toml
@@ -1,11 +1,13 @@
 [package]
 name = "python"
 description = "Python bindings for stac-rs"
-license = "MIT OR Apache-2.0"
-repository = "https://github.com/stac-utils/stac-rs"
-homepage = "https://github.com/stac-utils/stac-rs"
 version = "0.1.3"
-edition = "2021"
+authors.workspace = true
+license.workspace = true
+homepage.workspace = true
+repository.workspace = true
+edition.workspace = true
+rust-version.workspace = true
 publish = false
 
 [lib]
@@ -16,11 +18,11 @@ crate-type = ["cdylib"]
 duckdb = { workspace = true, features = [
     "bundled",
 ] } # we don't use it directly, but we need to ensure it's bundled
-geojson = { workspace = true }
+geojson.workspace = true
 pyo3 = { workspace = true, features = ["extension-module"] }
-pythonize = { workspace = true }
-serde = { workspace = true }
-serde_json = { workspace = true }
+pythonize.workspace = true
+serde.workspace = true
+serde_json.workspace = true
 stac = { workspace = true, features = [
     "geoparquet-compression",
     "object-store-all",
@@ -28,5 +30,5 @@ stac = { workspace = true, features = [
     "validate-blocking",
 ] }
 stac-api = { workspace = true, features = ["client"] }
-stac-duckdb = { workspace = true }
+stac-duckdb.workspace = true
 tokio = { workspace = true, features = ["rt"] }
diff --git a/python/pyproject.toml b/python/pyproject.toml
index ee286603..cdd3d7cd 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -20,7 +20,7 @@ dynamic = ["version"]
 
 [project.urls]
 Repository = "https://github.com/stac-utils/stac-rs"
-Documentation = "https://stac-utils.github.io/stac-rs/python/"
+Documentation = "https://stac-utils.github.io/stac-rs"
 Issues = "https://github.com/stac-utils/stac-rs/issues"
 
 [tool.maturin]
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 8ea7e53c..9477da41 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -1,15 +1,15 @@
 [package]
 name = "stac-server"
-version = "0.3.1"
-edition = "2021"
-authors = ["Pete Gadomski <pete.gadomski@gmail.com>"]
 description = "SpatioTemporal Asset Catalog (STAC) API server"
-homepage = "https://github.com/stac-utils/stac-rs"
-repository = "https://github.com/stac-utils/stac-rs"
-license = "MIT OR Apache-2.0"
-keywords = ["geospatial", "stac", "metadata", "geo", "raster"]
+version = "0.3.1"
+keywords = ["geospatial", "stac", "metadata", "geo", "server"]
 categories = ["science", "data-structures"]
-rust-version = "1.75"
+edition.workspace = true
+authors.workspace = true
+homepage.workspace = true
+repository.workspace = true
+license.workspace = true
+rust-version.workspace = true
 
 [features]
 axum = ["dep:axum", "dep:bytes", "dep:mime", "dep:tower-http"]
@@ -20,24 +20,24 @@ axum = { workspace = true, optional = true }
 bb8 = { workspace = true, optional = true }
 bb8-postgres = { workspace = true, optional = true }
 bytes = { workspace = true, optional = true }
-http = { workspace = true }
+http.workspace = true
 mime = { workspace = true, optional = true }
 pgstac = { workspace = true, features = ["tls"], optional = true }
-serde = { workspace = true }
-serde_json = { workspace = true }
-serde_urlencoded = { workspace = true }
-stac = { workspace = true }
+serde.workspace = true
+serde_json.workspace = true
+serde_urlencoded.workspace = true
+stac.workspace = true
 stac-api = { workspace = true, features = ["geo"] }
-thiserror = { workspace = true }
+thiserror.workspace = true
 tokio-postgres = { workspace = true, optional = true }
 tower-http = { workspace = true, features = ["cors"], optional = true }
-url = { workspace = true }
+url.workspace = true
 
 [dev-dependencies]
-serde_json = { workspace = true }
+serde_json.workspace = true
 stac = { workspace = true, features = ["validate"] }
 tokio = { workspace = true, features = ["macros"] }
-tokio-test = { workspace = true }
+tokio-test.workspace = true
 tower = { workspace = true, features = ["util"] }
 
 [package.metadata.docs.rs]


Test patch:
diff --git a/duckdb/duckdb-test/Cargo.toml b/duckdb/duckdb-test/Cargo.toml
index f612d5e2..fb3b67eb 100644
--- a/duckdb/duckdb-test/Cargo.toml
+++ b/duckdb/duckdb-test/Cargo.toml
@@ -1,7 +1,7 @@
 [package]
 name = "duckdb-test"
 version = "0.0.0"
-edition = "2021"
+edition.workspace = true
 publish = false
 
 [lib]
@@ -10,5 +10,5 @@ test = false
 doctest = false
 
 [dependencies]
-quote = { workspace = true }
+quote.workspace = true
 syn = { workspace = true, features = ["full", "extra-traits"] }
diff --git a/pgstac/pgstac-test/Cargo.toml b/pgstac/pgstac-test/Cargo.toml
index 199a32bb..a1e8f627 100644
--- a/pgstac/pgstac-test/Cargo.toml
+++ b/pgstac/pgstac-test/Cargo.toml
@@ -1,7 +1,7 @@
 [package]
 name = "pgstac-test"
 version = "0.0.0"
-edition = "2021"
+edition.workspace = true
 publish = false
 
 [lib]
@@ -10,6 +10,6 @@ test = false
 doctest = false
 
 [dependencies]
-quote = { workspace = true }
+quote.workspace = true
 syn = { workspace = true, features = ["full", "extra-traits"] }
-tokio-postgres = { workspace = true }
+tokio-postgres.workspace = true