1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-28 09:33:27 +02:00

feat(server): reverse geocoding endpoint (#11430)

* feat(server): reverse geocoding endpoint

* chore: rename error message
This commit is contained in:
Jason Rasmussen 2024-07-29 18:17:26 -04:00 committed by GitHub
parent a70cd368af
commit ebc71e428d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 255 additions and 42 deletions

View File

@ -159,4 +159,75 @@ describe('/map', () => {
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); 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);
});
});
}); });

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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": { "/map/style.json": {
"get": { "get": {
"operationId": "getMapStyle", "operationId": "getMapStyle",
@ -9128,6 +9182,28 @@
], ],
"type": "object" "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": { "MapTheme": {
"enum": [ "enum": [
"light", "light",

View File

@ -554,6 +554,11 @@ export type MapMarkerResponseDto = {
lon: number; lon: number;
state: string | null; state: string | null;
}; };
export type MapReverseGeocodeResponseDto = {
city: string | null;
country: string | null;
state: string | null;
};
export type OnThisDayDto = { export type OnThisDayDto = {
year: number; year: number;
}; };
@ -1991,6 +1996,20 @@ export function getMapMarkers({ fileCreatedAfter, fileCreatedBefore, isArchived,
...opts ...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 }: { export function getMapStyle({ key, theme }: {
key?: string; key?: string;
theme: MapTheme; theme: MapTheme;

View File

@ -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 { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto'; 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 { MapThemeDto } from 'src/dtos/system-config.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { MapService } from 'src/services/map.service'; import { MapService } from 'src/services/map.service';
@ -22,4 +27,11 @@ export class MapController {
getMapStyle(@Query() dto: MapThemeDto) { getMapStyle(@Query() dto: MapThemeDto) {
return this.service.getMapStyle(dto.theme); return this.service.getMapStyle(dto.theme);
} }
@Authenticated()
@Get('reverse-geocode')
@HttpCode(HttpStatus.OK)
reverseGeocode(@Query() dto: MapReverseGeocodeDto): Promise<MapReverseGeocodeResponseDto[]> {
return this.service.reverseGeocode(dto);
}
} }

View File

@ -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;
}

View File

@ -289,26 +289,6 @@ export class SearchExploreResponseDto {
items!: SearchExploreItem[]; 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 { export class MemoryLaneDto {
@IsInt() @IsInt()
@Type(() => Number) @Type(() => Number)
@ -324,22 +304,3 @@ export class MemoryLaneDto {
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer' })
month!: number; 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;
}

View File

@ -1,7 +1,7 @@
import { Inject } from '@nestjs/common'; import { Inject } from '@nestjs/common';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { AuthDto } from 'src/dtos/auth.dto'; 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 { IAlbumRepository } from 'src/interfaces/album.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMapRepository } from 'src/interfaces/map.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`)); 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] : [];
}
} }