Skip to content

Commit 3a5e6c1

Browse files
committed
Add geocode endpoints
1 parent 2feecd9 commit 3a5e6c1

File tree

4 files changed

+550
-0
lines changed

4 files changed

+550
-0
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package org.nitri.ors
2+
3+
import android.content.Context
4+
import androidx.test.core.app.ApplicationProvider
5+
import androidx.test.ext.junit.runners.AndroidJUnit4
6+
import kotlinx.coroutines.runBlocking
7+
import org.junit.Assert.assertNotNull
8+
import org.junit.Assert.assertTrue
9+
import org.junit.Test
10+
import org.junit.runner.RunWith
11+
import org.nitri.ors.client.OpenRouteServiceClient
12+
import org.nitri.ors.repository.GeocodeRepository
13+
14+
@RunWith(AndroidJUnit4::class)
15+
class GeocodeInstrumentedTest {
16+
17+
private fun createRepository(context: Context): GeocodeRepository {
18+
val apiKey = context.getString(R.string.ors_api_key)
19+
val api = OpenRouteServiceClient.create(apiKey, context)
20+
return GeocodeRepository(api)
21+
}
22+
23+
@Test
24+
fun testGeocode_search_successful() = runBlocking {
25+
val context = ApplicationProvider.getApplicationContext<Context>()
26+
val repo = createRepository(context)
27+
val apiKey = context.getString(R.string.ors_api_key)
28+
29+
val response = repo.search(
30+
text = "Heidelberg",
31+
apiKey = apiKey,
32+
size = 5
33+
)
34+
35+
assertNotNull("Search response should not be null", response)
36+
assertTrue("Features should not be empty for Heidelberg search", response.features.isNotEmpty())
37+
38+
val first = response.features.first()
39+
// Basic geometry sanity
40+
assertNotNull("First feature geometry should not be null", first.geometry)
41+
val geom = first.geometry!!
42+
assertTrue("Geometry coordinates should have at least [lon, lat]", geom.coordinates.size >= 2)
43+
// Basic properties sanity
44+
assertNotNull("First feature properties should not be null", first.properties)
45+
val name = first.properties?.name ?: first.properties?.label
46+
assertTrue("Feature should have a name or label", !name.isNullOrBlank())
47+
}
48+
49+
@Test
50+
fun testGeocode_reverse_successful() = runBlocking {
51+
val context = ApplicationProvider.getApplicationContext<Context>()
52+
val repo = createRepository(context)
53+
val apiKey = context.getString(R.string.ors_api_key)
54+
55+
// Point near Heidelberg, Germany
56+
val lon = 8.681495
57+
val lat = 49.41461
58+
59+
val response = repo.reverse(
60+
apiKey = apiKey,
61+
lon = lon,
62+
lat = lat,
63+
size = 5
64+
)
65+
66+
assertNotNull("Reverse response should not be null", response)
67+
assertTrue("Reverse should return at least one feature", response.features.isNotEmpty())
68+
val first = response.features.first()
69+
assertNotNull("First reverse feature properties should not be null", first.properties)
70+
val label = first.properties?.label ?: first.properties?.name
71+
assertTrue("Reverse feature should provide a label/name", !label.isNullOrBlank())
72+
}
73+
74+
@Test
75+
fun testGeocode_autocomplete_successful() = runBlocking {
76+
val context = ApplicationProvider.getApplicationContext<Context>()
77+
val repo = createRepository(context)
78+
val apiKey = context.getString(R.string.ors_api_key)
79+
80+
val response = repo.autocomplete(
81+
apiKey = apiKey,
82+
text = "Heidelb",
83+
size = 5
84+
)
85+
86+
assertNotNull("Autocomplete response should not be null", response)
87+
assertTrue("Autocomplete should return suggestions", response.features.isNotEmpty())
88+
}
89+
}

ors-client/src/main/java/org/nitri/ors/api/OpenRouteServiceApi.kt

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
package org.nitri.ors.api
22

33
import okhttp3.ResponseBody
4+
import org.nitri.ors.model.elevation.ElevationLineRequest
5+
import org.nitri.ors.model.elevation.ElevationLineResponse
6+
import org.nitri.ors.model.elevation.ElevationPointRequest
7+
import org.nitri.ors.model.elevation.ElevationPointResponse
48
import org.nitri.ors.model.export.ExportRequest
59
import org.nitri.ors.model.export.ExportResponse
610
import org.nitri.ors.model.export.TopoJsonExportResponse
11+
import org.nitri.ors.model.geocode.GeocodeSearchResponse
712
import org.nitri.ors.model.isochrones.IsochronesRequest
813
import org.nitri.ors.model.isochrones.IsochronesResponse
914
import org.nitri.ors.model.matrix.MatrixRequest
@@ -127,4 +132,131 @@ interface OpenRouteServiceApi {
127132
@Body request: OptimizationRequest
128133
): OptimizationResponse
129134

135+
// Elevation
136+
137+
@POST("elevation/line")
138+
suspend fun getElevationLine(
139+
@Body request: ElevationLineRequest
140+
): ElevationLineResponse
141+
142+
@GET("elevation/point")
143+
suspend fun getElevationPointSimple(
144+
@Query("geometry") start: String, // e.g. "8.681495,49.41461"
145+
): RouteResponse
146+
147+
@POST("elevation/point")
148+
suspend fun getElevationPoint(
149+
@Body request: ElevationPointRequest
150+
): ElevationPointResponse
151+
152+
// Geocode
153+
154+
@GET("geocode/search")
155+
suspend fun geocodeSearch(
156+
@Query("text") text: String,
157+
158+
// Optional focus point
159+
@Query("focus.point.lon") focusLon: Double? = null,
160+
@Query("focus.point.lat") focusLat: Double? = null,
161+
162+
// Optional rectangular boundary
163+
@Query("boundary.rect.min_lon") rectMinLon: Double? = null,
164+
@Query("boundary.rect.min_lat") rectMinLat: Double? = null,
165+
@Query("boundary.rect.max_lon") rectMaxLon: Double? = null,
166+
@Query("boundary.rect.max_lat") rectMaxLat: Double? = null,
167+
168+
// Optional circular boundary
169+
@Query("boundary.circle.lon") circleLon: Double? = null,
170+
@Query("boundary.circle.lat") circleLat: Double? = null,
171+
@Query("boundary.circle.radius") circleRadiusMeters: Double? = null,
172+
173+
// Other optional filters
174+
@Query("boundary.gid") boundaryGid: String? = null,
175+
// Pass comma-separated if multiple, e.g. "DE,AT"
176+
@Query("boundary.country") boundaryCountry: String? = null,
177+
// Pelias expects comma-separated values; join your list before passing.
178+
@Query("sources") sourcesCsv: String? = null, // e.g. "osm,oa,gn,wof"
179+
@Query("layers") layersCsv: String? = null, // e.g. "region,country,locality,address"
180+
@Query("size") size: Int? = 10,
181+
182+
// Geocoder uses api_key as query
183+
@Query("api_key") apiKey: String
184+
): GeocodeSearchResponse
185+
186+
@GET("geocode/autocomplete")
187+
suspend fun autocomplete(
188+
@Query("api_key") apiKey: String,
189+
@Query("text") text: String,
190+
@Query("focus.point.lon") focusLon: Double? = null,
191+
@Query("focus.point.lat") focusLat: Double? = null,
192+
@Query("boundary.rect.min_lon") rectMinLon: Double? = null,
193+
@Query("boundary.rect.min_lat") rectMinLat: Double? = null,
194+
@Query("boundary.rect.max_lon") rectMaxLon: Double? = null,
195+
@Query("boundary.rect.max_lat") rectMaxLat: Double? = null,
196+
@Query("boundary.circle.lon") circleLon: Double? = null,
197+
@Query("boundary.circle.lat") circleLat: Double? = null,
198+
@Query("boundary.circle.radius") circleRadius: Double? = null,
199+
@Query("boundary.country") country: String? = null,
200+
@Query("sources") sources: List<String>? = null,
201+
@Query("layers") layers: List<String>? = null,
202+
@Query("size") size: Int? = null
203+
): GeocodeSearchResponse
204+
205+
206+
@GET("geocode/search/structured")
207+
suspend fun geocodeStructured(
208+
@Query("api_key") apiKey: String,
209+
210+
// Structured query parts (all optional; send only what you have)
211+
@Query("address") address: String? = null,
212+
@Query("neighbourhood") neighbourhood: String? = null,
213+
@Query("borough") borough: String? = null,
214+
@Query("locality") locality: String? = null, // e.g., city
215+
@Query("county") county: String? = null,
216+
@Query("region") region: String? = null, // e.g., state/province
217+
@Query("country") country: String? = null, // ISO code or name
218+
@Query("postalcode") postalcode: String? = null,
219+
220+
// Focus point (used for ranking)
221+
@Query("focus.point.lon") focusLon: Double? = null,
222+
@Query("focus.point.lat") focusLat: Double? = null,
223+
224+
// Bounding rectangle (optional)
225+
@Query("boundary.rect.min_lon") rectMinLon: Double? = null,
226+
@Query("boundary.rect.min_lat") rectMinLat: Double? = null,
227+
@Query("boundary.rect.max_lon") rectMaxLon: Double? = null,
228+
@Query("boundary.rect.max_lat") rectMaxLat: Double? = null,
229+
230+
// Bounding circle (optional)
231+
@Query("boundary.circle.lon") circleLon: Double? = null,
232+
@Query("boundary.circle.lat") circleLat: Double? = null,
233+
@Query("boundary.circle.radius") circleRadiusMeters: Double? = null,
234+
235+
// Limit results to a specific country (ISO code)
236+
@Query("boundary.country") boundaryCountry: String? = null,
237+
238+
// Filters
239+
@Query("layers") layers: List<String>? = null, // e.g. ["address","venue","street","locality","region","country"]
240+
@Query("sources") sources: List<String>? = null, // e.g. ["osm","oa","gn","wof"]
241+
242+
// Number of results (default 10)
243+
@Query("size") size: Int? = null
244+
): GeocodeSearchResponse
245+
246+
247+
@GET("geocode/reverse")
248+
suspend fun geocodeReverse(
249+
@Query("api_key") apiKey: String,
250+
251+
// required
252+
@Query("point.lon") lon: Double,
253+
@Query("point.lat") lat: Double,
254+
255+
// optional ranking/filters
256+
@Query("boundary.circle.radius") radiusKm: Double? = null, // Pelias expects km
257+
@Query("size") size: Int? = null, // default 10
258+
@Query("layers") layers: List<String>? = null, // e.g. ["address","venue"]
259+
@Query("sources") sources: List<String>? = null, // e.g. ["osm","oa","gn","wof"]
260+
@Query("boundary.country") boundaryCountry: String? = null // ISO code, e.g. "FR"
261+
): GeocodeSearchResponse
130262
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package org.nitri.ors.model.geocode
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
import kotlinx.serialization.json.JsonObject
6+
7+
@Serializable
8+
data class GeocodeSearchResponse(
9+
val geocoding: Geocoding? = null,
10+
val type: String? = null, // "FeatureCollection"
11+
val features: List<GeocodeFeature> = emptyList(),
12+
val bbox: List<Double>? = null,
13+
val errors: List<String>? = null,
14+
val warnings: List<String>? = null,
15+
val engine: Engine? = null,
16+
val timestamp: Long? = null
17+
)
18+
19+
@Serializable
20+
data class Geocoding(
21+
val version: String? = null, // "0.2"
22+
val attribution: String? = null,
23+
val query: GeocodeQuery? = null
24+
)
25+
26+
@Serializable
27+
data class Engine(
28+
val name: String? = null, // "Pelias"
29+
val author: String? = null, // "Mapzen"
30+
val version: String? = null
31+
)
32+
33+
/**
34+
* Pelias "query" block varies by endpoint; everything is optional.
35+
* Fields like "point.lat" are addressed via @SerialName to match JSON.
36+
*/
37+
@Serializable
38+
data class GeocodeQuery(
39+
// common
40+
val text: String? = null,
41+
val size: Int? = null,
42+
val layers: List<String>? = null,
43+
val sources: List<String>? = null,
44+
val private: Boolean? = null,
45+
val parsed_text: JsonObject? = null, // present on some search/structured responses
46+
val lang: PeliasLang? = null,
47+
val querySize: Int? = null,
48+
49+
// focus point (search/autocomplete)
50+
@SerialName("focus.point.lon") val focusPointLon: Double? = null,
51+
@SerialName("focus.point.lat") val focusPointLat: Double? = null,
52+
53+
// reverse & circle bounds
54+
@SerialName("point.lon") val pointLon: Double? = null,
55+
@SerialName("point.lat") val pointLat: Double? = null,
56+
@SerialName("boundary.circle.lon") val boundaryCircleLon: Double? = null,
57+
@SerialName("boundary.circle.lat") val boundaryCircleLat: Double? = null,
58+
@SerialName("boundary.circle.radius") val boundaryCircleRadius: Double? = null,
59+
60+
// rect bounds (search/autocomplete/structured)
61+
@SerialName("boundary.rect.min_lon") val rectMinLon: Double? = null,
62+
@SerialName("boundary.rect.min_lat") val rectMinLat: Double? = null,
63+
@SerialName("boundary.rect.max_lon") val rectMaxLon: Double? = null,
64+
@SerialName("boundary.rect.max_lat") val rectMaxLat: Double? = null,
65+
66+
// country limit (ISO-3166-1 alpha-2)
67+
@SerialName("boundary.country") val boundaryCountry: String? = null,
68+
69+
// structured forward fields (all optional; API requires at least one)
70+
val venue: String? = null,
71+
val address: String? = null,
72+
val neighbourhood: String? = null,
73+
val borough: String? = null,
74+
val locality: String? = null,
75+
val county: String? = null,
76+
val region: String? = null,
77+
val country: String? = null,
78+
val postcode: String? = null
79+
)
80+
81+
@Serializable
82+
data class PeliasLang(
83+
val name: String? = null, // "English"
84+
val iso6391: String? = null, // "en"
85+
val iso6393: String? = null, // "eng"
86+
val via: String? = null, // "header"
87+
val defaulted: Boolean? = null
88+
)
89+
90+
@Serializable
91+
data class GeocodeFeature(
92+
val type: String? = null, // "Feature"
93+
val geometry: GeocodeGeometry? = null,
94+
val properties: GeocodeProperties? = null,
95+
val bbox: List<Double>? = null
96+
)
97+
98+
@Serializable
99+
data class GeocodeGeometry(
100+
val type: String? = null, // typically "Point"
101+
val coordinates: List<Double> = emptyList() // [lon, lat] (+ elevation if provided)
102+
)
103+
104+
/**
105+
* Properties are rich and vary by source; keep them optional.
106+
* `addendum` is left as JsonObject to pass through extra provider-specific content (e.g., OSM).
107+
*/
108+
@Serializable
109+
data class GeocodeProperties(
110+
val id: String? = null,
111+
val gid: String? = null,
112+
val layer: String? = null,
113+
val source: String? = null,
114+
@SerialName("source_id") val sourceId: String? = null,
115+
116+
val name: String? = null,
117+
val confidence: Double? = null,
118+
val distance: Double? = null,
119+
val accuracy: String? = null,
120+
121+
// address-ish
122+
val label: String? = null,
123+
val street: String? = null,
124+
val housenumber: String? = null,
125+
val postalcode: String? = null,
126+
127+
// admin hierarchy
128+
val country: String? = null,
129+
@SerialName("country_gid") val countryGid: String? = null,
130+
@SerialName("country_a") val countryA: String? = null,
131+
132+
val macroregion: String? = null,
133+
@SerialName("macroregion_gid") val macroregionGid: String? = null,
134+
@SerialName("macroregion_a") val macroregionA: String? = null,
135+
136+
val region: String? = null,
137+
@SerialName("region_gid") val regionGid: String? = null,
138+
@SerialName("region_a") val regionA: String? = null,
139+
140+
val localadmin: String? = null,
141+
@SerialName("localadmin_gid") val localadminGid: String? = null,
142+
143+
val locality: String? = null,
144+
@SerialName("locality_gid") val localityGid: String? = null,
145+
146+
val borough: String? = null,
147+
@SerialName("borough_gid") val boroughGid: String? = null,
148+
149+
val neighbourhood: String? = null,
150+
@SerialName("neighbourhood_gid") val neighbourhoodGid: String? = null,
151+
152+
val continent: String? = null,
153+
@SerialName("continent_gid") val continentGid: String? = null,
154+
155+
// provider extras: e.g. wheelchair, website, wikidata, etc.
156+
val addendum: JsonObject? = null
157+
)

0 commit comments

Comments
 (0)