diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f7fbc3ad..db4bc496 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,6 +5,7 @@ on: # Publish `master` as Docker `latest` image. branches: - master + - stage # Publish `v1.2.3` tags as releases. tags: @@ -60,6 +61,8 @@ jobs: # Use Docker `latest` tag convention [ "$VERSION" == "master" ] && VERSION=latest + [ "$VERSION" == "stage" ] && VERSION=stage + echo IMAGE_ID=$IMAGE_ID echo VERSION=$VERSION diff --git a/app.js b/app.js index 47475d49..d0815402 100644 --- a/app.js +++ b/app.js @@ -7,7 +7,7 @@ const bodyParser = require('koa-bodyparser'); const mongoose = require('mongoose'); const { requestLogger, logger } = require('./middleware/logger'); const { responseTime, errors } = require('./middleware'); -const { v4 } = require('./routes'); +const { v4, v5 } = require('./routes'); const app = new Koa(); @@ -63,7 +63,8 @@ app.use(responseTime); // Request logging app.use(requestLogger); -// V4 routes +// Routes app.use(v4.routes()); +app.use(v5.routes()); module.exports = app; diff --git a/jobs/fairings.js b/jobs/fairings.js new file mode 100644 index 00000000..a7355589 --- /dev/null +++ b/jobs/fairings.js @@ -0,0 +1,134 @@ +const got = require('got'); +const { logger } = require('../middleware/logger'); + +const API = process.env.SPACEX_API; +const KEY = process.env.SPACEX_KEY; +const HEALTHCHECK = process.env.FAIRINGS_HEALTHCHECK; + +/** + * Update fairing aggregate stats + * @return {Promise} + */ +module.exports = async () => { + try { + const fairings = await got.post(`${API}/fairings/query`, { + json: { + options: { + pagination: false, + }, + }, + resolveBodyOnly: true, + responseType: 'json', + }); + + const reuseUpdates = fairings.docs.map(async (fairing) => { + if (!fairing?.id) return; + const [ + netLandingAttempts, + netLanding, + waterLandingAttempts, + waterLanding, + ] = await Promise.all([ + got.post(`${API}/launches/query`, { + json: { + query: { + upcoming: false, + fairings: { + $elemMatch: { + fairing: fairing.id, + net_attempt: true, + }, + }, + }, + options: { + pagination: false, + }, + }, + resolveBodyOnly: true, + responseType: 'json', + throwHttpErrors: true, + }), + got.post(`${API}/launches/query`, { + json: { + query: { + upcoming: false, + fairings: { + $elemMatch: { + fairing: fairing.id, + net_attempt: true, + net_landing: true, + }, + }, + }, + options: { + pagination: false, + }, + }, + resolveBodyOnly: true, + responseType: 'json', + throwHttpErrors: true, + }), + got.post(`${API}/launches/query`, { + json: { + query: { + upcoming: false, + fairings: { + $elemMatch: { + fairing: fairing.id, + water_attempt: true, + }, + }, + }, + options: { + pagination: false, + }, + }, + resolveBodyOnly: true, + responseType: 'json', + throwHttpErrors: true, + }), + got.post(`${API}/launches/query`, { + json: { + query: { + upcoming: false, + fairings: { + $elemMatch: { + fairing: fairing.id, + water_attempt: true, + water_landing: true, + }, + }, + }, + options: { + pagination: false, + }, + }, + resolveBodyOnly: true, + responseType: 'json', + throwHttpErrors: true, + }), + ]); + await got.patch(`${API}/fairings/${fairing.id}`, { + json: { + reuse_count: (fairing.launches.length > 0) ? fairing.launches.length - 1 : 0, + net_landing_attempts: netLandingAttempts.totalDocs, + net_landing: netLanding.totalDocs, + water_landing_attempts: waterLandingAttempts.totalDocs, + water_landing: waterLanding.totalDocs, + }, + headers: { + 'spacex-key': KEY, + }, + }); + }); + + await Promise.all(reuseUpdates); + logger.info('Fairing reuse updated'); + + if (HEALTHCHECK) { + await got(HEALTHCHECK); + } + } catch (error) { + console.log(`Fairings Error: ${error.message}`); + } +}; diff --git a/jobs/launches.js b/jobs/launches.js index 8267bdf4..f114ccd2 100644 --- a/jobs/launches.js +++ b/jobs/launches.js @@ -233,6 +233,39 @@ module.exports = async () => { }); await Promise.all(shipLaunches); + // Update fairing launches + const fairings = await got.post(`${API}/fairings/query`, { + json: { + options: { + pagination: false, + }, + }, + resolveBodyOnly: true, + responseType: 'json', + }); + + const fairingLaunches = fairings.docs.map(async (fairing) => { + const launchIds = launches.docs + .filter((launch) => { + if (launch.fairings && launch.fairings.length) { + return launch.fairings.find((f) => f.fairing === fairing.id); + } + return false; + }) + .map(({ id }) => id); + + await got.patch(`${API}/fairings/${fairing.id}`, { + json: { + launches: launchIds, + }, + headers: { + 'spacex-key': KEY, + }, + }); + results.fairing = true; + }); + await Promise.all(fairingLaunches); + logger.info(results); if (HEALTHCHECK) { diff --git a/jobs/worker.js b/jobs/worker.js index 77a0d063..1658566b 100644 --- a/jobs/worker.js +++ b/jobs/worker.js @@ -11,6 +11,7 @@ const upcoming = require('./upcoming'); const starlink = require('./starlink'); const webcast = require('./webcast'); const launchLibrary = require('./launch-library'); +const fairings = require('./fairings'); // Every 10 minutes const launchesJob = new CronJob('*/10 * * * *', launches); @@ -45,6 +46,9 @@ const webcastJob = new CronJob('*/5 * * * *', webcast); // Every hour on :45 const launchLibraryJob = new CronJob('45 * * * *', launchLibrary); +// Every 10 minutes +const fairingsJob = new CronJob('*/10 * * * *', fairings); + try { launchesJob.start(); payloadsJob.start(); @@ -57,6 +61,7 @@ try { starlinkJob.start(); webcastJob.start(); launchLibraryJob.start(); + fairingsJob.start(); } catch (error) { const formatted = { name: 'worker', diff --git a/routes/v4/capsules/model.js b/models/capsule.js similarity index 100% rename from routes/v4/capsules/model.js rename to models/capsule.js diff --git a/routes/v4/company/model.js b/models/company.js similarity index 100% rename from routes/v4/company/model.js rename to models/company.js diff --git a/routes/v4/cores/model.js b/models/core.js similarity index 100% rename from routes/v4/cores/model.js rename to models/core.js diff --git a/routes/v4/crew/model.js b/models/crew.js similarity index 100% rename from routes/v4/crew/model.js rename to models/crew.js diff --git a/routes/v4/dragons/model.js b/models/dragon.js similarity index 100% rename from routes/v4/dragons/model.js rename to models/dragon.js diff --git a/routes/v4/fairings/model.js b/models/fairing.js similarity index 100% rename from routes/v4/fairings/model.js rename to models/fairing.js diff --git a/routes/v4/history/model.js b/models/history.js similarity index 100% rename from routes/v4/history/model.js rename to models/history.js diff --git a/routes/v4/landpads/model.js b/models/landpad.js similarity index 100% rename from routes/v4/landpads/model.js rename to models/landpad.js diff --git a/routes/v4/launches/model.js b/models/launch.js similarity index 86% rename from routes/v4/launches/model.js rename to models/launch.js index 590425f7..c35de16d 100644 --- a/routes/v4/launches/model.js +++ b/models/launch.js @@ -80,12 +80,34 @@ const launchSchema = new mongoose.Schema({ type: String, default: null, }, - fairings: { + fairings: [{ + _id: false, + fairing: { + type: mongoose.ObjectId, + ref: 'Fairing', + default: null, + }, + flight: { + type: Number, + default: null, + }, reused: { type: Boolean, default: null, }, - recovery_attempt: { + net_attempt: { + type: Boolean, + default: null, + }, + net_landing: { + type: Boolean, + default: null, + }, + water_attempt: { + type: Boolean, + default: null, + }, + water_landing: { type: Boolean, default: null, }, @@ -97,10 +119,18 @@ const launchSchema = new mongoose.Schema({ type: mongoose.ObjectId, ref: 'Ship', }], - }, + }], crew: [{ - type: mongoose.ObjectId, - ref: 'Crew', + _id: false, + crew: { + type: mongoose.ObjectId, + ref: 'Crew', + default: null, + }, + role: { + type: String, + default: null, + }, }], ships: [{ type: mongoose.ObjectId, diff --git a/routes/v4/launchpads/model.js b/models/launchpad.js similarity index 100% rename from routes/v4/launchpads/model.js rename to models/launchpad.js diff --git a/routes/v4/payloads/model.js b/models/payload.js similarity index 100% rename from routes/v4/payloads/model.js rename to models/payload.js diff --git a/routes/v4/roadster/model.js b/models/roadster.js similarity index 100% rename from routes/v4/roadster/model.js rename to models/roadster.js diff --git a/routes/v4/rockets/model.js b/models/rocket.js similarity index 100% rename from routes/v4/rockets/model.js rename to models/rocket.js diff --git a/routes/v4/ships/model.js b/models/ship.js similarity index 100% rename from routes/v4/ships/model.js rename to models/ship.js diff --git a/routes/v4/starlink/model.js b/models/starlink.js similarity index 100% rename from routes/v4/starlink/model.js rename to models/starlink.js diff --git a/routes/v4/users/model.js b/models/user.js similarity index 100% rename from routes/v4/users/model.js rename to models/user.js diff --git a/routes/index.js b/routes/index.js index 5f7747e2..7d16e601 100644 --- a/routes/index.js +++ b/routes/index.js @@ -2,3 +2,4 @@ * Export versions */ module.exports.v4 = require('./v4'); +module.exports.v5 = require('./v5'); diff --git a/routes/v4/capsules/routes.js b/routes/v4/capsules/routes.js index 2098320d..299d5983 100644 --- a/routes/v4/capsules/routes.js +++ b/routes/v4/capsules/routes.js @@ -1,5 +1,5 @@ const Router = require('koa-router'); -const Capsule = require('./model'); +const Capsule = require('../../../models/capsule'); const { auth, authz, cache } = require('../../../middleware'); const router = new Router({ diff --git a/routes/v4/company/routes.js b/routes/v4/company/routes.js index 59958d84..88f2f910 100644 --- a/routes/v4/company/routes.js +++ b/routes/v4/company/routes.js @@ -1,5 +1,5 @@ const Router = require('koa-router'); -const Company = require('./model'); +const Company = require('../../../models/company'); const { auth, authz, cache } = require('../../../middleware'); const router = new Router({ diff --git a/routes/v4/cores/routes.js b/routes/v4/cores/routes.js index 16ad106b..4a106c55 100644 --- a/routes/v4/cores/routes.js +++ b/routes/v4/cores/routes.js @@ -1,5 +1,5 @@ const Router = require('koa-router'); -const Core = require('./model'); +const Core = require('../../../models/core'); const { auth, authz, cache } = require('../../../middleware'); const router = new Router({ diff --git a/routes/v4/crew/routes.js b/routes/v4/crew/routes.js index 3e12e9cc..bc810932 100644 --- a/routes/v4/crew/routes.js +++ b/routes/v4/crew/routes.js @@ -1,5 +1,5 @@ const Router = require('koa-router'); -const Crew = require('./model'); +const Crew = require('../../../models/crew'); const { auth, authz, cache } = require('../../../middleware'); const router = new Router({ diff --git a/routes/v4/dragons/routes.js b/routes/v4/dragons/routes.js index c1065b74..2dd78c5f 100644 --- a/routes/v4/dragons/routes.js +++ b/routes/v4/dragons/routes.js @@ -1,5 +1,5 @@ const Router = require('koa-router'); -const Dragon = require('./model'); +const Dragon = require('../../../models/dragon'); const { auth, authz, cache } = require('../../../middleware'); const router = new Router({ diff --git a/routes/v4/history/routes.js b/routes/v4/history/routes.js index a34b9b37..8f520301 100644 --- a/routes/v4/history/routes.js +++ b/routes/v4/history/routes.js @@ -1,5 +1,5 @@ const Router = require('koa-router'); -const History = require('./model'); +const History = require('../../../models/history'); const { auth, authz, cache } = require('../../../middleware'); const router = new Router({ diff --git a/routes/v4/index.js b/routes/v4/index.js index ae07fbbb..c726f261 100644 --- a/routes/v4/index.js +++ b/routes/v4/index.js @@ -15,7 +15,6 @@ const company = require('./company/routes'); const roadster = require('./roadster/routes'); const starlink = require('./starlink/routes'); const history = require('./history/routes'); -const fairings = require('./fairings/routes'); const v4 = new Router({ prefix: '/v4', @@ -37,6 +36,5 @@ v4.use(company.routes()); v4.use(roadster.routes()); v4.use(starlink.routes()); v4.use(history.routes()); -v4.use(fairings.routes()); module.exports = v4; diff --git a/routes/v4/landpads/routes.js b/routes/v4/landpads/routes.js index 9975050b..883ecff6 100644 --- a/routes/v4/landpads/routes.js +++ b/routes/v4/landpads/routes.js @@ -1,5 +1,5 @@ const Router = require('koa-router'); -const Landpad = require('./model'); +const Landpad = require('../../../models/landpad'); const { auth, authz, cache } = require('../../../middleware'); const router = new Router({ diff --git a/routes/v4/launches/_format-v4.js b/routes/v4/launches/_format-v4.js new file mode 100644 index 00000000..58c4d150 --- /dev/null +++ b/routes/v4/launches/_format-v4.js @@ -0,0 +1,42 @@ +/* eslint-disable no-underscore-dangle */ + +const buildFairings = (launch) => { + let fairings = {}; + if (launch?.fairings?.length) { + fairings.recovery_attempt = launch.fairings.some((f) => f.recovered); + fairings.reused = launch.fairings.some((f) => f.reused); + fairings.recovered = launch.fairings.some((f) => f.net_attempt || f.water_attempt); + fairings.ships = launch.fairings.flatMap((f) => f.ships); + } else { + fairings = null; + } + return fairings; +}; + +const buildCrew = (launch) => launch.crew.map((crew) => crew.crew); + +module.exports = async (payload) => { + if (Array.isArray(payload)) { + return payload.map((launch) => ({ + ...launch.toObject(), + fairings: buildFairings(launch.toObject()), + crew: buildCrew(launch), + })); + } + if (Array.isArray(payload?.docs)) { + const docs = payload.docs.map((launch) => ({ + ...launch.toObject(), + fairings: buildFairings(launch.toObject()), + crew: buildCrew(launch), + })); + return { + ...payload, + docs, + }; + } + return { + ...payload.toObject(), + fairings: buildFairings(payload.toObject()), + crew: buildCrew(payload._doc), + }; +}; diff --git a/routes/v4/launches/routes.js b/routes/v4/launches/routes.js index e62bea1e..640f5f0b 100644 --- a/routes/v4/launches/routes.js +++ b/routes/v4/launches/routes.js @@ -1,6 +1,7 @@ const Router = require('koa-router'); -const Launch = require('./model'); +const Launch = require('../../../models/launch'); const { auth, authz, cache } = require('../../../middleware'); +const formatV4 = require('./_format-v4'); const router = new Router({ prefix: '/launches', @@ -21,7 +22,7 @@ router.get('/past', cache(20), async (ctx) => { }, }); ctx.status = 200; - ctx.body = result; + ctx.body = await formatV4(result); } catch (error) { ctx.throw(400, error.message); } @@ -38,7 +39,7 @@ router.get('/upcoming', cache(20), async (ctx) => { }, }); ctx.status = 200; - ctx.body = result; + ctx.body = await formatV4(result); } catch (error) { ctx.throw(400, error.message); } @@ -55,7 +56,7 @@ router.get('/latest', cache(20), async (ctx) => { }, }); ctx.status = 200; - ctx.body = result; + ctx.body = await formatV4(result); } catch (error) { ctx.throw(400, error.message); } @@ -72,7 +73,7 @@ router.get('/next', cache(20), async (ctx) => { }, }); ctx.status = 200; - ctx.body = result; + ctx.body = await formatV4(result); } catch (error) { ctx.throw(400, error.message); } @@ -87,7 +88,7 @@ router.get('/', cache(20), async (ctx) => { try { const result = await Launch.find({}); ctx.status = 200; - ctx.body = result; + ctx.body = await formatV4(result); } catch (error) { ctx.throw(400, error.message); } @@ -100,7 +101,7 @@ router.get('/:id', cache(20), async (ctx) => { ctx.throw(404); } ctx.status = 200; - ctx.body = result; + ctx.body = await formatV4(result); }); // Query launches @@ -109,7 +110,7 @@ router.post('/query', cache(20), async (ctx) => { try { const result = await Launch.paginate(query, options); ctx.status = 200; - ctx.body = result; + ctx.body = await formatV4(result); } catch (error) { ctx.throw(400, error.message); } diff --git a/routes/v4/launchpads/routes.js b/routes/v4/launchpads/routes.js index c04f2d92..332d48f3 100644 --- a/routes/v4/launchpads/routes.js +++ b/routes/v4/launchpads/routes.js @@ -1,5 +1,5 @@ const Router = require('koa-router'); -const Launchpad = require('./model'); +const Launchpad = require('../../../models/launchpad'); const { auth, authz, cache } = require('../../../middleware'); const router = new Router({ diff --git a/routes/v4/payloads/routes.js b/routes/v4/payloads/routes.js index e477dd89..008453a2 100644 --- a/routes/v4/payloads/routes.js +++ b/routes/v4/payloads/routes.js @@ -1,5 +1,5 @@ const Router = require('koa-router'); -const Payload = require('./model'); +const Payload = require('../../../models/payload'); const { auth, authz, cache } = require('../../../middleware'); const router = new Router({ diff --git a/routes/v4/roadster/routes.js b/routes/v4/roadster/routes.js index 3ffd20d7..e3a936f0 100644 --- a/routes/v4/roadster/routes.js +++ b/routes/v4/roadster/routes.js @@ -1,5 +1,5 @@ const Router = require('koa-router'); -const Roadster = require('./model'); +const Roadster = require('../../../models/roadster'); const { auth, authz, cache } = require('../../../middleware'); const router = new Router({ diff --git a/routes/v4/rockets/routes.js b/routes/v4/rockets/routes.js index e9b9a29a..4e89e7d6 100644 --- a/routes/v4/rockets/routes.js +++ b/routes/v4/rockets/routes.js @@ -1,5 +1,5 @@ const Router = require('koa-router'); -const Rocket = require('./model'); +const Rocket = require('../../../models/rocket'); const { auth, authz, cache } = require('../../../middleware'); const router = new Router({ diff --git a/routes/v4/ships/routes.js b/routes/v4/ships/routes.js index dbc96308..edd0e51b 100644 --- a/routes/v4/ships/routes.js +++ b/routes/v4/ships/routes.js @@ -1,5 +1,5 @@ const Router = require('koa-router'); -const Ship = require('./model'); +const Ship = require('../../../models/ship'); const { auth, authz, cache } = require('../../../middleware'); const router = new Router({ diff --git a/routes/v4/starlink/routes.js b/routes/v4/starlink/routes.js index 8dc09d76..79fa9dab 100644 --- a/routes/v4/starlink/routes.js +++ b/routes/v4/starlink/routes.js @@ -1,5 +1,5 @@ const Router = require('koa-router'); -const Starlink = require('./model'); +const Starlink = require('../../../models/starlink'); const { auth, authz, cache } = require('../../../middleware'); const router = new Router({ diff --git a/routes/v4/users/routes.js b/routes/v4/users/routes.js index 75a577f5..30e7e198 100644 --- a/routes/v4/users/routes.js +++ b/routes/v4/users/routes.js @@ -1,5 +1,5 @@ const Router = require('koa-router'); -const User = require('./model'); +const User = require('../../../models/user'); const { auth, authz } = require('../../../middleware'); const router = new Router({ diff --git a/routes/v4/fairings/routes.js b/routes/v5/fairings/routes.js similarity index 97% rename from routes/v4/fairings/routes.js rename to routes/v5/fairings/routes.js index 653f46bb..499c4c49 100644 --- a/routes/v4/fairings/routes.js +++ b/routes/v5/fairings/routes.js @@ -1,5 +1,5 @@ const Router = require('koa-router'); -const Fairing = require('./model'); +const Fairing = require('../../../models/fairing'); const { auth, authz, cache } = require('../../../middleware'); const router = new Router({ diff --git a/routes/v5/index.js b/routes/v5/index.js new file mode 100644 index 00000000..dcb64b44 --- /dev/null +++ b/routes/v5/index.js @@ -0,0 +1,12 @@ +const Router = require('koa-router'); +const launches = require('./launches/routes'); +const fairings = require('./fairings/routes'); + +const v5 = new Router({ + prefix: '/v5', +}); + +v5.use(launches.routes()); +v5.use(fairings.routes()); + +module.exports = v5; diff --git a/routes/v5/launches/routes.js b/routes/v5/launches/routes.js new file mode 100644 index 00000000..ed57d4c5 --- /dev/null +++ b/routes/v5/launches/routes.js @@ -0,0 +1,151 @@ +const Router = require('koa-router'); +const Launch = require('../../../models/launch'); +const { auth, authz, cache } = require('../../../middleware'); + +const router = new Router({ + prefix: '/launches', +}); + +// +// Convenience Endpoints +// + +// Get past launches +router.get('/past', cache(20), async (ctx) => { + try { + const result = await Launch.find({ + upcoming: false, + }, null, { + sort: { + flight_number: 'asc', + }, + }); + ctx.status = 200; + ctx.body = result; + } catch (error) { + ctx.throw(400, error.message); + } +}); + +// Get upcoming launches +router.get('/upcoming', cache(20), async (ctx) => { + try { + const result = await Launch.find({ + upcoming: true, + }, null, { + sort: { + flight_number: 'asc', + }, + }); + ctx.status = 200; + ctx.body = result; + } catch (error) { + ctx.throw(400, error.message); + } +}); + +// Get latest launch +router.get('/latest', cache(20), async (ctx) => { + try { + const result = await Launch.findOne({ + upcoming: false, + }, null, { + sort: { + flight_number: 'desc', + }, + }); + ctx.status = 200; + ctx.body = result; + } catch (error) { + ctx.throw(400, error.message); + } +}); + +// Get next launch +router.get('/next', cache(20), async (ctx) => { + try { + const result = await Launch.findOne({ + upcoming: true, + }, null, { + sort: { + flight_number: 'asc', + }, + }); + ctx.status = 200; + ctx.body = result; + } catch (error) { + ctx.throw(400, error.message); + } +}); + +// +// Standard Endpoints +// + +// Get all launches +router.get('/', cache(20), async (ctx) => { + try { + const result = await Launch.find({}); + ctx.status = 200; + ctx.body = result; + } catch (error) { + ctx.throw(400, error.message); + } +}); + +// Get one launch +router.get('/:id', cache(20), async (ctx) => { + const result = await Launch.findById(ctx.params.id); + if (!result) { + ctx.throw(404); + } + ctx.status = 200; + ctx.body = result; +}); + +// Query launches +router.post('/query', cache(20), async (ctx) => { + const { query = {}, options = {} } = ctx.request.body; + try { + const result = await Launch.paginate(query, options); + ctx.status = 200; + ctx.body = result; + } catch (error) { + ctx.throw(400, error.message); + } +}); + +// Create a launch +router.post('/', auth, authz('launch:create'), async (ctx) => { + try { + const launch = new Launch(ctx.request.body); + await launch.save(); + ctx.status = 201; + } catch (error) { + ctx.throw(400, error.message); + } +}); + +// Update a launch +router.patch('/:id', auth, authz('launch:update'), async (ctx) => { + try { + await Launch.findByIdAndUpdate(ctx.params.id, ctx.request.body, { + runValidators: true, + }); + ctx.status = 200; + } catch (error) { + ctx.throw(400, error.message); + } +}); + +// Delete a launch +router.delete('/:id', auth, authz('launch:delete'), async (ctx) => { + try { + await Launch.findByIdAndDelete(ctx.params.id); + ctx.status = 200; + } catch (error) { + ctx.throw(400, error.message); + } +}); + +module.exports = router;