Skip to content
University of Maryland
CTRL K
    GitHub Atom Feed
    Give Now

    BTAA Geoportal

    The Big Ten Academic Alliance (BTAA) Geoportal connects users to digital geospatial resources, including GIS datasets, web services, and digitized historical maps from multiple data clearinghouses and library catalogs. The site is solely a search tool and does not host any data.

    JSON API

    Search Endpoint: https://geo.btaa.org/api/v1/search

    OpenAPI Description: https://geo.btaa.org/api/openapi.json

    Interactive Documentation: https://geo.btaa.org/api/docs

    The search endpoint takes q for the keyword query, along with page, per_page (maximum 100), sort, and facet parameters:

    #!/bin/bash
    
    curl "https://geo.btaa.org/api/v1/search?q=maryland"
    
    curl "https://geo.btaa.org/api/v1/search?q=maryland&per_page=25&page=2"

    Each record in the data array carries its metadata under attributes.ogm, using OpenGeoMetadata Aardvark field names such as dct_title_s, dct_publisher_sm, and gbl_resourceClass_sm. Individual records are also available from /api/v1/resources/{id}.

    Example: geoportal-search.py

    geoportal-search.py
    #!/usr/bin/env -S uv run --script
    # /// script
    # requires-python = ">=3.12"
    # dependencies = [
    #     "requests==2.34.2",
    #     "urllib3==2.7.0",
    # ]
    # ///
    
    """Search the BTAA Geoportal and print matching records.
    
    The geoportal serves a JSON:API at `https://geo.btaa.org/api/v1/search`. An
    OpenAPI description is published at <https://geo.btaa.org/api/openapi.json>,
    with Swagger UI at <https://geo.btaa.org/api/docs>.
    
    Each record carries its metadata under `attributes.ogm`, using OpenGeoMetadata
    Aardvark field names (`dct_title_s`, `dct_publisher_sm`, and so on).
    """
    
    import sys
    
    import requests
    import urllib3
    
    ENDPOINT = "https://geo.btaa.org/api/v1/search"
    QUERY = "maryland"
    PER_PAGE = 10
    TIMEOUT_SECONDS = 30
    
    RECORD_PAGE = "https://geo.btaa.org/resources/{id}"
    
    
    def is_transient(error):
        """Decide whether a failed request is worth trying again later.
    
        A host name that does not resolve, or a certificate that will not verify,
        is a settled fact about the URL rather than a passing condition. requests
        reports the first as a ConnectionError wrapping urllib3's
        NameResolutionError, and the second as its own SSLError subclass -- which
        carries no response, so a status code alone cannot tell them apart from a
        refused connection.
        """
        if isinstance(error, requests.exceptions.SSLError):
            return False
        cause = error.args[0] if error.args else None
        if isinstance(getattr(cause, "reason", None), urllib3.exceptions.NameResolutionError):
            return False
    
        status = getattr(error.response, "status_code", None)
        # No status at all means the request never reached an application; 5xx and
        # 429 mean the service cannot serve right now. A 4xx is this example's
        # problem.
        return status is None or status >= 500 or status == 429
    
    
    try:
        response = requests.get(
            ENDPOINT,
            params={"q": QUERY, "per_page": PER_PAGE},
            timeout=TIMEOUT_SECONDS,
        )
        response.raise_for_status()
        payload = response.json()
    except requests.exceptions.JSONDecodeError:
        # requests.exceptions.JSONDecodeError subclasses RequestException, so this
        # clause has to come first to be reachable.
        content_type = response.headers.get("content-type", "an unknown type")
        print(f"{ENDPOINT} returned {content_type} rather than JSON", file=sys.stderr)
        # A body that is not JSON means the API changed under this example.
        sys.exit(1)
    except requests.RequestException as error:
        print(f"{ENDPOINT} could not be read: {error}", file=sys.stderr)
        # 75 = EX_TEMPFAIL: the condition may clear on its own.
        sys.exit(75 if is_transient(error) else 1)
    
    
    def first(values, default):
        """Return the first entry of an Aardvark multi-valued (`_sm`) field."""
        if isinstance(values, list) and values:
            return values[0]
        return default
    
    
    # response.url is the request as finally encoded, so it can be pasted straight
    # into a browser or curl to see the raw JSON.
    print(f"GET {response.url}")
    
    # A search that matches nothing is a legitimate answer, so check for the
    # JSON:API envelope rather than for records: "meta.totalCount" beside a "data"
    # list is what says the response was understood. Defaulting these instead would
    # report any other JSON body as a search with no hits.
    meta = payload.get("meta")
    records = payload.get("data")
    if not isinstance(meta, dict) or "totalCount" not in meta or not isinstance(records, list):
        print(f'{ENDPOINT} did not return a JSON:API envelope with "meta.totalCount" '
              f'and a "data" list', file=sys.stderr)
        sys.exit(1)
    
    total = meta["totalCount"]
    print(f'{total} records match "{QUERY}"; showing {len(records)}.')
    
    for record in records:
        metadata = record.get("attributes", {}).get("ogm", {})
        identifier = record.get("id", "")
    
        print("----")
        print(f"Title:     {metadata.get('dct_title_s', '(untitled)')}")
        print(f"Publisher: {first(metadata.get('dct_publisher_sm'), '(unknown)')}")
        print(f"Class:     {first(metadata.get('gbl_resourceClass_sm'), '(unknown)')}")
        print(f"Link:      {RECORD_PAGE.format(id=identifier)}")
    

    Run this example with uv — no download or setup required:

    uv run https://opendata.lib.umd.edu/code/geoportal-search.py

    OGC API - Records

    API Description: OGC API - Records

    Landing Page: https://geo.btaa.org/api/v1/ogc/

    The BTAA Geoportal’s catalog is also exposed as an OGC API - Records service. It declares six Part 1: Core conformance classes, including sorting, and holds a single collection of records, btaa-records. Each document leads to the next: the collection list gives the collection id, and the sortables document gives the values sortby accepts:

    #!/bin/bash
    
    curl "https://geo.btaa.org/api/v1/ogc/conformance"
    
    curl "https://geo.btaa.org/api/v1/ogc/collections"
    
    curl "https://geo.btaa.org/api/v1/ogc/collections/btaa-records/sortables"
    
    curl "https://geo.btaa.org/api/v1/ogc/collections/btaa-records/items?q=maryland&sortby=title&limit=3"

    Records are GeoJSON Features whose properties carry the catalog metadata (title, resourceClass, spatial, accessRights, and so on).

    Example: geoportal-ogc-records.py

    geoportal-ogc-records.py
    #!/usr/bin/env -S uv run --script
    # /// script
    # requires-python = ">=3.12"
    # dependencies = [
    #     "requests==2.34.2",
    #     "urllib3==2.7.0",
    # ]
    # ///
    
    """Search the BTAA Geoportal through its OGC API - Records catalog.
    
    OGC API - Records (<https://ogcapi.ogc.org/records/>) is the Open Geospatial
    Consortium (OGC) standard for searchable catalogs of geospatial resources.
    The Big Ten Academic Alliance (BTAA) Geoportal serves one at
    `https://geo.btaa.org/api/v1/ogc`.
    
    A Records catalog is self-describing, and this example walks the discovery
    chain a client is meant to follow: `/conformance` says which parts of the
    standard the server implements, `/collections` lists the record collections
    it holds, each collection's `/sortables` names the properties an ordering
    can use, and `/items` runs the search itself. Records come back as GeoJSON
    Features whose `properties` carry the catalog metadata.
    """
    
    import sys
    
    import requests
    import urllib3
    
    API = "https://geo.btaa.org/api/v1/ogc"
    QUERY = "maryland"
    LIMIT = 3
    TIMEOUT_SECONDS = 30
    
    RECORD_PAGE = "https://geo.btaa.org/resources/{id}"
    
    
    def is_transient(error):
        """Decide whether a failed request is worth trying again later.
    
        A host name that does not resolve, or a certificate that will not verify,
        is a settled fact about the URL rather than a passing condition. requests
        reports the first as a ConnectionError wrapping urllib3's
        NameResolutionError, and the second as its own SSLError subclass -- which
        carries no response, so a status code alone cannot tell them apart from a
        refused connection.
        """
        if isinstance(error, requests.exceptions.SSLError):
            return False
        cause = error.args[0] if error.args else None
        if isinstance(getattr(cause, "reason", None), urllib3.exceptions.NameResolutionError):
            return False
    
        status = getattr(error.response, "status_code", None)
        # No status at all means the request never reached an application; 5xx and
        # 429 mean the service cannot serve right now. A 4xx is this example's
        # problem.
        return status is None or status >= 500 or status == 429
    
    
    def get_json(path, params=None):
        """Fetch one catalog document and echo the URL so it can be replayed."""
        url = API + path
        try:
            response = requests.get(url, params=params, timeout=TIMEOUT_SECONDS)
            response.raise_for_status()
            payload = response.json()
        except requests.exceptions.JSONDecodeError:
            # requests.exceptions.JSONDecodeError subclasses RequestException, so
            # this clause has to come first to be reachable.
            content_type = response.headers.get("content-type", "an unknown type")
            print(f"{url} returned {content_type} rather than JSON", file=sys.stderr)
            # A body that is not JSON means the API changed under this example.
            sys.exit(1)
        except requests.RequestException as error:
            print(f"{url} could not be read: {error}", file=sys.stderr)
            # 75 = EX_TEMPFAIL: the condition may clear on its own.
            sys.exit(75 if is_transient(error) else 1)
    
        # response.url is the request as finally encoded, so it can be pasted
        # straight into a browser or curl to see the raw JSON.
        print(f"GET {response.url}")
        return payload
    
    
    def first(values, default):
        """Return the first entry of a multi-valued property such as `spatial`."""
        if isinstance(values, list) and values:
            return values[0]
        return default
    
    
    # Every OGC API declares the conformance classes it implements; a class URI
    # ending in /conf/sorting, for example, promises the sorting behaviour that
    # part of the standard defines.
    conformance = get_json("/conformance")
    classes = conformance.get("conformsTo")
    if not isinstance(classes, list):
        # sys.exit with a string prints it to stderr and exits 1: a document
        # without its defining key means the API changed under this example.
        sys.exit('the conformance document has no "conformsTo" list')
    print(f"Conformance: {', '.join(uri.rsplit('/', 1)[-1] for uri in classes)}")
    
    # A catalog groups its records into collections, each searchable through its
    # own /items endpoint.
    collections = get_json("/collections").get("collections")
    if not isinstance(collections, list):
        sys.exit('the collections document has no "collections" list')
    if not collections:
        print("The catalog lists no collections.")
        sys.exit(0)
    collection = collections[0]
    collection_id = collection.get("id")
    if not collection_id:
        sys.exit('the collection entry has no "id"')
    print(f"Collection:  {collection_id} - {collection.get('title') or '(untitled)'}")
    
    # The sortables document is a JSON Schema whose properties name the fields an
    # ordering may use -- the behaviour the "sorting" conformance class above
    # promises. "title" below comes from this list.
    sortables = get_json(f"/collections/{collection_id}/sortables")
    properties = sortables.get("properties")
    if not isinstance(properties, dict):
        sys.exit('the sortables document is not a JSON Schema with "properties"')
    print(f"Sortables:   {', '.join(sorted(properties))}")
    
    # `q` is the standard's free-text search parameter; `sortby` takes any
    # property the sortables document names.
    results = get_json(f"/collections/{collection_id}/items",
                       params={"q": QUERY, "sortby": "title", "limit": LIMIT})
    # A search that matches nothing is a legitimate answer, so check for the
    # GeoJSON envelope rather than for records: a FeatureCollection with a
    # "features" list is what says the query was understood.
    if results.get("type") != "FeatureCollection" or not isinstance(results.get("features"), list):
        sys.exit("the items response is not a GeoJSON FeatureCollection")
    
    features = results["features"]
    if not features:
        print(f'No records matched "{QUERY}".')
        sys.exit(0)
    
    print(f'Showing {len(features)} records matching "{QUERY}":')
    for feature in features:
        # A record's catalog metadata rides in the Feature's "properties"; a
        # property the catalog has no value for is present but null.
        record = feature.get("properties") or {}
        print("----")
        print(f"Title:   {record.get('title') or '(untitled)'}")
        print(f"Class:   {first(record.get('resourceClass'), '(unknown)')}")
        print(f"Spatial: {first(record.get('spatial'), '(unknown)')}")
        print(f"Access:  {record.get('accessRights') or '(unknown)'}")
        print(f"Link:    {RECORD_PAGE.format(id=feature.get('id', ''))}")
    

    Run this example with uv — no download or setup required:

    uv run https://opendata.lib.umd.edu/code/geoportal-ogc-records.py