From ebc71e428dfad51704ad50b29a26f7f3cd22ccab Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 29 Jul 2024 18:17:26 -0400 Subject: [PATCH] feat(server): reverse geocoding endpoint (#11430) * feat(server): reverse geocoding endpoint * chore: rename error message --- e2e/src/api/specs/map.e2e-spec.ts | 71 ++++++++++++++++ mobile/openapi/README.md | Bin 31074 -> 31243 bytes mobile/openapi/lib/api.dart | Bin 10583 -> 10635 bytes mobile/openapi/lib/api/map_api.dart | Bin 5299 -> 7186 bytes mobile/openapi/lib/api_client.dart | Bin 27704 -> 27812 bytes .../map_reverse_geocode_response_dto.dart | Bin 0 -> 3733 bytes 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, 255 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 dcfdf0bc58..343a7c91d0 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 number between -90 and 90'])); + }); + + 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 number between -90 and 90'])); + }); + + 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 number between -90 and 90'])); + }); + + 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 number between -180 and 180'])); + }); + + 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 fa054333cb431dda6e9bf3728acc7a19c8b7f1f1..5323982046727322b2d8e3602500a0c067038be1 100644 GIT binary patch delta 164 zcmaF#iLv_&;|4yp=)?lYf=n%i8ii;rt)kSj)S}{4_tgC4{FGEJtyqne{A7K7U$7dz z+!SS~f^>)iO`t9(VilITN#Rd6!#i=eO`3Tcd PluTX_DZ1G)K~WL_;Xyj8 delta 19 bcmeDF!uaSD;|4yp&G~9(R-4}@$VmbKTE++| diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index b332e73e71210b554a1f03b5281e42b87f7bc764..e7aaf38de70cabea01e022b0fecf14c83d361ac6 100644 GIT binary patch delta 32 ocmcZ})E&IxfCzt4YFTPgacX>eYJPHlO6p`oF2&7zL}rTs0OLXo?EnA( delta 12 TcmeAUz8D=jH4 zO11IHEH1I}O)Ln4coXW*pw!}m{Ji2+my&!tdpiYGPvCO6rb1$IWnQvEHPCbgg|y7P q#GJ`;TtX_Sx>14v?02B!bvCndM>FxEI%0AfuO_n- z+&FU907)eB+`sP}QvH6X--ma9UXNbAKf64;dUt(x3g;g_pCxcQg3Hl0T#QaXod11* zVkG%?qFfmKmj3#tL$Btpv`%MJ>$0gS^bst|h1Ziz7y4e8A*tTY{oJ~+Q$rO?wes0~ z5i+O$T^NVzrMkzzbLH@V(9WZA=Xa~;w)8=jAu|(nXr@LxR`)xd6{gfaD{Og(VqvDr z?9J;epDE`%9dsAKPQlJ~spcld-{VdvFO>Iiq2^cm8%CnvYn$7NzS4eJMG+@^B_hQrQvipp)0J+cl^l?KAh_%t_RmF!x#i!!*KTUex^NoReC zSpj?r&Xjj2K&G}A#KzVdEAz@*i&bz~Ycz3dZ9&y?Dp&`DL2q>XRp(g4Fw^c3215`` za#i#w?4;WH{#~BH?5XU_mz*RU1c%*&m24 zM$T?)eiN-roonxvTM;dhX*2DOXR#iYkgS)BqJR^44M!Z~L0TC+gkPfMCeWs^tnQGn z18+$4T0`~`EP^ZqD-S(JqT9x?F=a4#!K{K@qQO^rNTh0m7wq_^%l^>X?_xfI$qL)m z4)_(*ThVL$szC_NEIs+!0KbyrO>)JXs4#XXs8Uy^nUsyoAGTiUM7lgfsBMSSBZ0#LNLmWPWdo}-y>Nc8QujV=$ANHtC5{N`Ul-W*du?hF$y=0gov&4jA0jY=^sy*s;+LrWEHJJz@8=6x73WhB(qE@h!Vy`OU5mZlX zrHK^QuBLEn9f_Jd@v5pKoo}7UC$!0~}T9f=`yW#%;|YVqJSX4oR8B zc5*3!=k-3?XtL}XZhwYr&!o|FC>h6mW3KFF9Ok%9go_rmQi8NDX@hu4-TIp$kko7x zGHp!|blV_NAFgM*=1cvVfz(`/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 223e6b8147..d6c26c58a0 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 0000000000..1d0b84a4d0 --- /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({ message: ({ property }) => `${property} must be a number between -90 and 90` }) + lat!: number; + + @ApiProperty({ format: 'double' }) + @Type(() => Number) + @IsLongitude({ message: ({ property }) => `${property} must be a number between -180 and 180` }) + 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 59bb95b475..0874300d5f 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 453b76691a..ffd84a3e02 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] : []; + } }