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 like extend 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 meaningful self or options, 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 current super pattern. Organizing all of these extensions in extendMethods makes the intent very clear. Note that if you just want to “override” (replace) a method, you declare it in methods 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 because super is a reserved word.
  • handlers and extendHandlers provide similar structure for promise event handlers. Again, these get grouped together, making them easier to find, just like Vue groups together computed, 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 and extendHelpers: you get the idea. For nunjucks helpers.
  • apiRoutes and extendApiRoutes: you’d actually be able to add apiRoutes, htmlRoutes and plain old routes. 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 and extendMiddleware: replaces the current expressMiddleware property, which is a bit of a hack, with a clear way to define and activate middleware globally. Note the before option which covers some of the less common but most important uses of expressMiddleware 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 name afterConstruct.
  • 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 it beforeConstruct which says nothing about the timing relative to the base class. It is used to manipulate options before the parent class sees them, however most 2.x use cases have been eliminated by the introduction of the fields section.
  • queries replaces what people used to put in cursor.js files, specifically addFilters calls as well as custom methods. It obviates the need for a separate moog 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 from apostrophe-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)

Most upvoted comments

Now that these will just be “apostrophe types”, the “moogBundle” property can just become “bundle”.

I changed startup to init 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 vanilla route.

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.

  apiRoutes: {
    post: {
      getHits: [
        // standard express middleware functions
        self.apos.login.requiredMiddleware,
        self.apos.middleware.uploads,
        async(req) {
          // route fn code goes here for /modules/my-module-name/get-hits
        }
      ],
      trackHit(req) {
        // route fn goes here for /modules/my-module-name/track-hit
      }
    }
  }

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:

You can imagine that a wrapper is similar to React.memo(App) and others. The reason to make it as a wrapper is because we can adjust the way module is structured are much more cleaner than needed to adjust the whole apostrophe core modules. What do you think ?

— You are receiving this because you authored the thread. Reply to this email directly, view it on GitHub https://github.com/apostrophecms/apostrophe/issues/1940?email_source=notifications&email_token=AAAH27IPTT67UTL6FJ3NMHTPZFMCDA5CNFSM4HTK3NIKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGODXDZ25A#issuecomment-499621236, or mute the thread https://github.com/notifications/unsubscribe-auth/AAAH27P5THQZD2EMA7XJV6LPZFMCDANCNFSM4HTK3NIA .

Thomas Boutell, Chief Software Architect P’unk Avenue | (215) 755-1330 | punkave.com