diff --git a/dart/geodata/CHANGELOG.md b/dart/geodata/CHANGELOG.md index 1506e1b6..90bcf8e2 100644 --- a/dart/geodata/CHANGELOG.md +++ b/dart/geodata/CHANGELOG.md @@ -13,6 +13,7 @@ NOTE: Version 0.12.0 currently [under development](https://github.com/navibyte/g * [Add support for API definition (like Open API 3.0) when accessing OGC API Features clients #170](https://github.com/navibyte/geospatial/issues/170) * [Map HTTP status codes to feature service exception (OGC API Features) #68](https://github.com/navibyte/geospatial/issues/68) * [Add initial support for OGC API - Features - Part 3: Filtering #180](https://github.com/navibyte/geospatial/issues/180) +* [Cache metadata object in OGC API Features clients for short periods #181](https://github.com/navibyte/geospatial/issues/181) 🛠 Maintenance: * Removed extra internal export files and made internal imports more excplicit. diff --git a/dart/geodata/lib/src/geojson/service/client/geojson_feature_client.dart b/dart/geodata/lib/src/geojson/service/client/geojson_feature_client.dart index 085954ee..90fd7c44 100644 --- a/dart/geodata/lib/src/geojson/service/client/geojson_feature_client.dart +++ b/dart/geodata/lib/src/geojson/service/client/geojson_feature_client.dart @@ -27,8 +27,12 @@ class GeoJSONFeatures { /// The required [location] should refer to a web resource containing GeoJSON /// compliant data. /// - /// When given the optional [client] is used for http requests, otherwise the - /// default client of the `package:http/http.dart` package is used. + /// When given an optional [client] is used for http requests, otherwise the + /// default client of the `package:http/http.dart` package is used (a new + /// instance of default client for each service request). When [client] is + /// given, this allows a client to better maintain persistent connections to a + /// service, but it's also responsibility of a caller to close it + /// appropriately. /// /// When given [headers] are injected to http requests (however some can be /// overridden by the feature source implementation). diff --git a/dart/geodata/lib/src/ogcapi_common/service/client/ogc_client.dart b/dart/geodata/lib/src/ogcapi_common/service/client/ogc_client.dart index 684c1c84..a209e6b2 100644 --- a/dart/geodata/lib/src/ogcapi_common/service/client/ogc_client.dart +++ b/dart/geodata/lib/src/ogcapi_common/service/client/ogc_client.dart @@ -12,6 +12,7 @@ import '/src/common/service/service_exception.dart'; import '/src/core/api/open_api_document.dart'; import '/src/ogcapi_common/model/ogc_service.dart'; import '/src/ogcapi_common/model/ogc_service_meta.dart'; +import '/src/utils/cached_object.dart'; import '/src/utils/feature_http_adapter.dart'; import '/src/utils/resolve_api_call.dart'; @@ -33,10 +34,11 @@ const _expectJSONOpenAPI = [ @internal abstract class OGCClientHttp implements OGCService { /// Create a http client with HTTP(S) [endpoint] and [adapter]. - const OGCClientHttp( + OGCClientHttp( this.endpoint, { required this.adapter, - }); + this.metaMaxAge = const Duration(minutes: 15), + }) : _cachedMeta = CachedObject(metaMaxAge); /// The endpoint for this client. final Uri endpoint; @@ -44,69 +46,77 @@ abstract class OGCClientHttp implements OGCService { /// An adapter used to access HTTP(S) resource. final FeatureHttpAdapter adapter; + /// The max age to cache metadata objects retrieved from a service and that + /// are cached internally (in-memory) by this client. + final Duration metaMaxAge; + + final CachedObject _cachedMeta; + @override - Future meta() async { - // fetch data as JSON Object, and parse meta data - return adapter.getEntityFromJsonObject( - endpoint, - toEntity: (data, _) { - final links = Links.fromJson(data['links'] as Iterable); - return _OGCServiceMetaImpl( - service: this, - title: data['title'] as String? ?? - links.self().first.title ?? - 'An OGC API service', - links: links, - description: data['description'] as String?, - attribution: data['attribution'] as String?, + Future meta() => _cachedMeta.getAsync(() { + // fetch data as JSON Object, and parse meta data + return adapter.getEntityFromJsonObject( + endpoint, + toEntity: (data, _) { + final links = Links.fromJson(data['links'] as Iterable); + return _OGCServiceMetaImpl( + service: this, + title: data['title'] as String? ?? + links.self().first.title ?? + 'An OGC API service', + links: links, + description: data['description'] as String?, + attribution: data['attribution'] as String?, + ); + }, ); - }, - ); - } + }); } class _OGCServiceMetaImpl extends OGCServiceMeta { final OGCClientHttp service; - const _OGCServiceMetaImpl({ + final CachedObject _cachedAPI; + + _OGCServiceMetaImpl({ required this.service, required super.title, super.description, super.attribution, required super.links, - }); + }) : _cachedAPI = CachedObject(service.metaMaxAge); @override - Future openAPI() { - // 1. Get a link for the relation "service-desc". - // 2. Ensure it's type is "application/vnd.oai.openapi+json". - // (here we are allowing other JSON based content types too) - final link = _resolveServiceDescLink(); - if (link == null) { - throw const ServiceException('No valid service-desc link.'); - } - final url = resolveLinkReferenceUri(service.endpoint, link.href); - - // 3. Read JSON content from a HTTP service. - // 4. Decode content received as JSON Object using the standard JSON decoder - // 5. Wrap such decoded object in an [OpenAPIDefinition] instance. - final type = link.type; - if (type != null) { - return service.adapter.getEntityFromJsonObject( - url, - headers: {'accept': type}, - expect: _expectJSONOpenAPI, - toEntity: (data, _) => OpenAPIDocument(content: data), - ); - } else { - return service.adapter.getEntityFromJsonObject( - url, - headers: _acceptJSONOpenAPI, - expect: _expectJSONOpenAPI, - toEntity: (data, _) => OpenAPIDocument(content: data), - ); - } - } + Future openAPI() => _cachedAPI.getAsync(() { + // 1. Get a link for the relation "service-desc". + // 2. Ensure it's type is "application/vnd.oai.openapi+json". + // (here we are allowing other JSON based content types too) + final link = _resolveServiceDescLink(); + if (link == null) { + throw const ServiceException('No valid service-desc link.'); + } + final url = resolveLinkReferenceUri(service.endpoint, link.href); + + // 3. Read JSON content from a HTTP service. + // 4. Decode content received as JSON Object using standard JSON decoder + // 5. Wrap such decoded object in an [OpenAPIDefinition] instance. + final type = link.type; + if (type != null) { + return service.adapter.getEntityFromJsonObject( + url, + headers: {'accept': type}, + expect: _expectJSONOpenAPI, + toEntity: (data, _) => OpenAPIDocument(content: data), + ); + } else { + return service.adapter.getEntityFromJsonObject( + url, + headers: _acceptJSONOpenAPI, + expect: _expectJSONOpenAPI, + toEntity: (data, _) => OpenAPIDocument(content: data), + ); + } + }); /// Resolve an url that's providing OpenAPI or JSON service description. Link? _resolveServiceDescLink() { diff --git a/dart/geodata/lib/src/ogcapi_features/service/client/ogc_feature_client.dart b/dart/geodata/lib/src/ogcapi_features/service/client/ogc_feature_client.dart index 1fe91fbc..47bd38b0 100644 --- a/dart/geodata/lib/src/ogcapi_features/service/client/ogc_feature_client.dart +++ b/dart/geodata/lib/src/ogcapi_features/service/client/ogc_feature_client.dart @@ -24,6 +24,7 @@ import '/src/ogcapi_features/model/ogc_feature_item.dart'; import '/src/ogcapi_features/model/ogc_feature_items.dart'; import '/src/ogcapi_features/model/ogc_feature_service.dart'; import '/src/ogcapi_features/model/ogc_feature_source.dart'; +import '/src/utils/cached_object.dart'; import '/src/utils/feature_http_adapter.dart'; import '/src/utils/resolve_api_call.dart'; @@ -35,8 +36,12 @@ class OGCAPIFeatures { /// /// The required [endpoint] should refer to a base url of a feature service. /// - /// When given the optional [client] is used for http requests, otherwise the - /// default client of the `package:http/http.dart` package is used. + /// When given an optional [client] is used for http requests, otherwise the + /// default client of the `package:http/http.dart` package is used (a new + /// instance of default client for each service request). When [client] is + /// given, this allows a client to better maintain persistent connections to a + /// service, but it's also responsibility of a caller to close it + /// appropriately. /// /// When given [headers] are injected to http requests as http headers /// (however some can be overridden by the feature service implementation). @@ -48,12 +53,16 @@ class OGCAPIFeatures { /// as a default. Note that currently only GeoJSON is supported, but it's /// possible to inject another format implementation (or with custom /// configuration) to the default one. + /// + /// [metaMaxAge] specifies a max age to cache metadata objects retrieved from + /// a service and that are cached internally (in-memory) by this client. static OGCFeatureService http({ required Uri endpoint, Client? client, Map? headers, Map? extraParams, TextReaderFormat format = GeoJSON.feature, + Duration metaMaxAge = const Duration(minutes: 15), }) => _OGCFeatureClientHttp( endpoint, @@ -63,6 +72,7 @@ class OGCAPIFeatures { extraParams: extraParams, ), format: format, + metaMaxAge: metaMaxAge, ); } @@ -89,128 +99,144 @@ const _expectJSONSchema = [ /// A client for accessing `OGC API Features` compliant sources via http(s). class _OGCFeatureClientHttp extends OGCClientHttp implements OGCFeatureService { - const _OGCFeatureClientHttp( + _OGCFeatureClientHttp( super.endpoint, { required super.adapter, + super.metaMaxAge, required this.format, - }); + }) : _cachedConformance = CachedObject(metaMaxAge), + _cachedCollections = CachedObject(metaMaxAge); final TextReaderFormat format; + final CachedObject _cachedConformance; + final CachedObject> _cachedCollections; + @override Future collection(String id) => Future.value(_OGCFeatureSourceHttp(this, id)); @override - Future conformance() async { - // fetch data as JSON Object, and parse conformance classes - final url = resolveSubResource(endpoint, 'conformance'); - return adapter.getEntityFromJson( - url, - toEntity: (data, _) { - if (data is Map) { - // standard: root has JSON Object with "conformsTo" containing classes - return OGCFeatureConformance( - (data['conformsTo'] as Iterable?)?.cast() ?? [], - ); - } else if (data is Iterable) { - // NOT STANDARD: root has JSON Array containing conformance classes - return OGCFeatureConformance(data.cast()); - } - throw const ServiceException('Could not parse conformance classes'); - }, - ); - } + Future conformance() => + _cachedConformance.getAsync(() { + // fetch data as JSON Object, and parse conformance classes + final url = resolveSubResource(endpoint, 'conformance'); + return adapter.getEntityFromJson( + url, + toEntity: (data, _) { + if (data is Map) { + // standard: root has JSON Object with "conformsTo" containing + // conformance classes + return OGCFeatureConformance( + (data['conformsTo'] as Iterable?)?.cast() ?? + [], + ); + } else if (data is Iterable) { + // NOT STANDARD: root has JSON Array with conformance classes + return OGCFeatureConformance(data.cast()); + } + throw const ServiceException('Could not parse conformance classes'); + }, + ); + }); @override - Future> collections() async { - // fetch data as JSON Object, and parse conformance classes - final url = resolveSubResource(endpoint, 'collections'); - return adapter.getEntityFromJsonObject( - url, - toEntity: (data, _) { - // global crs identifiers supported by all collection that refer to them - final globalCrs = (data['crs'] as Iterable?)?.cast(); - - // get collections and parse each of them as `OGCCollectionMeta` - final list = data['collections'] as Iterable; - return list - .map( - (e) => _collectionFromJson( - e as Map, - globalCrs: globalCrs, - ), - ) - .toList(growable: false); - }, - ); - } + Future> collections() => + _cachedCollections.getAsync(() { + // fetch data as JSON Object, and parse conformance classes + final url = resolveSubResource(endpoint, 'collections'); + return adapter.getEntityFromJsonObject( + url, + toEntity: (data, _) { + // global crs identifiers supported by all collection that refer to + final globalCrs = + (data['crs'] as Iterable?)?.cast(); + + // get collections and parse each of them as `OGCCollectionMeta` + final list = data['collections'] as Iterable; + return list + .map( + (e) => _collectionFromJson( + e as Map, + globalCrs: globalCrs, + ), + ) + .toList(growable: false); + }, + ); + }); } /// A data source for accessing a `OGC API Features` collection. class _OGCFeatureSourceHttp implements OGCFeatureSource { - const _OGCFeatureSourceHttp(this.service, this.collectionId); + _OGCFeatureSourceHttp(this.service, this.collectionId) + : _cachedMeta = CachedObject(service.metaMaxAge), + _cachedQueryables = CachedObject(service.metaMaxAge); final _OGCFeatureClientHttp service; final String collectionId; - @override - Future meta() async { - // read "collections/{collectionId} + final CachedObject _cachedMeta; + final CachedObject _cachedQueryables; - final url = - resolveSubResource(service.endpoint, 'collections/$collectionId'); - return service.adapter.getEntityFromJsonObject( - url, - toEntity: (data, _) { - // data should contain a single collection as JSON Object - // but some services seem to return this under "collections"... - final collections = data['collections']; - if (collections is Iterable) { - for (final coll in collections) { - final collObj = coll as Map; - if (collObj['id'] == collectionId) { - return _collectionFromJson(collObj); + @override + Future meta() => _cachedMeta.getAsync(() { + // read "collections/{collectionId} + final url = + resolveSubResource(service.endpoint, 'collections/$collectionId'); + return service.adapter.getEntityFromJsonObject( + url, + toEntity: (data, _) { + // data should contain a single collection as JSON Object + // but some services seem to return this under "collections"... + final collections = data['collections']; + if (collections is Iterable) { + for (final coll in collections) { + final collObj = coll as Map; + if (collObj['id'] == collectionId) { + return _collectionFromJson(collObj); + } + } } - } - } - // this is the way the standard suggests - // (single collection meta as JSON object) - return _collectionFromJson(data); - }, - ); - } + // this is the way the standard suggests + // (single collection meta as JSON object) + return _collectionFromJson(data); + }, + ); + }); @override - Future queryables() async { - // need metadata for links - final collectionMeta = await meta(); - - // try to get queryables links with type "application/schema+json" - var links = collectionMeta.links.queryables(type: _contentTypeJSONSchema); - if (links.isEmpty) { - // if did got any, try to get basic JSON (some service announced as such) - links = collectionMeta.links.queryables(type: _contentTypeJSON); - - if (links.isEmpty) { - // if did got any, try to get without specifying type - links = collectionMeta.links.queryables(); - } - } - if (links.isEmpty) { - return null; // no link --> no queryables - } + Future queryables() => + _cachedQueryables.getAsync(() async { + // need metadata for links + final collectionMeta = await meta(); + + // try to get queryables links with type "application/schema+json" + var links = + collectionMeta.links.queryables(type: _contentTypeJSONSchema); + if (links.isEmpty) { + // if did got any, try to get basic JSON (some service announced such) + links = collectionMeta.links.queryables(type: _contentTypeJSON); + + if (links.isEmpty) { + // if did got any, try to get without specifying type + links = collectionMeta.links.queryables(); + } + } + if (links.isEmpty) { + return null; // no link --> no queryables + } - final url = resolveLinkReferenceUri(service.endpoint, links.first.href); + final url = resolveLinkReferenceUri(service.endpoint, links.first.href); - return service.adapter.getEntityFromJsonObject( - url, - headers: _acceptJSONSchema, - expect: _expectJSONSchema, - toEntity: (data, _) => OGCQueryableObject.fromJson(data), - ); - } + return service.adapter.getEntityFromJsonObject( + url, + headers: _acceptJSONSchema, + expect: _expectJSONSchema, + toEntity: (data, _) => OGCQueryableObject.fromJson(data), + ); + }); @override Future itemById(Object id) => item(ItemQuery(id: id)); @@ -317,15 +343,6 @@ class _OGCFeatureSourceHttp implements OGCFeatureSource { params = Map.of(query.queryablesAsParameters!)..addAll(params); } - /* - print(resolveSubResourcelUri(service.endpoint, - Uri( - path: 'collections/$collectionId/items', - queryParameters: params, - ), - )); - */ - // read from client and return paged feature collection response return _OGCPagedFeaturesItems.parse( service, diff --git a/dart/geodata/lib/src/utils/cached_object.dart b/dart/geodata/lib/src/utils/cached_object.dart new file mode 100644 index 00000000..cb777e33 --- /dev/null +++ b/dart/geodata/lib/src/utils/cached_object.dart @@ -0,0 +1,65 @@ +// Copyright (c) 2020-2023 Navibyte (https://navibyte.com). All rights reserved. +// Use of this source code is governed by a “BSD-3-Clause”-style license that is +// specified in the LICENSE file. +// +// Docs: https://github.com/navibyte/geospatial + +import 'package:meta/meta.dart'; + +/// A simple utility class to cache a single object for short periods. +@internal +class CachedObject { + final Duration _maxAge; + + T? _object; + DateTime? _cachedTime; + + /// Create a cached object with [_maxAge] and empty state. + CachedObject(this._maxAge); + + /// Get cached object of [T] synchronously, or when it's too old access a new + /// one and return it. + T get(T Function() accessor) { + final obj = _getCached(); + if (obj != null) return obj; + + // got nothing from cache, need to access + final newObj = accessor.call(); + _object = newObj; + _cachedTime = DateTime.now(); + return newObj; + } + + /// Get cached object of [T] asynchronously, or when it's too old access a new + /// one and return it. + Future getAsync(Future Function() accessor) async { + final obj = _getCached(); + if (obj != null) return obj; + + // got nothing from cache, need to access + final newObj = await accessor.call(); + _object = newObj; + _cachedTime = DateTime.now(); + return newObj; + } + + T? _getCached() { + final obj = _object; + final time = _cachedTime; + if (obj != null && time != null) { + if (DateTime.now().difference(time) > _maxAge) { + // too old + _object = null; + _cachedTime = null; + } else { + //print('Cache hit ${obj.runtimeType}'); + + // still valid + return obj; + } + } + + // got nothing from cache + return null; + } +}