diff --git a/lib/controllers/v1/saved_locations_controller.js b/lib/controllers/v1/saved_locations_controller.js new file mode 100644 index 00000000..1576cddc --- /dev/null +++ b/lib/controllers/v1/saved_locations_controller.js @@ -0,0 +1,18 @@ +const { saved_locations: savedLocations } = require( "inaturalistjs" ); +const InaturalistAPI = require( "../../inaturalist_api" ); + +const SavedLocationsController = class SavedLocationsController { + static async search( req ) { + return InaturalistAPI.iNatJSWrap( savedLocations.search, req ); + } + + static async create( req ) { + return InaturalistAPI.iNatJSWrap( savedLocations.create, req ); + } + + static async delete( req ) { + return InaturalistAPI.iNatJSWrap( savedLocations.delete, req ); + } +}; + +module.exports = SavedLocationsController; diff --git a/lib/controllers/v2/saved_locations_controller.js b/lib/controllers/v2/saved_locations_controller.js new file mode 100644 index 00000000..2565e29f --- /dev/null +++ b/lib/controllers/v2/saved_locations_controller.js @@ -0,0 +1,29 @@ +const _ = require( "lodash" ); +const ctrlv1 = require( "../v1/saved_locations_controller" ); + +const search = async req => { + const response = await ctrlv1.search( req ); + response.results = _.map( response.results, r => { + if ( _.isEmpty( r.geoprivacy ) ) { + r.geoprivacy = null; + } + return r; + } ); + return response; +}; + +const create = async req => { + const savedLocation = await ctrlv1.create( req ); + return { + page: 1, + per_page: 1, + total_results: 1, + results: [savedLocation] + }; +}; + +module.exports = { + search, + create, + delete: ctrlv1.delete +}; diff --git a/openapi/paths/v2/saved_locations.js b/openapi/paths/v2/saved_locations.js new file mode 100644 index 00000000..1153b7d8 --- /dev/null +++ b/openapi/paths/v2/saved_locations.js @@ -0,0 +1,87 @@ +const _ = require( "lodash" ); +const savedLocationsSearchSchema = require( "../../schema/request/saved_locations_search" ); +const transform = require( "../../joi_to_openapi_parameter" ); +const SavedLocationsController = require( "../../../lib/controllers/v2/saved_locations_controller" ); + +module.exports = sendWrapper => { + async function GET( req, res ) { + const response = await SavedLocationsController.search( req ); + sendWrapper( req, res, null, response ); + } + + GET.apiDoc = { + tags: ["SavedLocations"], + summary: "Retrieve saved locations for the authenticated user", + security: [{ + userJwtRequired: [] + }], + parameters: [ + { + in: "header", + name: "X-HTTP-Method-Override", + schema: { + type: "string" + } + } + ].concat( _.map( savedLocationsSearchSchema.$_terms.keys, child => ( + transform( child.schema.label( child.key ) ) + ) ) ), + responses: { + 200: { + description: "An array of saved locations", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ResultsSavedLocations" + } + } + } + }, + default: { + $ref: "#/components/responses/Error" + } + } + }; + + async function POST( req, res ) { + const results = await SavedLocationsController.create( req ); + sendWrapper( req, res, null, results ); + } + + POST.apiDoc = { + tags: ["SavedLocations"], + summary: "Create a saved location", + security: [{ + userJwtRequired: [] + }], + requestBody: { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/SavedLocationsCreate" + } + } + } + }, + responses: { + 200: { + description: "An array of saved locations", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ResultsSavedLocations" + } + } + } + }, + default: { + $ref: "#/components/responses/Error" + } + } + }; + + return { + GET, + POST + }; +}; diff --git a/openapi/paths/v2/saved_locations/{id}.js b/openapi/paths/v2/saved_locations/{id}.js new file mode 100644 index 00000000..6c2678ce --- /dev/null +++ b/openapi/paths/v2/saved_locations/{id}.js @@ -0,0 +1,36 @@ +const Joi = require( "joi" ); +const transform = require( "../../../joi_to_openapi_parameter" ); +const SavedLocationsController = require( "../../../../lib/controllers/v2/saved_locations_controller" ); + +module.exports = sendWrapper => { + async function DELETE( req, res ) { + await SavedLocationsController.delete( req ); + sendWrapper( req, res, null, null ); + } + + DELETE.apiDoc = { + tags: ["SavedLocations"], + summary: "Delete a saved location", + security: [{ + userJwtRequired: [] + }], + parameters: [ + transform( + Joi.number( ).integer( ) + .label( "id" ) + .meta( { in: "path" } ) + .required( ) + .description( "A single ID" ) + ) + ], + responses: { + 200: { + description: "No response body; success implies deletion" + } + } + }; + + return { + DELETE + }; +}; diff --git a/openapi/schema/request/saved_locations_create.js b/openapi/schema/request/saved_locations_create.js new file mode 100644 index 00000000..37744a6b --- /dev/null +++ b/openapi/schema/request/saved_locations_create.js @@ -0,0 +1,12 @@ +const Joi = require( "joi" ); + +module.exports = Joi.object( ).keys( { + fields: Joi.any( ), + saved_location: Joi.object( ).keys( { + latitude: Joi.number( ), + longitude: Joi.number( ), + title: Joi.string( ), + positional_accuracy: Joi.number( ).integer( ), + geoprivacy: Joi.string( ).valid( "open", "obscured", "private" ) + } ).required( ) +} ); diff --git a/openapi/schema/request/saved_locations_search.js b/openapi/schema/request/saved_locations_search.js new file mode 100644 index 00000000..befdea54 --- /dev/null +++ b/openapi/schema/request/saved_locations_search.js @@ -0,0 +1,7 @@ +const Joi = require( "joi" ); + +module.exports = Joi.object( ).keys( { + q: Joi.string( ), + page: Joi.number( ).integer( ), + fields: Joi.any( ) +} ).unknown( false ); diff --git a/openapi/schema/response/results_saved_locations.js b/openapi/schema/response/results_saved_locations.js new file mode 100644 index 00000000..0e9dc41e --- /dev/null +++ b/openapi/schema/response/results_saved_locations.js @@ -0,0 +1,9 @@ +const Joi = require( "joi" ); +const savedLocation = require( "./saved_location" ); + +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( savedLocation ).required( ) +} ).unknown( false ); diff --git a/openapi/schema/response/saved_location.js b/openapi/schema/response/saved_location.js new file mode 100644 index 00000000..00d858e1 --- /dev/null +++ b/openapi/schema/response/saved_location.js @@ -0,0 +1,14 @@ +const Joi = require( "joi" ); + +module.exports = Joi.object( ).keys( { + id: Joi.number( ).integer( ) + .description( "Unique auto-increment integer identifier." ).required( ), + user_id: Joi.number( ).integer( ), + latitude: Joi.number( ), + longitude: Joi.number( ), + title: Joi.string( ), + positional_accuracy: Joi.number( ).integer( ), + created_at: Joi.date( ), + updated_at: Joi.date( ), + geoprivacy: Joi.string( ).valid( "open", "obscured", "private" ).valid( null ) +} ).unknown( false ).meta( { className: "SavedLocation" } ); diff --git a/package-lock.json b/package-lock.json index 0bf7e371..92170dc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6964,8 +6964,7 @@ }, "node_modules/inaturalistjs": { "version": "2.13.0", - "resolved": "git+ssh://git@github.com/inaturalist/inaturalistjs.git#32b4d201c614ece4595d1cfed21be9fc0fb39aa9", - "integrity": "sha512-A+9pkeCYkSy9DmItH9rbbcF+7QX1S7I1ohGJUkNLLvl9arPen/GEUtQQuTqyA0aX6n+ave8jQC8QX6VrgnP0cg==", + "resolved": "git+ssh://git@github.com/inaturalist/inaturalistjs.git#4bef59273e2c4930e3b0ef0103a5826b4a87af3c", "dependencies": { "cross-fetch": "^4.0.0", "form-data": "^4.0.0", diff --git a/test/integration/v2/saved_locations.js b/test/integration/v2/saved_locations.js new file mode 100644 index 00000000..75f34cac --- /dev/null +++ b/test/integration/v2/saved_locations.js @@ -0,0 +1,76 @@ +const { expect } = require( "chai" ); +const request = require( "supertest" ); +const nock = require( "nock" ); +const jwt = require( "jsonwebtoken" ); +const config = require( "../../../config" ); + +describe( "SavedLocations", ( ) => { + const token = jwt.sign( + { user_id: 333 }, + config.jwtSecret || "secret", + { algorithm: "HS512" } + ); + + describe( "search", ( ) => { + it( "returns JSON", function ( done ) { + nock( "http://localhost:3000" ) + .get( "/saved_locations" ) + .reply( 200, { + total_results: 1, + page: 1, + per_page: 1, + results: [{ + id: 1 + }] + } ); + request( this.app ).get( "/v2/saved_locations" ) + .set( "Authorization", token ) + .expect( "Content-Type", /json/ ) + .expect( 200, done ); + } ); + } ); + + describe( "create", ( ) => { + it( "returns JSON", function ( done ) { + nock( "http://localhost:3000" ) + .post( "/saved_locations" ) + .reply( 200, { id: 1 } ); + request( this.app ).post( "/v2/saved_locations" ) + .set( "Authorization", token ) + .set( "Content-Type", "application/json" ) + // Actual values of what we send don't matter since we're mocking the + // Rails response, but we need it to pass request schema validation + .send( { + saved_location: { + latitude: 1.1, + longitude: 2.2, + title: "NewSavedLocation", + positional_accuracy: 33, + geoprivacy: "open" + } + } ) + .expect( 200 ) + .expect( res => { + expect( res.body.results[0].id ).to.eq( 1 ); + } ) + .expect( "Content-Type", /json/ ) + .expect( 200, done ); + } ); + } ); + + describe( "delete", ( ) => { + it( "should not return anything if successful", function ( done ) { + nock( "http://localhost:3000" ) + .delete( "/saved_locations/1" ) + .reply( 200 ); + request( this.app ).delete( "/v2/saved_locations/1" ) + .set( "Authorization", token ) + .set( "Content-Type", "application/json" ) + .expect( 200 ) + .expect( res => { + expect( res.body ).to.eq( "" ); + } ) + .expect( 200, done ); + } ); + } ); +} );