apostrophe: module inheritance (moog) is difficult to master, developers don't know where to put their code in modules
Here is the planned replacement for the current index.js
syntax for modules. This is not backwards compatible, it’s the 3.0 format. However a conversion tool is under construction and has already been used to help convert apostrophe core itself.
In general, we are deprecating the imperative, “build the module by calling stuff” style and replacing it with a declarative style, while avoiding technical terms and invented language.
// lib/modules/shoes/index.js, a pieces subclass
module.exports = {
extends: 'apostrophe-pieces',
// "improve" would also go at this level
// set up schema fields. If there are subclasses with fields they will
// merge sensibly with what is done here.
// fields may just be an object if you don't have any conditional fields to add, or not add, based
// on options
fields(self, options) => ({
add: {
shoeSize: {
type: 'integer',
label: 'Shoe Size'
},
price: {
type: 'float'
},
// ES2015 makes option-dependent fields easy
...(options.specialField ? {
special: {
type: 'whatever'
}
} : {})
},
remove: [ 'tags' ],
groups: {
shoes: {
label: 'Shoes',
fields: [ 'shoeSize' ]
},
// The "utility rail" at right which appears no matter what tab is active.
// Overuse of this space is discouraged
utility: {
fields: [ 'title', 'slug' ]
}
}
}),
options: {
// "Plain old options" now go in their own distinct section. They
// override straightforwardly in subclasses
searchable: false
// "name" option for pieces is dead, defaults to module name
// as it should have in the first place, sorry
},
async init(self, options) {
// options, fields and methods are ready for use here
await self.connectToShoestoreBackend();
},
async afterAllSections(self, options) {
// You'll probably never write one of these! But if you were adding support for an
// entirely new section of module config (like routes or methods, but something
// else) you might need to run code at this point to attach the routes to express, etc.
// "init" has already resolved at this point and all of the sections have been attached
// to `self`.
},
methods: (self, options) => ({
async fetchMonkeys(req, doc) {
... code for this method ...
// having self in scope is key here and in pretty much all other sections
// containing functions
self.doSomething();
},
doThing() {
... code for this method ...
},
doOtherThing() {
... code for this method ...
},
// Middleware we'll add selectively to certain routes
requireAdmin(req, res, next) {
if (!self.apos.permissions.can('admin')) {
return res.status(403).send('forbidden');
}
}
}),
extendMethods: (self, options) => ({
async adjustHovercraft(_super, req, doc, options) {
await _super(req, doc, options);
doSomethingMore();
}
}),
handlers: (self, options) => ({
'apostrophe-pages:beforeSend': {
// Named so they can be extended
async addPopularProducts(req) { ... },
async addBoringBooks(req) { ... }
}
}),
extendHandlers: (self, options) => ({
'apostrophe-pages:beforeSend': {
// Inherited from base class, we can use _super to
// invoke the original and do more work
async addNavigation(_super, req) { ... }
}
}),
helpers: (self, options) => ({
includes(arr, item) {
return arr.includes(item);
}
}),
extendHelpers: (self, options) => ({
// Extend a base class helper called colorCode with a new default
colorCode(_super, item) {
const color = _super(item);
return color || 'red';
}
}),
// middleware registered in the middleware block runs on ALL requests
middleware(self, options) => ({
ensureData(req, res, next) {
req.data = req.data || {};
return next();
}),
// This middleware needs to run before that provided by another module,
// so we need to pass an option as well as a function
reallyEarlyThing: {
before: 'other-module-name',
middleware: (req, res, next) => { }
}
}),
// there is no extendMiddleware because the middleware pattern does not lend itself to the
// "super" pattern
apiRoutes: (self, options) => ({
post: {
// This route needs middleware so we pass an array ending with the route function
upload: [
// Pass a method as middleware
self.requireAdmin,
// Actual route function
async (req) => { ... }
],
async insert(req) {
// No route-specific middleware, but global middleware always applies
}
// Leading / turns off automatic css-casing and automatic mapping to a module URL.
// Presto, public API at a non-Apostrophe-standard URL
'/track-hit': async (req) => {
// route becomes /modules/modulename/track-hit, auto-hyphenation so we can
// use nice camelcase method names to define routes
return self.apos.docs.db.update({ _id: req.body._id },
{ $inc: { hits: 1 } }
);
}
}
}),
extendApiRoutes: (self, options) => ({
post: {
// insert route is inherited from base class, let's
// extend the functionality
async insert(_super, req) {
await _super(req);
// Now do something more...
}
}
}),
// REST API URLs can be done with `apiRoutes` but they would
// look a little weird with `apiRoutes` because you
// would need to specify the empty string as the name of the "get everything"
// GET route, for instance. So let's provide a separate section
// to make it less confusing to set them up
restApiRoutes: (self, options) => ({
async getAll(req) { returns everything... },
async getOne(req, id) { returns one thing... },
async post(req) { inserts one thing via req.body... },
async put(req, id) { updates one thing via req.body and id... },
async delete(req, id) { deletes one thing via id... }
async patch(req, id) { patches one thing via req.body and id... }
}),
// extendRestApiRoutes works like extendApiRoutes of course
components: (self, options) => ({
// In template: {% component 'shoes:brandBrowser' with { color: 'blue' } %}
async brandBrowser(req, data) {
// Renders the `brandBrowser.html` template of this module,
// with `data.brands` available containing the results of this
// third party API call
return {
// Pass on parameter we got from the component tag in the template
brands: await rq('https://shoebrands.api', { color: data.color })
};
}
}),
extendComponents: (self, options) => ({
// Extend a component's behavior, reusing the original to do most of the work
async brandBrowser(_super, req, data) {
const result = await _super(req, data);
if (result.color === 'grey') {
result.color = 'gray';
}
return result;
}
}),
// Typically used to adjust `options` before the base class sees it, if you need
// to do that programmatically in ways the `options` property doesn't allow for.
// Pretty rare now that `fields` is available
beforeSuperClass(self, options) {
// ...
},
// Add features to database queries, i.e. the objects returned by self.find() in this module.
// `self` is the module, `query` is the individual query object
queries(self, query) {
return {
// Query builders. These are chainable methods; they get a chainable setter method for free,
// you only need to specify what happens when the query is about to execute
builders: {
free: {
finalize() {
const free = query.get('free');
const criteria = query.get('criteria');
query.set('criteria', { $and: [ criteria, { price: free ? 0 : { $gte: 0 } } ] });
}
}
},
methods: {
// Return a random object matching the query
async toRandomObject() {
const subquery = query.clone();
const count = await subquery.toCount();
query.skip(Math.floor(count * Math.random));
query.limit(1);
const results = await query.toArray();
return results[0];
}
}
};
},
};
Why do we think this is better?
- An explicit
self, options
function for each section that contains functions provides a scope with access to the module. Separating those functions by section is a little wordy, but attempting to merge them in a single function leads to significant confusion. For instance, you can’t pass a method as the function for a route unless methods are initialized first in a separate call. And properties likeextend
must be sniffable before construction of the object begins, which means we can’t just export one big function that returns one object, or else we’d have to invoke it twice; the first time it would be without a meaningfulself
oroptions
, leading to obvious potential for bugs. methods
is a simple and descriptive name, familiar from Vue, which has been very successful in achieving developer acceptance, even though Vue also does not use ES6 classes for not entirely dissimilar reasons. In general, Vue components have been designed with simple and descriptive language wherever possible, and we can learn from that and avoid inside baseball jargon.extendMethods
is a similar, however here each method’s first argument is_super
, where_super
is a reference to the method we’re overriding from a parent class. We now have complete freedom to call_super
first, last, or in the middle in our new function. It is much less verbose than our currentsuper
pattern. Organizing all of these extensions inextendMethods
makes the intent very clear. Note that if you just want to “override” (replace) a method, you declare it inmethods
and that straight up crushes the inherited method.extendMethods
is for scenarios where you need reuse of the original method as part of the new one. We use_super
becausesuper
is a reserved word.handlers
andextendHandlers
provide similar structure for promise event handlers. Again, these get grouped together, making them easier to find, just like Vue groups togethercomputed
,methods
, etc. As always, handlers must be named. Handlers for the same event are grouped beneath it. This is loosely similar to Vue lifecycle hooks, but intentionally not identical because Apostrophe involves inheritance, and everything needs to be named uniquely so it can be overridden or extended easily.helpers
andextendHelpers
: you get the idea. For nunjucks helpers.apiRoutes
andextendApiRoutes
: you’d actually be able to addapiRoutes
,htmlRoutes
and plain oldroutes
. see recent Apostrophe changelogs if this is unfamiliar. Note subproperties separating routes of the same name with different HTTP methods.fields
: just… just look at it. This clobbers addFields/removeFields with tons of beforeConstruct boilerplate.middleware
andextendMiddleware
: replaces the currentexpressMiddleware
property, which is a bit of a hack, with a clear way to define and activate middleware globally. Note thebefore
option which covers some of the less common but most important uses ofexpressMiddleware
in 2.x. As for middleware that is only used by certain routes, methods are a good way to deliver that, as shown here. Note that methods will completely initialize, from base class through to child class, before routes start to initialize, so they will see the final version of each method.init
is a common name in other frameworks for a function that runs as soon as the module is fully constructed and ready to support method calls, etc. This replaces the inside-baseball nameafterConstruct
.beforeSuperClass
is very explicit and hopefully, finally, clarifies when this function runs: before the base class is constructed. It is the only function that runs “bottom to top,” i.e. the one in the subclass goes first. We used to call itbeforeConstruct
which says nothing about the timing relative to the base class. It is used to manipulateoptions
before the parent class sees them, however most 2.x use cases have been eliminated by the introduction of thefields
section.queries
replaces what people used to put incursor.js
files, specificallyaddFilters
calls as well as custom methods. It obviates the need for a separatemoog
type for cursors,moog
is now used only to instantiate modules and ceases to have its own “brand.”queries
only makes sense in a module that inherits fromapostrophe-doc-type-manager
.
“What about breaking a module into multiple files?” Well that’s a good question, we do this now and it’s a good thing. But, nobody’s stopping anyone from using require
in here. It would work like it does today, you’d pass in self
or self, options
to a function in the required file.
About this issue
- Original URL
- State: closed
- Created 5 years ago
- Reactions: 5
- Comments: 36 (36 by maintainers)
Now that these will just be “apostrophe types”, the “moogBundle” property can just become “bundle”.
I changed
startup
toinit
because it’s just as familiar a word, perhaps even more so for this purpose, and it is already used by Apostrophe (afterInit
events for example).I am merging moog into a3 rather than releasing another separate npm version of moog, which has never generated much interest by itself, so it makes sense to drop an npm dependency. moog-require too.
A new wrinkle: what about middleware? The above syntax has no way of accommodating middleware in, say, an
apiRoute
or plain vanillaroute
.Here is a syntax that does allow middleware. I also included a regular route without middleware to show that the simplified syntax isn’t going away.
This is what I’m implementing for now but discussion is welcome.
What if a middleware method is a method of the same module? Will it exist in time to be passed to
apiRoute
? The answer is yes, because we construct methods fully (through the entire inheritance tree) before we construct routes.I think that for 3.0 we could do without the wrapper. If we want to be able to use this syntax in 2.x without a bc break, the wrapper makes a lot of sense.
On Thu, Jun 6, 2019 at 2:51 PM Amin Shazrin notifications@github.com wrote:
–
Thomas Boutell, Chief Software Architect P’unk Avenue | (215) 755-1330 | punkave.com