Skip to main content

stac_server/
routes.rs

1//! Routes for serving API endpoints.
2
3use crate::{Api, Backend};
4use axum::{
5    Json, Router,
6    extract::{Path, Query, State, rejection::JsonRejection},
7    http::{HeaderValue, StatusCode, header::CONTENT_TYPE},
8    response::{Html, IntoResponse, Response},
9    routing::{get, post},
10};
11use bytes::{BufMut, BytesMut};
12use http::Method;
13use serde::Serialize;
14use stac::api::{Collections, GetItems, GetSearch, ItemCollection, Items, Root, Search};
15use stac::{
16    Collection, Item,
17    mime::{APPLICATION_GEOJSON, APPLICATION_OPENAPI_3_0},
18};
19use tower_http::{cors::CorsLayer, trace::TraceLayer};
20
21/// Errors for our axum routes.
22#[derive(Debug)]
23#[non_exhaustive]
24pub enum Error {
25    /// An server error.
26    Server(crate::Error),
27
28    /// An error raised when something is not found.
29    NotFound(String),
30
31    /// An error raised when it's a bad request from the client.
32    BadRequest(String),
33}
34
35type Result<T> = std::result::Result<T, Error>;
36
37/// A wrapper struct for any geojson response.
38// Taken from https://docs.rs/axum/latest/src/axum/json.rs.html#93
39#[derive(Debug)]
40pub struct GeoJson<T>(pub T);
41
42impl IntoResponse for Error {
43    fn into_response(self) -> Response {
44        match self {
45            Error::Server(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()),
46            Error::NotFound(message) => (StatusCode::NOT_FOUND, message),
47            Error::BadRequest(message) => (StatusCode::BAD_REQUEST, message),
48        }
49        .into_response()
50    }
51}
52
53impl From<crate::Error> for Error {
54    fn from(error: crate::Error) -> Self {
55        Error::Server(error)
56    }
57}
58
59impl From<JsonRejection> for Error {
60    fn from(json_rejection: JsonRejection) -> Self {
61        Error::BadRequest(format!("bad request, json rejection: {json_rejection}"))
62    }
63}
64
65impl<T> IntoResponse for GeoJson<T>
66where
67    T: Serialize,
68{
69    fn into_response(self) -> Response {
70        // Use a small initial capacity of 128 bytes like serde_json::to_vec
71        // https://docs.rs/serde_json/1.0.82/src/serde_json/ser.rs.html#2189
72        let mut buf = BytesMut::with_capacity(128).writer();
73        match serde_json::to_writer(&mut buf, &self.0) {
74            Ok(()) => (
75                [(CONTENT_TYPE, HeaderValue::from_static(APPLICATION_GEOJSON))],
76                buf.into_inner().freeze(),
77            )
78                .into_response(),
79            Err(err) => (
80                StatusCode::INTERNAL_SERVER_ERROR,
81                [(
82                    CONTENT_TYPE,
83                    HeaderValue::from_static(mime::TEXT_PLAIN_UTF_8.as_ref()),
84                )],
85                err.to_string(),
86            )
87                .into_response(),
88        }
89    }
90}
91
92/// Creates an [axum::Router] from an [Api].
93///
94/// # Examples
95///
96/// ```
97/// use stac_server::{Api, MemoryBackend, routes};
98///
99/// let api = Api::new(MemoryBackend::new(), "http://stac.test").unwrap();
100/// let router = routes::from_api(api);
101/// ```
102pub fn from_api<B: Backend>(api: Api<B>) -> Router {
103    Router::new()
104        .route("/", get(root))
105        .route("/api", get(service_desc))
106        .route("/api.html", get(service_doc))
107        .route("/conformance", get(conformance))
108        .route("/queryables", get(queryables))
109        .route("/collections", get(collections))
110        .route("/collections/{collection_id}", get(collection))
111        .route("/collections/{collection_id}/items", get(items))
112        .route("/collections/{collection_id}/items/{item_id}", get(item))
113        .route("/search", get(get_search))
114        .route("/search", post(post_search))
115        .layer(CorsLayer::permissive()) // TODO make this configurable
116        .layer(TraceLayer::new_for_http())
117        .with_state(api)
118}
119
120/// Returns the `/` endpoint from the [core conformance
121/// class](https://github.com/radiantearth/stac-api-spec/tree/release/v1.0.0/core#endpoints).
122pub async fn root<B: Backend>(State(api): State<Api<B>>) -> Result<Json<Root>> {
123    api.root().await.map(Json).map_err(Error::from)
124}
125
126/// Returns the `/api` endpoint from the [core conformance
127/// class](https://github.com/radiantearth/stac-api-spec/tree/release/v1.0.0/core#endpoints).
128pub async fn service_desc() -> Response {
129    // The OpenAPI definition is completely stolen from [stac-server](https://github.com/stac-utils/stac-server/blob/dd7e3acbf47485425e2068fd7fbbceeafe4b4e8c/src/lambdas/api/openapi.yaml).
130    //
131    // TODO add a script to update the definition in this library.
132    (
133        [(CONTENT_TYPE, APPLICATION_OPENAPI_3_0)],
134        include_str!("openapi.yaml"),
135    )
136        .into_response()
137}
138
139/// Returns the `/api.html` endpoint from the [core conformance
140/// class](https://github.com/radiantearth/stac-api-spec/tree/release/v1.0.0/core#endpoints).
141pub async fn service_doc() -> Response {
142    // The redoc file is completely stolen from [stac-server](https://github.com/stac-utils/stac-server/blob/dd7e3acbf47485425e2068fd7fbbceeafe4b4e8c/src/lambdas/api/redoc.html).
143    Html(include_str!("redoc.html")).into_response()
144}
145
146/// Returns the `/conformance` endpoint from the [ogcapi-features conformance
147/// class](https://github.com/radiantearth/stac-api-spec/blob/release/v1.0.0/ogcapi-features/README.md#endpoints).
148pub async fn conformance<B: Backend>(State(api): State<Api<B>>) -> Response {
149    Json(api.conformance()).into_response()
150}
151
152/// Returns the `/queryables` endpoint.
153pub async fn queryables<B: Backend>(State(api): State<Api<B>>) -> Response {
154    (
155        [(CONTENT_TYPE, "application/schema+json")],
156        Json(api.queryables()),
157    )
158        .into_response()
159}
160
161/// Returns the `/collections` endpoint from the [ogcapi-features conformance
162/// class](https://github.com/radiantearth/stac-api-spec/blob/release/v1.0.0/ogcapi-features/README.md#endpoints).
163pub async fn collections<B: Backend>(State(api): State<Api<B>>) -> Result<Json<Collections>> {
164    api.collections().await.map(Json).map_err(Error::from)
165}
166
167/// Returns the `/collections/{collectionId}` endpoint from the [ogcapi-features
168/// conformance
169/// class](https://github.com/radiantearth/stac-api-spec/blob/release/v1.0.0/ogcapi-features/README.md#endpoints).
170pub async fn collection<B: Backend>(
171    State(api): State<Api<B>>,
172    Path(collection_id): Path<String>,
173) -> Result<Json<Collection>> {
174    api.collection(&collection_id)
175        .await
176        .map_err(Error::from)
177        .and_then(|option| {
178            option
179                .ok_or_else(|| Error::NotFound(format!("no collection with id='{collection_id}'")))
180        })
181        .map(Json)
182}
183
184/// Returns the `/collections/{collectionId}/items` endpoint from the
185/// [ogcapi-features conformance
186/// class](https://github.com/radiantearth/stac-api-spec/tree/release/v1.0.0/ogcapi-features#collection-items-collectionscollectioniditems)
187pub async fn items<B: Backend>(
188    State(api): State<Api<B>>,
189    Path(collection_id): Path<String>,
190    items: Query<GetItems>,
191) -> Result<GeoJson<ItemCollection>> {
192    let items = Items::try_from(items.0)
193        .and_then(Items::valid)
194        .map_err(|error| Error::BadRequest(format!("invalid query: {error}")))?;
195    api.items(&collection_id, items)
196        .await
197        .map_err(Error::from)
198        .and_then(|option| {
199            option
200                .ok_or_else(|| Error::NotFound(format!(" no collection with id='{collection_id}'")))
201        })
202        .map(GeoJson)
203}
204
205/// Returns the `/collections/{collectionId}/items/{itemId}` endpoint from the
206/// [ogcapi-features conformance
207/// class](https://github.com/radiantearth/stac-api-spec/tree/release/v1.0.0/ogcapi-features#collection-items-collectionscollectioniditems)
208pub async fn item<B: Backend>(
209    State(api): State<Api<B>>,
210    Path((collection_id, item_id)): Path<(String, String)>,
211) -> Result<GeoJson<Item>> {
212    api.item(&collection_id, &item_id)
213        .await?
214        .ok_or_else(|| {
215            Error::NotFound(format!(
216                "no item with id='{item_id}' in collection='{collection_id}'"
217            ))
218        })
219        .map(GeoJson)
220}
221
222/// Returns the GET `/search` endpoint from the [item search conformance
223/// class](https://github.com/radiantearth/stac-api-spec/tree/release/v1.0.0/item-search)
224pub async fn get_search<B: Backend>(
225    State(api): State<Api<B>>,
226    search: Query<GetSearch>,
227) -> Result<GeoJson<ItemCollection>> {
228    tracing::debug!("GET /search: {:?}", search.0);
229    let search = Search::try_from(search.0)
230        .and_then(Search::valid)
231        .map_err(|error| Error::BadRequest(error.to_string()))?;
232
233    Ok(GeoJson(api.search(search, Method::GET).await?))
234}
235
236/// Returns the POST `/search` endpoint from the [item search conformance
237/// class](https://github.com/radiantearth/stac-api-spec/tree/release/v1.0.0/item-search)
238pub async fn post_search<B: Backend>(
239    State(api): State<Api<B>>,
240    search: std::result::Result<Json<Search>, JsonRejection>,
241) -> Result<GeoJson<ItemCollection>> {
242    let search = search?
243        .0
244        .valid()
245        .map_err(|error| Error::BadRequest(error.to_string()))?;
246    Ok(GeoJson(api.search(search, Method::POST).await?))
247}
248
249#[cfg(test)]
250mod tests {
251    use crate::{Api, MemoryBackend};
252    use axum::{
253        body::Body,
254        http::{Request, Response, StatusCode, header::CONTENT_TYPE},
255    };
256    use stac::api::TransactionClient;
257    use stac::{Collection, Item};
258    use tower::util::ServiceExt;
259
260    async fn get(backend: MemoryBackend, uri: &str) -> Response<Body> {
261        let router = super::from_api(
262            Api::new(backend, "http://stac.test/")
263                .unwrap()
264                .id("an-id")
265                .description("a description"),
266        );
267        router
268            .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
269            .await
270            .unwrap()
271    }
272
273    async fn post(backend: MemoryBackend, uri: &str) -> Response<Body> {
274        let router = super::from_api(
275            Api::new(backend, "http://stac.test/")
276                .unwrap()
277                .id("an-id")
278                .description("a description"),
279        );
280        router
281            .oneshot(
282                Request::builder()
283                    .uri(uri)
284                    .method("POST")
285                    .header("Content-Type", "application/json")
286                    .body("{}".to_string())
287                    .unwrap(),
288            )
289            .await
290            .unwrap()
291    }
292
293    #[tokio::test]
294    async fn root() {
295        let response = get(MemoryBackend::new(), "/").await;
296        assert_eq!(response.status(), StatusCode::OK);
297        assert_eq!(
298            response.headers().get(CONTENT_TYPE).unwrap(),
299            "application/json"
300        );
301    }
302
303    #[tokio::test]
304    async fn service_description() {
305        let response = get(MemoryBackend::new(), "/api").await;
306        assert_eq!(response.status(), StatusCode::OK);
307        assert_eq!(
308            response.headers().get(CONTENT_TYPE).unwrap(),
309            "application/vnd.oai.openapi+json;version=3.0"
310        );
311    }
312
313    #[tokio::test]
314    async fn service_doc() {
315        let response = get(MemoryBackend::new(), "/api.html").await;
316        assert_eq!(response.status(), StatusCode::OK);
317        assert_eq!(
318            response.headers().get(CONTENT_TYPE).unwrap(),
319            "text/html; charset=utf-8"
320        );
321    }
322
323    #[tokio::test]
324    async fn conformance() {
325        let response = get(MemoryBackend::new(), "/conformance").await;
326        assert_eq!(response.status(), StatusCode::OK);
327        assert_eq!(
328            response.headers().get(CONTENT_TYPE).unwrap(),
329            "application/json"
330        );
331    }
332
333    #[tokio::test]
334    async fn collections() {
335        let response = get(MemoryBackend::new(), "/collections").await;
336        assert_eq!(response.status(), StatusCode::OK);
337        assert_eq!(
338            response.headers().get(CONTENT_TYPE).unwrap(),
339            "application/json"
340        );
341    }
342
343    #[tokio::test]
344    async fn collection() {
345        let response = get(MemoryBackend::new(), "/collections/an-id").await;
346        assert_eq!(response.status(), StatusCode::NOT_FOUND);
347        let mut backend = MemoryBackend::new();
348        backend
349            .add_collection(Collection::new("an-id", "A description"))
350            .await
351            .unwrap();
352        let response = get(backend, "/collections/an-id").await;
353        assert_eq!(response.status(), StatusCode::OK);
354        assert_eq!(
355            response.headers().get(CONTENT_TYPE).unwrap(),
356            "application/json"
357        );
358    }
359
360    #[tokio::test]
361    async fn items() {
362        let response = get(MemoryBackend::new(), "/collections/collection-id/items").await;
363        assert_eq!(response.status(), StatusCode::NOT_FOUND);
364
365        let mut backend = MemoryBackend::new();
366        backend
367            .add_collection(Collection::new("collection-id", "A description"))
368            .await
369            .unwrap();
370        backend
371            .add_item(Item::new("item-id").collection("collection-id"))
372            .await
373            .unwrap();
374        let response = get(backend, "/collections/collection-id/items").await;
375        assert_eq!(response.status(), StatusCode::OK);
376        assert_eq!(
377            response.headers().get(CONTENT_TYPE).unwrap(),
378            "application/geo+json"
379        );
380    }
381
382    #[tokio::test]
383    async fn item() {
384        let response = get(
385            MemoryBackend::new(),
386            "/collections/collection-id/items/item-id",
387        )
388        .await;
389        assert_eq!(response.status(), StatusCode::NOT_FOUND);
390
391        let mut backend = MemoryBackend::new();
392        backend
393            .add_collection(Collection::new("collection-id", "A description"))
394            .await
395            .unwrap();
396        backend
397            .add_item(Item::new("item-id").collection("collection-id"))
398            .await
399            .unwrap();
400        let response = get(backend, "/collections/collection-id/items/item-id").await;
401        assert_eq!(response.status(), StatusCode::OK);
402        assert_eq!(
403            response.headers().get(CONTENT_TYPE).unwrap(),
404            "application/geo+json"
405        );
406    }
407
408    #[tokio::test]
409    async fn get_search() {
410        let response = get(MemoryBackend::new(), "/search").await;
411        assert_eq!(response.status(), StatusCode::OK);
412        assert_eq!(
413            response.headers().get(CONTENT_TYPE).unwrap(),
414            "application/geo+json"
415        );
416    }
417
418    #[tokio::test]
419    async fn post_search() {
420        let response = post(MemoryBackend::new(), "/search").await;
421        assert_eq!(response.status(), StatusCode::OK);
422        assert_eq!(
423            response.headers().get(CONTENT_TYPE).unwrap(),
424            "application/geo+json"
425        );
426    }
427}