diff --git a/CHANGELOG.md b/CHANGELOG.md index 37b3271..54f4961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## Unreleased + +#### Fixed +- Issue #20: Allow [manual response](https://github.com/kahmali/meteor-restivus#thisresponse) in + endpoints using underlying [Node response object](https://nodejs.org/api/http.html#http_class_http_serverresponse). + ## [v0.6.2] - 2015-03-04 #### Fixed diff --git a/README.md b/README.md index 4df0bcc..0759fa0 100644 --- a/README.md +++ b/README.md @@ -926,30 +926,53 @@ In the above examples, all the endpoints except the GETs will require [authentic Each endpoint has access to: ##### `this.user` +- _Meteor.user_ - The authenticated `Meteor.user`. Only available if `useAuth` and `authRequired` are both `true`. If not, it will be `undefined`. ##### `this.userId` +- _String_ - The authenticated user's `Meteor.userId`. Only available if `useAuth` and `authRequired` are both `true`. If not, it will be `undefined`. ##### `this.urlParams` +- _Object_ - Non-optional parameters extracted from the URL. A parameter `id` on the path `posts/:id` would be available as `this.urlParams.id`. ##### `this.queryParams` +- _Object_ - Optional query parameters from the URL. Given the url `https://yoursite.com/posts?likes=true`, `this.queryParams.likes => true`. ##### `this.bodyParams` +- _Object_ - Parameters passed in the request body. Given the request body `{ "friend": { "name": "Jack" } }`, `this.bodyParams.friend.name => "Jack"`. ##### `this.request` -- The [Node request object][node-request] +- [_Node request object_][node-request] ##### `this.response` -- The [Node response object][node-response] +- [_Node response object_][node-response] +- If you handle the response yourself using `this.response.write()` or `this.response.writeHead()` + you **must** call `this.done()`. In addition to preventing the default response (which will throw + an error if you've initiated the response yourself), it will also close the connection using + `this.response.end()`, so you can safely omit that from your endpoint. + +##### `this.done()` +- _Function_ +- **Must** be called after handling the response manually with `this.response.write()` or + `this.response.writeHead()`. This must be called immediately before returning from an endpoint. + ```javascript + Restivus.addRoute('manualResponse', { + get: function () { + console.log('Testing manual response'); + this.response.write('This is a manual response'); + this.done(); // Must call this immediately before return! + } + }); + ``` ### Response Data diff --git a/lib/route.coffee b/lib/route.coffee index 97fecc4..d1da880 100644 --- a/lib/route.coffee +++ b/lib/route.coffee @@ -19,19 +19,24 @@ class @Route @_resolveEndpoints() @_configureEndpoints() - # Append the path to the base API path - fullPath = @api.config.apiPath + @path + # 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 Router.route fullPath, where: 'server' action: -> - # Flatten parameters in the URL and request body (and give them better names) + # 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 = @params @queryParams = @params.query @bodyParams = @request.body + # Add function to endpoint context for indicating a response has been initiated manually + @done = => + @_responseInitiated = true + # Respond to the requested HTTP method if an endpoint has been provided for it method = @request.method if method is 'GET' and self.endpoints.get @@ -49,6 +54,16 @@ class @Route else responseData = {statusCode: 404, body: {status: "error", message:'API endpoint not found'}} + if responseData is null or responseData is undefined + throw new Error "Cannot return null or undefined from an endpoint: #{method} #{fullPath}" + if @response.headersSent and not @_responseInitiated + throw new Error "Must call this.done() after handling endpoint response manually: #{method} #{fullPath}" + + if @_responseInitiated + # Ensure the response is properly completed + @response.end() + return + # Generate and return the http response, handling the different endpoint response types if responseData.body and (responseData.statusCode or responseData.headers) responseData.statusCode or= 200 @@ -57,9 +72,6 @@ class @Route else self._respond this, responseData - # Add the path to our list of existing paths - @api.config.paths.push @path - ### Convert all endpoints on the given route into our expected endpoint object if it is a bare function diff --git a/test/api_tests.coffee b/test/api_tests.coffee index c3aae79..53a61ce 100644 --- a/test/api_tests.coffee +++ b/test/api_tests.coffee @@ -1,7 +1,7 @@ if Meteor.isServer Meteor.startup -> - describe 'A Restivus API', -> + describe 'An API', -> context 'that hasn\'t been configured', -> it 'should have default settings', (test) -> test.equal Restivus.config.apiPath, 'api/' @@ -90,18 +90,80 @@ if Meteor.isServer test.equal response.message, 'API endpoint not found' next() -# describe 'A route', -> -# context 'that has been authenticated', -> -# it 'should have access to this.user and this.userId', (test) -> + describe 'An endpoint', -> + + it 'should cause an error when it returns null', (test, next) -> + Restivus.addRoute 'testNullResponse', + get: -> + null + + HTTP.get 'http://localhost:3000/api/v1/testNullResponse', (error, result) -> + test.isTrue error + test.equal result.statusCode, 500 + next() + + it 'should cause an error when it returns undefined', (test, next) -> + Restivus.addRoute 'testUndefinedResponse', + get: -> + undefined + + HTTP.get 'http://localhost:3000/api/v1/testUndefinedResponse', (error, result) -> + test.isTrue error + test.equal result.statusCode, 500 + next() + + it 'should be able to handle it\'s response manually', (test, next) -> + Restivus.addRoute 'testManualResponse', + get: -> + @response.write 'Testing manual response.' + @response.end() + @done() + + HTTP.get 'http://localhost:3000/api/v1/testManualResponse', (error, result) -> + response = result.content + + test.equal result.statusCode, 200 + test.equal response, 'Testing manual response.' + next() + + it 'should not have to call this.response.end() when handling the response manually', (test, next) -> + Restivus.addRoute 'testManualResponseNoEnd', + get: -> + @response.write 'Testing this.end()' + @done() + HTTP.get 'http://localhost:3000/api/v1/testManualResponseNoEnd', (error, result) -> + response = result.content + + test.isFalse error + test.equal result.statusCode, 200 + test.equal response, 'Testing this.end()' + next() + it 'should be able to send it\'s response in chunks', (test, next) -> + Restivus.addRoute 'testChunkedResponse', + get: -> + @response.write 'Testing ' + @response.write 'chunked response.' +# @done() + + HTTP.get 'http://localhost:3000/api/v1/testChunkedResponse', (error, result) -> + response = result.content + + test.equal result.statusCode, 200 + test.equal response, 'Testing chunked response.' + next() + it 'should respond with an error if this.done() isn\'t called after response is handled manually', (test, next) -> + Restivus.addRoute 'testManualResponseWithoutDone', + get: -> + undefined -#Tinytest.add 'A route - should be configurable', (test)-> -# Restivus.configure -# apiPath: '/api/v1' -# prettyJson: true -# auth: -# token: 'apiKey' -# -# test.equal Restivus.config.apiPath, '/api/v1' + HTTP.get 'http://localhost:3000/api/v1/testManualResponseWithoutDone', (error, result) -> + test.isTrue error + test.equal result.statusCode, 500 + next() + + +# context 'that has been authenticated', -> +# it 'should have access to this.user and this.userId', (test) ->