Skip to content
Snippets Groups Projects
Commit d6b7032b authored by Paul Erickson's avatar Paul Erickson
Browse files

Initial commit

parent 88b1be7e
Branches master
No related tags found
No related merge requests found
Showing
with 3582 additions and 0 deletions
root = true
[*]
charset = utf-8
continuation_indent_size = 2
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
*.iml
*.swp
/.idea
/.scannerwork/
/bundle.mjs
/coverage/
/db/
/dynamodb-local/
/instantclient/
/node_modules/
/usr/
shell.nix
# Contributing
When contributing to this repository, please first discuss the change you wish to make via issue,
email, in person, or any other method with the owners of this repository before making a change.
Please note we have a code of conduct, please follow it in all your interactions with the project.
## Pull Request Process
1. Ensure any build or configuration artifacts are removed before submission
2. Increase the version number to the new version that this Pull Request would represent.
3. Update the README.md with relevant details of the change, including new environment
variables, exposed ports, etc.
The versioning scheme we use is [SemVer](http://semver.org/).
4. Open a pull request from your fork to the upstream master branch. Include 'WIP' in the title
if the changeset is not yet ready to merge and deploy to production. Remove it once finalized.
5. Squash commits to the smallest meaningful number of revisions. Do not include merge commits.
6. Fully test the change—deploying, using production data, and collaborating with maintainers as
necessary—before merging the pull request.
7. You may merge the Pull Request in once you feel comfortable with it and have the sign-off of at
least one other developer, or if you do not have permission to do that, you may request the
reviewer to merge it for you.
## Code of Conduct
### Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of experience,
nationality, personal appearance, race, religion, or sexual identity and
orientation.
### Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
### Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
### Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
### Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team by [email][email]. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
### Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at [http://contributor-covenant.org/version/1/4][version]
[email]: mailto:ia-leads@lists.wisc.edu
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/
This diff is collapsed.
{
"name": "app-directory-service",
"version": "0.1.0",
"description": "A RESTful Web Service for MyUW's app directory",
"private": true,
"main": "src/main.mjs",
"scripts": {
"posttest": "c8 report --reporter=lcov",
"sonar": "sonar-scanner",
"start": "node --experimental-modules src/main.mjs",
"test": "c8 node --experimental-modules src/test/runTests.mjs"
},
"license": "Apache-2.0",
"dependencies": {
"express": "^4.17.1",
"express-jwt": "^5.3.1",
"http-errors": "^1.7.3",
"nedb": "^1.8.0",
"nedb-promise": "^2.0.1",
"uuid": "^3.3.3",
"winston": "^3.2.1"
},
"devDependencies": {
"c8": "^6.0.1",
"glob": "^7.1.6",
"sonarqube-scanner": "^2.5.0"
}
}
import express from 'express';
import os from 'os';
import winston from 'winston';
import RootResource from './resource/RootResource.mjs';
import config from './config.mjs';
import manifest from '../package.json';
export default class Application {
constructor() {
this.configureLogging();
process.on('SIGINT', () => process.exit(130));
process.on('exit', () => this.stop());
}
async start() {
this.expressApp = express();
winston.info(`\x1b[2m
\x1b[0m—————————————————————————————————————————————————————————————————————
${manifest.description}
Version: ${manifest.version}
System Information:
Platform: Node ${process.version} / ${os.type}
CPUs: ${os.cpus().length}
Memory: ${Math.round(os.freemem()/1048576)}M free / ${Math.round(os.totalmem()/1048576)}M total
PID: ${process.pid}
`);
RootResource.instance.attach(this.expressApp);
this.expressApp.listen(config.port, () => winston.info(`Listening on port ${config.port}`));
}
/**
* Put shutdown hooks here (closing databases, etc.)
*/
stop() {
winston.info('Stopping…');
}
configureLogging() {
winston.configure({
format: winston.format.combine(
winston.format(info => {
// Highlight the bad stuff, just a bit
if (['warn', 'error'].includes(info.level)) {
info.message = `\x1b[1m${info.message}\x1b[0m`;
}
// Add and pretty print additional args, e.g. "winston.info('There was an error', error)"
const additionalArgs = info[Symbol.for('splat')];
const replacer = (i, arg) => {
if (!Array.isArray(arg) && typeof arg === 'object') {
// The stack and message properties of Errors aren't enumerable, so make a copy that does expose those.
// To modify the original object would have side effects elsewhere, where we don't want them to serialize
return {...arg, message: arg.message, stack: arg.stack};
}
return arg;
};
if (additionalArgs) {
info.message = `${info.message} ${JSON.stringify(additionalArgs, replacer, 2)}`;
}
return info;
})(),
winston.format.align(),
winston.format.colorize(),
winston.format.timestamp(),
winston.format.printf(({timestamp, level, message}) => `\x1b[2m${timestamp}\x1b[0m [${level}] ${message}`)
),
level: config.logLevel,
transports: [new winston.transports.Console()]
});
process.on('uncaughtException', (reason, origin) => winston.error('Uncaught exception:', origin, reason));
process.on('unhandledRejection', (reason, promise) => winston.error('Unhandled rejection:', promise, reason));
process.on('warning', warning => winston.warn('NodeJS warning:', warning));
}
}
export default {
baseUrl: process.env.BASE_URL || 'http://localhost:3000',
dbPath: process.env.DB_PATH || false,
jwtPublicKeyBase64: process.env.JWT_PUBLIC_KEY_BASE64 || '',
logLevel: process.env.LOG_LEVEL || 'verbose',
origins: (process.env.ORIGINS || 'http://localhost:3000,http://127.0.0.1:3000').split(',').map(it => it.trim()),
port: process.env.PORT || 3000,
revokedSubjects: (process.env.REVOKED_SUBJECTS || '').split(',').map(it => it.trim()), // TODO: same for jti and iss
securityDisabled: process.env.SECURITY_DISABLED === 'true'
}
import DataStore from 'nedb-promise'
import HttpErrors from 'http-errors'
import winston from 'winston'
import config from '../../config'
import Entity from "../../domain/abstract/Entity";
export default class CrudDao {
/**
* Generic CRUD persistence for use as a starting point
*
* @param {class} EntityClass the type of object to handle
*/
constructor(EntityClass = Entity) {
this.EntityClass = EntityClass;
this.db = config.dbPath? new DataStore({ filename: `${config.dbPath}/${EntityClass.name}.db`, autoload: true }) : new DataStore();
}
/**
* Create or replace an existing entity
* Assumes id is already valid, that caller created one if needed
*
* @param {EntityClass} object
* @param id
* @returns {Promise<void>}
*/
async create(object, id) {
await this.db.update({_id: id}, {...object, _id: id}, {upsert: true});
}
/**
*
* @param id
* @returns {Promise<EntityClass>}
*/
async retrieve(id) {
const result = await this.db.findOne({_id: id});
if (result) {
return new this.EntityClass(result);
}
return undefined;
}
/**
* Update only the specified properties without overwriting the whole document
*
* @param {EntityClass} object
* @param id
* @returns {Promise<void>}
*/
async update(object, id) {
const rowsAffected = await this.db.update({_id: id}, {$set: {...object, _id: id}});
if (rowsAffected === 1) {
return;
}
if (rowsAffected === 0) {
throw new HttpErrors.NotFound();
}
winston.error(`${rowsAffected} records affected after updating ${id} with document`, object);
}
/**
* Delete an entity
*
* @param id
* @returns {Promise<void>}
*/
async delete(id) {
winston.warn(`Deleting entity ${id}! This is okay for proof-of-concept, but should probably change to soft delete`);
await this.db.remove({_id: id});
}
/**
* Retrieve array of entities, optionally filtering
*
* @param query
* @returns {Promise<Array<EntityClass>>}
*/
async retrieveAll(query = {}) {
winston.info(`Finding entity by query ${JSON.stringify(query)}`);
try {
const result = await this.db.find(query);
return result.map(each => new this.EntityClass(each));
}
catch(e) {
winston.error(e);
return [];
}
}
}
import Entity from './abstract/Entity.mjs';
import Schema from './metadata/Schema.mjs';
import config from '../config.mjs';
export default class Widget extends Entity {
static get schema() {
if (this._schema === undefined) {
this._schema = new Schema({
"$schema": "http://json-schema.org/schema#",
"$id": `${config.baseUrl}/schema/${this.mediaSubtype}`,
"base": config.baseUrl,
"title": "Widget",
"description": "An entry in the app directory",
"type": "object",
"properties": {
"id": {
"readonly": true,
"title": "ID",
"type": "string"
},
"name": {
"title": "Name",
"type": "string"
},
"fname": {
"description": "uPortal reference",
"title": "Functional Name",
"type": "string"
},
"url": {
"type": "string"
},
"icon": {
"type": "string",
"default": "star"
},
"widget-type": { // TODO: consider typing
"type": "string",
"default": "basic",
"enum": ["basic", "list-of-links", "custom"]
},
"widget-config": { // TODO: consider extracting schema or flattening as properties
"type": "object"
}
},
"required": [
"name",
"fname",
"url"
],
"links": [
{ "title": "Self", "href": "widget/{id}", "rel": "self", "templateRequired": ["id"] },
{ "title": "Collection", "href": "widget", "rel": "collection" }
]
});
}
return this._schema;
}
constructor(object) {
super(object);
}
}
import Collection from './abstract/Collection.mjs';
import Widget from './Widget.mjs';
export default class WidgetCollection extends Collection(Widget) {
static get mediaSubtype() {
return `${Widget.mediaSubtype}.collection`;
}
constructor(collection) {
super(collection);
}
}
import JsonSchemaSupport from '../mixins/JsonSchemaSupport.mjs';
import config from '../../config.mjs';
import Schema from '../metadata/Schema.mjs';
export default (EntityClass) => class extends JsonSchemaSupport(Array) {
static get EntityClass() { return EntityClass; }
static get schema() {
return new Schema({
"$schema": "http://json-schema.org/schema#",
"$id": `${config.baseUrl}/schema/${this.mediaSubtype}`,
"base": config.baseUrl,
"title": `${this.EntityClass.schema.title} Collection`,
"type": "array",
"items": this.EntityClass.schema
});
}
constructor(collection) {
super();
Object.assign(this, collection);
}
}
import Schema from '../metadata/Schema.mjs';
import JsonSchemaSupport from '../mixins/JsonSchemaSupport.mjs';
export default class Entity extends JsonSchemaSupport(Object) {
/**
* JSON Schema definition to describe this domain object
*
* @returns {Object}
*/
static get schema() {
return new Schema({
"title": "Entity",
"description": "Something that has an identity (intended only as a base class)",
"type": "object",
"properties": {
"id": { "readonly": true, "title": "ID", "type": "string" },
},
"links": [
{ "href": ".", "rel": "self" },
]
});
}
constructor(object) {
super();
Object.assign(this, object);
if (this.id !== undefined) {
this.id = this.id.toString();
}
}
};
/**
* The JSON Schema specification defines this MIME type, so we won't try to
* define the schema of the schema, just making this object similar to the
* JsonSchemaSupport interface so that our header middleware picks it up.
*
* @returns {string}
*/
export default class Schema {
static get mediaType() {
return 'application';
}
static get mediaSubtype() {
return 'schema';
}
static get mimeType() {
return `${this.mediaType}/${this.mediaSubtype}+json`;
}
constructor(object) {
Object.assign(this, object);
}
}
import Schema from '../metadata/Schema.mjs';
import config from '../../config.mjs';
/**
* Mixin to associate JSON Schema information to an object.
* Classes should override methods they want to customize.
*
* @param {class} Superclass
* @returns
*/
export default (Superclass) => class extends Superclass {
static get mediaType() {
return 'application';
}
static get mediaSubtype() {
return `x.uw.${this.name.toString().toLowerCase()}`;
}
static get mimeType() {
const type = this.mediaType;
const subtype = this.mediaSubtype;
const suffix = 'json';
if (this.schema === undefined) {
return `${type}/${subtype}+${suffix}`;
}
const schemaUrl = new URL(`schema/${subtype}`, config.baseUrl);
const parameters = `profile="${schemaUrl}"`;
return `${type}/${subtype}+${suffix};${parameters}`;
}
/**
* JSON Schema definition to describe this domain object
*
* @returns {Object}
*/
static get schema() {
return new Schema({
"title": this.name,
"type": "object"
});
}
}
import Application from './Application.mjs'
new Application().start();
export default (Superclass) => class extends Superclass {
static get instance() {
if (this._instance === undefined) {
this._instance = new this();
}
return this._instance;
}
/**
* Singleton is expected not to need arguments
*/
constructor() {
super();
if (this.constructor._instance !== undefined) {
throw `Can't instantiate more than one ${this.constructor.name} because it is a singleton. Use ${this.constructor.name}.instance`
}
this.constructor._instance = this;
}
}
import winston from 'winston';
import WidgetCollectionResource from './widget/WidgetCollectionResource.mjs';
import Resource from './abstract/Resource.mjs';
import SchemaCollectionResource from './schema/SchemaCollectionResource.mjs';
import manifest from '../../package.json';
export default class RootResource extends Resource {
constructor() {
super();
this.pathSegment = '/';
this.resources = [
SchemaCollectionResource.instance,
WidgetCollectionResource.instance
];
}
/**
* Make a little HTML doc to help folks discover the resources implemented by the service
*
* @override
* @private
*/
async handleGet(req, res) {
function* traverse(resource, parentPath = ''){
if (!resource) {
return;
}
const path = `${parentPath}${resource.pathSegment}`.replace('?', '').replace('//', '/');
yield `<li><a href="${path}">${path}</a></li>`;
if (resource.resources) {
yield `<ul>`;
for (let child of resource.resources) {
yield* traverse(child, path);
}
yield `</ul>`;
}
}
res.set('Content-Type', 'text/html');
res.write(`<h1>MyUW App Directory Service v${manifest.version}</h1>`);
res.write('<h2>Resources</h2>');
res.write('<ul>');
for (let each of traverse(this)) {
res.write(each);
}
res.write('</ul>');
res.end();
}
authorizeRequest(req, next) {
winston.debug(`Skipping authorization because ${this.constructor.name} is a public resource`);
next();
}
}
import HttpErrors from 'http-errors'
import Resource from './Resource.mjs'
// TODO: implement a Page class, check for it and handle in the handler methods
// TODO: implement a Range class and handle Range header
export default class CollectionResource extends Resource {
constructor() {
super()
}
async createEntity(json, ...ids) {
throw HttpErrors.MethodNotAllowed()
}
async removeAll(query, ...ids) {
throw HttpErrors.MethodNotAllowed()
}
async replaceAll(query, ...ids) {
throw HttpErrors.MethodNotAllowed()
}
async retrieveAll(query, ...ids) {
throw HttpErrors.MethodNotAllowed()
}
/**
* @override
* @private
*/
async handleDelete(req, res) {
await this.removeAll(req.query, ...this.getOrderedParams(req));
res.sendStatus(204);
}
/**
* @override
* @private
*/
async handleGet(req, res) {
const entities = await this.retrieveAll(req.query, ...this.getOrderedParams(req));
if (entities === undefined) {
throw HttpErrors.NotFound();
}
if (entities.constructor.mediaSubtype) {
res.type(entities.constructor.mimeType);
}
// This overwrites the subresource link headers set in Resource, since the variable path segment wouldn't make sense
if (entities.constructor.schema) {
const links = entities.constructor.schema.links;
if (links) {
links
.map(link => `rel="${link.rel}"; title="${link.title}"; href="${link.href}"`)
.join(',');
res.header('Link', links);
}
}
res.json(entities);
}
/**
* @override
* @private
*/
async handleOptions(req, res) {
res.header('Access-Control-Allow-Methods', 'GET, OPTIONS, POST');
res.sendStatus(204);
}
/**
* @override
* @private
*/
async handlePost(req, res) {
const id = await this.createEntity(req.body, ...this.getOrderedParams(req));
if (id !== undefined) {
res.location(id);
}
res.sendStatus(201);
}
/**
* @override
* @private
*/
async handlePut(req, res) {
await this.replaceAll(req.body, ...this.getOrderedParams(req));
res.sendStatus(201);
}
}
import uuidv4 from 'uuid/v4.js'
import winston from 'winston'
import CollectionResource from './CollectionResource.mjs'
import CrudService from '../../service/abstract/CrudService.mjs'
import CrudEntityResource from './CrudEntityResource.mjs'
export default class CrudCollectionResource extends CollectionResource {
/**
* Delegates to an EntityResource for creating entities (i.e. handling POST request)
*
* If entityResource is provided, then service must be also!
*
* @param {class} CollectionClass
* @param {CrudService} [service]
* @param {CrudEntityResource} [entityResource]
*/
constructor(CollectionClass, service, entityResource) {
super();
this.CollectionClass = CollectionClass;
this.EntityClass = CollectionClass.EntityClass;
this.pathSegment = `/${CollectionClass.EntityClass.name}s?`;
this.service = service || new CrudService(CollectionClass.constructor.EntityClass);
this.entityResource = entityResource || new CrudEntityResource(this.service);
this.resources = [ this.entityResource ];
}
/**
* Query is treated as an example/matcher object, in order to search against its properties
*
* @param query
* @returns {Promise<Array<EntityClass>>}
*/
async retrieveAll(query) {
winston.info(`Retrieving ${this.EntityClass.name}s with query ${JSON.stringify(query)}`);
const collection = await this.service.retrieveAll(new this.EntityClass(query));
return new this.CollectionClass(collection);
}
/**
* Returns ID of created source, creating one if needed
*
* @param object
* @returns {Promise<string>}
*/
async createEntity(object) {
const id = object.id || uuidv4();
await this.entityResource.createEntity(object, id);
return id;
}
}
import winston from 'winston'
import CrudService from '../../service/abstract/CrudService.mjs';
import EntityResource from './EntityResource.mjs'
export default class CrudEntityResource extends EntityResource {
/**
* Request handling for basic CRUD operations (create, retrieve, update, delete)
*
* @param {class} EntityClass
* @param {CrudService} [service]
*/
constructor(EntityClass, service) {
super();
this.EntityClass = EntityClass;
this.pathSegment = `/:${EntityClass.name}Id`;
this.service = service || new CrudService(EntityClass);
}
/**
*
* @param object
* @param id
* @returns {Promise<void>}
*/
async createEntity(object, id) {
winston.info(`Submitting entity ${id}: ${JSON.stringify(object)}`);
await this.service.create(new this.EntityClass(object), id);
}
/**
*
* @param id
* @returns {Promise<EntityClass>}
*/
async retrieveEntity(id) {
winston.info(`Retrieving entity ${id}`);
return this.service.retrieve(id);
}
/**
*
* @param object
* @param id
* @returns {Promise<void>}
*/
async updateEntity(object, id) {
winston.info(`Updating entity ${id}: ${JSON.stringify(object)}`);
await this.service.update(object, id);
}
/**
*
* @param id
* @returns {Promise<void>}
*/
async deleteEntity(id) {
winston.info(`Deleting entity ${id}`);
await this.service.delete(id);
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment