Skip to main content

GeoJSON

By Bob Coret Renders any Omeka S API item query as an RFC 7946 FeatureCollection, using format=geojson or an Accept: application/geo+json header.
Download 1.0.0

GeoJSON

Omeka S module that renders any item query from the REST API as an RFC 7946 FeatureCollection.

This module answers a 2023 proposal on the Omeka forum for exposing item sets as GeoJSON so they can be put on a map. Nobody replied, so the idea ran for a few years as a standalone script at Gouda Tijdmachine before being rewritten as this module.

Requesting GeoJSON

There is no new route. Any API item query works, in two forms:

curl 'https://example.org/api/items?item_set_id=1234&format=geojson'
curl -H 'Accept: application/geo+json' 'https://example.org/api/items?item_set_id=1234'

geojson is registered as a first-class API output format, so content negotiation and the Content-Type: application/geo+json header are handled by Omeka core. The Accept header is matched explicitly: a browser sending */* still gets the usual JSON-LD.

Because it is a normal API query, every Omeka query argument keeps working — item_set_id, resource_class_id, property[], fulltext_search, and so on — and so do permissions. Items the requester may not see are not in the output.

Items without a geometry are silently omitted: a feature needs one.

Which requests are answered

geojson is registered as an API output format globally, which is what makes content negotiation work. Requests are then answered in one of two ways:

  • The streaming fast path, for collection requests on a resource listed in resources (default: items). This is the one that scales — it resolves the query to ids and streams the output without ever hydrating a resource.
  • The fallback serializer, for everything else. It renders whatever items are in the already-hydrated payload, so:
  • a single-item request (/api/items/37893?format=geojson) returns a FeatureCollection holding that one feature;
  • a request for a resource that carries no items — /api/item_sets?format=geojson, say — returns an empty FeatureCollection, not an error.

Adding a resource to resources moves it from the second path to the first. Removing one does not stop format=geojson from being accepted for it; the request is simply answered by the fallback instead. Keep resources limited to what you actually want to stream in bulk.

The fast path answers the request itself rather than going through Omeka's view layer, so callback is ignored: there is no JSONP wrapping of a FeatureCollection, on either path. Use CORS instead.

Output

The module emits exactly the RFC 7946 members, and nothing else:

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "id": "https://example.org/api/items/37893",
      "properties": {"title": "Gemeente Gouda, kadastrale sectie A"},
      "geometry": {"type": "Polygon", "coordinates": [[[4.724438, 52.007094]]]}
    }
  ]
}

Note that id is the RFC 7946 Feature member, not an entry inside properties. There is deliberately no collection-level properties member — RFC 7946 does not define one.

Coordinate reference system

RFC 7946 has exactly one CRS: WGS 84 longitude/latitude (CRS84), in that order. The module does not reproject and does not check, because the database function it hands the geometry to cannot tell you what the coordinates mean — it just converts them. data_type_geography is WGS 84 by construction, but a geometry stored in a projected system, or a WKT literal parsed by ST_GeomFromText without an SRID, is emitted exactly as stored and is then not valid GeoJSON, however well-formed it looks.

If your coordinates are in RD (EPSG:28992), Web Mercator or anything else, convert them on the way in. A quick check on any feature: longitude comes first, and for the Netherlands both numbers are small — [4.72, 52.01], not [104000, 447000].

Canonical URLs

With the default feature_id of api_url, each feature's id is an absolute URL, and its scheme and host come from the request — which means the client's Host header decides what goes into the output, and cached responses are keyed per host so that one client cannot write an entry that is then served to everyone else.

On any site reachable under more than one name, or behind a proxy or CDN, set the canonical one and take the decision away from the client entirely:

'base_url' => 'https://example.org',

Only the scheme, host and port are taken from it; the path still comes from Omeka's own routing, so this keeps working under a subdirectory install.

Example

Gouda Tijdmachine runs this module in production, publishing its historical collections of the city of Gouda as GeoJSON. Five of its item sets, live:

Those are five of roughly fifty. The full list is on the site's data page, which builds it from a SPARQL query over every item set holding a geometry, so it stays current as sets are added.

Configuration

Everything lives in the geojson array in config/module.config.php. There is no admin UI (configurable = false); the file is the interface. The shipped defaults are Gouda Tijdmachine's mapping — treat them as a worked example and replace them with your own terms.

'geojson' => [
    'resources'  => ['items'],
    'precision'  => 6,
    'max_items'  => 100000,
    ...
],
Key Meaning
resources API resources that get the streaming fast path. See Which requests are answered — this does not restrict which resources accept format=geojson.
base_url Scheme and host for the feature id URLs. null derives them from the request. See Canonical URLs.
precision Decimal digits kept by ST_AsGeoJSON. 6 digits is about 0.11 m.
max_items Refuse to render more than this many items; the response is a 413. Guards against someone accidentally exporting a million-item set.
feature_id api_url (default), item_id, or an RDF term such as owl:sameAs to use a persistent identifier.
thumbnail_property / thumbnail_type Feature property holding the thumbnail URL of the item's primary media, and which derivative to use. Set the property to null to omit thumbnails. Like the rules below, it never overwrites a key that is already filled.

Geometry sources

Sources are tried in the configured order, and the first one that yields a geometry for an item wins:

'geometry_sources' => [
    ['type' => 'data_type_geometry'],
    ['type' => 'data_type_geography'],
    ['type' => 'literal_wkt', 'properties' => ['geo:asWKT']],
    ['type' => 'mapping_feature'],
],
Type Where the geometry comes from
data_type_geometry geometry values stored by module DataTypeGeometry
data_type_geography geography values stored by the same module
literal_wkt plain literal WKT values of the listed properties, parsed with ST_GeomFromText
mapping_feature features of module Mapping 2.0 and later

data_type_geometry and data_type_geography also accept an optional properties key to restrict them to specific terms. Sources whose table is not present are skipped, so the module works whether or not those modules are installed. A malformed WKT literal costs that one item its geometry and nothing more.

Property mapping

Property values become feature properties through an ordered list of rules, keyed by RDF term rather than by numeric property id — ids differ per installation and drift when vocabularies are reinstalled. Terms that do not exist are logged and skipped.

'properties' => [
    ['term' => 'skos:prefLabel', 'key' => 'title'],
    ['term' => 'sdo:name',       'key' => 'title'],
    ['term' => 'dcterms:title',  'key' => 'title'],
],

Each rule takes:

Field Values
term required, the RDF term of the property
key required, the name of the GeoJSON feature property
source value (default) · uri · resource_id · exists
transform none (default) · year · lower · upper · int · float · prefix:<string> · suffix:<string>

source picks which column of the value is used: the literal value, the uri of a URI value, the numeric resource_id of a linked resource, or exists, which emits "1" when the property is present at all regardless of its content.

transform rewrites the result: year keeps the first four digits as an integer (so 1872-04-01 becomes 1872, and -0500-01-01 becomes -500), lower/upper change case, int/float cast numerics, and prefix:/suffix: wrap the value — e.g. 'transform' => 'prefix:Perceel ' turns GDA01-A1 into Perceel GDA01-A1.

Rules never overwrite a key an earlier rule already filled, so the order of the list is a preference chain. In the example above skos:prefLabel wins over sdo:name, which wins over dcterms:title. Several rules may also target the same term with different keys, which is how one property can appear both as a URI and as its label:

['term' => 'sdo:mainEntityOfPage', 'key' => 'mainEntityOfPage_uri', 'source' => 'uri'],
['term' => 'sdo:mainEntityOfPage', 'key' => 'mainEntityOfPage_value'],

Private values are included only when the requester is allowed to view them.

Caching

Generated collections are cached as files under OMEKA_PATH/files/geojson/, one per query, plus an index.json recording which item sets each query touched. Entries are dropped by event — creating, updating or deleting an item, item set or media invalidates every entry that could have contained it — rather than by waiting out a TTL, so an edit shows up in the next request.

'cache' => [
    'enabled'     => true,
    'path'        => null,       // null puts the cache in OMEKA_PATH/files/geojson
    'ttl'         => 0,          // 0 relies on event invalidation alone
    'max_age'     => 3600,       // the Cache-Control max-age sent to clients
    'max_entries' => 500,        // 0 for no limit
    'max_bytes'   => 536870912,  // 512 MiB; 0 for no limit
],

The cache key covers the query, the canonical URL the feature ids are built from, and a fingerprint of the configuration above it — so editing your property mapping, precision or feature_id retires every entry built under the old one immediately, rather than leaving stale output to be served until something happens to be edited.

Only anonymous responses are ever cached. A response built for a signed-in user can contain resources the next visitor is not allowed to see, so authenticated requests are always built fresh, never written to the cache, and sent with Cache-Control: private, no-store so that no shared cache stores them either. This is a security property, not an optimisation — keep it in mind before changing how the cache key is computed.

One entry is stored per distinct query string, and the query string belongs to the client, so max_entries and max_bytes put a ceiling on what the cache can occupy; when either is passed, the least recently written entries go first. Do not set both to 0 on a publicly reachable site.

Where the entries live

Inside files/geojson/, the entries go in a directory whose name is generated once per installation and kept in Omeka's settings. This is deliberate: files/ is served by the web server, so an entry under a predictable name could be fetched straight off disk, bypassing the API and everything it enforces. An .htaccess and a web.config denying access are written alongside, but plenty of servers are configured not to read them (AllowOverride None), so the unguessable name is what actually carries the guarantee.

If you would rather keep the cache out of the web root entirely — the cleanest answer — point path somewhere that is not served:

'path' => '/var/lib/omeka/geojson-cache',

Uninstalling the module removes the cache directory and the setting naming it.

Client caching

Responses carry ETag, Last-Modified and Vary: Accept, and conditional requests are answered with 304. The Vary matters: the same URL answers with GeoJSON or JSON-LD depending on the Accept header, so a shared cache that ignored it would hand one to a client that asked for the other.

If a proxy or CDN sits in front of Omeka, check that it is not adding cache directives of its own on top of these. immutable, in particular, tells clients not to revalidate at all for the lifetime of the response, which throws away the event invalidation above: an edit would then take up to max_age to reach a browser that already has a copy.

Scale

The module never hydrates Omeka resources. It resolves the query to item ids with an id-only API search, then builds the output from plain SQL in batches, streaming it to disk as it goes — so memory stays flat no matter how large the collection is. Rendering a 26,222-item set takes about 1.2 s and 14 MB of output while peak memory grows by roughly 6 MB.

Requirements

Omeka S ^4.0.0
PHP 8.1 or later (the minimum Omeka S 4 itself requires)
Database MySQL 5.7.5+ or MariaDB 10.2.4+

The database requirement is the only one worth a second look: the module hands the geometry conversion to the database with ST_AsGeoJSON(geometry, precision), so the server must support that function and its precision argument. Every MySQL and MariaDB release that Omeka S 4 runs on already does.

Developed and verified against Omeka S 4.2.1, PHP 8.4 and MariaDB 12.0.2; the code is linted for PHP 8.4 and 8.5.

Dependencies

There are no required dependencies. No Composer packages, no other Omeka S modules — the module is self-contained and works on a stock Omeka S install.

Some geometry sources do need a companion module, but only the source itself is affected: the module checks whether the backing table exists, skips sources it cannot read, and moves on to the next one. An installation with none of these modules still serves valid GeoJSON via the literal_wkt source (or an empty FeatureCollection if nothing carries a geometry).

Geometry source Needs Verified with
data_type_geometry DataTypeGeometry 3.4.7
data_type_geography DataTypeGeometry 3.4.7
mapping_feature Mapping 2.0 or later 2.2.0
literal_wkt — (plain literal values, no module)

If you remove one of those modules later, drop the matching entry from geometry_sources too. Leaving it in is harmless — the source is skipped — but the configuration then claims something the installation cannot deliver.

Note for packagers: do not add jmikola/geojson as a dependency — its namespace is also GeoJson\ and would collide with this module's.

Installation

From a release zip

  1. Download GeoJson-<version>.zip from the latest release.
  2. Unzip it into your Omeka S modules/ directory, so the module lives at modules/GeoJson/.
  3. In the Omeka S admin, go to Modules, find GeoJSON and click Install.

From source

cd /path/to/omeka-s/modules
git clone https://github.com/coret/Omeka-S-module-GeoJson.git GeoJson

Then install it from Modules in the admin as above.

The directory name must be GeoJson — Omeka S resolves the module by directory name, and it has to match the GeoJson namespace in the code. Note that this differs from the repository name, so keep the trailing GeoJson argument on the clone: without it you get a Omeka-S-module-GeoJson/ directory that Omeka will not load.

Code style

The module follows Omeka S's own code style, as described in the contributing guide: PSR-2 plus core's additions. .php-cs-fixer.dist.php holds core's rule set copied verbatim, so the module tracks core instead of inventing a house style; only the finder differs, since core's excludes are core-repository paths.

There is nothing to install — run Omeka's own PHP CS Fixer against the module:

cd /path/to/omeka-s/modules/GeoJson
php ../../vendor/bin/php-cs-fixer fix

That needs an Omeka S checkout with its dev dependencies installed (friendsofphp/php-cs-fixer is in core's require-dev); a release zip does not carry them. Add --dry-run --diff to see what would change without writing.

Note that the contributing guide still calls the file .php_cs. That is the PHP-CS-Fixer v2 name, which v3 does not read; core itself ships .php-cs-fixer.dist.php, which is what this module matches.

License

This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. The full text is in LICENSE.

GPL-3.0 matches Omeka S itself, which this module is a part of at runtime.

The optional geometry sources need no licence consideration: the module reads the tables that DataTypeGeometry and Mapping create, with plain SQL, and uses no code from either.

Version Released Minimum Omeka version
1.0.0August 11, 2026 [info]^4.0.0