Skip to main content

supabase_client_query/
geojson_select.rs

1use reqwest::header::HeaderValue;
2use serde_json::Value as JsonValue;
3
4use supabase_client_core::SupabaseError;
5
6use crate::backend::QueryBackend;
7use crate::filter::Filterable;
8use crate::modifier::Modifiable;
9use crate::sql::{FilterCondition, ParamStore, SqlParts};
10
11/// Builder for SELECT queries that return GeoJSON.
12///
13/// Created by calling `.geojson()` on a `SelectBuilder`. Returns a
14/// `serde_json::Value` (GeoJSON FeatureCollection). REST-only.
15pub struct GeoJsonSelectBuilder {
16    pub(crate) backend: QueryBackend,
17    pub(crate) parts: SqlParts,
18    pub(crate) params: ParamStore,
19}
20
21impl Filterable for GeoJsonSelectBuilder {
22    fn filters_mut(&mut self) -> &mut Vec<FilterCondition> {
23        &mut self.parts.filters
24    }
25    fn params_mut(&mut self) -> &mut ParamStore {
26        &mut self.params
27    }
28}
29
30impl Modifiable for GeoJsonSelectBuilder {
31    fn parts_mut(&mut self) -> &mut SqlParts {
32        &mut self.parts
33    }
34}
35
36impl GeoJsonSelectBuilder {
37    /// Override the schema for this query.
38    pub fn schema(mut self, schema: &str) -> Self {
39        self.parts.schema_override = Some(schema.to_string());
40        self
41    }
42
43    /// Execute the SELECT query and return the response as GeoJSON.
44    pub async fn execute(self) -> Result<JsonValue, SupabaseError> {
45        match &self.backend {
46            QueryBackend::Rest { http, base_url, api_key, schema } => {
47                let (url, mut headers) = crate::postgrest::build_postgrest_select(
48                    base_url, &self.parts, &self.params,
49                )
50                .map_err(SupabaseError::QueryBuilder)?;
51
52                // Override Accept to GeoJSON
53                headers.insert(
54                    "Accept",
55                    HeaderValue::from_static("application/geo+json"),
56                );
57
58                // Standard auth headers
59                headers.insert("apikey", HeaderValue::from_str(api_key).unwrap());
60                headers.insert(
61                    "Authorization",
62                    HeaderValue::from_str(&format!("Bearer {}", api_key)).unwrap(),
63                );
64
65                // Schema profile
66                if let Some(ref so) = self.parts.schema_override {
67                    headers.insert(
68                        "Accept-Profile",
69                        HeaderValue::from_str(so).unwrap(),
70                    );
71                } else if schema != "public" {
72                    headers.entry("Accept-Profile")
73                        .or_insert_with(|| HeaderValue::from_str(schema).unwrap());
74                }
75
76                let response = http
77                    .get(&url)
78                    .headers(headers)
79                    .send()
80                    .await
81                    .map_err(|e| SupabaseError::Http(e.to_string()))?;
82
83                let status = response.status().as_u16();
84                let body = response
85                    .text()
86                    .await
87                    .map_err(|e| SupabaseError::Http(e.to_string()))?;
88
89                if status >= 400 {
90                    return Err(SupabaseError::postgrest(status, body, None));
91                }
92
93                serde_json::from_str(&body).map_err(|e| {
94                    SupabaseError::Serialization(format!(
95                        "Failed to parse GeoJSON response: {}",
96                        e
97                    ))
98                })
99            }
100            #[cfg(feature = "direct-sql")]
101            QueryBackend::DirectSql { .. } => {
102                Err(SupabaseError::query_builder(
103                    "GeoJSON output is only supported with the REST (PostgREST) backend",
104                ))
105            }
106        }
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::sql::{SqlOperation, SqlParts, ParamStore};
114
115    #[test]
116    fn test_geojson_builder_modifiable() {
117        let mut builder = GeoJsonSelectBuilder {
118            backend: QueryBackend::Rest {
119                http: reqwest::Client::new(),
120                base_url: "http://localhost".into(),
121                api_key: "key".into(),
122                schema: "public".to_string(),
123            },
124            parts: SqlParts::new(SqlOperation::Select, "public", "cities"),
125            params: ParamStore::new(),
126        };
127        builder = builder.limit(10);
128        assert_eq!(builder.parts.limit, Some(10));
129    }
130
131    #[test]
132    fn test_geojson_builder_filterable() {
133        let builder = GeoJsonSelectBuilder {
134            backend: QueryBackend::Rest {
135                http: reqwest::Client::new(),
136                base_url: "http://localhost".into(),
137                api_key: "key".into(),
138                schema: "public".to_string(),
139            },
140            parts: SqlParts::new(SqlOperation::Select, "public", "cities"),
141            params: ParamStore::new(),
142        };
143        let builder = builder.eq("name", "Auckland");
144        assert_eq!(builder.parts.filters.len(), 1);
145    }
146
147    #[test]
148    fn test_geojson_accept_header() {
149        let parts = SqlParts::new(SqlOperation::Select, "public", "cities");
150        let params = ParamStore::new();
151        let (_, mut headers) = crate::postgrest::build_postgrest_select(
152            "http://localhost:64321", &parts, &params,
153        ).unwrap();
154        // Simulate what GeoJsonSelectBuilder does
155        headers.insert("Accept", HeaderValue::from_static("application/geo+json"));
156        assert_eq!(headers.get("Accept").unwrap(), "application/geo+json");
157    }
158
159    #[test]
160    fn test_geojson_schema_sets_override() {
161        let builder = GeoJsonSelectBuilder {
162            backend: QueryBackend::Rest {
163                http: reqwest::Client::new(),
164                base_url: "http://localhost".into(),
165                api_key: "key".into(),
166                schema: "public".to_string(),
167            },
168            parts: SqlParts::new(SqlOperation::Select, "public", "cities"),
169            params: ParamStore::new(),
170        };
171        let builder = builder.schema("geo_schema");
172        assert_eq!(builder.parts.schema_override.as_deref(), Some("geo_schema"));
173    }
174
175    // ---- execute() via wiremock ----
176
177    #[tokio::test]
178    async fn test_geojson_execute_success() {
179        use wiremock::matchers::{method, path};
180        use wiremock::{Mock, MockServer, ResponseTemplate};
181
182        let mock_server = MockServer::start().await;
183        let geojson = serde_json::json!({
184            "type": "FeatureCollection",
185            "features": [
186                {
187                    "type": "Feature",
188                    "geometry": {
189                        "type": "Point",
190                        "coordinates": [174.7633, -36.8485]
191                    },
192                    "properties": {
193                        "name": "Auckland"
194                    }
195                }
196            ]
197        });
198        Mock::given(method("GET"))
199            .and(path("/rest/v1/cities"))
200            .respond_with(
201                ResponseTemplate::new(200)
202                    .set_body_json(geojson.clone()),
203            )
204            .mount(&mock_server)
205            .await;
206
207        let builder = GeoJsonSelectBuilder {
208            backend: QueryBackend::Rest {
209                http: reqwest::Client::new(),
210                base_url: mock_server.uri().into(),
211                api_key: "test-key".into(),
212                schema: "public".to_string(),
213            },
214            parts: SqlParts::new(SqlOperation::Select, "public", "cities"),
215            params: ParamStore::new(),
216        };
217
218        let result = builder.execute().await;
219        assert!(result.is_ok());
220        let value = result.unwrap();
221        assert_eq!(value["type"], "FeatureCollection");
222        assert_eq!(value["features"][0]["properties"]["name"], "Auckland");
223    }
224
225    #[tokio::test]
226    async fn test_geojson_execute_error() {
227        use wiremock::matchers::{method, path};
228        use wiremock::{Mock, MockServer, ResponseTemplate};
229
230        let mock_server = MockServer::start().await;
231        Mock::given(method("GET"))
232            .and(path("/rest/v1/nonexistent"))
233            .respond_with(
234                ResponseTemplate::new(404)
235                    .set_body_string("Relation not found"),
236            )
237            .mount(&mock_server)
238            .await;
239
240        let builder = GeoJsonSelectBuilder {
241            backend: QueryBackend::Rest {
242                http: reqwest::Client::new(),
243                base_url: mock_server.uri().into(),
244                api_key: "test-key".into(),
245                schema: "public".to_string(),
246            },
247            parts: SqlParts::new(SqlOperation::Select, "public", "nonexistent"),
248            params: ParamStore::new(),
249        };
250
251        let result = builder.execute().await;
252        assert!(result.is_err());
253        match result.unwrap_err() {
254            SupabaseError::PostgRest { status, .. } => {
255                assert_eq!(status, 404);
256            }
257            other => panic!("Expected PostgRest error, got {:?}", other),
258        }
259    }
260
261    #[tokio::test]
262    async fn test_geojson_execute_invalid_json_body() {
263        use wiremock::matchers::{method, path};
264        use wiremock::{Mock, MockServer, ResponseTemplate};
265
266        let mock_server = MockServer::start().await;
267        Mock::given(method("GET"))
268            .and(path("/rest/v1/cities"))
269            .respond_with(
270                ResponseTemplate::new(200)
271                    .set_body_string("this is not json"),
272            )
273            .mount(&mock_server)
274            .await;
275
276        let builder = GeoJsonSelectBuilder {
277            backend: QueryBackend::Rest {
278                http: reqwest::Client::new(),
279                base_url: mock_server.uri().into(),
280                api_key: "test-key".into(),
281                schema: "public".to_string(),
282            },
283            parts: SqlParts::new(SqlOperation::Select, "public", "cities"),
284            params: ParamStore::new(),
285        };
286
287        let result = builder.execute().await;
288        assert!(result.is_err());
289        match result.unwrap_err() {
290            SupabaseError::Serialization(msg) => {
291                assert!(msg.contains("Failed to parse GeoJSON"));
292            }
293            other => panic!("Expected Serialization error, got {:?}", other),
294        }
295    }
296}