Skip to content
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
61 changes: 16 additions & 45 deletions lib/ContentPluginModule.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import AbstractApiModule from 'adapt-authoring-api'
import apidefs from './apidefs.js'
import fs from 'fs/promises'
import fs from 'node:fs/promises'
import { glob } from 'glob'
import path from 'path'
import path from 'node:path'
import { readJson } from 'adapt-authoring-core'
import { loadRouteConfig } from 'adapt-authoring-server'
import {
backupPluginVersion,
getMostRecentBackup,
Expand Down Expand Up @@ -34,37 +34,8 @@ class ContentPluginModule extends AbstractApiModule {
*/
this.newPlugins = []

const middleware = await this.app.waitForModule('middleware')

this.useDefaultRouteConfig()
// remove unnecessary routes
delete this.routes.find(r => r.route === '/').handlers.post
delete this.routes.find(r => r.route === '/:_id').handlers.put
// extra routes
this.routes.push({
route: '/install',
handlers: {
post: [
middleware.fileUploadParser(middleware.zipTypes, { unzip: true }),
this.installHandler.bind(this)
]
},
permissions: { post: [`install:${this.root}`] },
validate: false,
meta: apidefs.install
},
{
route: '/:_id/update',
handlers: { post: this.updateHandler.bind(this) },
permissions: { post: [`update:${this.root}`] },
meta: apidefs.update
},
{
route: '/:_id/uses',
handlers: { get: this.usesHandler.bind(this) },
permissions: { get: [`read:${this.root}`] },
meta: apidefs.uses
})
const config = await loadRouteConfig(this.rootDir, this, { schema: 'apiroutes' })
if (config) this.applyRouteConfig(config)
}

/** @override */
Expand Down Expand Up @@ -426,18 +397,16 @@ class ContentPluginModule extends AbstractApiModule {
}

/** @override */
serveSchema () {
return async (req, res, next) => {
try {
const plugin = await this.get({ name: req.apiData.query.type }) || {}
const schema = await this.getSchema(plugin.schemaName)
if (!schema) {
return res.sendError(this.app.errors.NOT_FOUND.setData({ type: 'schema', id: plugin.schemaName }))
}
res.type('application/schema+json').json(schema)
} catch (e) {
return next(e)
async serveSchema (req, res, next) {
try {
const plugin = await this.get({ name: req.apiData.query.type }) || {}
const schema = await this.getSchema(plugin.schemaName)
if (!schema) {
return next(this.app.errors.NO_SCHEMA_DEF)
}
res.type('application/schema+json').json(schema.built)
} catch (e) {
return next(e)
}
}

Expand All @@ -449,6 +418,8 @@ class ContentPluginModule extends AbstractApiModule {
*/
async installHandler (req, res, next) {
try {
const middleware = await this.app.waitForModule('middleware')
await middleware.fileUploadParser(middleware.zipTypes, { unzip: true, promisify: true })(req, res)
const [pluginData] = await this.installPlugins([
[
req.body.name,
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"adapt-authoring-content": "^2.0.0",
"adapt-authoring-jsonschema": "^1.2.0",
"adapt-authoring-middleware": "^1.0.2",
"adapt-authoring-mongodb": "^3.0.0"
"adapt-authoring-mongodb": "^3.0.0",
"adapt-authoring-server": "^2.1.0"
},
"devDependencies": {
"@semantic-release/git": "^10.0.1",
Expand Down
76 changes: 76 additions & 0 deletions routes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{
"_comment": "Routes are defined explicitly rather than using defaults to omit POST / and PUT /:_id",
"root": "contentplugins",
"routes": [
{
"route": "/",
"handlers": { "get": "requestHandler" },
"permissions": { "get": ["read:${scope}"] }
},
{
"route": "/schema",
"handlers": { "get": "serveSchema" },
"permissions": { "get": ["read:schema"] }
},
{
"route": "/:_id",
"handlers": { "get": "requestHandler", "patch": "requestHandler", "delete": "requestHandler" },
"permissions": { "get": ["read:${scope}"], "patch": ["write:${scope}"], "delete": ["write:${scope}"] }
},
{
"route": "/query",
"validate": false,
"modifying": false,
"handlers": { "post": "queryHandler" },
"permissions": { "post": ["read:${scope}"] }
},
{
"route": "/install",
"validate": false,
"handlers": { "post": "installHandler" },
"permissions": { "post": ["install:${scope}"] },
"meta": {
"post": {
"summary": "Install a content plugin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "type": "string" },
"version": { "type": "string" },
"force": { "type": "boolean", "default": false }
}
}
}
}
}
}
}
},
{
"route": "/:_id/update",
"handlers": { "post": "updateHandler" },
"permissions": { "post": ["update:${scope}"] },
"meta": {
"post": {
"summary": "Update a single content plugin",
"parameters": [{ "name": "_id", "in": "path", "description": "Content plugin _id", "required": true }]
}
}
},
{
"route": "/:_id/uses",
"handlers": { "get": "usesHandler" },
"permissions": { "get": ["read:${scope}"] },
"meta": {
"get": {
"summary": "Return courses using a single content plugin",
"parameters": [{ "name": "_id", "in": "path", "description": "Content plugin _id", "required": true }]
}
}
}
]
}
65 changes: 27 additions & 38 deletions tests/ContentPluginModule.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ function createInstance (overrides = {}) {
NOT_FOUND: Object.assign(new Error('NOT_FOUND'), {
code: 'NOT_FOUND',
setData: mock.fn(function (d) { this.data = d; return this })
}),
NO_SCHEMA_DEF: Object.assign(new Error('NO_SCHEMA_DEF'), {
code: 'NO_SCHEMA_DEF'
})
}
},
Expand Down Expand Up @@ -151,18 +154,16 @@ async function installPlugins (plugins, options = { strict: false, force: false
return installed
}

function serveSchema () {
return async (req, res, next) => {
try {
const plugin = await this.get({ name: req.apiData.query.type }) || {}
const schema = await this.getSchema(plugin.schemaName)
if (!schema) {
return res.sendError(this.app.errors.NOT_FOUND.setData({ type: 'schema', id: plugin.schemaName }))
}
res.type('application/schema+json').json(schema)
} catch (e) {
return next(e)
async function serveSchema (req, res, next) {
try {
const plugin = await this.get({ name: req.apiData.query.type }) || {}
const schema = await this.getSchema(plugin.schemaName)
if (!schema) {
return next(this.app.errors.NO_SCHEMA_DEF)
}
res.type('application/schema+json').json(schema.built)
} catch (e) {
return next(e)
}
}

Expand Down Expand Up @@ -471,50 +472,41 @@ describe('ContentPluginModule', () => {
// serveSchema
// -----------------------------------------------------------------------
describe('serveSchema()', () => {
it('should return a middleware function', () => {
const middleware = inst.serveSchema()
assert.equal(typeof middleware, 'function')
})

it('should send schema JSON when found', async () => {
const schemaData = { type: 'object', properties: {} }
it('should send schema.built JSON when found', async () => {
const builtSchema = { type: 'object', properties: {} }
inst.get = mock.fn(async () => ({ schemaName: 'mySchema' }))
inst.getSchema = mock.fn(async () => schemaData)
inst.getSchema = mock.fn(async () => ({ built: builtSchema }))

const req = { apiData: { query: { type: 'myPlugin' } } }
let sentType = null
let sentJson = null
const res = {
type: mock.fn(function (t) { sentType = t; return this }),
json: mock.fn((data) => { sentJson = data }),
sendError: mock.fn()
json: mock.fn((data) => { sentJson = data })
}
const next = mock.fn()

const handler = inst.serveSchema()
await handler(req, res, next)
await inst.serveSchema(req, res, next)

assert.equal(sentType, 'application/schema+json')
assert.deepEqual(sentJson, schemaData)
assert.equal(res.sendError.mock.callCount(), 0)
assert.deepEqual(sentJson, builtSchema)
assert.equal(next.mock.callCount(), 0)
})

it('should send NOT_FOUND error when schema is null', async () => {
it('should call next with NOT_FOUND error when schema is null', async () => {
inst.get = mock.fn(async () => ({ schemaName: 'missing' }))
inst.getSchema = mock.fn(async () => null)

const req = { apiData: { query: { type: 'myPlugin' } } }
const res = {
type: mock.fn(function () { return this }),
json: mock.fn(),
sendError: mock.fn()
json: mock.fn()
}
const next = mock.fn()

const handler = inst.serveSchema()
await handler(req, res, next)
await inst.serveSchema(req, res, next)

assert.equal(res.sendError.mock.callCount(), 1)
assert.equal(next.mock.callCount(), 1)
})

it('should use empty object fallback when get returns null', async () => {
Expand All @@ -524,17 +516,15 @@ describe('ContentPluginModule', () => {
const req = { apiData: { query: { type: 'unknown' } } }
const res = {
type: mock.fn(function () { return this }),
json: mock.fn(),
sendError: mock.fn()
json: mock.fn()
}
const next = mock.fn()

const handler = inst.serveSchema()
await handler(req, res, next)
await inst.serveSchema(req, res, next)

// getSchema should be called with undefined (from {}.schemaName)
assert.equal(inst.getSchema.mock.calls[0].arguments[0], undefined)
assert.equal(res.sendError.mock.callCount(), 1)
assert.equal(next.mock.callCount(), 1)
})

it('should call next with the error when an exception occurs', async () => {
Expand All @@ -545,8 +535,7 @@ describe('ContentPluginModule', () => {
const res = { sendError: mock.fn() }
const next = mock.fn()

const handler = inst.serveSchema()
await handler(req, res, next)
await inst.serveSchema(req, res, next)

assert.equal(next.mock.callCount(), 1)
assert.equal(next.mock.calls[0].arguments[0], testError)
Expand Down