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 aFeatureCollectionholding that one feature; - a request for a resource that carries no items —
/api/item_sets?format=geojson, say — returns an emptyFeatureCollection, 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:
- Molens (windmills, points and polygons) » https://www.goudatijdmachine.nl/omeka/api/items?item_set_id=92174&format=geojson (view via geojson.tools)
- Bruggen (bridges, points) » https://www.goudatijdmachine.nl/omeka/api/items?item_set_id=92170&format=geojson (view via geojson.tools)
- Buurten Gouda (BAG) (neighbourhoods, polygons) » https://www.goudatijdmachine.nl/omeka/api/items?item_set_id=45965&format=geojson (view via geojson.tools)
- Historische tramwegen (historic tramways, multi-linestrings) » https://www.goudatijdmachine.nl/omeka/api/items?item_set_id=3048796&format=geojson (view via geojson.tools)
- Gevelstenen (gable stones, points) » https://www.goudatijdmachine.nl/omeka/api/items?item_set_id=16&format=geojson (view via geojson.tools)
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
- Download
GeoJson-<version>.zipfrom the latest release. - Unzip it into your Omeka S
modules/directory, so the module lives atmodules/GeoJson/. - 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.