ethanresnick / json-api

Turn your node app into a JSON API server (http://jsonapi.org/)
GNU Lesser General Public License v3.0
268 stars 41 forks source link

Customize behavior while using most APIController logic? #110

Closed adam-ts closed 6 years ago

adam-ts commented 8 years ago

This question is about how to take advantage of most of what the APIController (and the modules it imports) does, such as checking that the body (if any) is a valid JSON API document, performing transforms and returning JSON API-compliant error responses, in endpoints that require behavior outside of what the doGET, doPOST, doPATCH and doDELETE functions allow.

Example case

In my API, I have a user resource, which is the sort of thing someone uses to log in, and an account resource, which is like a team or company of users. Most other resources are associated with an account rather than a user to make it easier for users to share them with each other. Suppose one such resource is an image. In order to prevent users from creating images that don’t have an account associated with them, I have them POST to /account/:accountId/image. That endpoint creates the image with a relationship to the account and then, in the same request, updates the account’s reverse images relationship to have the created image. If updating the account fails, the created image is deleted and an error is returned to the client.

The problem

Because my endpoint performs two different actions on the database, I cannot just pass a request through to the apiRequest method on the HTTP strategy class. Even modifying the request in middleware before passing it off to json-api is not sufficient. So I end up having to do a lot of the same things that would normally happen in the handle method of the APIController, in my own code, to replace little more than the part that calls doPOST. I even copied and pasted some code. To avoid copying and pasting the functions in src/steps/pre-query, I imported them. However, the fact that they are not exported in index.js seems to imply that they are not meant to be public and my code cannot rely on them working the same way in the future. It seems to me that one should be able to use most of the logic in the APIController handle method for custom-behavior endpoints such as these.

While I understand that the JSON API specification may have something to say about inverse relationships in the future, my question is more generally about how to customize the behavior of this module without forking it. I'm starting to think that my endpoints will fit into two categories:

  1. Endpoints that require very little code because they suit the module's default behavior or can be customized either through middleware that modifies the request or through the ResourceTypeRegistry
  2. Other endpoints, for which I seem to be forced to copy a lot of code out of the module

Is there currently an alternative to that?

ethanresnick commented 8 years ago

I think you've diagnosed the situation pretty well.

One of my long-term plans has been to refactor this library in such a way that the series of transformations from request to response can be configured on a per-request basis, likely with settings stored in the ResourceTypeRegistry configuring which steps are used on which requests. This ability would then be used to knock out issues like this one, and could also be generalized to solve your issue, by providing a mechanism for users to customize the pipeline a request goes through.

However, that refactor is a ways off, as I haven't had much time to work on this library lately. Any chance you'd like to collaborate on that? If so, I'd be happy to take you through the codebase in more detail and brainstorm the design (e.g. over Skype).

adam-ts commented 8 years ago

Hi Ethan. I've sent you an e-mail at hi@ethanr.me regarding possible collaboration.

ethanresnick commented 6 years ago

@adam-ts I have no idea if you're still using JSON:API, and I'm sorry I dropped the ball on a possible collaboration. 2+ years later, though, I at least think this can finally be closed as having been addressed — and reasonably elegantly, even though there are still a couple rough edges. In particular, v3 of this library separates the request parsing, query construction, and response construction stages into composable parts. Architecturally, each request is still mapped to only one query (for the moment), but the function/step that turns this main query's result into the final response doesn't need to be pure, so it's possible to perform extra queries in there and use their results to create the final result. In v3, the example use case you gave is addressed like this:

  app.post('/account/:accountId/:type(image)',
    Front.customAPIRequest({
      queryFactory: async (opts) => {
        // make the default, library-generated query. because we manipulated req.params 
        // to have type = image, this query is a CreateQuery for the image in the body.
        const query = await opts.makeQuery(opts);
        const accountId = opts.serverReq.params.accountId;

        // Update the to-be-created resource in the query to force it to have a 
        // relationship to the account. This would be unnecessary if it's already 
        // happening in beforeSave.
        query.records = query.records.map(it => {
          it.setRelationship("account", new ResourceIdentifier("account", accountId));
          return it;
        });

        // capture the library's built-in function for turning the data returned
        // by running the query into a response.
        const origReturning = query.returning;

        // And return a new query, based on the original, but that will run an 
        // update query on the account before producing the final result
        return query.resultsIn(async (imgQueryResult) => {
          const newImageId = imgQueryResult.primary.unwrap().id;
          const accountQuery = new AddToRelationshipQuery({ 
            type: "account", 
            id: accountId,
            relationshipName: "images",
            linkage: Data.of([new ResourceIdentifier("image", newImageId)]),
            /* dummy returning; we don't do anything with the account query's result */ 
            returning(res: any) { return {}; }
          });

          return await opts.registry.dbAdapter("account")
            .addToRelationship(accountQuery)
            .then(accountRes => {
               // return the result we would've returned just from creating the image.
               return origReturning(imgQueryResult);
             }, (e) => {
               // account update failed, so delete image and return an error response.
               // delete query left to the reader. Manual rollback is a pain; hopefully
               // mongo's forthcoming transactions support will obviate all this.
               return { status: 500, ..... }; // this is a Result; see types file.
             });
        })
      })
    })
  );