diff --git a/app/services/photon.js b/app/services/photon.js new file mode 100644 index 0000000..4ea76cd --- /dev/null +++ b/app/services/photon.js @@ -0,0 +1,102 @@ +import Service from '@ember/service'; + +export default class PhotonService extends Service { + baseUrl = 'https://photon.komoot.io/api/'; + + async search(query, lat, lon, limit = 10) { + if (!query || query.length < 2) return []; + + const params = new URLSearchParams({ + q: query, + limit: String(limit), + }); + + if (lat && lon) { + params.append('lat', String(lat)); + params.append('lon', String(lon)); + } + + const url = `${this.baseUrl}?${params.toString()}`; + + try { + const res = await this.fetchWithRetry(url); + if (!res.ok) { + throw new Error(`Photon request failed with status ${res.status}`); + } + const data = await res.json(); + + if (!data.features) return []; + + return data.features.map((f) => this.normalizeFeature(f)); + } catch (e) { + console.error('Photon search error:', e); + // Return empty array on error so UI doesn't break + return []; + } + } + + normalizeFeature(feature) { + const props = feature.properties || {}; + const geom = feature.geometry || {}; + const coords = geom.coordinates || []; + + // Photon returns [lon, lat] for Point geometries + const lon = coords[0]; + const lat = coords[1]; + + // Construct a description from address fields + // Priority: name -> street -> city -> state -> country + const addressParts = []; + if (props.street) + addressParts.push( + props.housenumber + ? `${props.street} ${props.housenumber}` + : props.street + ); + if (props.city && props.city !== props.name) addressParts.push(props.city); + if (props.state && props.state !== props.city) + addressParts.push(props.state); + if (props.country) addressParts.push(props.country); + + const description = addressParts.join(', '); + const title = props.name || description || 'Unknown Place'; + + return { + title, + lat, + lon, + osmId: props.osm_id, + osmType: props.osm_type, // 'N', 'W', 'R' + osmTags: props, // Keep all properties as tags for now + description: props.name ? description : addressParts.slice(1).join(', '), + source: 'photon', + }; + } + + async fetchWithRetry(url, options = {}, retries = 3) { + try { + // eslint-disable-next-line warp-drive/no-external-request-patterns + const res = await fetch(url, options); + + // Retry on 5xx errors or 429 Too Many Requests + if (!res.ok && retries > 0 && [502, 503, 504, 429].includes(res.status)) { + console.warn( + `Photon request failed with ${res.status}. Retrying... (${retries} left)` + ); + // Exponential backoff or fixed delay? Let's do 1s fixed delay for simplicity + await new Promise((r) => setTimeout(r, 1000)); + return this.fetchWithRetry(url, options, retries - 1); + } + + return res; + } catch (e) { + // Retry on network errors (fetch throws) except AbortError + if (retries > 0 && e.name !== 'AbortError') { + console.debug(`Retrying Photon request... (${retries} left)`, e); + await new Promise((r) => setTimeout(r, 1000)); + return this.fetchWithRetry(url, options, retries - 1); + } + throw e; + } + } +} diff --git a/tests/unit/services/photon-test.js b/tests/unit/services/photon-test.js new file mode 100644 index 0000000..f0e76cd --- /dev/null +++ b/tests/unit/services/photon-test.js @@ -0,0 +1,90 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'marco/tests/helpers'; + +module('Unit | Service | photon', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let service = this.owner.lookup('service:photon'); + assert.ok(service); + }); + + test('search handles successful response', async function (assert) { + let service = this.owner.lookup('service:photon'); + + // Mock fetch + const originalFetch = window.fetch; + window.fetch = async () => { + return { + ok: true, + json: async () => ({ + features: [ + { + properties: { + name: 'Test Place', + osm_id: 123, + osm_type: 'N', + city: 'Test City', + country: 'Test Country', + }, + geometry: { + coordinates: [13.4, 52.5], // lon, lat + }, + }, + ], + }), + }; + }; + + try { + const results = await service.search('Test', 52.5, 13.4); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].title, 'Test Place'); + assert.strictEqual(results[0].lat, 52.5); + assert.strictEqual(results[0].lon, 13.4); + assert.strictEqual(results[0].description, 'Test City, Test Country'); + } finally { + window.fetch = originalFetch; + } + }); + + test('search handles empty response', async function (assert) { + let service = this.owner.lookup('service:photon'); + + // Mock fetch + const originalFetch = window.fetch; + window.fetch = async () => { + return { + ok: true, + json: async () => ({ features: [] }), + }; + }; + + try { + const results = await service.search('Nonexistent', 52.5, 13.4); + assert.strictEqual(results.length, 0); + } finally { + window.fetch = originalFetch; + } + }); + + test('normalizeFeature handles missing properties', function (assert) { + let service = this.owner.lookup('service:photon'); + + const feature = { + properties: { + street: 'Main St', + housenumber: '123', + city: 'Metropolis', + }, + geometry: { + coordinates: [10, 20], + }, + }; + + const result = service.normalizeFeature(feature); + assert.strictEqual(result.title, 'Main St 123, Metropolis'); // Fallback to address description + assert.strictEqual(result.lat, 20); + assert.strictEqual(result.lon, 10); + }); +});