...
 
Commits (3)
......@@ -51,17 +51,8 @@ Finds OpenStreetMap places that match the search terms, possibly within a boundi
Generates an error if provided without `lon`.
* `d`: Half of the length of one edge of the bounding box, in meters.
Generates an error if provided without `lat` and `lon`. Optional.
Defaults to 50.
* `limit`: Number of places to return. Defaults to 10.
* `offset`: Number of places to skip. Defaults to 0.
* `before`: Show `count` items that would be shown *before* this item, not
inclusive. So, given numerical sorting, `search?count=3&before=9` would return
values 6, 7, and 8. (Note: just an example. The relevance is more complicated than this,
and that's not how OSM IDs work!).
* `after`: Show `count` items that would be shown *after* this item, not
inclusive. So, given numerical sorting, `search?count=3&after=9` would return
values 10, 11, and 12. (Note: just an example. The relevance is more complicated than this,
and that's not how OSM IDs work!).
Defaults to 50
* `page`: page of results to return. The page length is 10.
Some examples of good queries:
......@@ -76,7 +67,9 @@ Some examples of good queries:
The server will do [content negotation](https://www.w3.org/Protocols/rfc2616/rfc2616-sec12.html) to figure out what kind of content to return. This will usually be either HTML or Activity Streams 2.0 JSON.
If it is AS2, it will be a paged [collection](https://www.w3.org/TR/activitystreams-core/#collections) of Place objects.
If it is AS2, it will be a [collection](https://www.w3.org/TR/activitystreams-core/#collections)
with a `first` property being the first page of results, unless the `page`
parameter is provided, in which case it will be a [CollectionPage](https://www.w3.org/TR/activitystreams-core/#dfn-collectionpage).
The `first`, `last`, `next` and `prev` properties are useful for paging.
### <https://places.pub/version>
......
......@@ -27,6 +27,7 @@ const VCARD = 'http://www.w3.org/2006/vcard/ns#'
const CC = 'http://creativecommons.org/ns#'
const ODBL = 'http://opendatacommons.org/licenses/odbl/'
const DEFAULT_DISTANCE = 50
const PAGE_LENGTH = 10
function makeOSM (argv) {
function makeURI (path) {
......@@ -45,13 +46,9 @@ function makeOSM (argv) {
function nodeToAS2 (nplace) {
const as2place = {
'id': makeURI(`/osm/node/${nplace.id}`),
'@context': [AS2, {vcard: VCARD, cc: CC}],
type: 'Place',
latitude: parseFloat(nplace.lat),
longitude: parseFloat(nplace.lon),
'cc:license': ODBL,
'cc:attributionName': 'OpenStreetMap contributors',
'cc:attributionURL': 'http://www.openstreetmap.org/copyright'
latitude: Number(nplace.lat),
longitude: Number(nplace.lon)
}
if (nplace.tags) {
if (nplace.tags.name) {
......@@ -75,11 +72,7 @@ function makeOSM (argv) {
function wayToAS2 (way) {
const as2place = {
'id': makeURI(`/osm/way/${way.id}`),
'@context': [AS2, {vcard: VCARD, cc: CC}],
type: 'Place',
'cc:license': ODBL,
'cc:attributionName': 'OpenStreetMap contributors',
'cc:attributionURL': 'http://www.openstreetmap.org/copyright'
type: 'Place'
}
if (way.center) {
as2place.latitude = Number(way.center.lat)
......@@ -171,9 +164,48 @@ function makeOSM (argv) {
const osm = express.Router()
function as2Document (req, res, obj) {
// Top-level boilerplate for AS2 and OSM
const base = {
'@context': [AS2, {vcard: VCARD, cc: CC}],
'cc:license': ODBL,
'cc:attributionName': 'OpenStreetMap contributors',
'cc:attributionURL': 'http://www.openstreetmap.org/copyright'
}
const doc = _.extend(base, obj)
// For cache
if (doc.updated) {
res.set('Last-Modified', (new Date(doc.updated)).toUTCString())
}
// Content negotiation, mostly
if (req.accepts('application/activity+json')) {
res.type('application/activity+json')
res.send(Buffer.from(JSON.stringify(doc), 'utf8'))
} else if (req.accepts('application/ld+json')) {
res.type('application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
res.send(Buffer.from(JSON.stringify(doc), 'utf8'))
} else if (req.accepts('application/json')) {
res.json(doc)
} else {
// :(
res.sendStatus(406)
}
}
function searchSummary (q, lat, lon, d) {
let summary = 'search'
if (q) {
summary = `${summary} for '${q}'`
}
if (lat && lon) {
summary = `${summary} within ${d} meters of [${lat},${lon}]`
}
return summary
}
osm.get('/search', async (req, res, next) => {
debug('osm /search route matched')
const {q, lat, lon} = req.query
const {q, lat, lon, page} = req.query
let {d} = req.query
debug(`q = '${q}' before cleanup`)
if (!q && (!lat && !lon)) {
......@@ -204,9 +236,46 @@ function makeOSM (argv) {
throw new Error(`Unexpected result type: ${typeof njson}`)
}
debug(`Converting overpass data to AS2 for ${req.query}`)
const as2places = njson.elements.map(featureToAS2)
const items = njson.elements.map(featureToAS2)
const ePage = Number(page) || 1
const [offset, limit] = [(ePage - 1) * PAGE_LENGTH, PAGE_LENGTH]
if (page && (offset > items.length)) {
throw new Error(`No such page ${page}`)
}
const pp = qs.stringify(_.extend({}, req.query, {page: ePage}))
const qp = qs.stringify(_.omit(req.query, 'page'))
const summary = searchSummary(q, lat, lon, d)
const pageObject = {
type: 'CollectionPage',
id: makeURI(`/osm/search?${pp}`),
summary: `page ${ePage} of ${summary}`,
items: items.slice(offset, offset + limit)
}
if (items.length > offset + limit - 1) {
const npp = qs.stringify(_.extend({}, req.query, {page: ePage + 1}))
pageObject.next = makeURI(`/osm/search?${npp}`)
}
if (ePage > 1) {
const ppp = qs.stringify(_.extend({}, req.query, {page: ePage - 1}))
pageObject.prev = makeURI(`/osm/search?${ppp}`)
}
const collection = {
type: 'Collection',
summary: summary,
id: makeURI(`/osm/search?${qp}`),
totalItems: items.length
}
const doc = (page)
? _.extend({partOf: collection}, pageObject)
: _.extend({first: _.extend({partOf: collection.id}, pageObject)}, collection)
debug(`Returning AS2 for ${req.query}`)
res.json(as2places)
as2Document(req, res, doc)
} catch (err) {
return next(err)
}
......@@ -240,28 +309,14 @@ function makeOSM (argv) {
const as2place = nodeToAS2(nplace)
debug(as2place)
debug(`Returning AS2 for ${id}`)
if (as2place.updated) {
res.set('Last-Modified', (new Date(as2place.updated)).toUTCString())
}
// Content negotiation, mostly
if (req.accepts('application/activity+json')) {
res.type('application/activity+json')
res.send(Buffer.from(JSON.stringify(as2place), 'utf8'))
} else if (req.accepts('application/ld+json')) {
res.type('application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
res.send(Buffer.from(JSON.stringify(as2place), 'utf8'))
} else if (req.accepts('application/json')) {
res.json(as2place)
} else {
res.sendStatus(406)
}
as2Document(req, res, as2place)
} catch (err) {
debug(`ERROR: '${err.message}' for ${id}`)
next(err)
}
})
// Node route
// Way route
osm.get('/way/:id', async (req, res, next) => {
debug('osm /way/:id route matched')
......@@ -281,21 +336,7 @@ function makeOSM (argv) {
const as2place = wayToAS2(nplace)
debug(as2place)
debug(`Returning AS2 for ${id}`)
if (as2place.updated) {
res.set('Last-Modified', (new Date(as2place.updated)).toUTCString())
}
// Content negotiation, mostly
if (req.accepts('application/activity+json')) {
res.type('application/activity+json')
res.send(Buffer.from(JSON.stringify(as2place), 'utf8'))
} else if (req.accepts('application/ld+json')) {
res.type('application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
res.send(Buffer.from(JSON.stringify(as2place), 'utf8'))
} else if (req.accepts('application/json')) {
res.json(as2place)
} else {
res.sendStatus(406)
}
as2Document(req, res, as2place)
} catch (err) {
debug(`ERROR: '${err.message}' for ${id}`)
next(err)
......
......@@ -2733,5 +2733,5 @@
}
}
},
"version": "0.7.7"
"version": "0.8.0"
}
......@@ -31,5 +31,5 @@
"pug": "^2.0.0-rc.4",
"yargs": "^10.0.3"
},
"version": "0.7.7"
"version": "0.8.0"
}
......@@ -14,6 +14,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
const qs = require('querystring')
const fetch = require('node-fetch')
const vows = require('perjury')
const {assert} = vows
......@@ -54,6 +55,7 @@ const fetchFeature = (id, ftype, type) => {
},
'it looks correct': (err, body) => {
assert.ifError(err)
validate.document(body)
validate.place(body)
}
}
......@@ -68,6 +70,74 @@ const fetchWay = (id, type) => {
return fetchFeature(id, 'way', type)
}
const search = (q, lat, lon, type) => {
return {
topic () {
const params = qs.stringify({q: q, lat: lat, lon: lon})
const url = `${URL_ROOT}/osm/search?${params}`
const headers = {
'Accept': type
}
return fetch(url, {headers: headers})
},
'it works': (err, res) => {
assert.ifError(err)
assert.equal(res.status, 200)
},
'the headers look correct': (err, res) => {
assert.ifError(err)
assert.ok(res.headers.has('Content-Type'), 'response does not have a content type')
const contentType = res.headers.get('Content-Type')
const baseType = (contentType.indexOf(';') === -1) ? contentType : contentType.substr(0, contentType.indexOf(';'))
assert.equal(baseType, type)
},
'and we examine the body': {
topic (res) {
return res.json()
},
'it looks correct': (err, body) => {
assert.ifError(err)
validate.document(body)
validate.searchResults(body)
}
}
}
}
const searchPage = (q, lat, lon, page, type) => {
return {
topic () {
const params = qs.stringify({q: q, lat: lat, lon: lon, page: page})
const url = `${URL_ROOT}/osm/search?${params}`
const headers = {
'Accept': type
}
return fetch(url, {headers: headers})
},
'it works': (err, res) => {
assert.ifError(err)
assert.equal(res.status, 200)
},
'the headers look correct': (err, res) => {
assert.ifError(err)
assert.ok(res.headers.has('Content-Type'), 'response does not have a content type')
const contentType = res.headers.get('Content-Type')
const baseType = (contentType.indexOf(';') === -1) ? contentType : contentType.substr(0, contentType.indexOf(';'))
assert.equal(baseType, type)
},
'and we examine the body': {
topic (res) {
return res.json()
},
'it looks correct': (err, body) => {
assert.ifError(err)
validate.document(body)
validate.page(body)
}
}
}
}
vows.describe('content negotiation')
.addBatch({
'When we start the app': {
......@@ -86,6 +156,12 @@ vows.describe('content negotiation')
'and we fetch a way accepting only ActivityStreams 2.0': fetchWay(356350765, AS2_TYPE),
'and we fetch a way accepting only JSON-LD': fetchWay(356350765, JSONLD_TYPE),
'and we fetch a way accepting only JSON': fetchWay(356350765, JSON_TYPE),
'and we search accepting only ActivityStreams 2.0': search('Denali', 63.06912, -151.00624, AS2_TYPE),
'and we search accepting only JSON-LD': search('Denali', 63.06912, -151.00624, JSONLD_TYPE),
'and we search accepting only JSON': search('Denali', 63.06912, -151.00624, JSON_TYPE),
'and we search with a page accepting only ActivityStreams 2.0': searchPage('Denali', 63.06912, -151.00624, 1, AS2_TYPE),
'and we search with a page accepting only JSON-LD': searchPage('Denali', 63.06912, -151.00624, 1, JSONLD_TYPE),
'and we search with a page accepting only JSON': searchPage('Denali', 63.06912, -151.00624, 1, JSON_TYPE),
'teardown': (server) => {
return server.stop()
}
......
......@@ -68,6 +68,7 @@ vows.describe('node endpoint')
'street-address': '3895 Boulevard Saint-Laurent',
'postal-code': 'H2W 1X9'
}
validate.document(body)
validate.place(body, expected)
}
},
......
......@@ -30,12 +30,13 @@ const ACCEPT = 'application/activity+json;1.0,application/ld+json;0.5,applicatio
const HEADERS = {
'Accept': ACCEPT
}
const URL_ROOT = `http://${env.PLACES_PUB_HOSTNAME}:${env.PLACES_PUB_PORT}`
const searchBatch = (params, tests) => {
const batch = {
topic: async function () {
const qp = querystring.stringify(params)
const url = `http://${env.PLACES_PUB_HOSTNAME}:${env.PLACES_PUB_PORT}/osm/search?${qp}`
const url = `${URL_ROOT}/osm/search?${qp}`
return fetch(url, {headers: HEADERS})
},
'it works': (err, res) => {
......@@ -49,9 +50,8 @@ const searchBatch = (params, tests) => {
},
'it looks like the search results we expect': (err, results) => {
assert.ifError(err)
assert.isArray(results)
assert.greater(results.length, 0)
debug(JSON.stringify(results, null, 2))
debug(results)
validate.document(results)
validate.searchResults(results)
}
}
......@@ -62,6 +62,36 @@ const searchBatch = (params, tests) => {
return batch
}
const searchPageBatch = (params, tests) => {
const batch = {
topic: async function () {
const qp = querystring.stringify(params)
const url = `${URL_ROOT}/osm/search?${qp}`
return fetch(url, {headers: HEADERS})
},
'it works': (err, res) => {
assert.ifError(err)
assert.isObject(res)
assert.equal(res.status, 200)
},
'and we examine the body of the response': {
topic (res) {
return res.json()
},
'it looks like the search results we expect': (err, results) => {
assert.ifError(err)
debug(results)
validate.document(results)
validate.page(results)
}
}
}
if (tests) {
_.extend(batch['and we examine the body of the response'], tests)
}
return batch
}
const SCHWARTZS = "Schwartz's"
const LACOSTANERA_NAME = 'La Costanera'
const LACOSTANERA_LATLON = {
......@@ -84,6 +114,7 @@ const CASGRAIN = {
lon: -73.59526
}
const CASGRAIN_D = _.extend({d: 250}, CASGRAIN)
const CASGRAIN_D_PAGE = _.extend({page: 1}, CASGRAIN_D)
vows.describe('search endpoint')
.addBatch({
......@@ -101,20 +132,21 @@ vows.describe('search endpoint')
'and we search for a node by lat/lon': searchBatch(LACOSTANERA_LATLON, {
'it includes our expected result': (err, results) => {
assert.ifError(err)
assert.isArray(results)
assert.greater(results.length, 0)
assert.ok(_.some(results, (result) => {
return (result.name === LACOSTANERA_NAME)
assert.isObject(results.first)
assert.isArray(results.first.items)
assert.greater(results.first.items.length, 0)
assert.ok(_.some(results.first.items, (item) => {
return (item.name === LACOSTANERA_NAME)
}))
}
}),
'and we search for a node by name and lat/lon': searchBatch(LACOSTANERA_NAME_LATLON, {
'it includes our expected result': (err, results) => {
assert.ifError(err)
assert.isArray(results)
assert.greater(results.length, 0)
assert.ok(_.every(results, (result) => {
return result.name.match(LACOSTANERA_NAME)
assert.isArray(results.first.items)
assert.greater(results.first.items.length, 0)
assert.ok(_.some(results.first.items, (item) => {
return (item.name === LACOSTANERA_NAME)
}))
}
}),
......@@ -123,35 +155,35 @@ vows.describe('search endpoint')
'and we search for a way by name and lat/lon': searchBatch(THE_MAYFLOWER, {
'it includes our expected result': (err, results) => {
assert.ifError(err)
assert.ok(_.some(results, (result) => {
return result.id.match('way/444744679')
assert.isArray(results.first.items)
assert.greater(results.first.items.length, 0)
assert.ok(_.some(results.first.items, (item) => {
return item.id.match('way/444744679')
}))
}
}),
'and we search for mixed ways and nodes by name': searchBatch(LA_BANQUISE, {
'it includes our expected result': (err, results) => {
assert.ifError(err)
assert.ok(_.some(results, (result) => {
return result.id.match('way/356350765')
assert.isArray(results.first.items)
assert.greater(results.first.items.length, 0)
assert.ok(_.some(results.first.items, (item) => {
return item.id.match('way/356350765')
}))
}
}),
'and we search for mixed ways and nodes by lat/lon': searchBatch(CASGRAIN, {
'it includes our expected result': (err, results) => {
assert.ifError(err)
assert.ok(_.some(results, (result) => {
return result.id.match('way/172575402')
}))
}
}),
'and we search for mixed ways and nodes by lat/lon and distance': searchBatch(CASGRAIN_D, {
'it includes our expected result': (err, results) => {
assert.ifError(err)
assert.ok(_.some(results, (result) => {
return result.id.match('way/172575402')
assert.isArray(results.first.items)
assert.greater(results.first.items.length, 0)
assert.ok(_.some(results.first.items, (item) => {
return item.id.match('way/172575402')
}))
}
}),
'and we search for mixed ways and nodes by lat/lon and distance': searchBatch(CASGRAIN_D),
'and we search for a page of mixed ways and nodes by lat/lon and distance': searchPageBatch(CASGRAIN_D_PAGE),
'teardown': (server) => {
return server.stop()
}
......
......@@ -25,30 +25,36 @@ const VCARD = 'http://www.w3.org/2006/vcard/ns#'
const CC = 'http://creativecommons.org/ns#'
const ODBL = 'http://opendatacommons.org/licenses/odbl/'
const document = (body) => {
assert.isArray(body['@context'], '@context is not an array')
assert.lengthOf(body['@context'], 2, '@context is not a 2-element array')
assert.isString(body['@context'][0], '@context[0] is not a string')
assert.equal(body['@context'][0], AS2, '@context[0] is not AS2 context')
assert.isObject(body['@context'][1], 'Context element 1 is not an object')
assert.deepEqual(body['@context'][1], {vcard: VCARD, cc: CC}, '@context[1] is not vcard + cc')
assert.equal(body['cc:license'], ODBL, 'License value not set')
assert.equal(body['cc:attributionName'], 'OpenStreetMap contributors', 'Attribution name not set')
assert.equal(body['cc:attributionURL'], 'http://www.openstreetmap.org/copyright', 'Attribution url not set')
}
const place = (body, expected = {}) => {
assert.isObject(body, 'Body of place is not an object')
assert.isArray(body['@context'])
assert.lengthOf(body['@context'], 2)
assert.isString(body['@context'][0])
assert.equal(body['@context'][0], AS2)
assert.isObject(body['@context'][1], 'Context element 1 is not an object')
assert.deepEqual(body['@context'][1], {vcard: VCARD, cc: CC})
assert.isString(body.type)
assert.isString(body.type, 'Type of place is not a string')
assert.equal(body.type, 'Place')
assert.isString(body.id)
assert.isString(body.id, 'ID of place is not a string')
if (expected.id && expected.type) {
assert.equal(body.id, `${env.PLACES_PUB_ROOT}/osm/${expected.type}/${expected.id}`)
}
assert.isString(body.name)
assert.greater(body.name.length, 0)
assert.isString(body.name, 'Name of place is not a string')
assert.greater(body.name.length, 0, 'Name of place is zero-length')
if (expected.name) {
assert.equal(body.name, expected.name)
}
assert.isNumber(body.latitude)
assert.isNumber(body.latitude, 'Latitude of place is not a number')
if (expected.latitude) {
assert.inDelta(body.latitude, expected.latitude, 0.1)
}
assert.isNumber(body.longitude)
assert.isNumber(body.longitude, 'Longitude of place is not a number')
if (expected.longitude) {
assert.inDelta(body.longitude, expected.longitude, 0.1)
}
......@@ -59,7 +65,7 @@ const place = (body, expected = {}) => {
if (_.has(body, 'vcard:hasAddress')) {
assert.isObject(body['vcard:hasAddress'], 'Address of Place is not an object')
const addr = body['vcard:hasAddress']
assert.equal(addr.type, 'vcard:Address')
assert.equal(addr.type, 'vcard:Address', 'vcard address object has wrong type')
const props = ['street-address', 'locality', 'region', 'country-name', 'postal-code']
let prop = null
for (prop of props) {
......@@ -69,25 +75,42 @@ const place = (body, expected = {}) => {
}
}
}
assert.equal(body['cc:license'], ODBL)
assert.equal(body['cc:attributionName'], 'OpenStreetMap contributors')
assert.equal(body['cc:attributionURL'], 'http://www.openstreetmap.org/copyright')
assert.includes(body, 'updated')
}
const searchResults = (results, expected = []) => {
assert.isArray(results)
for (const i in results) {
const body = results[i]
const page = (page, expected = []) => {
assert.equal(page.type, 'CollectionPage', 'page has wrong type')
assert.isString(page.id, 'page has no id')
assert.includes(page, 'partOf')
assert.isString(page.summary, 'page has no summary')
assert.isArray(page.items, 'page has no items')
for (const i in page.items) {
const item = page.items[i]
if (expected.length > i) {
place(body, expected[i])
place(item, expected[i], false)
} else {
place(body)
place(item, {}, false)
}
}
}
const searchResults = (body, expected = []) => {
assert.isObject(body, 'search results is not an object')
assert.equal(body.type, 'Collection')
assert.isString(body.id, 'Search results have no ID')
assert.isString(body.summary, 'Search results have no summary')
assert.isNumber(body.totalItems, 'Search results have no totalItems')
assert.greater(body.totalItems, 0, 'Search results totalItems <= 0')
assert.isObject(body.first, 'Search results has no first page')
assert.isArray(body.first.items, 'Search results page has no items')
assert.ok(body.totalItems >= body.first.items.length, 'page items array longer than totalItems')
assert.equal(body.first.partOf, body.id, 'page.partOf does not match collection ID')
page(body.first, expected)
}
module.exports = {
place: place,
searchResults: searchResults
searchResults: searchResults,
document: document,
page: page
}
......@@ -68,6 +68,7 @@ vows.describe('way endpoint')
latitude: 45.5252489,
longitude: -73.5746408
}
validate.document(body)
validate.place(body, expected)
}
},
......@@ -107,6 +108,7 @@ vows.describe('way endpoint')
'locality': 'London',
'country': 'GB'
}
validate.document(body)
validate.place(body, expected)
}
},
......