From 1bb5f256a66fbadd2604dd08be24ad33bbe4a09e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 29 Jul 2024 11:44:33 -0400 Subject: [PATCH] feat(server): reverse geocoding endpoint --- e2e/src/api/specs/map.e2e-spec.ts | 71 ++++++++++ mobile/openapi/README.md | 2 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/map_api.dart | 57 ++++++++ mobile/openapi/lib/api_client.dart | 2 + .../map_reverse_geocode_response_dto.dart | 126 ++++++++++++++++++ open-api/immich-openapi-specs.json | 76 +++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 19 +++ server/src/controllers/map.controller.ts | 16 ++- server/src/dtos/map.dto.ts | 67 ++++++++++ server/src/dtos/search.dto.ts | 39 ------ server/src/services/map.service.ts | 9 +- 12 files changed, 443 insertions(+), 42 deletions(-) create mode 100644 mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart create mode 100644 server/src/dtos/map.dto.ts diff --git a/e2e/src/api/specs/map.e2e-spec.ts b/e2e/src/api/specs/map.e2e-spec.ts index dcfdf0bc58985..25f3ef1d8ca9b 100644 --- a/e2e/src/api/specs/map.e2e-spec.ts +++ b/e2e/src/api/specs/map.e2e-spec.ts @@ -159,4 +159,75 @@ describe('/map', () => { expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); }); }); + + describe('GET /map/reverse-geocode', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/map/reverse-geocode'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should throw an error if a lat is not provided', async () => { + const { status, body } = await request(app) + .get('/map/reverse-geocode?lon=123') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['lat must be a latitude string or number'])); + }); + + it('should throw an error if a lat is not a number', async () => { + const { status, body } = await request(app) + .get('/map/reverse-geocode?lat=abc&lon=123.456') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['lat must be a latitude string or number'])); + }); + + it('should throw an error if a lat is out of range', async () => { + const { status, body } = await request(app) + .get('/map/reverse-geocode?lat=91&lon=123.456') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['lat must be a latitude string or number'])); + }); + + it('should throw an error if a lon is not provided', async () => { + const { status, body } = await request(app) + .get('/map/reverse-geocode?lat=75') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['lon must be a longitude string or number'])); + }); + + const reverseGeocodeTestCases = [ + { + name: 'Vaucluse', + lat: -33.858_977_058_663_13, + lon: 151.278_490_730_270_48, + results: [{ city: 'Vaucluse', state: 'New South Wales', country: 'Australia' }], + }, + { + name: 'Ravenhall', + lat: -37.765_732_399_174_75, + lon: 144.752_453_164_883_3, + results: [{ city: 'Ravenhall', state: 'Victoria', country: 'Australia' }], + }, + { + name: 'Scarborough', + lat: -31.894_346_156_789_997, + lon: 115.757_617_103_904_64, + results: [{ city: 'Scarborough', state: 'Western Australia', country: 'Australia' }], + }, + ]; + + it.each(reverseGeocodeTestCases)(`should resolve to $name`, async ({ lat, lon, results }) => { + const { status, body } = await request(app) + .get(`/map/reverse-geocode?lat=${lat}&lon=${lon}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(results.length); + expect(body).toEqual(results); + }); + }); }); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index fa054333cb431..5323982046727 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -146,6 +146,7 @@ Class | Method | HTTP request | Description *LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | *MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | *MapApi* | [**getMapStyle**](doc//MapApi.md#getmapstyle) | **GET** /map/style.json | +*MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode | *MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | *MemoriesApi* | [**createMemory**](doc//MemoriesApi.md#creatememory) | **POST** /memories | *MemoriesApi* | [**deleteMemory**](doc//MemoriesApi.md#deletememory) | **DELETE** /memories/{id} | @@ -339,6 +340,7 @@ Class | Method | HTTP request | Description - [LoginResponseDto](doc//LoginResponseDto.md) - [LogoutResponseDto](doc//LogoutResponseDto.md) - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md) + - [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md) - [MapTheme](doc//MapTheme.md) - [MemoryCreateDto](doc//MemoryCreateDto.md) - [MemoryLaneResponseDto](doc//MemoryLaneResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index b332e73e71210..e7aaf38de70ca 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -152,6 +152,7 @@ part 'model/login_credential_dto.dart'; part 'model/login_response_dto.dart'; part 'model/logout_response_dto.dart'; part 'model/map_marker_response_dto.dart'; +part 'model/map_reverse_geocode_response_dto.dart'; part 'model/map_theme.dart'; part 'model/memory_create_dto.dart'; part 'model/memory_lane_response_dto.dart'; diff --git a/mobile/openapi/lib/api/map_api.dart b/mobile/openapi/lib/api/map_api.dart index 7a33498c73053..2846dae6c3582 100644 --- a/mobile/openapi/lib/api/map_api.dart +++ b/mobile/openapi/lib/api/map_api.dart @@ -160,4 +160,61 @@ class MapApi { } return null; } + + /// Performs an HTTP 'GET /map/reverse-geocode' operation and returns the [Response]. + /// Parameters: + /// + /// * [double] lat (required): + /// + /// * [double] lon (required): + Future reverseGeocodeWithHttpInfo(double lat, double lon,) async { + // ignore: prefer_const_declarations + final path = r'/map/reverse-geocode'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + queryParams.addAll(_queryParams('', 'lat', lat)); + queryParams.addAll(_queryParams('', 'lon', lon)); + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [double] lat (required): + /// + /// * [double] lon (required): + Future?> reverseGeocode(double lat, double lon,) async { + final response = await reverseGeocodeWithHttpInfo(lat, lon,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index f423676c5f2a9..4fe810b886e47 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -362,6 +362,8 @@ class ApiClient { return LogoutResponseDto.fromJson(value); case 'MapMarkerResponseDto': return MapMarkerResponseDto.fromJson(value); + case 'MapReverseGeocodeResponseDto': + return MapReverseGeocodeResponseDto.fromJson(value); case 'MapTheme': return MapThemeTypeTransformer().decode(value); case 'MemoryCreateDto': diff --git a/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart b/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart new file mode 100644 index 0000000000000..ac99dd91a9915 --- /dev/null +++ b/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart @@ -0,0 +1,126 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class MapReverseGeocodeResponseDto { + /// Returns a new [MapReverseGeocodeResponseDto] instance. + MapReverseGeocodeResponseDto({ + required this.city, + required this.country, + required this.state, + }); + + String? city; + + String? country; + + String? state; + + @override + bool operator ==(Object other) => identical(this, other) || other is MapReverseGeocodeResponseDto && + other.city == city && + other.country == country && + other.state == state; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (city == null ? 0 : city!.hashCode) + + (country == null ? 0 : country!.hashCode) + + (state == null ? 0 : state!.hashCode); + + @override + String toString() => 'MapReverseGeocodeResponseDto[city=$city, country=$country, state=$state]'; + + Map toJson() { + final json = {}; + if (this.city != null) { + json[r'city'] = this.city; + } else { + // json[r'city'] = null; + } + if (this.country != null) { + json[r'country'] = this.country; + } else { + // json[r'country'] = null; + } + if (this.state != null) { + json[r'state'] = this.state; + } else { + // json[r'state'] = null; + } + return json; + } + + /// Returns a new [MapReverseGeocodeResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static MapReverseGeocodeResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return MapReverseGeocodeResponseDto( + city: mapValueOfType(json, r'city'), + country: mapValueOfType(json, r'country'), + state: mapValueOfType(json, r'state'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = MapReverseGeocodeResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = MapReverseGeocodeResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of MapReverseGeocodeResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = MapReverseGeocodeResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'city', + 'country', + 'state', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d7c2e5af2e559..5a01da88a23ff 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3109,6 +3109,60 @@ ] } }, + "/map/reverse-geocode": { + "get": { + "operationId": "reverseGeocode", + "parameters": [ + { + "name": "lat", + "required": true, + "in": "query", + "schema": { + "format": "double", + "type": "number" + } + }, + { + "name": "lon", + "required": true, + "in": "query", + "schema": { + "format": "double", + "type": "number" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/MapReverseGeocodeResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Map" + ] + } + }, "/map/style.json": { "get": { "operationId": "getMapStyle", @@ -9128,6 +9182,28 @@ ], "type": "object" }, + "MapReverseGeocodeResponseDto": { + "properties": { + "city": { + "nullable": true, + "type": "string" + }, + "country": { + "nullable": true, + "type": "string" + }, + "state": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "city", + "country", + "state" + ], + "type": "object" + }, "MapTheme": { "enum": [ "light", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 106250a6b39a7..5d6c2a0eccb6c 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -554,6 +554,11 @@ export type MapMarkerResponseDto = { lon: number; state: string | null; }; +export type MapReverseGeocodeResponseDto = { + city: string | null; + country: string | null; + state: string | null; +}; export type OnThisDayDto = { year: number; }; @@ -1991,6 +1996,20 @@ export function getMapMarkers({ fileCreatedAfter, fileCreatedBefore, isArchived, ...opts })); } +export function reverseGeocode({ lat, lon }: { + lat: number; + lon: number; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: MapReverseGeocodeResponseDto[]; + }>(`/map/reverse-geocode${QS.query(QS.explode({ + lat, + lon + }))}`, { + ...opts + })); +} export function getMapStyle({ key, theme }: { key?: string; theme: MapTheme; diff --git a/server/src/controllers/map.controller.ts b/server/src/controllers/map.controller.ts index 223e6b8147619..d6c26c58a073c 100644 --- a/server/src/controllers/map.controller.ts +++ b/server/src/controllers/map.controller.ts @@ -1,7 +1,12 @@ -import { Controller, Get, Query } from '@nestjs/common'; +import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; -import { MapMarkerDto, MapMarkerResponseDto } from 'src/dtos/search.dto'; +import { + MapMarkerDto, + MapMarkerResponseDto, + MapReverseGeocodeDto, + MapReverseGeocodeResponseDto, +} from 'src/dtos/map.dto'; import { MapThemeDto } from 'src/dtos/system-config.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { MapService } from 'src/services/map.service'; @@ -22,4 +27,11 @@ export class MapController { getMapStyle(@Query() dto: MapThemeDto) { return this.service.getMapStyle(dto.theme); } + + @Authenticated() + @Get('reverse-geocode') + @HttpCode(HttpStatus.OK) + reverseGeocode(@Query() dto: MapReverseGeocodeDto): Promise { + return this.service.reverseGeocode(dto); + } } diff --git a/server/src/dtos/map.dto.ts b/server/src/dtos/map.dto.ts new file mode 100644 index 0000000000000..9a452d44abdf1 --- /dev/null +++ b/server/src/dtos/map.dto.ts @@ -0,0 +1,67 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsLatitude, IsLongitude } from 'class-validator'; +import { ValidateBoolean, ValidateDate } from 'src/validation'; + +export class MapReverseGeocodeDto { + @ApiProperty({ format: 'double' }) + @Type(() => Number) + @IsLatitude() + lat!: number; + + @ApiProperty({ format: 'double' }) + @Type(() => Number) + @IsLongitude() + lon!: number; +} + +export class MapReverseGeocodeResponseDto { + @ApiProperty() + city!: string | null; + + @ApiProperty() + state!: string | null; + + @ApiProperty() + country!: string | null; +} + +export class MapMarkerDto { + @ValidateBoolean({ optional: true }) + isArchived?: boolean; + + @ValidateBoolean({ optional: true }) + isFavorite?: boolean; + + @ValidateDate({ optional: true }) + fileCreatedAfter?: Date; + + @ValidateDate({ optional: true }) + fileCreatedBefore?: Date; + + @ValidateBoolean({ optional: true }) + withPartners?: boolean; + + @ValidateBoolean({ optional: true }) + withSharedAlbums?: boolean; +} + +export class MapMarkerResponseDto { + @ApiProperty() + id!: string; + + @ApiProperty({ format: 'double' }) + lat!: number; + + @ApiProperty({ format: 'double' }) + lon!: number; + + @ApiProperty() + city!: string | null; + + @ApiProperty() + state!: string | null; + + @ApiProperty() + country!: string | null; +} diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 59bb95b47595e..0874300d5fab2 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -289,26 +289,6 @@ export class SearchExploreResponseDto { items!: SearchExploreItem[]; } -export class MapMarkerDto { - @ValidateBoolean({ optional: true }) - isArchived?: boolean; - - @ValidateBoolean({ optional: true }) - isFavorite?: boolean; - - @ValidateDate({ optional: true }) - fileCreatedAfter?: Date; - - @ValidateDate({ optional: true }) - fileCreatedBefore?: Date; - - @ValidateBoolean({ optional: true }) - withPartners?: boolean; - - @ValidateBoolean({ optional: true }) - withSharedAlbums?: boolean; -} - export class MemoryLaneDto { @IsInt() @Type(() => Number) @@ -324,22 +304,3 @@ export class MemoryLaneDto { @ApiProperty({ type: 'integer' }) month!: number; } -export class MapMarkerResponseDto { - @ApiProperty() - id!: string; - - @ApiProperty({ format: 'double' }) - lat!: number; - - @ApiProperty({ format: 'double' }) - lon!: number; - - @ApiProperty() - city!: string | null; - - @ApiProperty() - state!: string | null; - - @ApiProperty() - country!: string | null; -} diff --git a/server/src/services/map.service.ts b/server/src/services/map.service.ts index 453b76691ac96..ffd84a3e02bf6 100644 --- a/server/src/services/map.service.ts +++ b/server/src/services/map.service.ts @@ -1,7 +1,7 @@ import { Inject } from '@nestjs/common'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { AuthDto } from 'src/dtos/auth.dto'; -import { MapMarkerDto, MapMarkerResponseDto } from 'src/dtos/search.dto'; +import { MapMarkerDto, MapMarkerResponseDto, MapReverseGeocodeDto } from 'src/dtos/map.dto'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMapRepository } from 'src/interfaces/map.interface'; @@ -53,4 +53,11 @@ export class MapService { return JSON.parse(await this.systemMetadataRepository.readFile(`./resources/style-${theme}.json`)); } + + async reverseGeocode(dto: MapReverseGeocodeDto) { + const { lat: latitude, lon: longitude } = dto; + // eventually this should probably return an array of results + const result = await this.mapRepository.reverseGeocode({ latitude, longitude }); + return result ? [result] : []; + } }