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

Way to add a X-Total-Count header? #140

Closed te-online closed 6 years ago

te-online commented 6 years ago

Is there a way to add a custom header that counts data-items returned in a request?

Right now for testing, I've hardcoded this to beforeRender:

"users": {
    urlTemplates: {
      "self": "/api/users/{id}"
    },
    beforeRender: function(resource, req, res) {
      // if(!userIsAdmin(req)) resource.removeAttr("password");
      res.set({
        'X-Total-Count': '100',
        'Access-Control-Expose-Headers': 'X-Total-Count'
      })
      return resource
    }
},

Is there a hook where I can count the items in a response and set the number as a header?

Thanks, Thomas

ethanresnick commented 6 years ago

@te-online Are you using v3? If so, the easiest way to do this probably a query transform that returns a query with a different returning function. The Query.returning function controls how the json:api response is built from the query results, so overriding that lets you add custom headers.

Something like:

app.get('/api/:type(users)',
  Front.transformedAPIRequest((req, query) => {
    const origReturning = query.returning;

    return query.resultsIn((...args) => {
      const origResult = origReturning(...args);

      return {
        ...origResult, 
        headers: { 
          ...origResult.headers, 
          'X-Total-Count': origResult.document.primary.resources.length 
        }
      };
    })
  })
);

A couple notes on the above:

te-online commented 6 years ago

Thank you so much, this is a drop in solution and it would have taken me hours to figure out by myself! 😊 🎉 Thanks for the great library, also. Looking forward to the first v3 stable release 🤓

te-online commented 6 years ago

@ethanresnick Sorry to jump in here again. I found that this solution of course counts the documents in the response. In theory, the X-Total-Count header should contain the complete number of documents, because this value is used for pagination in my app. Can I achieve outputting the total number of documents in a similar way?

ethanresnick commented 6 years ago

@te-online Yes. The response document generated by the library's default query.returning function also includes the total collection size when pagination is in effect. It puts that size in meta.total, so you just have to read that value from the document, similar to before. Something like:

app.get('/api/:type(users)',
  Front.transformedAPIRequest((req, query) => {
    const origReturning = query.returning;

    return query.resultsIn((...args) => {
      const origResult = origReturning(...args);

      // meta.total will be missing if no pagination params are in use, 
      // in which case the collection size === the number of resources in the document,
      // so fall back to reading that
      const count = (origResult.document.meta && origResult.document.meta.total)
        || origResult.document.primary.values.length;

      return {
        ...origResult, 
        headers: { 
          ...origResult.headers, 
          'X-Total-Count': count 
        }
      };
    })
  })
);
te-online commented 6 years ago

Thank you, once again!

This seems to work for me, because it also takes care of single document requests.

const transformedApiRequest = Front.transformedAPIRequest((req, query) => {
  const origReturning = query.returning

  return query.resultsIn((...args) => {
    const origResult = origReturning(...args)

    let count = 1
    if(origResult.document && origResult.document.meta && origResult.document.meta.total) {
      count = origResult.document.meta.total
    } else if(origResult.document && origResult.document.primary.data) {
      count = origResult.document.primary.data.value.data.length
    }

    return {
      ...origResult,
      headers: {
        ...origResult.headers,
        'X-Total-Count': count,
        'Access-Control-Expose-Headers': 'X-Total-Count'
      },
    }
  })
})
ethanresnick commented 6 years ago

That looks pretty good, except for the else if block. In that condition, and the assignment on the next line, you shouldn't access origResult.document.primary.data, as the .data property is intended to be "private" (and has actually been renamed to ._data in the last couple betas). Instead, use the public origResult.document.primary.values to get an array of the primary data values (which might be empty in the data: null case).

These APIs don't yet have separate documentation outside the code, so it would've been hard to know that .data wasn't meant to be public. The best way to handle this, then, is to actually look in the code to see what's public and what types are possible where. E.g., Document.primary is public, and the values it can hold all extend MaybeDataWithLinks (if it's not empty), which has the public values getter.

te-online commented 6 years ago

Thank you for the heads-up.

Does this look correct? (Maybe not perfect, but I'd be satisfied with “quite correct” ;-))

const transformedApiRequest = Front.transformedAPIRequest((req, query) => {
  const origReturning = query.returning

  return query.resultsIn((...args) => {
    const origResult = origReturning(...args)

    let count = 1
    if(origResult.document && origResult.document.meta && origResult.document.meta.total) {
      count = origResult.document.meta.total
    } else if(origResult.document && origResult.document.primary) {
      count = origResult.document.primary.values.length
    }

    return {
      ...origResult,
      headers: {
        ...origResult.headers,
        'X-Total-Count': count,
        'Access-Control-Expose-Headers': 'X-Total-Count'
      },
    }
  })
})

I'll keep in mind to look at the source next time instead of spamming the issues.

ethanresnick commented 6 years ago

I think so.

What do you want X-Total-Count to be when the primary data is null (e.g., on an empty to-one relationship)? Right now, this code will make it zero, which is probably correct, because origResult.document.primary.values will be an empty array in that case.

Also, origResult.document.primary.values is always defined (if origResult.document.primary is), so you don't really need that check in your else if.

Another small bug is that if meta.total is zero, it'll fail the truthiness check in your if, and the count will be reported as 1.

te-online commented 6 years ago

Thanks for your post – I'm learning a lot here 😉

What do you want X-Total-Count to be when the primary data is null

Basically, the frontend only needs the x-total-count header for pagination and therefore only for multi-document-responses. I'm not particular sure which requests fall into the category that need this header right now, so I simply added it for all responses and – on purpose – chose it to be 1 if the value is not available. This is not a general solution, thanks for pointing this out!

That it will be 0 for empty-to-one-relationships is also fine by me, since there are no entries to show.

Also, origResult.document.primary.values is always defined

I fixed this my code and also in the snippet above for copy & pasters.

I'm wondering if there is a standard regarding the total-count of a collection. When you need to implement pagination you will also need to know how many documents exist to calculate the number of pages, right? So this is an essential information for most frontends.

ethanresnick commented 6 years ago

Thanks for your post – I'm learning a lot here 😉

Sure thing :)

I fixed this my code and also in the snippet above for copy & pasters.

👍

I'm wondering if there is a standard regarding the total-count of a collection.

There isn't a standard that I know of. I'd imagine there are some RDF vocabularies that cover this (and could be brought into JSON using JSON-LD), and maybe there are some other media types that have first class link templates, where you could express the total size as a constraint in your template's instructions for how the client should build links to individual pages. All of that's a bit beyond the scope of JSON:API, though.

I think the most natural way in JSON:API is a key in meta, which could be associated in the future with a formal specification if/when profile extensions happen. That's why this library defaults to putting the value in meta.total on requests where pagination is in use.