feat(train): search for places in the route planner, through Photon (ADR 0030, D50)
A `search` endpoint asks the instance's Photon (compose.search.yaml, Europe's database from GraphHopper, renewed monthly) for people signed in, limited and unlogged. The planner gets a field over its map: the places found nearest the map's centre first, in the app's language; one chosen is the next waypoint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
190c3872f4
commit
edd2df79bd
10 changed files with 2184 additions and 7 deletions
|
|
@ -28,6 +28,7 @@ import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart
|
|||
import '../firmware/firmware_endpoint.dart' as _id2y0j3h;
|
||||
import '../map/map_endpoint.dart' as _i35zr6r3;
|
||||
import '../routing/routing_endpoint.dart' as _ivjydyye;
|
||||
import '../search/search_endpoint.dart' as _i2f0v2ey;
|
||||
export 'future_calls.dart' show ServerpodFutureCallsGetter;
|
||||
|
||||
class Endpoints extends _is.EndpointDispatch {
|
||||
|
|
@ -52,6 +53,12 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
'routing',
|
||||
null,
|
||||
),
|
||||
'search': _i2f0v2ey.SearchEndpoint()
|
||||
..initialize(
|
||||
server,
|
||||
'search',
|
||||
null,
|
||||
),
|
||||
};
|
||||
connectors['firmware'] = _is.EndpointConnector(
|
||||
name: 'firmware',
|
||||
|
|
@ -182,6 +189,53 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
),
|
||||
},
|
||||
);
|
||||
connectors['search'] = _is.EndpointConnector(
|
||||
name: 'search',
|
||||
endpoint: endpoints['search']!,
|
||||
methodConnectors: {
|
||||
'offered': _is.MethodConnector(
|
||||
name: 'offered',
|
||||
params: {},
|
||||
call:
|
||||
(
|
||||
_is.Session session,
|
||||
Map<String, dynamic> params,
|
||||
) async => (endpoints['search'] as _i2f0v2ey.SearchEndpoint)
|
||||
.offered(session),
|
||||
),
|
||||
'places': _is.MethodConnector(
|
||||
name: 'places',
|
||||
params: {
|
||||
'query': _is.ParameterDescription(
|
||||
name: 'query',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
'near': _is.ParameterDescription(
|
||||
name: 'near',
|
||||
type: _is.getType<_is4pcfub.RoutePoint?>(),
|
||||
nullable: true,
|
||||
),
|
||||
'language': _is.ParameterDescription(
|
||||
name: 'language',
|
||||
type: _is.getType<String?>(),
|
||||
nullable: true,
|
||||
),
|
||||
},
|
||||
call:
|
||||
(
|
||||
_is.Session session,
|
||||
Map<String, dynamic> params,
|
||||
) async =>
|
||||
(endpoints['search'] as _i2f0v2ey.SearchEndpoint).places(
|
||||
session,
|
||||
params['query'],
|
||||
near: params['near'],
|
||||
language: params['language'],
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
modules['halkyon_base'] = _ik8molhd.Endpoints()
|
||||
..initializeEndpoints(server);
|
||||
modules['serverpod_auth_idp'] = _iais.Endpoints()
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import 'package:halkyon_base_server/halkyon_base_server.dart' as _ik8molhd;
|
|||
import 'package:halkyon_oidc_server/halkyon_oidc_server.dart' as _ipoqfiqr;
|
||||
import 'package:halkyon_train_server/src/generated/routing/route_point.dart'
|
||||
as _is4pcfub;
|
||||
import 'package:halkyon_train_server/src/generated/search/place.dart'
|
||||
as _i6fosizj;
|
||||
import 'package:serverpod/protocol.dart' as _isp;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
|
||||
|
|
@ -39,6 +41,8 @@ import 'routing/route_point.dart' as _isqtfus6;
|
|||
import 'routing/route_profile.dart' as _ifl4j5ep;
|
||||
import 'routing/route_refused.dart' as _ix4aepwk;
|
||||
import 'routing/route_stretch.dart' as _ic7f85bc;
|
||||
import 'search/place.dart' as _iadh29yw;
|
||||
import 'search/search_refused.dart' as _ihbxkwke;
|
||||
import 'workout/workout.dart' as _i5b9b17z;
|
||||
import 'workout/workout_series.dart' as _i92mefto;
|
||||
export 'day/day.dart';
|
||||
|
|
@ -57,6 +61,8 @@ export 'routing/route_point.dart';
|
|||
export 'routing/route_profile.dart';
|
||||
export 'routing/route_refused.dart';
|
||||
export 'routing/route_stretch.dart';
|
||||
export 'search/place.dart';
|
||||
export 'search/search_refused.dart';
|
||||
export 'workout/workout.dart';
|
||||
export 'workout/workout_series.dart';
|
||||
export 'sync_tables.dart';
|
||||
|
|
@ -809,6 +815,12 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _ic7f85bc.RouteStretch) {
|
||||
return _ic7f85bc.RouteStretch.fromJson(data) as T;
|
||||
}
|
||||
if (t == _iadh29yw.Place) {
|
||||
return _iadh29yw.Place.fromJson(data) as T;
|
||||
}
|
||||
if (t == _ihbxkwke.SearchRefused) {
|
||||
return _ihbxkwke.SearchRefused.fromJson(data) as T;
|
||||
}
|
||||
if (t == _i5b9b17z.Workout) {
|
||||
return _i5b9b17z.Workout.fromJson(data) as T;
|
||||
}
|
||||
|
|
@ -866,6 +878,13 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _is.getType<_ic7f85bc.RouteStretch?>()) {
|
||||
return (data != null ? _ic7f85bc.RouteStretch.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _is.getType<_iadh29yw.Place?>()) {
|
||||
return (data != null ? _iadh29yw.Place.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _is.getType<_ihbxkwke.SearchRefused?>()) {
|
||||
return (data != null ? _ihbxkwke.SearchRefused.fromJson(data) : null)
|
||||
as T;
|
||||
}
|
||||
if (t == _is.getType<_i5b9b17z.Workout?>()) {
|
||||
return (data != null ? _i5b9b17z.Workout.fromJson(data) : null) as T;
|
||||
}
|
||||
|
|
@ -900,6 +919,10 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
.toList()
|
||||
as T;
|
||||
}
|
||||
if (t == List<_i6fosizj.Place>) {
|
||||
return (data as List).map((e) => deserialize<_i6fosizj.Place>(e)).toList()
|
||||
as T;
|
||||
}
|
||||
try {
|
||||
return _ik8molhd.Protocol().deserialize<T>(data, t);
|
||||
} on _is.DeserializationTypeNotFoundException catch (_) {}
|
||||
|
|
@ -939,6 +962,8 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
_ifl4j5ep.RouteProfile => 'RouteProfile',
|
||||
_ix4aepwk.RouteRefused => 'RouteRefused',
|
||||
_ic7f85bc.RouteStretch => 'RouteStretch',
|
||||
_iadh29yw.Place => 'Place',
|
||||
_ihbxkwke.SearchRefused => 'SearchRefused',
|
||||
_i5b9b17z.Workout => 'Workout',
|
||||
_i92mefto.WorkoutSeries => 'WorkoutSeries',
|
||||
_ => null,
|
||||
|
|
@ -990,6 +1015,10 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
return 'RouteRefused';
|
||||
case _ic7f85bc.RouteStretch():
|
||||
return 'RouteStretch';
|
||||
case _iadh29yw.Place():
|
||||
return 'Place';
|
||||
case _ihbxkwke.SearchRefused():
|
||||
return 'SearchRefused';
|
||||
case _i5b9b17z.Workout():
|
||||
return 'Workout';
|
||||
case _i92mefto.WorkoutSeries():
|
||||
|
|
@ -1082,6 +1111,12 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (dataClassName == 'RouteStretch') {
|
||||
return deserialize<_ic7f85bc.RouteStretch>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'Place') {
|
||||
return deserialize<_iadh29yw.Place>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'SearchRefused') {
|
||||
return deserialize<_ihbxkwke.SearchRefused>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'Workout') {
|
||||
return deserialize<_i5b9b17z.Workout>(data['data']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,3 +7,6 @@ map:
|
|||
routing:
|
||||
- offered:
|
||||
- plan:
|
||||
search:
|
||||
- offered:
|
||||
- places:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
|
||||
/// A place found by the instance's search (ADR 0030, D50): what the planner
|
||||
/// lists and makes a waypoint of.
|
||||
abstract class Place
|
||||
implements _is.SerializableModel, _is.ProtocolSerialization {
|
||||
Place._({
|
||||
required this.name,
|
||||
this.detail,
|
||||
required this.kind,
|
||||
required this.lat,
|
||||
required this.lon,
|
||||
});
|
||||
|
||||
factory Place({
|
||||
required String name,
|
||||
String? detail,
|
||||
required String kind,
|
||||
required double lat,
|
||||
required double lon,
|
||||
}) = _PlaceImpl;
|
||||
|
||||
factory Place.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return Place(
|
||||
name: jsonSerialization['name'] as String,
|
||||
detail: jsonSerialization['detail'] as String?,
|
||||
kind: jsonSerialization['kind'] as String,
|
||||
lat: (jsonSerialization['lat'] as num).toDouble(),
|
||||
lon: (jsonSerialization['lon'] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Its name, or its street and number for an address.
|
||||
String name;
|
||||
|
||||
/// Where it is, as the list shows under the name: its street, its town and
|
||||
/// its country, those the name doesn't say already; null where none.
|
||||
String? detail;
|
||||
|
||||
/// What kind of place, as Photon names it (`city`, `street`, `house`,
|
||||
/// `locality`, `other`, …).
|
||||
String kind;
|
||||
|
||||
double lat;
|
||||
|
||||
double lon;
|
||||
|
||||
/// Returns a shallow copy of this [Place]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
Place copyWith({
|
||||
String? name,
|
||||
String? detail,
|
||||
String? kind,
|
||||
double? lat,
|
||||
double? lon,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'Place',
|
||||
'name': name,
|
||||
if (detail != null) 'detail': detail,
|
||||
'kind': kind,
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'Place',
|
||||
'name': name,
|
||||
if (detail != null) 'detail': detail,
|
||||
'kind': kind,
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _is.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _Undefined {}
|
||||
|
||||
class _PlaceImpl extends Place {
|
||||
_PlaceImpl({
|
||||
required String name,
|
||||
String? detail,
|
||||
required String kind,
|
||||
required double lat,
|
||||
required double lon,
|
||||
}) : super._(
|
||||
name: name,
|
||||
detail: detail,
|
||||
kind: kind,
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [Place]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
@override
|
||||
Place copyWith({
|
||||
String? name,
|
||||
Object? detail = _Undefined,
|
||||
String? kind,
|
||||
double? lat,
|
||||
double? lon,
|
||||
}) {
|
||||
return Place(
|
||||
name: name ?? this.name,
|
||||
detail: detail is String? ? detail : this.detail,
|
||||
kind: kind ?? this.kind,
|
||||
lat: lat ?? this.lat,
|
||||
lon: lon ?? this.lon,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
|
||||
/// Why the instance searches nothing (ADR 0030, D50): `off` where it has no
|
||||
/// Photon, `invalid` for a query shorter than `PlaceSearch.minQuery` or
|
||||
/// longer than `PlaceSearch.maxQuery` characters or a place off the earth,
|
||||
/// `tooOften` beyond `PlaceSearch.perMinute` searches a minute,
|
||||
/// `unreachable` where Photon didn't answer.
|
||||
abstract class SearchRefused
|
||||
implements
|
||||
_is.SerializableException,
|
||||
_is.SerializableModel,
|
||||
_is.ProtocolSerialization {
|
||||
SearchRefused._({required this.reason});
|
||||
|
||||
factory SearchRefused({required String reason}) = _SearchRefusedImpl;
|
||||
|
||||
factory SearchRefused.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return SearchRefused(reason: jsonSerialization['reason'] as String);
|
||||
}
|
||||
|
||||
String reason;
|
||||
|
||||
/// Returns a shallow copy of this [SearchRefused]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
SearchRefused copyWith({String? reason});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'SearchRefused',
|
||||
'reason': reason,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'SearchRefused',
|
||||
'reason': reason,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SearchRefused(reason: $reason)';
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchRefusedImpl extends SearchRefused {
|
||||
_SearchRefusedImpl({required String reason}) : super._(reason: reason);
|
||||
|
||||
/// Returns a shallow copy of this [SearchRefused]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
@override
|
||||
SearchRefused copyWith({String? reason}) {
|
||||
return SearchRefused(reason: reason ?? this.reason);
|
||||
}
|
||||
}
|
||||
118
apps/train/halkyon_train_server/lib/src/search/photon.dart
Normal file
118
apps/train/halkyon_train_server/lib/src/search/photon.dart
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_train_server/src/generated/protocol.dart';
|
||||
|
||||
/// Finds places for a query (ADR 0030, D50): the instance's Photon, or a
|
||||
/// fake in tests. Answers Photon's GeoJSON, else throws [PhotonFailed].
|
||||
abstract interface class PlaceFinder {
|
||||
Future<Map<String, Object?>> search(
|
||||
String query, {
|
||||
RoutePoint? near,
|
||||
String? language,
|
||||
required int limit,
|
||||
});
|
||||
}
|
||||
|
||||
/// Photon didn't answer, or not with GeoJSON.
|
||||
final class const PhotonFailed(final String reason) implements Exception {
|
||||
@override
|
||||
String toString() => 'PhotonFailed: $reason';
|
||||
}
|
||||
|
||||
/// Photon (v1.3.0) at [address], asked over plain HTTP on the network it
|
||||
/// shares with Train's server alone: `/api` with the query, the point its
|
||||
/// results are nearest to first, the language of their names.
|
||||
class HttpPhoton(final Uri address) implements PlaceFinder {
|
||||
static const _timeout = Duration(seconds: 10);
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> search(
|
||||
String query, {
|
||||
RoutePoint? near,
|
||||
String? language,
|
||||
required int limit,
|
||||
}) async {
|
||||
final client = HttpClient()..connectionTimeout = _timeout;
|
||||
try {
|
||||
final uri = address
|
||||
.resolve('api')
|
||||
.replace(
|
||||
queryParameters: {
|
||||
'q': query,
|
||||
'limit': '$limit',
|
||||
if (near != null) ...{
|
||||
'lat': '${near.lat}',
|
||||
'lon': '${near.lon}',
|
||||
},
|
||||
'lang': ?language,
|
||||
},
|
||||
);
|
||||
final HttpClientResponse response;
|
||||
final String body;
|
||||
try {
|
||||
response = await (await client.getUrl(uri)).close().timeout(_timeout);
|
||||
body = await utf8.decodeStream(response).timeout(_timeout);
|
||||
} on Object catch (error) {
|
||||
throw PhotonFailed(error.runtimeType.toString());
|
||||
}
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw PhotonFailed('HTTP ${response.statusCode}');
|
||||
}
|
||||
try {
|
||||
return jsonDecode(body) as Map<String, Object?>;
|
||||
} on Object {
|
||||
throw const PhotonFailed('no GeoJSON');
|
||||
}
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The places in Photon's [geoJson], in its order: each feature a point
|
||||
/// with a name, or a street (with its number) for an address; the rest
|
||||
/// left out.
|
||||
List<Place> placesOf(Map<String, Object?> geoJson) => [
|
||||
for (final feature in switch (geoJson['features']) {
|
||||
final List<Object?> features => features,
|
||||
_ => const <Object?>[],
|
||||
})
|
||||
if (feature case {
|
||||
'geometry': {
|
||||
'type': 'Point',
|
||||
'coordinates': [final num lon, final num lat, ...],
|
||||
},
|
||||
'properties': final Map<String, Object?> properties,
|
||||
})
|
||||
?_placeOf(properties, lat.toDouble(), lon.toDouble()),
|
||||
];
|
||||
|
||||
Place? _placeOf(Map<String, Object?> properties, double lat, double lon) {
|
||||
String? text(String key) => switch (properties[key]) {
|
||||
final String value when value.trim().isNotEmpty => value.trim(),
|
||||
_ => null,
|
||||
};
|
||||
final street = switch ((text('street'), text('housenumber'))) {
|
||||
(final street?, final number?) => '$street $number',
|
||||
(final street?, null) => street,
|
||||
_ => null,
|
||||
};
|
||||
final name = text('name') ?? street;
|
||||
if (name == null) {
|
||||
return null;
|
||||
}
|
||||
final town = text('city') ?? text('district') ?? text('county');
|
||||
final detail = <String>[
|
||||
if (street != null && street != name) street,
|
||||
if (town != null && town != name) town,
|
||||
if (text('country') case final country? when country != name) country,
|
||||
];
|
||||
return Place(
|
||||
name: name,
|
||||
detail: detail.isEmpty ? null : detail.join(', '),
|
||||
kind: text('type') ?? 'other',
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
### A place found by the instance's search (ADR 0030, D50): what the planner
|
||||
### lists and makes a waypoint of.
|
||||
class: Place
|
||||
fields:
|
||||
### Its name, or its street and number for an address.
|
||||
name: String
|
||||
### Where it is, as the list shows under the name: its street, its town and
|
||||
### its country, those the name doesn't say already; null where none.
|
||||
detail: String?
|
||||
### What kind of place, as Photon names it (`city`, `street`, `house`,
|
||||
### `locality`, `other`, …).
|
||||
kind: String
|
||||
lat: double
|
||||
lon: double
|
||||
109
apps/train/halkyon_train_server/lib/src/search/place_search.dart
Normal file
109
apps/train/halkyon_train_server/lib/src/search/place_search.dart
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_train_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_train_server/src/search/photon.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
/// Searching for places on this instance (ADR 0030, D50), through its Photon
|
||||
/// where it has one, for a person as often as [perMinute] allows. Neither
|
||||
/// the query nor the places are kept or logged; the counts live in memory
|
||||
/// alone.
|
||||
class PlaceSearch {
|
||||
PlaceSearch({this.finder});
|
||||
|
||||
/// The search of [pod], made the first time it is asked for, from the
|
||||
/// operator's `PHOTON_URL` (empty: none). Tests set their own.
|
||||
factory PlaceSearch.of(Serverpod pod) => _ofPod[pod] ??= PlaceSearch(
|
||||
finder: switch (Platform.environment['PHOTON_URL']?.trim()) {
|
||||
final url? when url.isNotEmpty => HttpPhoton(Uri.parse(url)),
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
static final _ofPod = Expando<PlaceSearch>('PlaceSearch');
|
||||
|
||||
/// Makes [search] the one of [pod].
|
||||
static void use(Serverpod pod, PlaceSearch search) => _ofPod[pod] = search;
|
||||
|
||||
/// The instance's Photon; none: it finds nothing.
|
||||
final PlaceFinder? finder;
|
||||
|
||||
/// The fewest and the most characters a query has.
|
||||
static const minQuery = 2;
|
||||
static const maxQuery = 200;
|
||||
|
||||
/// The most places an answer lists.
|
||||
static const limit = 8;
|
||||
|
||||
/// The most searches a person may make in a minute: the planner asks as
|
||||
/// they type, a pause after each word.
|
||||
static const perMinute = 60;
|
||||
|
||||
/// The languages Photon names places in; any other gets its own names.
|
||||
static const languages = {'en', 'de'};
|
||||
|
||||
final _asked = HashMap<UuidValue, Queue<DateTime>>();
|
||||
|
||||
/// The places [query] finds for [person], those nearest [near] first, named
|
||||
/// in [language]. Refused ([SearchRefused]) without a Photon, for a query
|
||||
/// or a point it doesn't take, beyond [perMinute], where Photon doesn't
|
||||
/// answer.
|
||||
Future<List<Place>> places(
|
||||
UuidValue person,
|
||||
String query, {
|
||||
RoutePoint? near,
|
||||
String? language,
|
||||
}) async {
|
||||
final finder = this.finder;
|
||||
if (finder == null) {
|
||||
throw SearchRefused(reason: 'off');
|
||||
}
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.length < minQuery ||
|
||||
trimmed.length > maxQuery ||
|
||||
(near != null && !_onEarth(near))) {
|
||||
throw SearchRefused(reason: 'invalid');
|
||||
}
|
||||
_count(person);
|
||||
try {
|
||||
return placesOf(
|
||||
await finder.search(
|
||||
trimmed,
|
||||
near: near,
|
||||
language: languages.contains(language) ? language : null,
|
||||
limit: limit,
|
||||
),
|
||||
);
|
||||
} on PhotonFailed {
|
||||
throw SearchRefused(reason: 'unreachable');
|
||||
}
|
||||
}
|
||||
|
||||
static bool _onEarth(RoutePoint point) =>
|
||||
point.lat.isFinite &&
|
||||
point.lon.isFinite &&
|
||||
point.lat.abs() <= 90 &&
|
||||
point.lon.abs() <= 180;
|
||||
|
||||
/// Counts a search of [person]'s, refusing it beyond [perMinute] in the
|
||||
/// last minute.
|
||||
void _count(UuidValue person) {
|
||||
final now = clock.now();
|
||||
final asked = _asked.putIfAbsent(person, Queue.new);
|
||||
while (asked.isNotEmpty &&
|
||||
now.difference(asked.first) >= const Duration(minutes: 1)) {
|
||||
asked.removeFirst();
|
||||
}
|
||||
if (asked.length >= perMinute) {
|
||||
throw SearchRefused(reason: 'tooOften');
|
||||
}
|
||||
asked.addLast(now);
|
||||
// Those who searched nothing for a minute are forgotten.
|
||||
_asked.removeWhere(
|
||||
(_, times) =>
|
||||
times.isEmpty ||
|
||||
now.difference(times.last) >= const Duration(minutes: 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import 'package:halkyon_train_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_train_server/src/search/place_search.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
/// Places found by the instance's Photon (ADR 0030, D50), for those signed
|
||||
/// in: Photon has no sign-in of its own, so the server alone reaches it.
|
||||
/// Neither the query nor the places are kept or logged. Refusals are
|
||||
/// `SearchRefused`.
|
||||
class SearchEndpoint extends Endpoint {
|
||||
@override
|
||||
bool get requireLogin => true;
|
||||
|
||||
/// Whether the instance searches for places.
|
||||
Future<bool> offered(Session session) async =>
|
||||
PlaceSearch.of(session.serverpod).finder != null;
|
||||
|
||||
/// The places [query] finds, nearest [near] first (the map's centre),
|
||||
/// named in [language] (`en`, `de`; else each its own name). Refused where
|
||||
/// the instance has no Photon or it fails, for a query of fewer than
|
||||
/// `PlaceSearch.minQuery` or more than `PlaceSearch.maxQuery` characters,
|
||||
/// and beyond `PlaceSearch.perMinute` searches a minute.
|
||||
Future<List<Place>> places(
|
||||
Session session,
|
||||
String query, {
|
||||
RoutePoint? near,
|
||||
String? language,
|
||||
}) => PlaceSearch.of(session.serverpod).places(
|
||||
session.authenticated!.authUserId,
|
||||
query,
|
||||
near: near,
|
||||
language: language,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
### Why the instance searches nothing (ADR 0030, D50): `off` where it has no
|
||||
### Photon, `invalid` for a query shorter than `PlaceSearch.minQuery` or
|
||||
### longer than `PlaceSearch.maxQuery` characters or a place off the earth,
|
||||
### `tooOften` beyond `PlaceSearch.perMinute` searches a minute,
|
||||
### `unreachable` where Photon didn't answer.
|
||||
exception: SearchRefused
|
||||
fields:
|
||||
reason: String
|
||||
Loading…
Reference in a new issue