From 860b294f061fb68611e7d208a77cfbec1dc945c3 Mon Sep 17 00:00:00 2001 From: Patrick Leary Date: Fri, 10 Jul 2026 10:28:51 -0400 Subject: [PATCH 1/2] feat: add v2 taxa map_layers endpoint, related to WEB-1061 --- lib/controllers/v1/taxa_controller.js | 2 +- lib/controllers/v2/taxa_controller.js | 11 ++++ openapi/paths/v2/taxa/{id}/map_layers.js | 53 +++++++++++++++++++ .../response/results_taxa_map_layers.js | 16 ++++++ schema/fixtures.js | 46 ++++++++++++++++ test/integration/v1/taxa.js | 27 ++++++++++ test/integration/v2/taxa.js | 33 ++++++++++++ 7 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 openapi/paths/v2/taxa/{id}/map_layers.js create mode 100644 openapi/schema/response/results_taxa_map_layers.js diff --git a/lib/controllers/v1/taxa_controller.js b/lib/controllers/v1/taxa_controller.js index abb976a3..29aa93a8 100644 --- a/lib/controllers/v1/taxa_controller.js +++ b/lib/controllers/v1/taxa_controller.js @@ -1418,7 +1418,7 @@ TaxaController.nearby = async req => { TaxaController.mapLayers = async req => { const taxon = await Taxon.findByID( req.params.id ); if ( !taxon ) { - throw new Error( 404 ); + throw util.httpError( 404, "Not Found" ); } let query = squel.select( ).field( "source_identifier" ) diff --git a/lib/controllers/v2/taxa_controller.js b/lib/controllers/v2/taxa_controller.js index 9954ed4d..3c0169ce 100644 --- a/lib/controllers/v2/taxa_controller.js +++ b/lib/controllers/v2/taxa_controller.js @@ -25,9 +25,20 @@ const suggest = async req => { return ctrlv1.suggest( req ); }; +const mapLayers = async req => { + const v1Response = await ctrlv1.mapLayers( req ); + return { + total_results: 1, + page: 1, + per_page: 1, + results: [v1Response] + }; +}; + module.exports = { autocomplete: ctrlv1.autocomplete, iconic: ctrlv1.iconic, + mapLayers, search: ctrlv1.search, show, suggest, diff --git a/openapi/paths/v2/taxa/{id}/map_layers.js b/openapi/paths/v2/taxa/{id}/map_layers.js new file mode 100644 index 00000000..482aafd1 --- /dev/null +++ b/openapi/paths/v2/taxa/{id}/map_layers.js @@ -0,0 +1,53 @@ +const _ = require( "lodash" ); +const Joi = require( "joi" ); +const taxaWantedSchema = require( "../../../../schema/request/taxa_wanted" ); +const transform = require( "../../../../joi_to_openapi_parameter" ); +const TaxaController = require( "../../../../../lib/controllers/v2/taxa_controller" ); + +module.exports = sendWrapper => { + async function GET( req, res ) { + const results = await TaxaController.mapLayers( req ); + sendWrapper( req, res, null, results ); + } + + const parameters = _.map( + taxaWantedSchema.$_terms.keys, + child => transform( child.schema.label( child.key ) ) + ); + parameters.push( + transform( + Joi.number( ).integer( ) + .label( "id" ) + .meta( { in: "path" } ) + .required( ) + .description( "A single ID" ) + ), + transform( Joi.string( ).label( "fields" ).meta( { in: "query" } ) ), + transform( Joi.string( ).label( "X-HTTP-Method-Override" ).meta( { in: "header" } ) ) + ); + + GET.apiDoc = { + tags: ["Taxa"], + summary: "Fetch information about which map layers are relevent for a taxon.", + security: [{ + userJwtOptional: [] + }], + parameters, + responses: { + 200: { + description: "An array of taxon map layer data.", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ResultsTaxaMapLayers" + } + } + } + } + } + }; + + return { + GET + }; +}; diff --git a/openapi/schema/response/results_taxa_map_layers.js b/openapi/schema/response/results_taxa_map_layers.js new file mode 100644 index 00000000..b201ecde --- /dev/null +++ b/openapi/schema/response/results_taxa_map_layers.js @@ -0,0 +1,16 @@ +const Joi = require( "joi" ); + +module.exports = Joi.object( ).keys( { + total_results: Joi.number( ).integer( ).required( ), + page: Joi.number( ).integer( ).required( ), + per_page: Joi.number( ).integer( ).required( ), + results: Joi.array( ).items( Joi.object( ).keys( { + id: Joi.number( ).integer( ) + .description( "Unique auto-increment integer identifier." ) + .required( ), + ranges: Joi.boolean( ).required( ), + gbif_id: Joi.number( ).integer( ).valid( null ).required( ), + listed_places: Joi.boolean( ).required( ), + geomodel: Joi.boolean( ).required( ) + } ) ).required( ) +} ).unknown( false ); diff --git a/schema/fixtures.js b/schema/fixtures.js index 5e18e5a2..eb086b95 100644 --- a/schema/fixtures.js +++ b/schema/fixtures.js @@ -2503,6 +2503,13 @@ "updated_at": "2021-10-08 01:00:00" } ], + "geo_model_taxa": [ + { + "taxon_id": 123, + "created_at": "2026-07-09 00:00:00", + "updated_at": "2026-07-09 00:00:00" + } + ], "identifications": [ { "id": 102, @@ -2584,6 +2591,10 @@ { "id": 1000, "title": "DetailsListedTaxonList" + }, + { + "id": 1001, + "title": "List for taxon places" } ], "listed_taxa": [ @@ -2608,6 +2619,11 @@ "list_id": 1000, "place_id": 432, "establishment_means": "endemic" + }, + { + "taxon_id": 123, + "list_id": 1001, + "place_id": 1 } ], "messages": [ @@ -3102,6 +3118,13 @@ "height": 300 } ], + "place_geometries": [ + { + "id": 1, + "place_id": 1, + "geom": "POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))" + } + ], "places": [ { "id": 1, @@ -3736,6 +3759,29 @@ "iconic_taxon_id": 113 } ], + "taxon_ranges": [ + { + "taxon_id": 123 + } + ], + "taxon_scheme_taxa": [ + { + "id": 1, + "taxon_scheme_id": 1, + "taxon_id": 123, + "source_identifier": 12345, + "created_at": "2026-07-09 00:00:00", + "updated_at": "2026-07-09 00:00:00" + } + ], + "taxon_schemes": [ + { + "id": 1, + "title": "GBIF", + "created_at": "2026-07-09 00:00:00", + "updated_at": "2026-07-09 00:00:00" + } + ], "users": [ { "id": 1, diff --git a/test/integration/v1/taxa.js b/test/integration/v1/taxa.js index a769466d..e5c8c2ec 100644 --- a/test/integration/v1/taxa.js +++ b/test/integration/v1/taxa.js @@ -370,6 +370,33 @@ describe( "Taxa", ( ) => { } ); } ); + describe( "map_layers", ( ) => { + it( "returns taxon map layers", function ( done ) { + request( this.app ) + .get( "/v1/taxa/123/map_layers" ) + .expect( res => { + expect( res.body.id ).to.eq( 123 ); + expect( res.body.ranges ).to.be.true; + expect( res.body.gbif_id ).to.eq( 12345 ); + expect( res.body.listed_places ).to.be.true; + expect( res.body.geomodel ).to.be.true; + } ) + .expect( "Content-Type", /json/ ) + .expect( 200, done ); + } ); + + it( "returns a 404 for unknown taxa", function ( done ) { + request( this.app ) + .get( "/v1/taxa/987654321/map_layers" ) + .expect( res => { + expect( res.body.status ).to.eq( 404 ); + expect( res.body.error ).to.eq( "Not Found" ); + } ) + .expect( "Content-Type", /json/ ) + .expect( 404, done ); + } ); + } ); + describe( "suggest", ( ) => { it( "should succeed when source is checklist and taxon_id specified", function ( done ) { request( this.app ).get( "/v1/taxa/suggest?source=checklist&taxon_id=1" ).expect( 200, done ); diff --git a/test/integration/v2/taxa.js b/test/integration/v2/taxa.js index 7069fee0..14affc10 100644 --- a/test/integration/v2/taxa.js +++ b/test/integration/v2/taxa.js @@ -246,4 +246,37 @@ describe( "Taxa", ( ) => { .expect( 200, done ); } ); } ); + + describe( "map_layers", ( ) => { + it( "returns taxon map layers", function ( done ) { + request( this.app ) + .get( "/v2/taxa/123/map_layers" ) + .expect( res => { + expect( res.body.total_results ).to.eq( 1 ); + expect( res.body.page ).to.eq( 1 ); + expect( res.body.per_page ).to.eq( 1 ); + expect( res.body.results.length ).to.eq( 1 ); + expect( res.body.results[0].id ).to.eq( 123 ); + expect( res.body.results[0].ranges ).to.be.true; + expect( res.body.results[0].gbif_id ).to.eq( 12345 ); + expect( res.body.results[0].listed_places ).to.be.true; + expect( res.body.results[0].geomodel ).to.be.true; + } ) + .expect( "Content-Type", /json/ ) + .expect( 200, done ); + } ); + + it( "returns a 404 for unknown taxa", function ( done ) { + request( this.app ) + .get( "/v2/taxa/987654321/map_layers" ) + .expect( res => { + expect( res.body.status ).to.eq( "404" ); + expect( res.body.errors.length ).to.eq( 1 ); + expect( res.body.errors[0].errorCode ).to.eq( "404" ); + expect( res.body.errors[0].message ).to.eq( "Not Found" ); + } ) + .expect( "Content-Type", /json/ ) + .expect( 404, done ); + } ); + } ); } ); From b154abed74982270baa365a0d9a904ba27862829 Mon Sep 17 00:00:00 2001 From: Patrick Leary Date: Fri, 10 Jul 2026 11:10:39 -0400 Subject: [PATCH 2/2] feat: add UTFGrid endoint for v2 heatmap tiles --- .../v2/heatmap/{zoom}/{x}/{y}.grid.json.js | 46 ++++++++++ .../paths/v2/heatmap/{zoom}/{x}/{y}.png.js | 2 +- openapi/paths/v2/points/{zoom}/{x}/{y}.png.js | 2 +- test/integration/v1/tileserver.js | 57 ++++++++++++ test/integration/v2/tileserver.js | 89 +++++++++++++++++++ 5 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 openapi/paths/v2/heatmap/{zoom}/{x}/{y}.grid.json.js create mode 100644 test/integration/v2/tileserver.js diff --git a/openapi/paths/v2/heatmap/{zoom}/{x}/{y}.grid.json.js b/openapi/paths/v2/heatmap/{zoom}/{x}/{y}.grid.json.js new file mode 100644 index 00000000..d512b081 --- /dev/null +++ b/openapi/paths/v2/heatmap/{zoom}/{x}/{y}.grid.json.js @@ -0,0 +1,46 @@ +const _ = require( "lodash" ); +const InaturalistMapserver = require( "../../../../../../lib/inaturalist_map_server" ); +const transform = require( "../../../../../joi_to_openapi_parameter" ); +const observationsSearchSchema = require( "../../../../../schema/request/observations_search" ); +const { tilePathParams } = require( "../../../../../common_parameters" ); + +const inheritdObsSearchParams = _.filter( + observationsSearchSchema.$_terms.keys, p => !_.includes( ["fields"], p.key ) +); +const transformedObsSearchParams = _.map( inheritdObsSearchParams, p => ( + transform( p.schema.label( p.key ) ) +) ); + +module.exports = sendWrapper => { + async function GET( req, res ) { + req.params.style = "heatmap"; + req.params.format = "grid.json"; + await InaturalistMapserver.defaultRoute( req, res ); + sendWrapper( req, res ); + } + + GET.apiDoc = { + tags: ["UTFGrid"], + summary: "JSON for heatmap tiles", + security: [{ + userJwtOptional: [] + }], + parameters: tilePathParams.concat( transformedObsSearchParams ), + responses: { + 200: { + description: "Returns a UTFGrid.", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/UtfGrid" + } + } + } + } + } + }; + + return { + GET + }; +}; diff --git a/openapi/paths/v2/heatmap/{zoom}/{x}/{y}.png.js b/openapi/paths/v2/heatmap/{zoom}/{x}/{y}.png.js index 52bce061..bc82839c 100644 --- a/openapi/paths/v2/heatmap/{zoom}/{x}/{y}.png.js +++ b/openapi/paths/v2/heatmap/{zoom}/{x}/{y}.png.js @@ -21,7 +21,7 @@ module.exports = sendWrapper => { GET.apiDoc = { tags: ["Observation Tiles"], - summary: "Grid Tiles", + summary: "Heatmap Tiles", security: [{ userJwtOptional: [] }], diff --git a/openapi/paths/v2/points/{zoom}/{x}/{y}.png.js b/openapi/paths/v2/points/{zoom}/{x}/{y}.png.js index 5260d871..e6438201 100644 --- a/openapi/paths/v2/points/{zoom}/{x}/{y}.png.js +++ b/openapi/paths/v2/points/{zoom}/{x}/{y}.png.js @@ -21,7 +21,7 @@ module.exports = sendWrapper => { GET.apiDoc = { tags: ["Observation Tiles"], - summary: "Grid Tiles", + summary: "Points Tiles", security: [{ userJwtOptional: [] }], diff --git a/test/integration/v1/tileserver.js b/test/integration/v1/tileserver.js index fd0c7d28..4d2d7b56 100644 --- a/test/integration/v1/tileserver.js +++ b/test/integration/v1/tileserver.js @@ -1,4 +1,5 @@ const request = require( "supertest" ); +const { expect } = require( "chai" ); describe( "Tileserver", ( ) => { describe( "taxon places", ( ) => { @@ -41,6 +42,18 @@ describe( "Tileserver", ( ) => { .expect( "Content-Type", /png/ ) .expect( 200, done ); } ); + + it( "renders heatmap UTFGrids", function ( done ) { + this.timeout( 5000 ); + request( this.app ).get( "/v1/heatmap/1/0/0.grid.json" ) + .expect( res => { + expect( res.body.grid ).not.to.be.empty; + expect( res.body.keys ).not.to.be.empty; + expect( res.body.data ).not.to.be.empty; + } ) + .expect( "Content-Type", /json/ ) + .expect( 200, done ); + } ); } ); describe( "colored heatmap", ( ) => { @@ -50,6 +63,18 @@ describe( "Tileserver", ( ) => { .expect( "Content-Type", /png/ ) .expect( 200, done ); } ); + + it( "renders colored_heatmap UTFGrids", function ( done ) { + this.timeout( 5000 ); + request( this.app ).get( "/v1/colored_heatmap/1/0/0.grid.json" ) + .expect( res => { + expect( res.body.grid ).not.to.be.empty; + expect( res.body.keys ).not.to.be.empty; + expect( res.body.data ).not.to.be.empty; + } ) + .expect( "Content-Type", /json/ ) + .expect( 200, done ); + } ); } ); describe( "points", ( ) => { @@ -58,5 +83,37 @@ describe( "Tileserver", ( ) => { .expect( "Content-Type", /png/ ) .expect( 200, done ); } ); + + it( "renders points UTFGrids", function ( done ) { + this.timeout( 5000 ); + request( this.app ).get( "/v1/points/1/0/0.grid.json" ) + .expect( res => { + expect( res.body.grid ).not.to.be.empty; + expect( res.body.keys ).not.to.be.empty; + expect( res.body.data ).not.to.be.empty; + } ) + .expect( "Content-Type", /json/ ) + .expect( 200, done ); + } ); + } ); + + describe( "grid", ( ) => { + it( "renders grid tiles", function ( done ) { + request( this.app ).get( "/v1/grid/1/0/0.png" ) + .expect( "Content-Type", /png/ ) + .expect( 200, done ); + } ); + + it( "renders grid UTFGrids", function ( done ) { + this.timeout( 5000 ); + request( this.app ).get( "/v1/grid/1/0/0.grid.json" ) + .expect( res => { + expect( res.body.grid ).not.to.be.empty; + expect( res.body.keys ).not.to.be.empty; + expect( res.body.data ).not.to.be.empty; + } ) + .expect( "Content-Type", /json/ ) + .expect( 200, done ); + } ); } ); } ); diff --git a/test/integration/v2/tileserver.js b/test/integration/v2/tileserver.js new file mode 100644 index 00000000..2fd3a167 --- /dev/null +++ b/test/integration/v2/tileserver.js @@ -0,0 +1,89 @@ +const request = require( "supertest" ); +const { expect } = require( "chai" ); + +describe( "Tileserver", ( ) => { + describe( "taxon places", ( ) => { + it( "renders taxon place tiles", function ( done ) { + request( this.app ).get( "/v2/taxon_places/1/1/0/0.png" ) + .expect( "Content-Type", /png/ ) + .expect( 200, done ); + } ); + } ); + + describe( "taxon ranges", ( ) => { + it( "renders taxon range tiles", function ( done ) { + request( this.app ).get( "/v2/taxon_ranges/1/1/0/0.png" ) + .expect( "Content-Type", /png/ ) + .expect( 200, done ); + } ); + } ); + + describe( "places", ( ) => { + it( "renders place tiles", function ( done ) { + request( this.app ).get( "/v2/places/1/1/0/0.png" ) + .expect( "Content-Type", /png/ ) + .expect( 200, done ); + } ); + } ); + + describe( "heatmap", ( ) => { + it( "renders heatmap tiles", function ( done ) { + this.timeout( 5000 ); + request( this.app ).get( "/v2/heatmap/1/0/0.png" ) + .expect( "Content-Type", /png/ ) + .expect( 200, done ); + } ); + + it( "renders heatmap UTFGrids", function ( done ) { + this.timeout( 5000 ); + request( this.app ).get( "/v2/heatmap/1/0/0.grid.json" ) + .expect( res => { + expect( res.body.grid ).not.to.be.empty; + expect( res.body.keys ).not.to.be.empty; + expect( res.body.data ).not.to.be.empty; + } ) + .expect( "Content-Type", /json/ ) + .expect( 200, done ); + } ); + } ); + + describe( "points", ( ) => { + it( "renders points tiles", function ( done ) { + request( this.app ).get( "/v2/points/1/0/0.png" ) + .expect( "Content-Type", /png/ ) + .expect( 200, done ); + } ); + + it( "renders points UTFGrids", function ( done ) { + this.timeout( 5000 ); + request( this.app ).get( "/v2/points/1/0/0.grid.json" ) + .expect( res => { + expect( res.body.grid ).not.to.be.empty; + expect( res.body.keys ).not.to.be.empty; + expect( res.body.data ).not.to.be.empty; + } ) + .expect( "Content-Type", /json/ ) + .expect( 200, done ); + } ); + } ); + + describe( "grid", ( ) => { + it( "renders grid tiles", function ( done ) { + request( this.app ).get( "/v2/grid/1/0/0.png" ) + .expect( "Content-Type", /png/ ) + .expect( 200, done ); + } ); + + it( "renders grid UTFGrids", function ( done ) { + this.timeout( 5000 ); + request( this.app ).get( "/v2/grid/1/0/0.grid.json" ) + .expect( res => { + expect( res.body.grid ).not.to.be.empty; + expect( res.body.keys ).not.to.be.empty; + expect( res.body.data ).not.to.be.empty; + } ) + .expect( "Content-Type", /json/ ) + .expect( 200, done ); + } ); + } ); +} );