From 46f213bff5cd8b8dafc3abb6ad21ff1c54b55653 Mon Sep 17 00:00:00 2001 From: Benjamin Webb <40066515+webb-ben@users.noreply.github.com> Date: Mon, 15 Aug 2022 09:44:25 -0400 Subject: [PATCH] Add Socrata provider for OGC API - Features (#955) --- .github/workflows/main.yml | 1 + Dockerfile | 5 +- docker/examples/socrata/README.md | 16 ++ docker/examples/socrata/docker-compose.yml | 42 +++ docker/examples/socrata/socrata.config.yml | 110 +++++++ .../data-publishing/ogcapi-features.rst | 27 ++ pygeoapi/plugin.py | 1 + pygeoapi/provider/socrata.py | 272 ++++++++++++++++++ requirements-provider.txt | 6 +- tests/test_socrata_provider.py | 161 +++++++++++ 10 files changed, 636 insertions(+), 5 deletions(-) create mode 100644 docker/examples/socrata/README.md create mode 100644 docker/examples/socrata/docker-compose.yml create mode 100644 docker/examples/socrata/socrata.config.yml create mode 100644 pygeoapi/provider/socrata.py create mode 100644 tests/test_socrata_provider.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e93b873..ea04ac0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -85,6 +85,7 @@ jobs: pytest tests/test_postgresql_provider.py pytest tests/test_rasterio_provider.py pytest tests/test_sensorthings_provider.py + pytest tests/test_socrata_provider.py #pytest tests/test_sqlite_geopackage_provider.py pytest tests/test_tinydb_catalogue_provider.py pytest tests/test_util.py diff --git a/Dockerfile b/Dockerfile index 0354292..75030e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -90,8 +90,9 @@ RUN \ && cd /pygeoapi \ # Optionally add development/test/doc packages && if [ "$BUILD_DEV_IMAGE" = "true" ] ; then pip3 install -r requirements-dev.txt; fi \ - # Temporary fix for elasticsearch-dsl module not available as deb package in bionic - && pip3 install elasticsearch-dsl \ + # Install pygeoapi providers + && pip3 install -r requirements-provider.txt \ + # Intall pygeopi && pip3 install -e . \ # OGC schemas local setup && mkdir /schemas.opengis.net \ diff --git a/docker/examples/socrata/README.md b/docker/examples/socrata/README.md new file mode 100644 index 0000000..03e76d3 --- /dev/null +++ b/docker/examples/socrata/README.md @@ -0,0 +1,16 @@ +# pygeoapi with Socrata + +This folder contains the docker-compose configuration necessary to setup an example +`pygeoapi` server using a remote Socrata Open Data API (SODA) endpoint. + +This config is only for local development and testing. + +## Building and Running + +To build and run the [Docker compose file](docker-compose.yml) in localhost: + +``` +docker compose up [--build] [-d] +``` + +Navigate to `localhost:5000`. diff --git a/docker/examples/socrata/docker-compose.yml b/docker/examples/socrata/docker-compose.yml new file mode 100644 index 0000000..795131b --- /dev/null +++ b/docker/examples/socrata/docker-compose.yml @@ -0,0 +1,42 @@ +# ================================================================= +# +# Authors: Benjamin Webb +# +# Copyright (c) 2022 Benjamin Webb +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +services: + pygeoapi: + image: geopython/pygeoapi:latest + # build: + # context: ../../.. + + container_name: pygeoapi_socrata + + ports: + - 5000:80 + + volumes: + - ./socrata.config.yml:/pygeoapi/local.config.yml diff --git a/docker/examples/socrata/socrata.config.yml b/docker/examples/socrata/socrata.config.yml new file mode 100644 index 0000000..5f97d84 --- /dev/null +++ b/docker/examples/socrata/socrata.config.yml @@ -0,0 +1,110 @@ +# ================================================================= +# +# Authors: Benjamin Webb +# +# Copyright (c) 2022 Benjamin Webb +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +server: + bind: + host: 0.0.0.0 + port: 80 + url: http://localhost:5000 + mimetype: application/json; charset=UTF-8 + encoding: utf-8 + gzip: false + language: en-US + cors: true + pretty_print: true + limit: 10 + # templates: /path/to/templates + map: + url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png + attribution: 'Wikimedia maps | Map data © OpenStreetMap contributors' + ogc_schemas_location: /schemas.opengis.net + +logging: + level: ERROR + #logfile: /tmp/pygeoapi.log + +metadata: + identification: + title: SODA pygeoapi demo instance + description: pygeoapi for Socrata Open Data API + keywords: + - soda + - socrata + - api + keywords_type: theme + terms_of_service: https://creativecommons.org/licenses/by/4.0/ + url: https://github.com/geopython/pygeoapi + license: + name: CC-BY 4.0 license + url: https://creativecommons.org/licenses/by/4.0/ + provider: + name: Center for Geospatial Solutions + url: https://www.lincolninst.edu/center-geospatial-solutions + contact: + name: Webb, Benjamin + position: Softare Developer + address: Mailing Address + city: City + stateorprovince: Administrative Area + postalcode: Zip or Postal Code + country: Canada + phone: +xx-xxx-xxx-xxxx + fax: +xx-xxx-xxx-xxxx + email: you@example.org + url: Contact URL + hours: Hours of Service + instructions: During hours of service. Off on weekends. + role: pointOfContact + +resources: + earthquakes: + type: collection + title: USGS Earthquakes Demo + description: USGS Earthquakes Demo + keywords: + - earthquakes + - usgs + links: + - type: text/html + rel: canonical + title: data source + href: https://soda.demo.socrata.com/dataset/USGS-Earthquakes-Demo/emdb-u46w/ + hreflang: en-US + extents: + spatial: + bbox: [-180, -90, 180, 90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: Socrata + data: https://soda.demo.socrata.com/ + resource_id: emdb-u46w + id_field: earthquake_id + time_field: datetime + geom_field: location diff --git a/docs/source/data-publishing/ogcapi-features.rst b/docs/source/data-publishing/ogcapi-features.rst index 648ea3a..d2e60c2 100644 --- a/docs/source/data-publishing/ogcapi-features.rst +++ b/docs/source/data-publishing/ogcapi-features.rst @@ -27,6 +27,7 @@ parameters. PostgreSQL,✅/✅,results/hits,✅,❌,✅,✅,❌ SQLiteGPKG,✅/❌,results/hits,✅,❌,❌,✅,❌ SensorThingsAPI,✅/✅,results/hits,✅,✅,✅,✅,❌ + Socrata,✅/✅,results/hits,✅,✅,✅,✅,❌ Below are specific connection examples based on supported providers. @@ -298,6 +299,32 @@ to the associated features in the ``Datastreams`` feature collection, and the ``Datastreams`` feature collection. Examples with three entities configured are included in the docker examples for SensorThings. +Socrata +^^^^^^^ + +To publish a `Socrata Open Data API (SODA) ` endpoint, pygeoapi heavily +relies on `sodapy `. + + +* ``data`` is the domain of the SODA endpoint. +* ``resource_id`` is the 4x4 resource id pattern. +* ``geom_field`` is required for bbox queries to work. +* ``token`` is optional and can be included in the configuration to pass +an `app token ` to Socrata. + + +.. code-block:: yaml + + providers: + - type: feature + name: Socrata + data: https://soda.demo.socrata.com/ + resource_id: emdb-u46w + id_field: earthquake_id + geom_field: location + time_field: datetime # Optional time_field for datetime queries + token: my_token # Optional app token + Data access examples -------------------- diff --git a/pygeoapi/plugin.py b/pygeoapi/plugin.py index 3c36d56..6334d13 100644 --- a/pygeoapi/plugin.py +++ b/pygeoapi/plugin.py @@ -53,6 +53,7 @@ PLUGINS = { 'MVT': 'pygeoapi.provider.mvt.MVTProvider', 'TinyDBCatalogue': 'pygeoapi.provider.tinydb_.TinyDBCatalogueProvider', 'SensorThings': 'pygeoapi.provider.sensorthings.SensorThingsProvider', + 'Socrata': 'pygeoapi.provider.socrata.SODAServiceProvider', 'xarray-edr': 'pygeoapi.provider.xarray_edr.XarrayEDRProvider' }, 'formatter': { diff --git a/pygeoapi/provider/socrata.py b/pygeoapi/provider/socrata.py new file mode 100644 index 0000000..6cb17ab --- /dev/null +++ b/pygeoapi/provider/socrata.py @@ -0,0 +1,272 @@ +# ================================================================= +# +# Authors: Benjamin Webb +# +# Copyright (c) 2022 Benjamin Webb +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +from copy import deepcopy +import json +from urllib.parse import urlparse +from sodapy import Socrata +import logging + +from pygeoapi.provider.base import (BaseProvider, ProviderQueryError, + ProviderConnectionError) +from pygeoapi.util import format_datetime + +LOGGER = logging.getLogger(__name__) + +FIELD_NAME = 'columns_field_name' +DATA_TYPE = 'columns_datatype' + + +class SODAServiceProvider(BaseProvider): + """Socrata Open Data API Provider + """ + + def __init__(self, provider_def): + """ + SODA Class constructor + + :param provider_def: provider definitions from yml pygeoapi-config. + data, id_field, name set in parent class + + :returns: pygeoapi.provider.socrata.SODAServiceProvider + """ + LOGGER.debug('Logger SODA Init') + + super().__init__(provider_def) + self.resource_id = provider_def['resource_id'] + self.token = provider_def.get('token') + self.geom_field = provider_def.get('geom_field') + self.url = urlparse(self.data).netloc + self.client = Socrata(self.url, self.token) + self.get_fields() + + def get_fields(self): + """ + Get fields of SODA Provider + + :returns: dict of fields + """ + + if not self.fields: + + try: + [dataset] = self.client.datasets(ids=[self.resource_id]) + resource = dataset['resource'] + except json.decoder.JSONDecodeError as err: + LOGGER.error('Bad response at {}'.format(self.data)) + raise ProviderConnectionError(err) + + fields = self.properties or resource[FIELD_NAME] + for field in fields: + idx = resource[FIELD_NAME].index(field) + self.fields[field] = {'type': resource[DATA_TYPE][idx]} + + return self.fields + + def query(self, offset=0, limit=10, resulttype='results', + bbox=[], datetime_=None, properties=[], sortby=[], + select_properties=[], skip_geometry=False, q=None, **kwargs): + """ + SODA query + + :param offset: starting record to return (default 0) + :param limit: number of records to return (default 10) + :param resulttype: return results or hit limit (default results) + :param bbox: bounding box [minx,miny,maxx,maxy] + :param datetime_: temporal (datestamp or extent) + :param properties: list of tuples (name, value) + :param sortby: list of dicts (property, order) + :param select_properties: list of property names + :param skip_geometry: bool of whether to skip geometry (default False) + :param q: full-text search term(s) + + :returns: dict of GeoJSON FeatureCollection + """ + + # Default feature collection and request parameters + + params = { + 'content_type': 'geojson', + 'select': self._make_fields(select_properties), + 'where': self._make_where(bbox, datetime_, properties), + } + + fc = { + 'type': 'FeatureCollection', + 'features': [], + 'numberMatched': self._get_count(params) + } + + if resulttype == 'hits': + # Return hits + LOGGER.debug('Returning hits') + return fc + + if sortby != []: + params['order'] = self._make_orderby(sortby) + + params['offset'] = offset + params['limit'] = limit + + def make_feature(f): + f['id'] = f['properties'].pop(self.id_field) + if skip_geometry is True: + f['geometry'] = None + return f + + try: + LOGGER.debug('Sending query') + resp = self.client.get(self.resource_id, **params) + + LOGGER.debug('Making features') + fc['features'] = [make_feature(f) for f in resp['features']] + except Exception as err: + msg = f'Provider query error: {err}' + LOGGER.error(msg) + raise ProviderQueryError(msg) + + fc['numberReturned'] = len(resp['features']) + + return fc + + def get(self, identifier, **kwargs): + """ + Query SODA by id + + :param identifier: feature id + + :returns: dict of single GeoJSON feature + """ + params = { + 'content_type': 'geojson', + 'limit': 1, + } + properties = [(self.id_field, identifier), ] + params['where'] = self._make_where(properties=properties) + + # Form URL for GET request + LOGGER.debug('Sending query') + fc = self.client.get(self.resource_id, **params) + f = fc.get('features').pop() + f['id'] = f['properties'].pop(self.id_field) + return f + + def _make_fields(self, select_properties=[]): + """ + Make SODA select clause + + :param select_properties: list of property names + + :returns: SODA query `$select` clause + """ + if self.properties == [] and select_properties == []: + return '*' + + if self.properties != [] and select_properties != []: + outFields = set(self.properties) & set(select_properties) + else: + outFields = set(self.properties) | set(select_properties) + + outFields = set([self.id_field, *outFields]) + return ','.join(outFields) + + @staticmethod + def _make_orderby(sortby=[]): + """ + Make SODA order clause + + :param sortby: `list` of dicts (property, order) + + :returns: SODA query `$order` clause + """ + __ = {'+': 'ASC', '-': 'DESC'} + ret = [f"{_['property']} {__[_['order']]}" for _ in sortby] + + return ','.join(ret) + + def _make_where(self, bbox=[], datetime_=None, properties=[]): + """ + Private function: Make SODA filter from query properties + + :param bbox: bounding box [minx,miny,maxx,maxy] + :param datetime_: temporal (datestamp or extent) + :param properties: `list` of tuples (name, value) + + :returns: SODA query `$where` clause + """ + + ret = [] + + if properties != []: + ret.extend( + [f'{k} = "{v}"' for (k, v) in properties] + ) + + if bbox != []: + minx, miny, maxx, maxy = bbox + bpoly = f"'POLYGON (({minx} {miny}, {maxx} {miny}, \ + {maxx} {maxy}, {minx} {maxy}, {minx} {miny}))'" + ret.append(f"within_polygon({self.geom_field}, {bpoly})") + + if datetime_ is not None: + + fmt_ = '%Y-%m-%dT%H:%M:%S' + if '/' in datetime_: + time_start, time_end = datetime_.split('/') + if time_start != '..': + iso_time = format_datetime(time_start, fmt_) + ret.append(f"{self.time_field} >= '{iso_time}'") + if time_end != '..': + iso_time = format_datetime(time_end, fmt_) + ret.append(f"{self.time_field} <= '{iso_time}'") + + else: + iso_time = format_datetime(datetime_, fmt_) + ret.append(f"{self.time_field} = '{iso_time}'") + + return ' AND '.join(ret) + + def _get_count(self, params): + """ + Count number of features from query args + + :param params: `dict` of query params + + :returns: `int` of feature count + """ + params = deepcopy(params) + + params['select'] = 'count(*)' + params['content_type'] = 'json' + + [response] = self.client.get(self.resource_id, **params) + return int(response['count']) + + def __repr__(self): + return ' {}'.format(self.data) diff --git a/requirements-provider.txt b/requirements-provider.txt index 28e898c..68e8caa 100644 --- a/requirements-provider.txt +++ b/requirements-provider.txt @@ -1,14 +1,14 @@ elasticsearch<8 +elasticsearch-dsl<8 fiona #GDAL>=3.0.0 netCDF4 pandas; python_version < '3.7' pandas==1.2.5; python_version >= '3.7' -psycopg2-binary==2.8.4 +psycopg2 pygeometa pymongo==3.10.1 scipy +sodapy xarray zarr -elasticsearch-dsl<8 - diff --git a/tests/test_socrata_provider.py b/tests/test_socrata_provider.py new file mode 100644 index 0000000..b41a051 --- /dev/null +++ b/tests/test_socrata_provider.py @@ -0,0 +1,161 @@ +# ================================================================= +# +# Authors: Benjamin Webb +# +# Copyright (c) 2022 Benjamin Webb +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +import pytest + +from pygeoapi.provider.socrata import SODAServiceProvider + + +@pytest.fixture() +def config(): + # USGS Earthquakes + # source: [USGS](http://usgs.gov) + # URL: https://soda.demo.socrata.com/dataset/emdb-u46w + # License: CC0 3.0 https://creativecommons.org/licenses/by-sa/3.0/ + return { + 'name': 'SODAServiceProvider', + 'type': 'feature', + 'data': 'https://soda.demo.socrata.com/', + 'resource_id': 'emdb-u46w', + 'id_field': 'earthquake_id', + 'time_field': 'datetime', + 'geom_field': 'location' + } + + +def test_query(config): + p = SODAServiceProvider(config) + + results = p.query() + assert results['features'][0]['id'] == '00388610' + assert results['numberReturned'] == 10 + + results = p.query(limit=50) + assert results['numberReturned'] == 50 + feature_10 = results['features'][10] + + results = p.query(offset=10) + assert results['features'][0] == feature_10 + assert results['numberReturned'] == 10 + + results = p.query(limit=10) + assert len(results['features']) == 10 + assert results['numberMatched'] == 1006 + + results = p.query(limit=10001, resulttype='hits') + assert results['numberMatched'] == 1006 + + +def test_geometry(config): + p = SODAServiceProvider(config) + + results = p.query() + geometry = results['features'][0]['geometry'] + assert geometry['coordinates'] == [-117.6135, 41.1085] + + results = p.query(skip_geometry=True) + assert results['features'][0]['geometry'] is None + + bbox = [-109, 37, -102, 41] + results = p.query(bbox=bbox) + assert results['numberMatched'] == 0 + + bbox = [-178.2, 18.9, -66.9, 71.4] + results = p.query(bbox=bbox) + assert results['numberMatched'] == 817 + + feature = results['features'][0] + x, y = feature['geometry']['coordinates'] + xmin, ymin, xmax, ymax = bbox + assert xmin <= x <= xmax + assert ymin <= y <= ymax + + +def test_query_properties(config): + p = SODAServiceProvider(config) + + results = p.query() + assert len(results['features'][0]['properties']) == 11 + + # Query by property + results = p.query(properties=[('region', 'Nevada'), ]) + assert results['numberMatched'] == 19 + + results = p.query(properties=[('region', 'Northern California'), ]) + assert results['numberMatched'] == 119 + + # Query for property + results = p.query(select_properties=['magnitude', ]) + assert len(results['features'][0]['properties']) == 1 + assert 'magnitude' in results['features'][0]['properties'] + + # Query with configured properties + config['properties'] = ['region', 'datetime', 'magnitude'] + p = SODAServiceProvider(config) + + results = p.query() + props = results['features'][0]['properties'] + assert all(p in props for p in config['properties']) + assert len(props) == 3 + + results = p.query(properties=[('region', 'Central California'), ]) + assert results['numberMatched'] == 92 + + results = p.query(select_properties=['region', ]) + assert len(results['features'][0]['properties']) == 1 + + +def test_query_sortby_datetime(config): + p = SODAServiceProvider(config) + + results = p.query(sortby=[{'property': 'datetime', 'order': '+'}]) + dt = results['features'][0]['properties']['datetime'] + assert dt == '2012-09-07T23:00:42.000' + + results = p.query(sortby=[{'property': 'datetime', 'order': '-'}]) + dt = results['features'][0]['properties']['datetime'] + assert dt == '2012-09-14T22:38:01.000' + + results = p.query(datetime_='../2012-09-10T00:00:00.00Z', + sortby=[{'property': 'datetime', 'order': '-'}]) + dt = results['features'][0]['properties']['datetime'] + assert dt == '2012-09-09T23:57:50.000' + + results = p.query(datetime_='2012-09-10T00:00:00.00Z/..', + sortby=[{'property': 'datetime', 'order': '+'}]) + dt = results['features'][0]['properties']['datetime'] + assert dt == '2012-09-10T00:04:44.000' + + +def test_get(config): + p = SODAServiceProvider(config) + + result = p.get('00388610') + assert result['id'] == '00388610' + assert result['properties']['magnitude'] == '2.7'