Skip to content

Commit

Permalink
Return 405 error for missing endpoints on route
Browse files Browse the repository at this point in the history
- Resolve task in Issue #48
  - Return a 405 error where applicable (the standard response when a
    request method is not allowed for a resource)
  - Return the mandatory (according to RFC 2616) 'Allow' response
    header with the full list of implemented methods for that route
  - Add and pass test for 405 error
  - Update tests that were expecting the standard Meteor static HTML
    response in this event to expect the 405 error instead
- Perform minor cosmetic code refactoring in Route
- Update change log to include all recently updated response codes
- Skip any README update for now, as the apiDoc will be coming very soon
  • Loading branch information
kahmali committed Jun 21, 2015
1 parent eb0a3fc commit d470f5b
Show file tree
Hide file tree
Showing 3 changed files with 137 additions and 86 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Change Log

## Unreleased

#### Fixed
- Issue #48:
- Return standard response codes in any autogenerated endpoints
- 201: Resource created (in `POST` collection endpoints)
- 403: Role permission errors
- 405: API method not found (but route exists)


## [v0.7.0] - 2015-06-18

**_WARNING!_ Potentially breaking changes! Please be aware when upgrading!**
Expand Down
161 changes: 82 additions & 79 deletions lib/route.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -9,76 +9,77 @@ class @Route

addToApi: ->
self = this

# Throw an error if a route has already been added at this path
# TODO: Check for collisions with paths that follow same pattern with different parameter names
if _.contains @api.config.paths, @path
throw new Error "Cannot add a route at an existing path: #{@path}"

# Configure each endpoint on this route
@_resolveEndpoints()
@_configureEndpoints()

# Add the path to our list of existing paths
@api.config.paths.push @path

# Setup endpoints on route using Iron Router
fullPath = @api.config.apiPath + @path

_.each @endpoints, (options, method) ->
JsonRoutes.add method, fullPath, (req, res, next) ->
# Add parameters in the URL and request body to the endpoint context
# TODO: Decide whether or not to nullify the copied objects. Makes sense to do it, right?
@urlParams = req.params
@queryParams = req.query
@bodyParams = req.body

# Run the requested endpoint
responseData = null

# Add the endpoint's resolved configuration options to its context
endpointContext = {};
_.extend endpointContext, options

# Add function to endpoint context for indicating a response has been initiated manually
responseInitiated = false
doneFunc = ->
responseInitiated = true

endpointContext =
urlParams: req.params
queryParams: req.query
bodyParams: req.body
request: req
response: res
done: doneFunc

try
responseData = self._callEndpoint endpointContext, options

if responseData is null or responseData is undefined
throw new Error "Cannot return null or undefined from an endpoint: #{method} #{fullPath}"
if res.headersSent and not responseInitiated
throw new Error "Must call this.done() after handling endpoint response manually: #{method} #{fullPath}"
catch error
# Do exactly what Iron Router would have done, to avoid changing the API
ironRouterSendErrorToResponse(error, req, res);
return

if responseInitiated
# Ensure the response is properly completed
res.end()
return

# Generate and return the http response, handling the different endpoint response types
if responseData.body and (responseData.statusCode or responseData.headers)
self._respond res, responseData.body, responseData.statusCode, responseData.headers
else
self._respond res, responseData


###
Convert all endpoints on the given route into our expected endpoint object if it is a bare function
methods = ['get', 'post', 'put', 'patch', 'delete', 'options']
allowedMethods = _.filter methods, (method) -> _.contains(_.keys(self.endpoints), method)
unallowedMethods = _.reject methods, (method) -> _.contains(_.keys(self.endpoints), method)

do =>

# Throw an error if a route has already been added at this path
# TODO: Check for collisions with paths that follow same pattern with different parameter names
if _.contains @api.config.paths, @path
throw new Error "Cannot add a route at an existing path: #{@path}"

# Configure each endpoint on this route
@_resolveEndpoints()
@_configureEndpoints()

# Add the path to our list of existing paths
@api.config.paths.push @path

# Setup endpoints on route
fullPath = @api.config.apiPath + @path
_.each allowedMethods, (method) ->
endpoint = self.endpoints[method]
JsonRoutes.add method, fullPath, (req, res, next) ->
# Add function to endpoint context for indicating a response has been initiated manually
responseInitiated = false
doneFunc = ->
responseInitiated = true

endpointContext =
urlParams: req.params
queryParams: req.query
bodyParams: req.body
request: req
response: res
done: doneFunc
# Add endpoint config options to context
_.extend endpointContext, endpoint

# Run the requested endpoint
responseData = null
try
responseData = self._callEndpoint endpointContext, endpoint
if responseData is null or responseData is undefined
throw new Error "Cannot return null or undefined from an endpoint: #{method} #{fullPath}"
if res.headersSent and not responseInitiated
throw new Error "Must call this.done() after handling endpoint response manually: #{method} #{fullPath}"
catch error
# Do exactly what Iron Router would have done, to avoid changing the API
ironRouterSendErrorToResponse(error, req, res);
return

if responseInitiated
# Ensure the response is properly completed
res.end()
return

# Generate and return the http response, handling the different endpoint response types
if responseData.body and (responseData.statusCode or responseData.headers)
self._respond res, responseData.body, responseData.statusCode, responseData.headers
else
self._respond res, responseData
_.each unallowedMethods, (method) ->
JsonRoutes.add method, fullPath, (req, res, next) ->
responseData = status: 'error', message: 'API endpoint does not exist'
headers = 'Allow': allowedMethods.join(', ').toUpperCase()
self._respond res, responseData, 405, headers


###
Convert all endpoints on the given route into our expected endpoint object if it is a bare
function
@param {Route} route The route the endpoints belong to
###
Expand All @@ -92,12 +93,13 @@ class @Route
###
Configure the authentication and role requirement on an endpoint
Once it's globally configured in the API, authentication can be required on an entire route or individual
endpoints. If required on an entire route, that serves as the default. If required in any individual endpoints, that
will override the default.
Once it's globally configured in the API, authentication can be required on an entire route or
individual endpoints. If required on an entire route, that serves as the default. If required in
any individual endpoints, that will override the default.
After the endpoint is configured, all authentication and role requirements of an endpoint can be accessed at
<code>endpoint.authRequired</code> and <code>endpoint.roleRequired</code>, respectively.
After the endpoint is configured, all authentication and role requirements of an endpoint can be
accessed at <code>endpoint.authRequired</code> and <code>endpoint.roleRequired</code>,
respectively.
@param {Route} route The route the endpoints belong to
@param {Endpoint} endpoint The endpoint to configure
Expand Down Expand Up @@ -148,9 +150,9 @@ class @Route
###
Authenticate the given endpoint if required
Once it's globally configured in the API, authentication can be required on an entire route or individual
endpoints. If required on an entire endpoint, that serves as the default. If required in any individual endpoints, that
will override the default.
Once it's globally configured in the API, authentication can be required on an entire route or
individual endpoints. If required on an entire endpoint, that serves as the default. If required
in any individual endpoints, that will override the default.
@returns False if authentication fails, and true otherwise
###
Expand Down Expand Up @@ -191,7 +193,8 @@ class @Route
Must be called after _authAccepted().
@returns True if the authenticated user belongs to <i>any</i> of the acceptable roles on the endpoint
@returns True if the authenticated user belongs to <i>any</i> of the acceptable roles on the
endpoint
###
_roleAccepted: (endpointContext, endpoint) ->
if endpoint.roleRequired
Expand Down
52 changes: 45 additions & 7 deletions test/api_tests.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,31 @@ Meteor.startup ->


describe 'A collection route', ->
it 'should be able to exclude endpoints using just the excludedEndpoints option', (test) ->
Restivus.addCollection new Mongo.Collection('tests2'),
it 'should be able to exclude endpoints using just the excludedEndpoints option', (test, next) ->
Restivus.addCollection new Mongo.Collection('test-excluded-endpoints'),
excludedEndpoints: ['get', 'getAll']

# Since the endpoints don't exist, they just return the Meteor HTML code
result = HTTP.get 'http://localhost:3000/api/v1/tests2/10'
test.isTrue /__meteor_runtime_config__/.test(result.content)
HTTP.get 'http://localhost:3000/api/v1/test-excluded-endpoints/10', (error, result) ->
response = JSON.parse result.content
test.isTrue error
test.equal result.statusCode, 405
test.equal response.status, 'error'
test.equal response.message, 'API endpoint does not exist'

result = HTTP.get 'http://localhost:3000/api/v1/tests2/'
test.isTrue /__meteor_runtime_config__/.test(result.content)
HTTP.get 'http://localhost:3000/api/v1/test-excluded-endpoints/', (error, result) ->
response = JSON.parse result.content
test.isTrue error
test.equal result.statusCode, 405
test.equal response.status, 'error'
test.equal response.message, 'API endpoint does not exist'

# Make sure it doesn't exclude any endpoints it shouldn't
HTTP.post 'http://localhost:3000/api/v1/test-excluded-endpoints/', data: test: 'abc', (error, result) ->
response = JSON.parse result.content
test.equal result.statusCode, 201
test.equal response.status, 'success'
test.equal response.data.test, 'abc'
next()

context 'with the default autogenerated endpoints', ->
Restivus.addCollection new Mongo.Collection('testAutogen')
Expand Down Expand Up @@ -170,6 +185,29 @@ Meteor.startup ->
test.isTrue result
next()

it 'should return a 405 error if that method is not implemented on the route', (test, next) ->
Restivus.addCollection new Mongo.Collection('test-method-not-implemented'),
excludedEndpoints: ['get', 'getAll']

HTTP.get 'http://localhost:3000/api/v1/test-method-not-implemented/', (error, result) ->
response = JSON.parse result.content
test.isTrue error
test.equal result.statusCode, 405
test.isTrue result.headers['allow'].indexOf('POST') != -1
test.isTrue result.headers['allow'].indexOf('DELETE') != -1
test.equal response.status, 'error'
test.equal response.message, 'API endpoint does not exist'

HTTP.get 'http://localhost:3000/api/v1/test-method-not-implemented/10', (error, result) ->
response = JSON.parse result.content
test.isTrue error
test.equal result.statusCode, 405
test.isTrue result.headers['allow'].indexOf('PUT') != -1
test.isTrue result.headers['allow'].indexOf('DELETE') != -1
test.equal response.status, 'error'
test.equal response.message, 'API endpoint does not exist'
next()

it 'should cause an error when it returns null', (test, next) ->
Restivus.addRoute 'testNullResponse',
get: ->
Expand Down

0 comments on commit d470f5b

Please sign in to comment.