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:
parent
a70cd368af
commit
ebc71e428d
@ -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
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/map_api.dart
generated
BIN
mobile/openapi/lib/api/map_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart
generated
Normal file
Binary file not shown.
@ -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",
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
67
server/src/dtos/map.dto.ts
Normal file
67
server/src/dtos/map.dto.ts
Normal 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;
|
||||||
|
}
|
@ -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;
|
|
||||||
}
|
|
||||||
|
@ -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] : [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user