Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Production deploy - possible breaking changes ⚠️ #53

Merged
merged 15 commits into from
Jul 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .docker/services/mongo/setup-mongodb.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ db.indexed_services.createIndex(
}
)
db.indexed_services.createIndex({
"locations.geometry": "2dsphere",
"service_at_locations.location.geometry": "2dsphere",
})
db.indexed_services.createIndex({
"taxonomies.slug": 1,
Expand Down
12 changes: 1 addition & 11 deletions .github/workflows/publish-outpost-api-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ on:
jobs:
publish-outpost-api-image:
runs-on: ubuntu-latest
strategy:
matrix:
platforms: ["linux/amd64", "linux/arm64", "linux/arm64/v8"]
steps:
- name: "Checkout GitHub Action"
uses: actions/checkout@main
Expand All @@ -37,19 +34,12 @@ jobs:
echo "tag=default" >> $GITHUB_ENV
fi

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build and push outpost api docker image
uses: docker/build-push-action@v5
with:
context: .
tags: ghcr.io/wearefuturegov/outpost-api-service:${{ env.tag }}
file: Dockerfile.production
platforms: ${{ matrix.platforms }}
push: true
outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=Outpost API Service image
outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=Outpost API Service image,oci-mediatypes=false
labels: org.opencontainers.image.source=https://github.com/wearefuturegov/outpost-api-service
40 changes: 30 additions & 10 deletions __tests__/unit/lib/filters.test.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,42 @@
const filters = require("./../../../src/lib/filters")

describe("locationGeometry", () => {
describe("filterLocation", () => {
it("should return an empty object if lat and lng are not provided", () => {
expect(filters.locationGeometry()).toEqual({})
expect(filters.filterLocation()).toEqual({})
})

it("should return a query object if lat and lng are provided", () => {
const lat = "40.7128"
const lng = "-74.0060"
const expectedQuery = {
"locations.geometry": {
$nearSphere: {
$geometry: {
type: "Point",
coordinates: [parseFloat(lng), parseFloat(lat)],
},
"service_at_locations.location.geometry": {
$geoWithin: {
$centerSphere: [[parseFloat(lng), parseFloat(lat)], 20 / 3963.2],
},
},
}
expect(filters.locationGeometry(lat, lng)).toEqual(expectedQuery)
expect(filters.filterLocation(lat, lng, false)).toEqual(expectedQuery)
})

it("should return a different query object if lat and lng and keywordSearch are provided", () => {
const lat = "40.7128"
const lng = "-74.0060"
const expectedQuery = {
$or: [
{
"service_at_locations.location.geometry": {
$geoWithin: {
$centerSphere: [
[parseFloat(lng), parseFloat(lat)],
20 / 3963.2, // miles x 1609.34 = Distance in meters
],
},
},
},
{ "service_at_locations.location.geometry": { $exists: false } },
],
}
expect(filters.filterLocation(lat, lng, true)).toEqual(expectedQuery)
})
})

Expand Down Expand Up @@ -203,7 +221,9 @@ describe("filterAccessibilities", () => {
"wheelchair-accessible-entrance",
]
const expectedQuery = {
"locations.accessibilities.slug": { $in: accessibilities },
"service_at_locations.location.accessibilities.slug": {
$in: accessibilities,
},
}
expect(filters.filterAccessibilities(accessibilities)).toEqual(
expectedQuery
Expand Down
4 changes: 2 additions & 2 deletions __tests__/unit/v1/services/routes/get-services.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ describe("get-services", () => {
)
expect(geocode).toHaveBeenCalledWith(queryParams.location)
expect(interpreted_location).toEqual(results[0].formatted_address)
expect(lat).toBeCloseTo(parseInt(results[0].geometry.location.lat))
expect(lng).toBeCloseTo(parseInt(results[0].geometry.location.lng))
expect(lat).toBeCloseTo(parseFloat(results[0].geometry.location.lat))
expect(lng).toBeCloseTo(parseFloat(results[0].geometry.location.lng))
})

it("should return undefined if invalid location is provided", async () => {
Expand Down
41 changes: 41 additions & 0 deletions docker-compose.with-outpost.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# if you want to use the Outpost API in dev mode with outpost in dev mode, you can use this file to start the service.
# Run outpost as normal and then use this docker compose file to tag along
# docker compose -f docker-compose.with-outpost.yml up -d

version: "3.7"
services:
# Outpost api

app:
image: "outpost_api:development"
build:
context: .
# uncomment for 'prod' like env locally
# NODE_ENV: production
container_name: outpost-api-service
# use if you just want to keep the container running
# entrypoint: ["tail"]
# command: ["-f", "/dev/null"]
environment:
DB_URI: mongodb://${MONGO_INITDB_USERNAME:-outpost}:${MONGO_INITDB_PASSWORD:-password}@host.docker.internal:27018/${MONGO_INITDB_DATABASE:-outpost_api_development}
platform: linux/arm64
volumes:
- ./:/app:cached
- /app/node_modules
ports:
- 3002:3000
networks:
- outpost_internal_network
- outpost_external_network
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/health"]
interval: 1m30s
timeout: 10s
retries: 3
start_period: 40s

networks:
outpost_internal_network:
external: true
outpost_external_network:
external: true
2 changes: 1 addition & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ It needs the right indices on the MongoDB collection to enable full-text and geo

```
db.indexed_services.createIndex({ name: "text", description: "text" })
db.indexed_services.createIndex({ "locations.coordinates": "2dsphere" })
db.indexed_services.createIndex({ "service_at_locations.location.geometry": "2dsphere" })
```

You can create these two, plus an index of taxonomy slugs, automatically with the `npm run prepare-indices` command.
Expand Down
87 changes: 25 additions & 62 deletions src/controllers/v1/services/routes/get-services.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ module.exports = {
const page = parseInt(queryParams.page) || 1
const keywords = queryParams.keywords
const location = queryParams.location
let lat = parseFloat(queryParams.lat) || undefined
let lng = parseFloat(queryParams.lng) || undefined
let lat = queryParams?.lat ? parseFloat(queryParams.lat) : undefined
let lng = queryParams?.lng ? parseFloat(queryParams.lng) : undefined
let directories = queryParams?.directories
? [].concat(queryParams.directories)
: []
Expand Down Expand Up @@ -55,11 +55,10 @@ module.exports = {
if (location && !(lat && lng)) {
try {
const { results } = await geocode(queryParams.location)
logger.debug(results)
if (results[0]) {
interpreted_location = results[0].formatted_address
lng = parseInt(results[0].geometry.location.lng)
lat = parseInt(results[0].geometry.location.lat)
lng = parseFloat(results[0].geometry.location.lng)
lat = parseFloat(results[0].geometry.location.lat)
}
} catch (error) {
logger.warn(error)
Expand Down Expand Up @@ -94,22 +93,9 @@ module.exports = {
let query = {}
query.$and = []

const locationInQuery =
parameters.location !== undefined ||
parameters.lat !== undefined ||
parameters.lng !== undefined
const filterKeywords = await filters.filterKeywords(
parameters.keywords,
locationInQuery
)
const filterKeywords = await filters.filterKeywords(parameters.keywords)
query = { ...filterKeywords, ...query }

const locationGeometry = filters.locationGeometry(
parameters.lat,
parameters.lng
)
query = { ...locationGeometry, ...query }

// add filtering for ages
const ages = filters.filterAges(parameters.minAge, parameters.maxAge)
query.$and.push(...ages)
Expand All @@ -124,6 +110,11 @@ module.exports = {

// add filtering
query.$and.push(
filters.filterLocation(
parameters.lat,
parameters.lng,
query?.$text ?? false
),
filters.filterDirectories(parameters.directories),
filters.filterTaxonomies(parameters.taxonomies),
filters.filterNeeds(parameters.needs),
Expand All @@ -138,37 +129,6 @@ module.exports = {
return query
},

/**
* this is done because of the $nearSphere method in locationGeometry.
* This is because The $nearSphere operator cannot be used with the
* countDocuments() method in MongoDB because countDocuments()
* uses an aggregation pipeline under the hood, and $nearSphere is not
* allowed in an aggregation pipeline.
* so as a workaround if we're using nearsphere we update the count
* query to prevent errors
* the result of nearsphere will include all services with a location
* so this query is a good substitute to get the totalElements value
* @TODO test this doesn't affect query object
* http://localhost:3001/api/v1/services?lat=51.2107714&lng=0.31105&per_page=10&suitabilities=physical-disabilities
* @param {*} query
* @returns
*/
createCountQuery: query => {
// "budget deep clone" we spread $and so can modify it for countQuery only
const countQuery = { ...query, $and: [...query.$and] }
if ("locations.geometry" in countQuery) {
delete countQuery["locations.geometry"]

countQuery["$and"].push({
"locations.geometry": {
$exists: true,
$ne: null,
},
})
}
return countQuery
},

/**
*
* @param {*} query
Expand All @@ -178,14 +138,6 @@ module.exports = {
*/
async executeQuery(query, perPage, page) {
const Service = db().collection("indexed_services")
const countQuery = this.createCountQuery(query)

logger.debug("query")
logger.debug(query)
logger.debug(JSON.stringify(query))
logger.debug("countQuery")
logger.debug(countQuery)
logger.debug(JSON.stringify(countQuery))

const queryProjection = query.$text
? {
Expand All @@ -196,16 +148,27 @@ module.exports = {
...projection,
}

const sort = query.$text
? {
score: { $meta: "textScore" },
updated_at: -1,
}
: {
updated_at: -1,
}

logger.debug("query")
logger.debug(query)
logger.debug(JSON.stringify(query))

const [results, count] = await Promise.all([
Service.find(query)
.project(queryProjection)
.sort(
query.$text ? { score: { $meta: "textScore" } } : { updated_at: -1 }
)
.sort(sort)
.limit(perPage)
.skip((page - 1) * perPage)
.toArray(),
Service.countDocuments(countQuery),
Service.countDocuments(query),
])

return { results, count }
Expand Down
17 changes: 17 additions & 0 deletions src/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@ module.exports = {
} else {
logger.info(`Connected to the "${dbName}" database`)
}

// ensure that the location index exists
const indexName = "service_at_locations.location.geometry_2dsphere"
try {
const indexExists = await db
.collection("indexed_services")
.indexExists(indexName)

if (!indexExists) {
logger.warn(
`The index ${indexName} does not exist on your collection, please run prepare-indices script to create it or you will not return correct results.`
)
}
} catch (err) {
logger.error(`Unable to check for location index ${err}`)
}

cb(db)
})
.catch(err => {
Expand Down
Loading
Loading