├── .babelrc ├── .gitignore ├── .tern-project ├── .travis.yml ├── CHANGELOG.md ├── CNAME ├── LICENSE.md ├── Makefile ├── README.md ├── book.json ├── docs ├── README.md ├── __intro.md ├── _home.md ├── api │ ├── README.md │ ├── Response.md │ ├── internal.md │ ├── node-middleware.md │ ├── node.md │ ├── request-response-map.md │ └── spirit.md ├── async.md ├── defining-routes.md ├── error-handling.md ├── flow-chart.png ├── getting-started.md ├── grouping-routes.md ├── rendering-routes.md ├── request.md ├── response.md ├── return-from-routes.md ├── using-middleware.md └── writing-middleware.md ├── index.js ├── package.json ├── spec ├── core │ ├── core-spec.js │ └── promise_utils-spec.js ├── http │ ├── node_adapter-spec.js │ ├── request-spec.js │ ├── response-spec.js │ ├── response_class-spec.js │ └── utils-spec.js ├── spirit-spec.js └── support │ ├── custom-errors.js │ ├── jasmine.json │ └── mock-response.js ├── src ├── core │ ├── core.js │ └── promise_utils.js └── http │ ├── node_adapter.js │ ├── request.js │ ├── response-class.js │ ├── response.js │ └── utils.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"], 3 | "plugins": [] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _book 2 | lib 3 | node_modules 4 | notes 5 | benchmarks/runner 6 | coverage/ 7 | .DS_Store 8 | npm-debug.log 9 | package-lock.json 10 | 11 | *.log 12 | -------------------------------------------------------------------------------- /.tern-project: -------------------------------------------------------------------------------- 1 | { 2 | "ecmaVersion": 6, 3 | "libs": [], 4 | "plugins": { 5 | "jasmine": {}, 6 | "node": {} 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | - "6" 5 | - "5" 6 | - "4.8" 7 | script: "make test-ci" 8 | after_script: "npm install coveralls@3.0.0 && cat ./coverage/lcov.info | coveralls" -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.6.0 2 | #### Release Date: 03-29-2017 3 | - http request map properly sets it's .url to _exclude_ the query string 4 | - request map .path added for the full url including query string 5 | - Promise warnings regarding async error handling have been fixed. `resolve_response` has been removed. `callp_response` added, which are similar. 6 | ##### spirit-router -> v0.5.0 7 | - spirit-router support for .url change, routes will __no__ longer support query string matches (this was unintentional). Please see [issue](https://github.com/spirit-js/spirit-router/issues/5) 8 | - spirit-router supports defining routes with regex `.get(/[a-z]{1,3}/, ...)` 9 | 10 | ## v0.4.0 11 | #### Release Date: 11-03-2016 12 | - Response#type will auto convert body to JSON when type is “json” 13 | - spirit-router -> v0.4.0 14 | - spirit-router not_found now goes through render() process 15 | - spirit-router not_found optionally takes a 'method' argument 16 | 17 | ## v0.3.0 18 | #### Release Date: 09-19-2016 19 | - spirit.node.adapter now automatically sets Content-Length response header when none is set (for string / buffer bodies). It is recommended to let the adapter do this instead of setting the Content-Length manually. 20 | 21 | ## v0.2.2 22 | #### Release Date: 09-12-2016 23 | - `spirit.node.adapter` now also accepts a single function as it's middleware argument 24 | 25 | ## v0.2.0 26 | #### Release Date: 08-29-2016 27 | - API has underwent minor changes: 28 | - `spirit.is_promise` is still there, but no longer covered under docs (considered internal api) 29 | - _All_ API under `spirit.node.utils` is now considered internal, they are still documented however 30 | - `spirit.node.is_Response` has moved to `spirit.node.utils.is_Response` 31 | - `spirit.node.middlewares` are moved to `spirit-common`. 32 | 33 | The reason for the change is to slim down the public API to only the essentials. The functions affected were all utility functions (and simple in functionality) and mostly used internally, but was and still is provided as a convenience. 34 | 35 | - `**` spirit.node.adapter initialized middlewares on every request, this wasn't intended, it's been changed. 36 | 37 | - `**` spirit.compose didn't pass arguments (other than the first) along to the next middleware, this wasn't intended, it's been changed. 38 | 39 | - New module for bootstrapping common middleware `spirit-common`, instead of pulling in 3, 4, 5 middleware all the time, just pull in one which sets it all up. 40 | 41 | `**` These changes are not considered a bug as all previous versions supported this, so no patch for v0.1.x. Instead it's considered a breaking change and added to v0.2.0. 42 | 43 | Other official spirit libraries are updated to support this release (they are scheduled to be released after this spirit release): 44 | - spirit-router -> v0.2.x 45 | - spirit-express -> v0.2.x 46 | - spirit-common -> v0.1.x (first version) 47 | 48 | ## v0.1.2 49 | #### Release Date: 08-18-2016 50 | - Added Response.cookie 51 | - Removed Response.location 52 | - Fix Response.attachment wasn’t returning itself (this) 53 | - Fix When production ENV was set and an error occured in which http adapter had to handle, it would strip the body but not it’s Content-Length. This would make it seem like the request froze (or client froze). 54 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | spirit.function.run 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ISC License (ISC) 2 | Copyright (c) 2016, hnry 3 | 4 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 5 | 6 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | COLOR = \033[1;33m 2 | COLOR_RESET = \033[0m 3 | 4 | default: build 5 | 6 | bench: build 7 | @NODE_ENV=production go run benchmarks/runner.go 8 | 9 | clean: 10 | @if [ -a lib ] ; \ 11 | then \ 12 | rm -r lib/ ; \ 13 | fi; 14 | 15 | build: 16 | @echo "Building src..." 17 | @node_modules/.bin/babel src -d lib 18 | 19 | release: 20 | make clean 21 | make test 22 | 23 | watch: 24 | @echo "Auto building..." 25 | node_modules/.bin/babel src -d lib 26 | 27 | test: build 28 | @echo "\n$(COLOR)Running tests...$(COLOR_RESET)" 29 | @node_modules/.bin/jasmine ${file} 30 | @echo "\n" 31 | 32 | test-ci: build 33 | @node_modules/.bin/istanbul cover -x "**/spec/**" node_modules/jasmine/bin/jasmine.js 34 | 35 | doc-watch: doc-build 36 | node_modules/.bin/gitbook serve 37 | 38 | doc-build: 39 | @rm -rf _book 40 | node_modules/.bin/gitbook install 41 | node_modules/.bin/gitbook build 42 | 43 | doc: doc-build 44 | cd _book && cp ../CNAME . && git init && git commit --allow-empty -m "make doc" && git checkout -b gh-pages && git add . && git commit -am "make doc" && git push git@github.com:spirit-js/spirit gh-pages --force 45 | 46 | 47 | .PHONY: default build test watch bench test-ci clean release 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spirit 2 | 3 | Modern and functional approach to building web applications. 4 | 5 | [![Build Status](https://travis-ci.org/spirit-js/spirit.svg?branch=master)](https://travis-ci.org/spirit-js/spirit) 6 | [![Coverage Status](https://coveralls.io/repos/github/spirit-js/spirit/badge.svg?branch=master)](https://coveralls.io/github/spirit-js/spirit?branch=master) 7 | [![Join the chat at https://gitter.im/spirit-js/spirit](https://badges.gitter.im/spirit-js/spirit.svg)](https://gitter.im/spirit-js/spirit?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 8 | 9 | ## Why? 10 | 11 | ```js 12 | const {adapter} = require("spirit").node 13 | const route = require("spirit-router") 14 | const http = require('http') 15 | 16 | const hello = () => "Hello World!" 17 | 18 | const greet = (name) => `Hello, ${name}` 19 | 20 | const app = route.define([ 21 | route.get("/", hello), 22 | route.get("/:name", ["name"], greet), 23 | ]) 24 | 25 | http.createServer(adapter(app)).listen(3000) 26 | ``` 27 | 28 | If we think about a web request in its simplest form, it's basically a function, it takes an input (request) and _returns_ an output (response). So why not write web applications this way? 29 | 30 | No more `req`, `res`. Spirit simplifies everything by abstracting away the complexity of `req` and `res` that normally resulted in impure and complex functions. 31 | 32 | Middleware in spirit can also transform the _returned_ response and not just the request. This is in contrast to other web libraries that can only transform the request. This is a simple idea but having this feature allows for much more DRY and expressive code. 33 | 34 | Given the above, it's much more easier to re-use, test, and reason about your code in spirit. 35 | 36 | Oh yea, most [Express middleware works in spirit](https://github.com/spirit-js/spirit-express) too! 37 | 38 | ## Getting Started 39 | 40 | [The Handbook](http://spirit.function.run/) 41 | 42 | - [collection of Examples](https://github.com/spirit-js/examples) 43 | - [spirit API Docs](docs/api) 44 | - [spirit + spirit-router Guide](https://github.com/spirit-js/spirit-router/tree/master/docs/Guide.md) 45 | - [Introduction Video](https://www.youtube.com/watch?v=YvxLBd12ZX8&list=PLHw25bReXDKvHd-5mCjMxVkgDvWrx5IFY) 46 | 47 | ### Components 48 | - `spirit` is a small library for composing functions and creating abstractions. Abstractions are defined in a "spirit adapter". Currently it comes with 1 builtin, the node adapter (`spirit.node`) for use with node.js' http module. Eventually there will be another one written for spirit to run in the browser. 49 | 50 | - [`spirit-router`](https://github.com/spirit-js/spirit-router) is a library for routing and creating routes. 51 | 52 | - [`spirit-common`](https://github.com/spirit-js/spirit-common) is a library that provides many common http related middleware. It's purpose is to make bootstrapping a multitude of middleware that everyone will need easier. 53 | 54 | - [`spirit-express`](https://github.com/spirit-js/spirit-express), is a library for converting most Express middleware to work in spirit. 55 | 56 | ### Third Party Components 57 | - [`spirit-body`](https://github.com/dodekeract/spirit-body) is a simple body parser middleware for spirit. Alternative to using `spirit-common` which wraps the Express body-parser module. 58 | 59 | ## Contributing 60 | 61 | All contributions are appreciated and welcomed. 62 | 63 | For backwards incompatible changes, or large changes, it would be best if you opened an issue before hand to outline your plans (to avoid conflict later on). 64 | 65 | This codebase avoids using unnecessary semi-colons, camelCase and one-liners. 66 | 67 | To run tests, use `make test`. This will also build changes to `src/*`, if you do not have `make` installed, you can look at the [Makefile](/Makefile) to see the steps to accomplish the task. 68 | 69 | ## Credits 70 | 71 | Spirit is heavily influenced by the design of [Ring](https://github.com/ring-clojure/ring). 72 | -------------------------------------------------------------------------------- /book.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "docs/", 3 | "title": "spirit", 4 | "description": "javascript web library", 5 | "structure": { 6 | "readme": "_home.md", 7 | "summary": "README.md" 8 | }, 9 | "plugins": [ "github", "ga", "anchorjs" ], 10 | "pluginsConfig": { 11 | "github": { 12 | "url": "https://github.com/spirit-js" 13 | }, 14 | "ga": { 15 | "token": "UA-83795647-1" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | * [Home](_home.md) 4 | 5 | ------- 6 | 7 | * [Getting Started](getting-started.md) 8 | * [Request](request.md) 9 | * [Response](response.md) 10 | * [Async Routes](async.md) 11 | * [Defining Routes](defining-routes.md) 12 | * [Grouping Routes](grouping-routes.md) 13 | * [Return From Routes](return-from-routes.md) 14 | * [Using Middleware](using-middleware.md) 15 | * [Writing Middleware](writing-middleware.md) 16 | * [Error Handling](error-handling.md) 17 | * [Rendering Routes](rendering-routes.md) 18 | -------------------------------------------------------------------------------- /docs/__intro.md: -------------------------------------------------------------------------------- 1 | ## Introduction 2 | What makes spirit unique from other web libraries or frameworks is it's separation of http code and regular javascript (your actual code). 3 | 4 | No more hard coding `req` and `res` objects into your web application. This makes your code more reusuable, testable, and more isomorphic. 5 | 6 | There are no such things as "route handlers". You simply define routes, which are a way of describing how to _call_ your regular javascript function in the context of a web request. 7 | 8 | Routes are thought of as definitions more than they are a logic, they also can be composed and _re-used_ on top of each other. 9 | 10 | Also middleware in spirit can act on __both__ the input (request) and the output (response) of a web request. This makes transforming both input and output much easier. 11 | 12 | Handling errors in spirit are not a burden like in other libraries, they can actually be used as an integral part of your web app and simplify your code. 13 | 14 | ### spirit components 15 | 16 | - `spirit` is a small library for composing functions and creating abstractions. Abstractions are defined in a "spirit adapter". Currently it comes with 1 builtin, the node adapter (`spirit.node`) for use with node.js's http modules. Eventually there will be another one written for spirit to run in the browser. 17 | 18 | - `spirit-router` is a library for routing and creating routes. 19 | 20 | - `spirit-common` is a library that provides many common http related middleware. It's purpose is to make bootstrapping a multitude of middleware that everyone will need easier. 21 | 22 | - Additionally there is `spirit-express`, which is a library for converting most Express middleware to work in spirit. 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /docs/_home.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /docs/api/README.md: -------------------------------------------------------------------------------- 1 | ## spirit API 2 | This covers spirit's exported API. 3 | 4 | - [compose](spirit.md#compose) 5 | - [callp](spirit.md#callp) 6 | 7 | node http adapter (`spirit.node`) requires you to be familiar with [request map](request-response-map.md#request-map) and [response map](request-response-map.md#response-map). 8 | 9 | - node 10 | * [adapter](node.md#adapter) 11 | * [make_stream](node.md#make_stream) 12 | * [response](node.md#response) 13 | * [file_response](node.md#file_response) 14 | * [err_response](node.md#err_response) 15 | * [redirect](node.md#redirect) 16 | * [Response](Response.md) 17 | * [is_response](node.md#is_response) 18 | 19 | 20 | #### Internal API 21 | The following are internal API that are exported and documented. 22 | 23 | `spirit.node.utils` namespace: 24 | 25 | - [is_Response](internal.md#is_Response) 26 | - [size_of](internal.md#size_of) 27 | - [type_of](internal.md#type_of) 28 | - [resolve_response](internal.md#resolve_response)] (Removed in v0.6.0) 29 | - [callp_response](internal.md#callp_response) 30 | -------------------------------------------------------------------------------- /docs/api/Response.md: -------------------------------------------------------------------------------- 1 | Properties: 2 | 3 | - [status](#properties) 4 | - [headers](#properties) 5 | - [body](#properties) 6 | 7 | Methods: 8 | 9 | - [field](#field) 10 | - [get](#get) 11 | - [set](#set) 12 | 13 | - [status_](#status_) 14 | - [body_](#body_) 15 | - [type](#type) 16 | - [len](#len) 17 | - [cookie](#cookie) 18 | - [attachment](#attachment) 19 | 20 | 21 | # Response 22 | 23 | A Response is a helper class for creating a response map. It satisfies the requirements of a response map, providing the properties or keys `{ status, headers, body }`, but it also provides chainable helpers for common tasks. 24 | 25 | Normally you do not need to create objects with Response directly. A Response usually comes from functions such as [spirit.node.response](node.md#response), [spirit.node.file_response](node.md#file_response), [spirit.node.redirect](node.md#redirect) etc, which set up additional properties for you. 26 | 27 | When working with a Response directly, it does not provide any special setup except that the `status` property always defaults to 200. 28 | 29 | When you pass an argument to the Response constructor, the argument is then set as it's body. 30 | 31 | For example: 32 | 33 | ```js 34 | const resp = new Response("hi") 35 | // resp = { 36 | // status: 200, 37 | // headers: {}, 38 | // body: "hi" 39 | // } 40 | ``` 41 | 42 | [Source: src/http/response-class.js](../../src/http/response-class.js) 43 | 44 | ## Properties 45 | Response only has 3 properties, which are the same 3 properties needed to satisfy a response map. 46 | 47 | * status {Number} The status code for this response 48 | * headers {object} A key, value object for response headers. All headers should be unique regardless of case. 49 | * body {undefined|string|buffer|stream} The body of the response. Must be either be (or eventually be) undefined, string, buffer or a stream. 50 | 51 | 52 | 53 | ## Methods 54 | 55 | #### field 56 | 57 | Static method. 58 | 59 | field is used internally to resolve header names without relying on knowing the case used. It should be rarely needed to be called directly. 60 | 61 | Looks up `key` from `response`. Specifically response.headers. Expects `response` to be a valid response map. 62 | 63 | The `key` can be case insensitive, and will return the header matching the key in it's original case. 64 | 65 | ##### Arguments 66 | * response {response map} An object that conforms to a response map (which Response is) 67 | * key {string} case insensitive string used to look up a header field in response 68 | 69 | ##### Return 70 | {*} The header field that matches _key_ in _response_'s headers or undefined if it doesn't exist. 71 | 72 | 73 | ------------------------------------------- 74 | 75 | 76 | #### get 77 | 78 | Can be a static method, as well as an instance method. 79 | 80 | When used as an instance method, ignore the first parameter `response` as it will always refer to `this`. 81 | 82 | Gets the header value of `key` from `response`, where `key` is the header field name and `response` is a valid response map. 83 | 84 | `key` is case insensitive. 85 | 86 | Example as instance method: 87 | ```js 88 | const resp = new Response() 89 | resp.headers["ABC"] = "123" // assign a header 90 | 91 | resp.get("abc") // #=> "123" 92 | ``` 93 | 94 | Example as static method: 95 | ```js 96 | const resp = { status: 200, headers: { ABC: "123" } } 97 | Response.get(resp, "abc") // #=> "123" 98 | ``` 99 | 100 | Of course you get a header's values simply by referring to the properties on response, `resp.headers["abc"]`, which is the same thing as this method but `get()` is case insensitive. 101 | 102 | 103 | ##### Arguments 104 | * response {response map} An object that conforms to a response map (which Response is) 105 | * key {string} case insensitive string representing the header field in a response 106 | 107 | ##### Return 108 | {*} The value of the `key` in `response`'s headers or undefined if it doesn't exist. 109 | 110 | 111 | ------------------------------------------- 112 | 113 | 114 | #### set 115 | 116 | Can be a static method, as well as an instance method. 117 | 118 | When used as an instance method, ignore the first parameter `response` as it will always refer to `this`. 119 | 120 | `key` is a header field name that exists in `response`, it is case insensitive. It sets `value` for `key`. It will override an existing value if one already exists. 121 | 122 | Example as instance method: 123 | ```js 124 | const resp = new Response() 125 | 126 | resp.set("http-header", "value") 127 | // #=> { status: 200, headers: { http-header: "value" } } 128 | ``` 129 | 130 | Example as static method: 131 | ```js 132 | const resp = { status: 200, headers: { a: "a" } } 133 | 134 | Response.set(resp, "a", "b") 135 | // #=> { status: 200, headers: { a: "b" } } 136 | ``` 137 | 138 | Of course one can always set a header, by just setting a property on a response: 139 | ```js 140 | const resp = new Response() 141 | resp.headers["abc"] = "123" 142 | ``` 143 | The difference is `set()` is case insensitive, and a chainable method. 144 | 145 | 146 | ##### Arguments 147 | * response {response map} An object that conforms to a response map (which Response is) 148 | * key {string} Case insensitive string representing the header field in a response 149 | * value {string} A string representing the header value for the header field (`key`) 150 | 151 | ##### Return 152 | {Response | response} The Response is returned when it's an instance method, or the passed in `response` is returned when a static method 153 | 154 | 155 | ------------------------------------------- 156 | 157 | 158 | #### status_ 159 | 160 | Sets the status of the response. The trailing _ in it's name is to denote it's a chainable method as oppose to the the property "status". 161 | 162 | ```js 163 | const resp = new Response() 164 | resp.status = 123 // using the property to set the status code 165 | resp.status_(123) // using this chainable method to set the status code 166 | ``` 167 | 168 | ##### Arguments 169 | * status {number} The status code to set the response to 170 | 171 | ##### Return 172 | {Response} 173 | 174 | 175 | ------------------------------------------- 176 | 177 | 178 | #### body_ 179 | 180 | Sets the body of the response. The trailing _ in it's name is to denote it's a chainable method as oppose to the the property "body". 181 | 182 | When using `body_`, it will __always__ modify the Content-Length header to be the size of the new body being set when the new body is a string or buffer. 183 | 184 | And in the event it cannot calculate one, it will remove the Content-Length (more specifically Content-Length will be undefined). 185 | 186 | ```js 187 | const resp = new Response("first body") 188 | // => { status: 200, headers: {}, body: "first body" } 189 | 190 | // using the property 191 | resp.body = "second body" 192 | // => { status: 200, headers: {}, body: "second body" } 193 | 194 | // using body_() 195 | resp.body_("third body") 196 | // => { status: 200, headers: { Content-Length: 10 }, body: "third body" } 197 | ``` 198 | 199 | ##### Arguments 200 | * body {undefined|string|buffer|stream} The body of the response 201 | 202 | ##### Return 203 | {Response} 204 | 205 | 206 | ------------------------------------------- 207 | 208 | 209 | #### type 210 | 211 | Sets the Content-Type header of the response. 212 | 213 | The `value` passed in will be looked up with the npm library [mime](https://www.npmjs.com/package/mime#mimelookuppath). So using shorthand mime types is ok. 214 | 215 | If the mime library fails to find a type, it will set `value` as-is as the Content-Type. 216 | 217 | For "text/*" content types, a utf-8 charset are automatically added. 218 | 219 | If `value` is `undefined`, it will clear the current value (effectively removing the header). 220 | 221 | ```js 222 | const resp = new Response() 223 | 224 | resp.type("txt") 225 | // => { status: 200, headers: { Content-Type: "text/plain; charset=utf-8" } } 226 | 227 | resp.type("json") 228 | // => { status: 200, headers: { Content-Type: "application/json" } } 229 | 230 | resp.type("meow") 231 | // => { status: 200, headers: { Content-Type: "meow" } } 232 | ``` 233 | 234 | 235 | ##### Arguments 236 | * value {string|undefined} Value to set Content-Type headers, either short-hand or literally. 237 | 238 | ##### Return 239 | {Response} 240 | 241 | 242 | ------------------------------------------- 243 | 244 | 245 | #### len 246 | 247 | Sets the Content-Length header of the response to the specified `value`. 248 | 249 | ```js 250 | const resp = new Response() 251 | resp.len(123) 252 | // #=> { status: 200, headers: { Content-Length: 123 } } 253 | ``` 254 | 255 | NOTE: It does not take into account the body, or calculates the body's size. It literally sets `value` for Content-Length. If you prefer to have the Content-Length set based on the response body's size, see [body_](#body_), which will automatically set it for you whenever possible. 256 | 257 | If `value` is `undefined` or `0`, it will clear the current value (effectively removing the header). 258 | 259 | ##### Arguments 260 | * value {number|undefined} The value to set for Content-Length header 261 | 262 | ##### Return 263 | {Response} 264 | 265 | 266 | ------------------------------------------- 267 | 268 | 269 | #### cookie 270 | 271 | Sets a single cookie for the response. If multiple cookies need to be set, call it multiple times. 272 | 273 | The `value` is encoded by default with encodeURIComponent. 274 | 275 | The `options` argument is optional and is an object that can have the following properties: 276 | * path {string} restrict the cookie to a specific path 277 | * domain {string} restrict the cookie to a specific domain 278 | * httponly {boolean} restrict the cookie to HTTP if true (not accessible via e.g. JavaScript) 279 | * maxage {string|number} the number of seconds until the cookie expires 280 | * secure {boolean} restrict the cookie to HTTPS URLs if true 281 | * expires {Date} a specific date and time the cookie expires 282 | * encode {function} a _optional_ function to use for encoding `value`, by default it uses encodeURIComponent 283 | 284 | ```js 285 | const resp = new Response() 286 | // #=> { status: 200, headers: {} } 287 | 288 | resp.cookie("ash", "pikachu") 289 | // #=> { status: 200, headers: { Set-Cookie: [ "ash=pikachu" ] } } 290 | 291 | resp.cookie("brock", "onyx", { expires: 3600 }) 292 | // #=> { status: 200, headers: { Set-Cookie: [ "ash=pikachu", "brock=onyx; Expires=3600" ] } } 293 | ``` 294 | 295 | It's important to note Set-Cookie is an an array. 296 | 297 | To clear (or remove) a cookie that's been set, `value` should be `undefined`, or simply omit a `value`. 298 | 299 | Continuing the example above: 300 | ```js 301 | resp.cookie("ash") 302 | #=> { status: 200, headers: { Set-Cookie: [ "brock=onyx; Expires=3600" ] } } 303 | ``` 304 | Which removes the "ash=pikachu" cookie, leaving only the "brock-onyx" cookie. 305 | 306 | It should also be noted the `name` __and__ `options.path` are both used for removing cookies, so they must match the cookie exactly. 307 | 308 | Also note that duplicate cookies will all be sent to the client, most clients will take the last cookie sent as the correct cookie to store if there are duplicates. 309 | 310 | ##### Arguments 311 | * name {string} The cookie name, or that is the key associated with `value`. 312 | * value {string|undefined} Optional. If skipped, it becomes undefined. The value associated with `name` for the cookie. 313 | * options {object} Optional. A object of optional values to set for the cookie (see description above). 314 | 315 | ##### Return 316 | {Response} 317 | 318 | 319 | ------------------------------------------- 320 | 321 | 322 | #### attachment 323 | 324 | It will set Content-Disposition to be an attachment using the value of `filename` as it's file name. 325 | 326 | Setting this header will notify a client (browser) to download the response body as a file with the file name `filename`. 327 | 328 | If you prefer to not have a file name, use an empty string `attachment("")`. 329 | 330 | If `filename` is `undefined`, ex: `attachment()`, it will clear the current value (effectively removing the header). 331 | 332 | Example: 333 | ```js 334 | new Response("hello world").attachment("hello.txt") 335 | ``` 336 | Sending this response back to a client (browser) will have the client download a file named "hello.txt" with "hello world" as it's file content. 337 | 338 | ##### Arguments 339 | * filename {string|undefined} The filename for the attachment 340 | 341 | ##### Return 342 | {Response} 343 | -------------------------------------------------------------------------------- /docs/api/internal.md: -------------------------------------------------------------------------------- 1 | Internal API 2 | 3 | Considered stable unless denoted with "**". 4 | 5 | NOTE: Unlike the rest of the spirit API, there are no camelCase variants. 6 | 7 | - [is_Response](#is_Response) 8 | - [size_of](#size_of) 9 | - [type_of **](#type_of) 10 | - [callp_response](#callp_response) 11 | - [resolve_response](#resolve_response) 12 | 13 | 14 | ------------------------------------------- 15 | 16 | 17 | # is_Response 18 | ##### (spirit.node.utils.is_Response) 19 | 20 | Returns `true` or `false` depending on if `v` is a instance of [Response](Response.md) 21 | 22 | Does not return true for a response map, this is a specific check. 23 | 24 | [Source: src/http/response-class.js (is_Response)](../../src/http/response-class.js#L4) 25 | 26 | #### Arguments 27 | * v {*} 28 | 29 | #### Return 30 | {boolean} 31 | 32 | 33 | ------------------------------------------- 34 | 35 | 36 | # size_of 37 | ##### (spirit.node.utils.size_of) 38 | 39 | Returns the size of `v` in bytes, utf-8 is assumed. 40 | 41 | It only returns a size for a string or buffer. Otherwise it returns undefined. 42 | 43 | This function is useful for determining the "Content-Length" of a response body that is a string or buffer. 44 | 45 | [Source: src/http/utils.js (size_of)](../../src/http/utils.js#L1) 46 | 47 | #### Arguments 48 | * v {string|buffer} A string or buffer to check 49 | 50 | #### Return 51 | {number} The size of the `v` (string or buffer) 52 | 53 | 54 | ------------------------------------------- 55 | 56 | 57 | # type_of 58 | ##### (spirit.node.utils.type_of) 59 | 60 | ** This function is expected to change in the future 61 | 62 | Returns a string representation of the type of `v`. It is similar to `typeof` but it will also correctly detect and report types for: null, array, buffer, stream, file-stream. 63 | 64 | As well as all types that `typeof` already identifies (undefined, string, number, etc.) 65 | 66 | Example: 67 | ```js 68 | type_of([1, 2, 3]) // "array" 69 | type_of(new Buffer("hi")) // "buffer" 70 | type_of(null) // "null" 71 | ``` 72 | 73 | [Source: src/http/utils.js (type_of)](../../src/http/utils.js#L18) 74 | 75 | #### Arguments 76 | * v {*} value or object to check 77 | 78 | #### Return 79 | {string} A string representation of the type of `v` 80 | 81 | 82 | ------------------------------------------- 83 | 84 | 85 | # callp_response 86 | ##### (spirit.node.utils.callp_response) 87 | 88 | Works similarly to `spirit.callp`. Except it adds special handling when the function being called returns a response map _but with a response body that is a Promise_. 89 | 90 | When this occurs, resolves the response's body first instead of passing along a Promise of a response map which has a Promise as it's body that may not be resolved yet. 91 | 92 | Additionally it surpresses Promise warnings regarding async error handling. 93 | 94 | This is mostly used internally. 95 | 96 | [Source: src/core/promise_utils.js (resolve_response)](../../src/core/promise_utils.js#L59) 97 | 98 | #### Arguments 99 | * p {Promise} 100 | 101 | #### Return 102 | {Promise} 103 | 104 | 105 | ------------------------------------------- 106 | 107 | 108 | # resolve_response 109 | ##### (spirit.node.utils.resolve_response) 110 | 111 | __REMOVED IN v0.6.0__ 112 | 113 | Resolves a response's body if it's a Promise. 114 | 115 | This is mostly used internally. 116 | 117 | #### Arguments 118 | * p {Promise} 119 | 120 | #### Return 121 | {Promise} 122 | -------------------------------------------------------------------------------- /docs/api/node-middleware.md: -------------------------------------------------------------------------------- 1 | HTTP related middleware 2 | 3 | - [if-modified](#if-modified) 4 | - [log](#log) 5 | - [proxy](#proxy) 6 | 7 | 8 | # if-modified 9 | ##### (spirit.node.middleware.ifmod) 10 | 11 | The middleware will compare If-Modified-Since and If-None-Match request headers to the corresponding response headers (Last-Modified and ETag). 12 | 13 | If the response status is 2xx and the headers match, it will override the response status to 304. 14 | 15 | Last-Modified response header _should_ be a Date object or a string that can converted to a accurate Date object. 16 | 17 | NOTE: This middleware does not generate Last-Modified and ETag headers. 18 | However Last-Modified headers are automatically generated for you when you use [file_response](node.md#file_response). 19 | 20 | 21 | An example (not using spirit-router) of writing out custom ETag headers: 22 | ```js 23 | const {response, middleware, adapter} = require("spirit").node 24 | 25 | const example = () => { 26 | return response("Hello").set("ETag", "123") 27 | } 28 | 29 | adapter(example, middleware.ifmod) 30 | ``` 31 | If the request has If-None-Match header populated with "123", then this will match and your response will be converted to a 304 response. 32 | 33 | It is important to point out when it matches and a 304 response status is set, the response body will be intact as it flows through spirit. 34 | 35 | Merely setting the response status to 304 will cause the response body to not be sent to the client. 36 | 37 | [Source: src/http/middleware/if_modified.js](../../src/http/middleware/if-modified.js) 38 | 39 | 40 | ------------------------------------------- 41 | 42 | 43 | # log 44 | ##### (spirit.node.middleware.log) 45 | 46 | Logs to console basic request information for when a request comes in and when it returns, and the time in milliseconds it took to complete. 47 | 48 | 49 | [Source: src/http/middleware/log.js](../../src/http/middleware/log.js) 50 | 51 | 52 | ------------------------------------------- 53 | 54 | 55 | # proxy 56 | ##### (spirit.node.middleware.proxy) 57 | 58 | Middleware for handling "Forwarded" and "X-Forwarded-For" request headers. It will set the request map ip `request.ip` to the value specified by these headers. "Forwarded" has priority if both headers exist. 59 | 60 | 61 | [Source: src/http/middleware/proxy.js](../../src/http/middleware/proxy.js) 62 | 63 | -------------------------------------------------------------------------------- /docs/api/node.md: -------------------------------------------------------------------------------- 1 | - [adapter](#adapter) node http adapter 2 | 3 | - [make_stream](#make_stream) creates a writable stream (useful for creating a streaming response) 4 | 5 | Functions for creating common responses with chainable helpers: 6 | - [response](#response) 7 | - [file_response](#file_response) 8 | - [err_response](#err_response) 9 | - [redirect](#redirect) 10 | 11 | For checking if an object is a valid response: 12 | - [is_response](#is_response) 13 | 14 | 15 | 16 | # adapter 17 | ##### (spirit.node.adapter) 18 | Interfaces with node's http, https, http2 modules. 19 | 20 | Takes a `handler` function and a array of spirit compatible `middlewares`. 21 | 22 | Returns a node compatible http handler, which is a function with the signature `(req, res)`. 23 | 24 | Normally the returned function is passed to a module that requires a node http handler, for example: 25 | 26 | ```js 27 | http.createServer(spirit.node.adapter(handler, middleware)) 28 | ``` 29 | 30 | It will abstract the node http IncomingRequest object (`req`) into a [request map](request-response-map.md#request-map). 31 | 32 | And pass the request map through `middlewares` and then to `handler`. The returned value from those will flow back to the adapter. And the adapter will write back to the connection the response. 33 | 34 | The returned value or response is expected to be a [response map](request-response-map.md#response-map). 35 | 36 | For the order of execution, given a `middlewares` that looks like: `[middleware1, middleware2]` 37 | 38 | Then the flow of a request would look like this: 39 | 40 | `(request) -> adapter -> middleware1 -> middleware2 -> handler` 41 | 42 | And a response would flow backwards in the same order: 43 | 44 | `adapter <- middleware1 <- middleware2 <- handler <- (response)` 45 | 46 | [Source: src/http/node_adapter.js (adapter)](../../src/http/node_adapter.js#L34) 47 | 48 | #### Arguments 49 | * handler {function} A handler function that takes a request map and returns a response map 50 | * middlewares {array} An array of spirit compatible function 51 | 52 | #### Return 53 | {function} A node http compatible handler 54 | 55 | 56 | ------------------------------------------- 57 | 58 | 59 | # make_stream 60 | ##### (spirit.node.make_stream) 61 | ##### Added 0.1.1 62 | ##### Alias makeStream 63 | 64 | It is meant as a helper to create a generic writable stream for when you need to stream a response body. 65 | 66 | It is a __must__ to end() the stream once you are done. 67 | 68 | __Note__, if you already have a stream, you do not need to use this function. It is meant to quickly create a generic writable stream if you do not have one already. 69 | 70 | Example: 71 | ```js 72 | const res = make_stream() 73 | 74 | // pass res to some async function, or can do some async here 75 | setTimeout(() => { 76 | res.write("streaming") 77 | 78 | setTimeout(() => { 79 | res.write("streaming still") 80 | res.end() 81 | }, 1000) 82 | 83 | }, 1000) 84 | 85 | return response(res) // 'return res' is ok if you are using spirit-router 86 | ``` 87 | 88 | In the example: 89 | 90 | 1. before the setTimeouts are called, the function returns the response with the stream as it's body. 91 | 92 | 2. The response goes through any middlewares, then the headers and status are written. This is similar to res.writeHead(). The body has not been written yet as the stream still has no body. 93 | 94 | 3. 1000ms later, the first setTimeout is triggered and the first chunk of the response body is written and sent to the client. 95 | 96 | 4. another 1000ms later, the final chunk of the response body is written to the client, and the stream has ended, thus ending the response. 97 | 98 | [Source: src/http/response.js (make_stream)](../../src/http/response.js#L57) 99 | 100 | #### Arguments 101 | None 102 | 103 | #### Return 104 | {Stream} 105 | 106 | 107 | ------------------------------------------- 108 | 109 | 110 | # response 111 | ##### (spirit.node.response) 112 | 113 | Returns a [Response](Response.md) with `body` as the response's body. 114 | 115 | A 200 status code would be set by default. 116 | 117 | If `body` is a string, "Content-Length" will be populated appropriately with the size of the body. And "Content-Type" will be set to "text/html; charset=utf-8". 118 | 119 | 120 | [Source: src/http/response.js (response)](../../src/http/response.js#L26) 121 | 122 | #### Arguments 123 | * body {undefined|string|buffer|stream} the body of the response 124 | 125 | #### Return 126 | {Response} 127 | 128 | 129 | ------------------------------------------- 130 | 131 | 132 | # file_response 133 | ##### (spirit.node.file_response) 134 | ##### Alias fileResponse 135 | 136 | `file` is either a string of the path of a file or a readable file stream (usually from `fs.createReadStream`). 137 | 138 | Returns a Promise of a [Response](Response.md) where the body is set to the stream of `file`. 139 | 140 | By default the Response is set to a 200 status code. 141 | 142 | A "Content-Type" will be set and be based on the file extension. 143 | 144 | "Content-Length" header will also be set to the file's size as well as "Last-Modified" will be set based on the file's mtime, both headers are based _on the time the returned Promise is resolved_. 145 | 146 | [Source: src/http/response.js (file_response)](../../src/http/response.js#L55) 147 | 148 | #### Arguments 149 | * file {string|file stream} A string of the path of a file, or a readable file stream 150 | 151 | #### Return 152 | {Promise} A Promise of a Response 153 | 154 | 155 | ------------------------------------------- 156 | 157 | 158 | # redirect 159 | ##### (spirit.node.redirect) 160 | 161 | Returns a [Response](Response.md) for a redirect. A 302 status code is assumed if not specified. 162 | 163 | Example: 164 | ```js 165 | redirect("http://www.google.com") 166 | 167 | redirect(301, "http://www.google.com") 168 | ``` 169 | 170 | [Source: src/http/response.js (redirect)](../../src/http/response.js#L110) 171 | 172 | #### Arguments 173 | * status {number} status code, 302 is the default 174 | * url {string} the URL to redirect to 175 | 176 | #### Return 177 | {Response} 178 | 179 | 180 | ------------------------------------------- 181 | 182 | 183 | # err_response 184 | ##### (spirit.node.err_response) 185 | ##### Alias errResponse 186 | 187 | Returns a [Response](Response.md) with a 500 status code and it's body set as `body`. 188 | 189 | `body` can be undefined, string, buffer, or stream, but also it can be an Error object. 190 | 191 | Example: 192 | ```js 193 | err_response(new Error("oops")) 194 | ``` 195 | 196 | [Source: src/http/response.js (err_response)](../../src/http/response.js#L153) 197 | 198 | #### Arguments 199 | * body {undefined|string|buffer|stream|Error} the body of the response 200 | 201 | #### Return 202 | {Response} 203 | 204 | 205 | ------------------------------------------- 206 | 207 | 208 | # is_response 209 | ##### (spirit.node.is_response) 210 | 211 | Returns `true` or `false` depending on if `v` is a [response map](request-response-map.md#response-map) 212 | 213 | It will also return `true` for a Response (instace of the class) as a Response is a valid response map. 214 | 215 | [Source: src/http/response.js (is_response)](../../src/http/response.js#L8) 216 | 217 | #### Arguments 218 | * v {*} 219 | 220 | #### Return 221 | {boolean} 222 | 223 | -------------------------------------------------------------------------------- /docs/api/request-response-map.md: -------------------------------------------------------------------------------- 1 | - [request map](#request-map) 2 | - [response map](#response-map) 3 | 4 | 5 | 6 | # request map 7 | 8 | A request map is a map (object literal) representation of a http request. 9 | 10 | By default the node http adapter will populate the following properties: 11 | 12 | - port {number} the port the request was made on 13 | - host {string} either a hostname or ip of the server 14 | - ip {string} the requesting client's ip 15 | - url {string} the request URI (_excluding_ query string) 16 | - path {string} the request URI (_including_ query string) 17 | - pathname {string} alias to `url` for those who prefer this naming, as it follows node.js's url api 18 | - method {string} the request method 19 | - protocol {string} either "http" or "https" 20 | - scheme {string} the transport protocol ex: "HTTP/1.1" 21 | - headers {object} the request headers (as node delivers it) 22 | - query {object} query string of request URI parsed as object (defaults to {}) 23 | 24 | And additionally a method: 25 | 26 | - req {function} returns the original node IncomingRequest object 27 | 28 | These properties should not be modified except under very rare circumstances where it is practical to do so. 29 | 30 | Additional properties can be added. For example it is common to have the request body be parsed and included as a body property, (this is provided via middleware). 31 | 32 | `headers` is as node.js delivers it, therefore all the header names are lower cased. 33 | 34 | # response map 35 | 36 | A response map is a map (object literal) that has __at least__ two properties and corresponding types, `status` (number), `headers` (object). 37 | 38 | The `headers` property is a object literal representing key, value of HTTP headers. 39 | 40 | It can _optionally_ also have a `body` property which can be of type undefined, string, Buffer, or stream (readable). 41 | 42 | A response map is considered a simplified representation of what is intended to be written back for a http request. 43 | 44 | Because of it's simple form, it provides an easy outlet for testing the proper response of functions. 45 | 46 | #### Example: 47 | ```js 48 | { 49 | status: 200, 50 | headers: { 51 | "Content-Type": "text/html; charset=utf-8" 52 | }, 53 | body: "

Hello World

" 54 | } 55 | ``` 56 | This would write back a 200 response with the Content-Type set with the body `"

Hello World

"`. 57 | 58 | #### Minimal, but valid example: 59 | ```js 60 | { 61 | status: 404, 62 | headers: {} 63 | } 64 | ``` 65 | This is a valid response, as only a `status` and `headers` are needed, and `headers` can be an empty object (which just means no headers). 66 | 67 | 68 | ### Note about headers 69 | 70 | Headers share exactly the same concept as headers in Node.js. That is, the header names should be unique regardless of case. And to set multiple values of the same header, use an array. 71 | 72 | Example: 73 | ```js 74 | { 75 | status: 200, 76 | headers: { 77 | "Set-Cookie": ["type=ninja", "language=javascript"] 78 | } 79 | } 80 | ``` 81 | 82 | Header names __should__ (meaning strongly recommended) be capitalized per word. For example `"Content-Type"` and not `"Content-type"`. 83 | 84 | There is a [Response](https://github.com/spirit-js/spirit/blob/master/docs/api/Response.md) object class that helps when working with response maps. It provides chainable helper functions to make it easier to work with headers. (You don't normally use it directly, but instead it's returned from `spirit.node.response`, `spirit.node.file_response`, `spirit.node.redirect`, `spirit.node.err_response`) 85 | 86 | A Response is a valid response map, as it has `status`, `headers`, the optional `body` property. 87 | 88 | -------------------------------------------------------------------------------- /docs/api/spirit.md: -------------------------------------------------------------------------------- 1 | - [compose](#compose) 2 | - [callp](#callp) 3 | 4 | # compose 5 | ##### (spirit.compose) 6 | 7 | Composes `middlewares` and `handler` together as a single function, which is the returned function. 8 | 9 | The order of execution is as follows: 10 | ```js 11 | const fn = spirit.compose(handler, [middleware1, middleware2]) 12 | ``` 13 | 14 | `(input) fn -> middleware1 -> middleware2 -> handler` 15 | 16 | It will then flow backwards returning the result like so: 17 | 18 | `fn <- middleware1 <- middleware2 <- handler (output)` 19 | 20 | [Source: src/core/core.js (compose)](../../src/core/core.js#L25) 21 | 22 | #### Arguments 23 | * handler {function} A handler function 24 | * middlewares {array} Array of spirit middlewares 25 | 26 | #### Return 27 | {function} Takes a single argument (input) and passes it through the `middlewares` and finally `handler` and returns a Promise of the result 28 | 29 | 30 | ----------------------------------------------------- 31 | 32 | 33 | # callp 34 | ##### (spirit.callp) 35 | 36 | Calls a function `fn` with `args` returning the value as a Promise if it's not already a Promise. 37 | 38 | Additionally if `fn` is _not_ a function, it will ignore `args` and return `fn` as a value wrapped as a Promise. 39 | 40 | [Source: src/core/promise_utils.js (callp)](../../src/core/promise_utils.js#L20) 41 | 42 | #### Arguments 43 | * fn {*} a function to call or a value of any type 44 | * args {array} an array of arguments to `fn` 45 | 46 | #### Return 47 | {Promise} The value of `fn(args)` wrapped as a Promise if it's not a Promise already 48 | 49 | -------------------------------------------------------------------------------- /docs/async.md: -------------------------------------------------------------------------------- 1 | Our examples so far have been synchrounous, but in a real world application you will want to do asynchrounous tasks, such as querying a database or reading a file. 2 | 3 | This can be accomplished by returning a Promise of the value. 4 | 5 | Most libraries support Promises, such as Mongoose: 6 | ```js 7 | const db = (title) => { 8 | return Books.findOne({ name: title }) 9 | } 10 | 11 | route.define([ 12 | route.get("/api/books/:title", ["title"], db) 13 | ]) 14 | ``` 15 | When you have a Promise already, you can just return it. 16 | 17 | Of course if you there are more things to do, you can `then` it and handle any errors with `catch` like normal: 18 | 19 | ```js 20 | const db = (title) => { 21 | return Books.findOne({ name: title }) 22 | .then((book) => { 23 | // do something with book / modify it 24 | return book 25 | }).catch((err) => { 26 | return "There was an error" 27 | }) 28 | } 29 | 30 | route.define([ 31 | route.get("/api/books/:title", ["title"], db) 32 | ]) 33 | ``` 34 | 35 | Your `db()` function has nothing to do with http, it's simply a database query function, which can re-used elsewhere in your application. 36 | 37 | ### Wrapping Callbacks 38 | If a library you are using doesn't support Promises, you can always it as a Promise. 39 | 40 | For example let's take node.js's `fs` library: 41 | 42 | ```js 43 | const readfile = () => { 44 | return new Promise((resolve, reject) => { 45 | fs.readFile("my-file.txt", (err, data) => { 46 | err ? reject(err) : resolve(data) 47 | }) 48 | }) 49 | } 50 | 51 | route.define([ 52 | route.get("/read-file", [], readfile) 53 | ]) 54 | ``` 55 | 56 | This isn't special to spirit, our `readfile()` is just writing javascript. The only thing http related is where we defined our route. 57 | 58 | There are libraries that automatically wrap other libraries's API to be a Promise. One library that does this for us is [bluebird](https://www.npmjs.com/package/bluebird). 59 | 60 | > bluebird is recommended when creating Promises, though not required. You can always use the native Promise implementation that node.js already supports. 61 | 62 | ### Async / Await 63 | 64 | It's worth mentioning you can use ES7 async/await too when working with Promises. 65 | 66 | There is nothing inherently special about using async/await with spirit, or Promises in general. So you would set it up and write it as as normal. Setting it up (via babeljs) is outside the scope of this guide. 67 | 68 | -------------------------------------------------------------------------------- /docs/defining-routes.md: -------------------------------------------------------------------------------- 1 | As mentioned in previous chapters, a route just describes _when_ and _how_ to call the function we passed in. 2 | 3 | In the previous chapters we focused on how it calls our function, now we will talk more about when a route is matched. 4 | 5 | > Remember when you see `route`, it's imported from `spirit-router` from our very first example. 6 | 7 | ```js 8 | const route = require("spirit-router") 9 | ``` 10 | 11 | ## Creating Routes 12 | 13 | Routes are _always_ grouped with `define`, even if we only have 1 route: 14 | 15 | ```js 16 | route.define([ 17 | route.get("/", [], some_function) 18 | ]) 19 | ``` 20 | 21 | This specifies the route will match and call our `some_function` with no arguments `[]`. It will match when the incoming request is a `GET /` request. That is, the request is a GET method, for path /. 22 | 23 | Since there are no arguments, we can optionally omit it and write the above example as: 24 | ```js 25 | route.define([ 26 | route.get("/", some_function) 27 | ]) 28 | ``` 29 | 30 | Routes are tried in the order they first appear, and are considered matched when the incoming request's method and path matches the routes. Once this happens, the function for the route will be called with any arguments defined. 31 | 32 | However, the router will only consider the routing is over _if_ the function of the route returns a value that isn't `undefined`. 33 | 34 | If `undefined` is returned, the router will keep trying until a route finally has a response. 35 | 36 | ```js 37 | // #=> GET / 38 | 39 | route.define([ 40 | // matches but returns undefined, the router continues 41 | route.get("/", () => undefined), 42 | // matches and returns a value, routing ends 43 | route.get("/", () => "Hello") 44 | ]) 45 | ``` 46 | 47 | #### Method 48 | 49 | `spirit-router` exports common methods besides just `get`. 50 | 51 | It also exports: `put`, `post`, `delete`, `head`, `options`, `patch`: 52 | 53 | ```js 54 | route.define([ 55 | route.post(...), 56 | route.delete(...) 57 | ]) 58 | ``` 59 | 60 | If we wanted a route to match _any_ http method, we use `any`: 61 | ```js 62 | route.any("/test", ...) 63 | ``` 64 | Which would would match any http request for path "/test". 65 | 66 | For custom http methods that aren't pre-defined we can use `method`: 67 | ```js 68 | route.method("custom", "/", ...) 69 | ``` 70 | Which would match for a http request with CUSTOM method for path "/". 71 | 72 | #### Path 73 | 74 | The path of a route doesn't have to always be a string. 75 | 76 | They can be a _string pattern_, or regexp. (They work exactly the same way as Express and other web libraries.) 77 | 78 | ```js 79 | const greet = (name) => { 80 | return "Hello, " + name 81 | } 82 | 83 | route.define([ 84 | route.get("/:name", ["name"], greet) 85 | ]) 86 | // #=> GET /bob 87 | // { status: 200, 88 | // headers: { "Content-Type": "text/html; charset=utf-8" }, 89 | // body: "Hello, bob" } 90 | ``` 91 | 92 | As shown in the comment, the result is "Hello, bob" when we make a `GET /bob` request. 93 | 94 | It's able to pass in `name` because of dependency injection (discussed also in [Request](request.md)). 95 | 96 | When the route matches, it'll see `"/:name"` means to hold on to the value in the path. The `["name"]` signals `greet` depends on the value of `"name"` before we can call it. So `"name"` is looked up on the request and `greet` is called with it. 97 | 98 | We can also use regexp itself, or regexp characters such as "*" (which matches any path): 99 | 100 | ```js 101 | const inspect = (url) => { 102 | return "You made a request to: " + url 103 | } 104 | 105 | route.define([ 106 | route.get("*", ["url"], inspect) 107 | ]) 108 | // #=> GET /test-test 109 | // { status: 200, 110 | // headers: { "Content-Type": "text/html; charset=utf-8" }, 111 | // body: "You made a request to: /test-test" } 112 | ``` -------------------------------------------------------------------------------- /docs/error-handling.md: -------------------------------------------------------------------------------- 1 | Since spirit makes heavy use of Promises, errors in spirit aren't a burden. In fact provide meaningful context for different handling in your web application. 2 | 3 | Errors can be handled on multiple levels, depending on how far you want to bubble the error up. 4 | 5 | We recover from errors using `.catch` and returning a response. 6 | 7 | ### Locally 8 | We can handle errors locally within our function: 9 | 10 | ```js 11 | const lookup = (title) => { 12 | return Books.findOne({ name: title }) 13 | .catch((err) => response("Book doesn't exist").status_(404)) 14 | } 15 | 16 | const app = route.define([ 17 | route.get("/:title", ["title"], lookup) 18 | ]) 19 | ``` 20 | 21 | ### Catch All 22 | We can catch all errors with a simple middleware passed to `spirit.node.adapter`: 23 | 24 | ```js 25 | const app = route.define([ 26 | route.get("/", () => { throw new Error("Oops") }) 27 | ]) 28 | 29 | const middleware = [ 30 | (handler) => (request) => handler(request) 31 | .catch((err) => { 32 | return response("Internal error: " + err.toString()).status_(500) 33 | }) 34 | ] 35 | 36 | http.createServer(adapter(app, middleware)) 37 | ``` 38 | 39 | All errors _not handled_ from our routes would eventually bubble up to this middleware and be caught and produce a generic 500 response. 40 | 41 | 42 | ### Specific Routes 43 | A catch all middleware that applies to all errors is great for keeping things DRY, but it can be too generic. We can still be DRY by handling specific routes differently. 44 | 45 | For example, showing a JSON error for API specific routes, and showing a 500 page for others: 46 | 47 | ```js 48 | let api = route.define("/api", [ 49 | route.get("/book", ...), 50 | route.post("/book", ...) 51 | ... // etc 52 | ]) 53 | 54 | api = route.wrap(api, (handler) => (request) => handler(request).catch((err) => { 55 | // handle different errors depending on the err from our api routes 56 | let msg 57 | if (err === ...) msg = "Book has been deleted" 58 | if (err === ...) msg = "Book doesn't exist" 59 | return response(msg).status_(404) 60 | }) 61 | 62 | const app = route.define([ 63 | ..., // other routes 64 | api 65 | ]) 66 | 67 | // our catch-all middleware for other non-api routes 68 | const middleware = [ 69 | (handler) => (request) => handler(request) 70 | .catch((err) => { 71 | return response("Internal error: " + err.toString()).status_(500) 72 | }) 73 | ] 74 | 75 | http.createServer(adapter(app, middleware)) 76 | ``` 77 | 78 | In this way, we can handle errors more gracefully for different routes that have different contexts. 79 | 80 | We can do so endlessly if we had multiple groups of routes that all do different things. -------------------------------------------------------------------------------- /docs/flow-chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spirit-js/spirit/7c41f5fcc5df9fd5642d5092b4043037f93c026c/docs/flow-chart.png -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | Install spirit and spirit-router: 2 | ```bash 3 | $ npm install spirit spirit-router 4 | ``` 5 | 6 | Start off with a simple app: 7 | 8 | ```js 9 | const {adapter} = require("spirit").node 10 | const route = require("spirit-router") 11 | 12 | const hello = () => { 13 | return "Hello World!" 14 | } 15 | 16 | const app = route.define([ 17 | route.get("/", [], hello) 18 | ]) 19 | 20 | const http = require("http") 21 | const server = http.createServer(adapter(app)) 22 | server.listen(3000) 23 | ``` 24 | 25 | If we run our example and visit `http://localhost:3000` in a browser, you will be greeted with Hello World! 26 | 27 | ### Separation of code 28 | Our `hello()` function is just a normal javascript function, what gets returned is what is sent back as the response. 29 | 30 | It does __not__ need to deal with a `req` or `res` object. 31 | 32 | Separation of concerns is a central concept to spirit. 33 | 34 | If we think about a web request in it's simpliest form, it's basically a function, it takes a input (request) and returns an output (response). So why not write web applications this way? 35 | 36 | spirit simplifies everything by abstracting away the complexity of `req` and `res` that normally resulted in impure and complex functions. 37 | 38 | ### Routes as definitions 39 | Routes in spirit should be thought of as _definitions_ and not some proprietary operation to perform. 40 | 41 | ```js 42 | route.get("/", [], hello) 43 | ``` 44 | 45 | When we defined this route, do not think of `hello` as a "routing function". 46 | 47 | When we define a route, we are simply describing the __when__ and __how__. When should we call `hello`, and how to call it. 48 | 49 | Routes in spirit serve as a boundary between http related ideas and regular javascript. 50 | 51 | ### Adapter 52 | ```js 53 | const server = http.createServer(adapter(app)) 54 | server.listen(3000) 55 | ``` 56 | spirit has a concept of an adapter where most of the abstractions happen. 57 | 58 | spirit comes with an adapter already for node.js. It converts our routes and app into something node.js's http module can understand. 59 | 60 | It can optionally take middlewares `adapter(app, _middleware_)` (more on that in [Using Middleware](using-middleware.md)). 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /docs/grouping-routes.md: -------------------------------------------------------------------------------- 1 | As shown previously every route must __always__ be grouped with `define`: 2 | 3 | ```js 4 | route.define([ 5 | route.get("/", ...) 6 | ]) 7 | ``` 8 | 9 | Grouped routes can take a path too, which applies to every route inside: 10 | 11 | ```js 12 | route.define("/users", [ 13 | route.get("/", ...), // will only match GET /users 14 | route.post("/", ...) // will only match POST /users 15 | ]) 16 | ``` 17 | 18 | ### Composing groups 19 | 20 | Groups can be composed and nested together to build out a structure: 21 | 22 | ```js 23 | const books = route.define("/books", [ 24 | // routes for querying a Book database 25 | ]) 26 | 27 | const users = route.define("/users", [ 28 | // routes for querying a User database 29 | ]) 30 | 31 | const api = route.define("/api", [ 32 | books, // becomes /api/books 33 | users // becomes /api/users 34 | ]) 35 | 36 | route.define([ 37 | route.get("/", homepage), 38 | api 39 | ]) 40 | ``` 41 | 42 | Composing this way can help us logically separate functionality in our application. 43 | 44 | But it can also allow us to __re-use__ these groups and keeping our application DRY. 45 | 46 | ### Re-using groups 47 | 48 | We can already re-use functions in routes (as they are not tied to `req` or `res` and are normal javascript functions), but entire groups of routes can be re-used too. 49 | 50 | Remeber middleware in spirit don't just modify a request, but it can also perform operations on a response. 51 | 52 | So if we take the above example to __also__ serve pages for our "books" and serve API we can do: 53 | 54 | ```js 55 | const books = route.define("/books", [ 56 | // routes for querying a Book database 57 | ]) 58 | 59 | const users = route.define("/users", [ 60 | // routes for querying a User database 61 | ]) 62 | 63 | const api = route.define("/api", [ 64 | books, // becomes /api/books 65 | users // becomes /api/users 66 | ]) 67 | 68 | route.define([ 69 | route.get("/", homepage), 70 | api, 71 | route.wrap(books, _middleware_that_renders_to_html_) 72 | ]) 73 | ``` 74 | 75 | In this way we can just re-use our 'books' api to serve two purposes. 76 | 77 | When a user accesses "/api/books" they still get API data, and when a user access "/books" they get HTML pages displaying the data from our API. 78 | 79 | This allows us to reduce redundancy and not have to write separate routes that call our 'books' api just to render HTML pages out of it. And can be all done in a single middleware. 80 | 81 | More on writing middleware in [Writing Middleware](writing-middleware.md). 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /docs/rendering-routes.md: -------------------------------------------------------------------------------- 1 | As mentioned previously in chapter [Response](response.md) whenever a route's function returns a non response map it will be converted into one in a process called _rendering_. 2 | 3 | We can extend or modify how values are rendered. 4 | 5 | For example, by default strings are rendered to a response with a 200 status code, and Content-Type set to "text/html", as well as Content-Length set. Lets replace it: 6 | 7 | ```js 8 | const {response} = require("spirit").node 9 | 10 | route.render.string = (request, body) => { 11 | return response(body).type("text") 12 | } 13 | 14 | const app = route.define([ 15 | route.get("/", "Hello World!") 16 | ]) 17 | ``` 18 | 19 | This would apply to __every__ _string_ value returned from a route's function to have a Content-Type of "text/plain". 20 | 21 | NOTE: Rendering is applies to __every__ route. If you wanted to transform a response, or do so conditionally based on certain routes, it's better to use a middleware and wrap that specific route or group of routes. 22 | 23 | In our example we use `spirit.node.response`, which is just a helper function that helps create a response with chainable helpers with Content-Length pre-filled for us, but we could have just returned a response manually. 24 | 25 | 26 | ### Renderable Types 27 | `render` understands and can be extended with the following types: "string", "number", "null", "buffer", "stream", "file-stream", "array", "object". 28 | 29 | NOTE: If a value is already a response map, it will skip the rendering process. Because the sole purpose of rendering is to convert a value to a response map. 30 | 31 | 32 | ### Templates 33 | In other web libraries there is a concept of "template engine". This notion is not http related, template rendering be different things depending on the template used. 34 | 35 | Instead spirit provides tools for helping you do what you want, instead of pre-defining what you need to do. 36 | 37 | Following that idea, we can build our own rendering for whatever template we like through the rendering process. 38 | 39 | By default, "objects" are rendered as a JSON response. We can extend it to also understand a custom object type for rendering. 40 | 41 | Let's say our custom object looks like `{ file: , local: }` and we are using `jade` templates: 42 | 43 | ```js 44 | const cache = {} 45 | 46 | route.render.object = (request, body) => { 47 | if (body.file) { 48 | const f = __dirname + "/" + body.file 49 | const temp = cache[f] = cache[f] || jade.compileFile(f) 50 | return response(temp(body.local)) 51 | } 52 | // otherwise render object type as json 53 | return response(JSON.stringify(body)).type("json") 54 | } 55 | 56 | const app = route.define([ 57 | route.get("/", () => { file: "index.jade" }), 58 | route.get("/contact", () => { file: "contact.jade", local: { email: "blah@example.com" } }), 59 | route.get("/home", () => { file: "home.jade" }) 60 | ]) 61 | ``` 62 | -------------------------------------------------------------------------------- /docs/request.md: -------------------------------------------------------------------------------- 1 | If a web request can be simply thought of as a function, that is it takes an input (request) and returns an output (response), then how do we get the input (the request) into our function? 2 | 3 | In the previous chapter our hello function did not need any information from the request and took no arguments, but what if our function looked like this: 4 | ```js 5 | const hello = (url) => { 6 | return "Hello from " + url 7 | } 8 | ``` 9 | 10 | Remember a route can be thought of as a definition, describing _when_ a route is considered matched (more on this in Defining Routes) and _how_ to call our function when it is matched. 11 | 12 | So therefore we can tell it _how_ to call our function, that is with what arguments. 13 | 14 | ### Dependency Injection 15 | 16 | In order to run our new hello function above we define our routes like so: 17 | 18 | ```js 19 | route.define([ 20 | route.get("/", ["url"], hello) 21 | ]) 22 | ``` 23 | 24 | Notice the `["url"]`, this tells our route that in order for our `hello` function to run successfully, we depend on "url" being passed into our `hello` function. 25 | 26 | And "url" is looked up off the request (which is an object) for us and `hello` is called with it's value. This is a form of dependency injection. 27 | 28 | To expand on this further and to include more dependencies: 29 | 30 | ```js 31 | const hello = (url, method) => { 32 | return "Hello from " + method + " " + url 33 | } 34 | route.define([ 35 | route.get("/", ["url", "method"], hello) 36 | ]) 37 | ``` 38 | 39 | Which would produce "Hello from GET /". 40 | 41 | ### Why Dependency Injection? 42 | 43 | You may be wondering why use dependency injection over just passing the entire request into our function. 44 | 45 | After all when working with node.js and other web libraries previously you may have gotten use to something like this: 46 | ```js 47 | const hello = (req, res) => { 48 | res.send("Hello from " + req.method + " " + req.url) 49 | } 50 | ``` 51 | 52 | This goes against spirit's philosophy of being re-usuable, testable and readable! 53 | 54 | Even if we went half way where our function still returned by the request was always passed in, this breaks our re-usuable philosophy! 55 | 56 | We can no longer re-use `hello()` in some other part of our code that doesn't have a request to give it. 57 | 58 | It also breaks testability! If we wanted to test our `hello()` function, we would have to mock an entire request object. 59 | 60 | _Besides_, if our function only needs 2 arguments (both strings), why pass it more information than it needs to run? 61 | 62 | And when we re-use our function, all we are concerned about now is satisfying it's arguments of providing it with 2 values that are strings. 63 | 64 | As mentioned in the previous chapter, do __not__ think of routes as having "routing functions". spirit tries to avoid http from leaking into your code and wants you to avoid writing bloated functions with complex dependencies. 65 | 66 | Instead think of them as normal functions that return a value and have real functional signatures. 67 | 68 | > Or if you are familiar with functional programming, think of them more in that sense. Writing leaner and more pure functions. 69 | 70 | ### request is not req 71 | 72 | `req` has been abstracted and simplified in spirit, and is called a request (or sometimes called request map). 73 | 74 | request and `req` are __not__ the same thing, but have a similar purpose in that they both represent and hold data from an incoming http request. 75 | 76 | It can be thought of as a "JSON-like" object. Or can be thought of as a hash, dict, or map in other programming languages. 77 | 78 | Unlike `req` it only has a few properties (or keys) with simple values associated with them. 79 | 80 | A request always has the following properties: port, host, ip, url, method, protocol, scheme, headers, query. See the [API doc](https://github.com/spirit-js/spirit/blob/master/docs/api/request-response-map.md#request-map) for more info. 81 | 82 | More properties can be added to the request (usually through middleware, such as a 'body'). `spirit-router` also adds the property 'param' to request. More on this in [Defining Routes](defining-routes.md). 83 | 84 | Sometimes you might need the request or `req` object for a route, which you can also inject it in: 85 | 86 | ```js 87 | const inspect = (request) => { 88 | return JSON.stringify(request) 89 | } 90 | route.define([ 91 | route.get("/", ["request"], inspect) 92 | ]) 93 | ``` 94 | -------------------------------------------------------------------------------- /docs/response.md: -------------------------------------------------------------------------------- 1 | ```js 2 | const hello = () => { 3 | return "Hello World!" 4 | } 5 | 6 | const app = route.define([ 7 | route.get("/", [], hello) 8 | ]) 9 | ``` 10 | 11 | When we ran our very first example above, it worked, we see "Hello World!" in our browser. 12 | 13 | But you may wonder how it's possible since a http response requires more information such as a status code or headers. 14 | 15 | ## Response 16 | 17 | In the previous chapter [Request](request.md), you saw how a request is abstracted to be a simple JSON-like object, the same applies to a response (or sometimes called response map). 18 | 19 | A response is a JSON-like object (or can be thought of as a hash, dict, map in other programming languages) representing the response intended to be sent back to a client (browser). 20 | 21 | A response is basically an object literal with 3 properties: status, headers, body (optional). 22 | 23 | Just like a request can be passed around in spirit, so can a response. This is in contrast to other web libraries. You will see this more in later chapters dealing with middleware. 24 | 25 | `status` would be a number corresponding to the http status code of the response. 26 | 27 | `headers` is a object with key, value pairs for it's respective http response header. 28 | 29 | `body` is the response body to write back to the client, it is optional. If it exists, it __must__ be either string, buffer, stream, file stream, or undefined. 30 | 31 | ### Route as a gateway 32 | 33 | As mentioned previously routes act as a boundary or gateway between http related code and your code. 34 | 35 | When a route is matched and calls your function, it will check the return value in a process called _rendering_. If the value is not a response map, it will render it to be one with some smart assumptions. 36 | 37 | The above `hello()` returns "Hello World!" which gets converted to a response: 38 | ```js 39 | { status: 200, 40 | headers: { 41 | Content-Type: "text/html; charset=utf-8" }, 42 | body: "Hello World!" } 43 | ``` 44 | 45 | To spirit both of the following functions are equivalent: 46 | ```js 47 | const hello = () => { 48 | return "Hello World!" 49 | } 50 | 51 | const hello2 = () => { 52 | return { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" }, body: "Hello World!" } 53 | } 54 | ``` 55 | 56 | If we wanted to test or re-use the second function (`hello2()`) and get our original value "Hello World!", we can just read the 'body' property: 57 | ```js 58 | hello2().body // "Hello World!" 59 | ``` 60 | Which is still easier than mocking a `res` object to pass in and extract information from it. 61 | 62 | Often times you will not need to write out a response map, but it's important to understanding how spirit works. 63 | 64 | ### Content Length 65 | If you noticed our response map examples above are missing an important response header: "Content-Length". `spirit.node.adapter` will automatically fill this property for us when the response body is a string or buffer __and__ we did not specify our own "Content-Length". 66 | 67 | So we can leave this out of our response when working with a response body that is a string or a buffer. 68 | 69 | A undefined body will result in "Content-Length" being 0. 70 | 71 | ### Custom Responses & Extending 72 | 73 | There are helper functions for creating responses when you want a more custom response. Explained in [Return From Routes](return-from-routes.md). 74 | 75 | Rendering in spirit (the process of converting return values to a response map) can be extended as well! So any return value can customized to suit your web application. 76 | 77 | Even custom return values can be created, such as an returning an object like `{ file: "my_file.md", html: true }`. 78 | 79 | This is explained in [Rendering Routes](rendering-routes.md). 80 | 81 | 82 | -------------------------------------------------------------------------------- /docs/return-from-routes.md: -------------------------------------------------------------------------------- 1 | In chapter [Response](response.md), we discussed how return values from a route's function get converted to a response if it already isn't one. 2 | 3 | ```js 4 | const hello = () => { 5 | return "Hello World!" 6 | } 7 | 8 | const app = route.define([ 9 | route.get("/", [], hello) 10 | ]) 11 | // #=> GET / 12 | // { status: 200, 13 | // headers: { 14 | // Content-Type: "text/html; charset=utf-8" }, 15 | // body: "Hello World!" } 16 | ``` 17 | 18 | "Hello World!" by itself doesn't make sense in terms of a http response, so spirit makes smart assumptions about what we probably meant. And it fills out our status and content type for us. 19 | 20 | ### Custom Response 21 | 22 | Sometimes we may want to customize our response, and as mentioned in the Response chapter, you can return your own response directly: 23 | 24 | ```js 25 | const hello = () => { 26 | return { status: 123, headers: {}, body: "Custom response!" } 27 | } 28 | 29 | const app = route.define([ 30 | route.get("/", [], hello) 31 | ]) 32 | // #=> GET / 33 | // { status: 123, 34 | // headers: {}, 35 | // body: "Custom response!" } 36 | ``` 37 | 38 | And spirit will see it's already a response and send this back to the client directly without trying to render it into a response. 39 | 40 | spirit has chainable helper function called `response()` for customizing a response that still try to fill in gaps for us to avoid errors: 41 | ```js 42 | const {response} = require("spirit").node 43 | 44 | const hello = () => { 45 | return response("Custom response!").status_(123) 46 | } 47 | 48 | const app = route.define([ 49 | route.get("/", [], hello) 50 | ]) 51 | // #=> GET / 52 | // { status: 123, 53 | // headers: { 54 | // Content-Type: "text/html; charset=utf-8" } 55 | // }, 56 | // body: "Custom response!" } 57 | ``` 58 | 59 | We get back the same response, but Content-Type are filled out for us. 60 | 61 | We can continue changing our response by using methods on it: 62 | ```js 63 | const resp = response("Custom response!") 64 | .status_(123) 65 | .type("text/plain") 66 | .set("Custom-Header", "test") 67 | .len(5) 68 | 69 | // resp = { status: 123, 70 | // headers: { 71 | // Content-Length: 5, 72 | // Content-Type: "text/plain; charset=utf-8" }, 73 | // Custom-Header: "test" 74 | // }, 75 | // body: "Custom response!" } 76 | 77 | // we can still access properties of a response 78 | resp.status // 123 79 | resp.body // "Custom response!" 80 | ``` 81 | 82 | ### File Response 83 | Often you will want to read from a file and send it back. 84 | 85 | You can of course send the file data back directly: 86 | 87 | ```js 88 | const fs = require("fs") 89 | 90 | const readfile = () => { 91 | return fs.createReadStream("my-file.txt") 92 | } 93 | 94 | route.define([ 95 | route.get("/", [], readfile) 96 | ]) 97 | // #=> GET / 98 | // { status: 200, 99 | // headers: { 100 | // Content-Length: , 101 | // Content-Type: , 102 | // Last-Modified: 103 | // }, 104 | // body: 105 | // } 106 | ``` 107 | 108 | As you can see spirit will make smart assumptions for generating our response here too. 109 | 110 | We could've also used `fs.readFile()` instead of creating a stream, but spirit understands a node.js stream and what we mean. 111 | 112 | You can also use `spirit.node.file_response`: 113 | 114 | ```js 115 | const {file_response} = require("spirit").node 116 | 117 | const readfile = () => { 118 | return file_response("my-file.txt") 119 | } 120 | 121 | route.define([ 122 | route.get("/", [], readfile) 123 | ]) 124 | ``` 125 | 126 | And if we wanted to customize the response for the file: 127 | ```js 128 | const readfile = () => { 129 | return file_response("my-file.txt").then((resp) => { 130 | return resp.status_(123).type("html").len(100) 131 | }) 132 | } 133 | ``` 134 | 135 | __NOTE:__ `file_response()` returns a Promise of the response unlike `response()`, this is because `file_response()` is async when reading the file. 136 | 137 | ### Other response helpers 138 | 139 | There are other helper functions for creating a response, such as `redirect()`: 140 | ```js 141 | () => { 142 | return redirect("http://google.com") 143 | } 144 | ``` 145 | More can be found at [spirit API](https://github.com/spirit-js/spirit/tree/master/docs/api) docs. 146 | 147 | For more info on the chainable methods see the [Response API](https://github.com/spirit-js/spirit/blob/master/docs/api/Response.md) doc. 148 | 149 | -------------------------------------------------------------------------------- /docs/using-middleware.md: -------------------------------------------------------------------------------- 1 | // TODO not finished yet 2 | 3 | Middlewares are functions to transform the request __and__ response. 4 | 5 | They can be added through `spirit.node.adapter` which will apply to _every_ web request (and it's response): 6 | 7 | ```js 8 | const {adapter} = require("spirit") 9 | const route = require("spirit-router") 10 | 11 | const app = route.define(...) // define routes 12 | 13 | const middlewares = [ ... ] // array of middlewares 14 | 15 | http.createServer(adapter(app, middlewares)) 16 | ``` 17 | 18 | They can also be applied to group of routes or routes through `spirit-router.wrap`: 19 | ```js 20 | const route = require("spirit-router") 21 | 22 | const app = route.define([ 23 | route.wrap(route.get("/"), [ ... ]) // middleware single route 24 | ]) 25 | 26 | route.wrap(app, [ ... ]) // middleware group of routes 27 | ``` 28 | 29 | 30 | ### Middlewares Flow Back 31 | 32 | 33 | ### Common Middlewares 34 | In our very first example in [Getting Started]() we also included `spirit-common` as a middleware to adapter. 35 | 36 | `spirit-common` provides many default middleware that most people will need 37 | 38 | ### Express Middlewares 39 | Most Express middleware can be used as-is in spirit through [spirit-express](https://github.com/spirit-js/spirit-express) library. 40 | 41 | For example to use the passport: 42 | ```js 43 | ... TODO 44 | ``` 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /docs/writing-middleware.md: -------------------------------------------------------------------------------- 1 | A simple middleware in spirit looks like this: 2 | 3 | ```js 4 | (handler) => { 5 | return (request) => { 6 | return handler(request) 7 | } 8 | } 9 | 10 | // or simply: 11 | (handler) => (request) => handler(request) 12 | ``` 13 | 14 | You can think of `handler` as "next", the next function (middleware /or handler) to call. 15 | 16 | It has access to the request that gets passed along. But it also has access to the return value when calling `handler(request)`, therefore we can modify _both_ the request (input) and response (output). 17 | 18 | 19 | ### Transform request 20 | 21 | We can tranform the request by changing what we pass into the next handler: 22 | 23 | ```js 24 | (handler) => (request) => { 25 | request.data = "some data to pass forward" 26 | return handler(request) 27 | } 28 | ``` 29 | 30 | 31 | ### Transform response 32 | 33 | Notice in our examples above we `return` the result of `handler(request)`. 34 | 35 | By unwinding and returning the response, middleware can also transform the response. 36 | 37 | The return of `handler(request)` is _always_ a Promise for async compatibility. 38 | 39 | ```js 40 | (handler) => (request) => { 41 | return handler(request).then((resp) => { 42 | if (request.headers["etag"] === resp.get("etag")) resp.status = 304 43 | return resp 44 | }) 45 | } 46 | ``` 47 | 48 | In this example we modify the response to have a 304 status code if certain conditions are met. 49 | 50 | ##### Recovering From Errors 51 | 52 | In the same way we can recover from errors: 53 | ```js 54 | const {response} = require("spirit").node 55 | 56 | (handler) => (request) => { 57 | return handler(request).catch((err) => { 58 | return response("Recovered from " + err) 59 | }) 60 | } 61 | ``` 62 | Which we can use to send back some custom response. 63 | 64 | ##### Async/Await Example 65 | 66 | If you have ES7 compatibility (via babeljs) then the above examples combined will look like: 67 | 68 | ```js 69 | (handler) => async (request) => { 70 | const resp = await handler(request) 71 | if (request.headers["etag"] === resp.get("etag")) resp.status = 304 72 | return resp 73 | } 74 | ``` 75 | 76 | ### Initializing 77 | Usually it's not needed, but you can have initialization code for your middleware: 78 | 79 | ```js 80 | (handler) => { 81 | // initialize middleware here 82 | 83 | return (request) => handler(request) 84 | } 85 | ``` 86 | 87 | This code gets called __once__ for every time your middleware is loaded. This is useful when you want to do some special setup for each instance of your middleware. 88 | 89 | 90 | ### Testing 91 | 92 | Notice middleware is just a closure function that takes a input (request) and returns an (output). 93 | 94 | They do not deal with with `req` or `res` either, but instead a request map or response map (which are small JSON-like objects). 95 | 96 | This makes testing very easy. 97 | 98 | For example if we wanted to test a middleware that sets every HEAD request to be GET: 99 | ```js 100 | const middleware = (handler) => (request) => { 101 | if (request.method === "HEAD") request.method = "GET" 102 | return handler(request) 103 | } 104 | 105 | // test 106 | it("sets GET for every HEAD", () => { 107 | const test = (assertion) => (request) => expect(request.method).toBe(assertion) 108 | middleware(test("GET"))({ method: "HEAD" }) 109 | middleware(test("GET"))({ method: "GET" }) // doesn't change if already GET 110 | middleware(test("POST"))({ method: "POST" }) // doesn't change if POST 111 | }) 112 | ``` 113 | 114 | We don't need to mock a `req` object at all, we only needed to pass in an object with the same properties the middleware actually uses. 115 | 116 | ### Composing 117 | spirit's middleware signature may look familiar to some of you, as it's a common closure pattern. 118 | 119 | It was purposely chosen because it's a natural way of composing functions together, following the motto of "it's just javascript". 120 | 121 | There is nothing magical that happens when spirit runs or sets up our middleware together, it's basically composing (or wrapping) functions together. 122 | 123 | For example if we had 3 middlewares `a, b, c`: 124 | ```js 125 | const final = (request) => { status: 200, headers: {}, body: "Hello World" } 126 | 127 | // wrap the middlewares together with `final` 128 | const app = a(b(c(final))) 129 | 130 | // now we have a single function that runs through `a, b, c, final` returning the response along the way 131 | app(web_request) 132 | ``` 133 | 134 | > This is basically what spirit does. Except spirit also ensures a Promise is always returned. 135 | 136 | You can compose manually this way, but spirit also provides a helper function to automatically do this for you in `spirit.compose`. 137 | 138 | `spirit-router.wrap` also does this, but it has special handling for working with routes. -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var core = require("./lib/core/core") 2 | var p_utils = require("./lib/core/promise_utils") 3 | 4 | //-- spirit.node 5 | var node_adapter = require("./lib/http/node_adapter") 6 | var response = require("./lib/http/response") 7 | var response_map = require("./lib/http/response-class") 8 | var node = response 9 | node.adapter = node_adapter.adapter 10 | node.Response = response_map.Response 11 | ///////// setup node camelCase aliases 12 | node.isResponse = node.is_response 13 | node.makeStream = node.make_stream 14 | node.fileResponse = node.file_response 15 | node.errResponse = node.err_response 16 | 17 | //-- spirit.utils (Internal API but exported) 18 | // no camelCase aliases! 19 | node.utils = require("./lib/http/utils") 20 | node.utils.is_Response = response_map.is_Response 21 | node.utils.callp_response = p_utils.callp_response 22 | 23 | module.exports = { 24 | compose: core.compose, 25 | callp: p_utils.callp, 26 | is_promise: p_utils.is_promise, // exported but not documented 27 | 28 | node: node 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spirit", 3 | "version": "0.6.1", 4 | "description": "extensible web library for building applications & frameworks", 5 | "main": "index.js", 6 | "scripts": { 7 | "prepublish": "make release", 8 | "test": "make test" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git@github.com:spirit-js/spirit.git" 13 | }, 14 | "keywords": [ 15 | "express", 16 | "web", 17 | "http", 18 | "framework", 19 | "api", 20 | "rack", 21 | "rest", 22 | "spirit" 23 | ], 24 | "files": [ 25 | "docs", 26 | "index.js", 27 | "lib" 28 | ], 29 | "author": "hnry", 30 | "license": "ISC", 31 | "homepage": "https://github.com/spirit-js/spirit", 32 | "devDependencies": { 33 | "babel-cli": "^6.26.0", 34 | "babel-preset-env": "^1.6.1", 35 | "gitbook-cli": "^2.3.0", 36 | "istanbul": "^1.1.0-alpha.1", 37 | "jasmine": "^3.1.0" 38 | }, 39 | "dependencies": { 40 | "bluebird": "~3.5.1", 41 | "mime": "~1.3.6" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /spec/core/core-spec.js: -------------------------------------------------------------------------------- 1 | const reduce = require("../../index").compose 2 | 3 | describe("core - compose", () => { 4 | it("order", (done) => { 5 | const handler = (request) => { 6 | expect(request.called).toBe(".abc") 7 | return Promise.resolve({ resp: "." }) 8 | } 9 | 10 | const middleware = [ 11 | (handler) => { 12 | return (request) => { 13 | request.called += "a" 14 | return handler(request).then((response) => { 15 | response.resp += "1" 16 | return response 17 | }) 18 | } 19 | }, 20 | (handler) => { 21 | return (request) => { 22 | request.called += "b" 23 | return handler(request).then((response) => { 24 | response.resp += "2" 25 | return response 26 | }) 27 | } 28 | }, 29 | (handler) => { 30 | return (request) => { 31 | request.called += "c" 32 | return handler(request).then((response) => { 33 | response.resp += "3" 34 | return response 35 | }) 36 | } 37 | } 38 | ] 39 | 40 | const mock_req = { called: "." } 41 | 42 | const route = reduce(handler, middleware) 43 | const resp = route(mock_req) 44 | resp.then((response) => { 45 | expect(response).toEqual({ 46 | resp: ".321" 47 | }) 48 | done() 49 | }) 50 | }) 51 | 52 | it("ok with no middleware, empty array []", (done) => { 53 | const handler = (request) => { 54 | return Promise.resolve("ok") 55 | } 56 | const route = reduce(handler, []) 57 | route({}).then((resp) => { 58 | expect(resp).toBe("ok") 59 | done() 60 | }) 61 | }) 62 | 63 | it("converts the result of handler to Promise if it isn't", (done) => { 64 | const handler = (request) => { 65 | return "ok" 66 | } 67 | const route = reduce(handler, [ 68 | (handler) => { 69 | return (request) => { 70 | return handler(request).then((resp) => { 71 | resp += "ok" 72 | return resp 73 | }) 74 | } 75 | } 76 | ]) 77 | route({}).then((resp) => { 78 | expect(resp).toBe("okok") 79 | done() 80 | }) 81 | }) 82 | 83 | it("converts the result of middleware to Promise if it isn't", (done) => { 84 | const handler = () => {} 85 | 86 | const route = reduce(handler, [ 87 | (handler) => { 88 | return () => { 89 | return "ok" 90 | } 91 | } 92 | ]) 93 | route().then((resp) => { 94 | expect(resp).toBe("ok") 95 | done() 96 | }) 97 | }) 98 | 99 | it("can exit early and does not call remaining middlewares/handler", (done) => { 100 | let free = "a" 101 | 102 | const handler = () => { 103 | free += "_final" 104 | } 105 | 106 | const route = reduce(handler, [ 107 | (handler) => { 108 | return () => { 109 | return "ok" 110 | } 111 | }, 112 | (handler) => { 113 | return () => { 114 | free += "_mw2" 115 | return handler() 116 | } 117 | } 118 | ]) 119 | route().then((resp) => { 120 | expect(resp).toBe("ok") 121 | setTimeout(() => { 122 | expect(free).toBe("a") 123 | done() 124 | }, 10) 125 | }) 126 | }) 127 | 128 | it("each middleware will correctly call the next func with arguments", (done) => { 129 | const handler = function() { 130 | return Array.prototype.slice.call(arguments).join(",") 131 | } 132 | const route = reduce(handler, [ 133 | (handler) => { 134 | return (a, b, c) => { 135 | expect(a).toBe(1) 136 | expect(b).toBe(2) 137 | expect(c).toBe(undefined) 138 | return handler(a, b, 3) 139 | } 140 | }, 141 | (handler) => { 142 | return (a, b, c, d) => { 143 | expect(a).toBe(1) 144 | expect(b).toBe(2) 145 | expect(c).toBe(3) 146 | expect(d).toBe(undefined) 147 | return handler(a, b, c, 4) 148 | } 149 | } 150 | ]) 151 | route(1, 2).then((result) => { 152 | expect(result).toBe("1,2,3,4") 153 | done() 154 | }) 155 | }) 156 | 157 | it("promises ok", (done) => { 158 | const handler = () => { 159 | return new Promise((resolve, reject) => { 160 | resolve("ok") 161 | }) 162 | } 163 | 164 | const middleware = (_handler) => { 165 | return () => { 166 | return new Promise((resolve, reject) => { 167 | const result = _handler() 168 | resolve(result) 169 | }) 170 | } 171 | } 172 | 173 | const fn = reduce(handler, [middleware, middleware]) 174 | fn().then((result) => { 175 | expect(result).toBe("ok") 176 | done() 177 | }) 178 | }) 179 | }) 180 | -------------------------------------------------------------------------------- /spec/core/promise_utils-spec.js: -------------------------------------------------------------------------------- 1 | const {callp, is_promise} = require("../../index") 2 | const Promise = require("bluebird") 3 | 4 | describe("promise utils", () => { 5 | 6 | describe("callp", () => { 7 | it("accepts values instead of a function, returns value back wrapped as Promise", (done) => { 8 | const vals = ["string", 123, {a:1}, [1, 2, 3]] 9 | const _args = [1, 2] // shouldn't actually be used 10 | 11 | const test = (idx, next) => { 12 | const v = vals[idx] 13 | const r = callp(v, _args) 14 | expect(typeof r.then).toBe("function") 15 | r.then((resolved_v) => { 16 | expect(resolved_v).toBe(v) 17 | if (idx === vals.length - 1) { 18 | return done() 19 | } 20 | test(idx + 1) 21 | }) 22 | } 23 | test(0) 24 | }) 25 | 26 | it("if passed function, will call fn with `args`", (done) => { 27 | const fn = (a, b) => { 28 | expect(a).toBe(1) 29 | expect(b).toEqual({ a: 1, b: 2 }) 30 | done() 31 | } 32 | const args = [1, { a: 1, b: 2 }] 33 | callp(fn, args) 34 | }) 35 | 36 | it("sync funcs will have their values wrapped as Promise", (done) => { 37 | const fn = () => { 38 | return "ok" 39 | } 40 | 41 | const r = callp(fn) 42 | expect(typeof r.then).toBe("function") 43 | r.then((v) => { 44 | expect(v).toBe("ok") 45 | done() 46 | }) 47 | }) 48 | 49 | it("if func returns (rejected) Promise, returns it as-is", (done) => { 50 | const fn = () => { 51 | return new Promise((resolve, reject) => { 52 | reject("err") 53 | }) 54 | } 55 | const r = callp(fn) 56 | expect(typeof r.then).toBe("function") 57 | r.catch((v) => { 58 | expect(v).toBe("err") 59 | done() 60 | }) 61 | }) 62 | 63 | it("if func returns (resolved) Promise, returns it as-is", (done) => { 64 | const fn = () => { 65 | return new Promise((resolve, reject) => { 66 | setTimeout(() => { 67 | resolve("ok promise") 68 | }, 1) 69 | }) 70 | } 71 | const r = callp(fn) 72 | expect(typeof r.then).toBe("function") 73 | r.then((v) => { 74 | expect(v).toBe("ok promise") 75 | done() 76 | }) 77 | }) 78 | }) 79 | 80 | describe("is_promise", () => { 81 | it("returns true for promise", () => { 82 | const ok = [new Promise(()=>{}), Promise.resolve()] 83 | ok.forEach((p) => { 84 | expect(is_promise(p)).toBe(true) 85 | }) 86 | 87 | // this will return true (unfortunately) 88 | const not_promise = { then: () => {} } 89 | expect(is_promise(not_promise)).toBe(true) 90 | }) 91 | 92 | it("returns false for non-promise", () => { 93 | const not_ok = ["abc", { b: 1 }, () => { return Promise.resolve() }, [Promise.resolve()]] 94 | 95 | not_ok.forEach((not_p) => { 96 | expect(is_promise(not_p)).toBe(false) 97 | }) 98 | }) 99 | }) 100 | 101 | }) 102 | -------------------------------------------------------------------------------- /spec/http/node_adapter-spec.js: -------------------------------------------------------------------------------- 1 | const adp = require("../../index").node.adapter 2 | const send = require("../../lib/http/node_adapter").send 3 | const mock_response = require("../support/mock-response") 4 | 5 | const stream = require("stream") 6 | 7 | describe("node adapter", () => { 8 | 9 | describe("adapter", () => { 10 | it("returns a (req, res) fn that wraps core.compose", (done) => { 11 | const handler = (request) => { 12 | return { status: 200, headers: {}, body: "ok" } 13 | } 14 | 15 | const middleware = (handler) => (request) => { 16 | return handler(request) 17 | } 18 | 19 | const app = adp(handler, [middleware, middleware]) 20 | const res = mock_response((result) => { 21 | expect(result.body).toBe("ok") 22 | done() 23 | }) 24 | app({}, res) 25 | }) 26 | 27 | it("abstracts node's `req` by creating a request map", (done) => { 28 | const handler = (request) => { 29 | expect(request).toEqual(jasmine.objectContaining({ 30 | method: "GET", 31 | url: "/hi", 32 | headers: { a: 1 } 33 | })) 34 | expect(typeof request.req).toBe("function") 35 | return "ok" 36 | } 37 | 38 | const app = adp(handler, []) 39 | const res = mock_response(done) 40 | app({ 41 | method: "GET", 42 | url: "/hi", 43 | headers: { a: 1 } 44 | }, res) 45 | }) 46 | 47 | it("throws an err when a response map is not returned from handler + middleware, as it relies on a response map to write back to node's `res`", (done) => { 48 | const handler = (request) => { 49 | return "ok" 50 | } 51 | 52 | const app = adp(handler, []) 53 | const res = mock_response((result) => { 54 | expect(result.status).toBe(500) 55 | expect(result.headers["Content-Length"] > 20).toBe(true) 56 | expect(result.body).toMatch(/node.js adapter did not/) 57 | done() 58 | }) 59 | app({}, res) 60 | }) 61 | 62 | it("errors are surpressed when in NODE_ENV 'production'", (done) => { 63 | const handler = (request) => { 64 | return "ok" 65 | } 66 | const app = adp(handler, []) 67 | const res = mock_response((result) => { 68 | expect(result.status).toBe(500) 69 | expect(result.headers).toEqual({ "Content-Length": 0 }) 70 | expect(result.body).toBe(undefined) 71 | done() 72 | }) 73 | process.env.NODE_ENV = "production" 74 | app({}, res) 75 | }) 76 | 77 | it("middleware argument is optional", (done) => { 78 | const handler = (request) => { 79 | return "ok" 80 | } 81 | const res = mock_response(done) 82 | const app = adp(handler) 83 | app({}, res) 84 | }) 85 | 86 | it("middleware argument accepts a function", (done) => { 87 | const handler = (request) => { 88 | expect(request.a).toBe(1) 89 | return "ok" 90 | } 91 | const mw = (handler) => (request) => handler({a:1}) 92 | const app = adp(handler, mw) 93 | 94 | const res = mock_response(done) 95 | app({}, res) 96 | }) 97 | 98 | it("middleware are initialized once only", (done) => { 99 | let init = 0 100 | let called = 0 101 | 102 | const handler = (request) => { 103 | return { status: 200, headers: {}, body: "ok" } 104 | } 105 | const middleware = (handler) => { 106 | init += 1 107 | return (request) => { 108 | called += 1 109 | return handler(request) 110 | } 111 | } 112 | const app = adp(handler, [middleware, middleware]) 113 | 114 | const fake_request = (test_val, cb) => { 115 | app({}, mock_response(() => { 116 | expect(init).toBe(2) 117 | expect(called).toBe(test_val) 118 | cb() 119 | })) 120 | } 121 | 122 | const a = fake_request.bind(undefined, 8, done) 123 | const b = fake_request.bind(undefined, 6, a) 124 | const c = fake_request.bind(undefined, 4, b) 125 | fake_request(2, c) 126 | }) 127 | }) 128 | 129 | describe("send", () => { 130 | it("writes a response map (string)", (done) => { 131 | const res = mock_response((result) => { 132 | expect(result.status).toBe(123) 133 | expect(result.body).toBe("hi") 134 | expect(result.headers).toEqual({ 135 | a: 1, 136 | "Content-Length": 2 137 | }) 138 | done() 139 | }) 140 | 141 | send(res, { 142 | status: 123, 143 | headers: {"a": 1}, 144 | body: "hi" 145 | }) 146 | }) 147 | 148 | it("sets Content-Length header if prop exists but undefined", (done) => { 149 | const res = mock_response((result) => { 150 | expect(Object.keys(result.headers).length).toBe(1) 151 | expect(result.headers["Content-Length"]).toBe(2) 152 | done() 153 | }) 154 | send(res, { status: 200, headers: { "Content-Length": undefined }, body: "ok" }) 155 | }) 156 | 157 | it("no Content-Length when body is equivalent to empty", (done) => { 158 | const res = mock_response((result) => { 159 | expect(Object.keys(result.headers).length).toBe(1) 160 | expect(result.headers["Content-Length"]).toBe(0) 161 | 162 | const res2 = mock_response((result) => { 163 | expect(Object.keys(result.headers).length).toBe(1) 164 | expect(result.headers["Content-Length"]).toBe(0) 165 | done() 166 | }) 167 | send(res2, { status: 200, headers: {}, body: undefined }) 168 | }) 169 | send(res, { status: 200, headers: {}, body: "" }) 170 | }) 171 | 172 | it("does not set Content-Length header if already set", (done) => { 173 | const res = mock_response((result) => { 174 | expect(Object.keys(result.headers).length).toBe(1) 175 | expect(result.headers["Content-lEnGth"]).toBe("abc") 176 | done() 177 | }) 178 | send(res, { status: 200, headers: { "Content-lEnGth": "abc" }, body: "ok" }) 179 | }) 180 | 181 | it("writes a response map with piping (stream)", (done) => { 182 | const res = mock_response((result) => { 183 | expect(result.status).toBe(100) 184 | expect(result.headers).toEqual({ 185 | a: 2 186 | }) 187 | expect(result.body).toBe("hi from streamhi from stream") 188 | done() 189 | }) 190 | 191 | const rs = new stream.Readable({ 192 | read(n) { 193 | this.push("hi from stream") 194 | this.push("hi from stream") 195 | this.push(null) 196 | } 197 | }) 198 | 199 | send(res, { 200 | status: 100, 201 | headers: {"a": 2}, 202 | body: rs 203 | }) 204 | }) 205 | 206 | it("buffer ok", (done) => { 207 | const res = mock_response((result) => { 208 | expect(result.status).toBe(1) 209 | expect(result.body).toBe("hi from buffer") 210 | done() 211 | }) 212 | 213 | const buf = new Buffer("hi from buffer") 214 | send(res, { 215 | status: 1, headers: {}, body: buf 216 | }) 217 | }) 218 | 219 | it("undefined body ok", (done) => { 220 | const res = mock_response((result) => { 221 | expect(result.status).toBe(1) 222 | expect(result.headers).toEqual({ 223 | a: 1, 224 | "Content-Length": 0 225 | }) 226 | expect(result.body).toBe(undefined) 227 | done() 228 | }) 229 | 230 | send(res, { 231 | status: 1, headers: {a: 1}, body: undefined 232 | }) 233 | }) 234 | 235 | it("strips headers with undefined values before writing", (done) => { 236 | const res = mock_response((result) => { 237 | expect(result.headers).toEqual({ 238 | "Content-Length": 0, 239 | "Blah": 10 240 | }) 241 | done() 242 | }) 243 | 244 | send(res, { 245 | status: 123, 246 | headers: { 247 | "Blah": 10, 248 | "Content-Type": undefined 249 | } 250 | }) 251 | }) 252 | 253 | it("supports http2 api") 254 | }) 255 | 256 | }) 257 | -------------------------------------------------------------------------------- /spec/http/request-spec.js: -------------------------------------------------------------------------------- 1 | const request = require("../../lib/http/request") 2 | 3 | describe("http request", () => { 4 | 5 | let mock_req = { 6 | connection: { remoteAddress: "74.125.127.100" }, 7 | headers: { 8 | host: "localhost:3009" 9 | }, 10 | httpVersion: "1.1", 11 | method: "GET", 12 | url: "/hello" 13 | } 14 | 15 | describe("host & port", () => { 16 | it("sets host (string) and port (number)", () => { 17 | const rmap = {} 18 | request.hostport(mock_req, rmap) 19 | expect(rmap).toEqual({ 20 | host: "localhost", 21 | port: 3009 22 | }) 23 | }) 24 | 25 | it("supports ipv6 hosts and port", () => { 26 | const rmap = {} 27 | mock_req.headers.host = "[2620:10d:c021:11::75]" 28 | request.hostport(mock_req, rmap) 29 | expect(rmap).toEqual({ 30 | host: "[2620:10d:c021:11::75]" 31 | }) 32 | 33 | mock_req.headers.host = "[2620:10d:c021:11::75]:8000" 34 | request.hostport(mock_req, rmap) 35 | expect(rmap).toEqual({ 36 | host: "[2620:10d:c021:11::75]", 37 | port: 8000 38 | }) 39 | }) 40 | }) 41 | 42 | describe("urlquery", () => { 43 | it("sets the url and defaults query to {} when no query found", () => { 44 | const rmap = {} 45 | request.urlquery(mock_req, rmap) 46 | expect(rmap.url).toBe("/hello") 47 | expect(typeof rmap.query).toBe("object") 48 | expect(Object.keys(rmap.query).length).toBe(0) 49 | }) 50 | 51 | it("sets query as an object and keeps original url as url", () => { 52 | const rmap = {} 53 | mock_req.url = "/p/a/t/h?hi=test#hash" 54 | request.urlquery(mock_req, rmap) 55 | expect(rmap.url).toBe("/p/a/t/h") 56 | expect(rmap.query.hi).toBe("test") 57 | }) 58 | }) 59 | 60 | describe("protocol", () => { 61 | it("defaults to http", () => { 62 | const rmap = {} 63 | request.protocol(mock_req, rmap) 64 | expect(rmap.protocol).toBe("http") 65 | }) 66 | 67 | it("flags as https if req.connection.encrypted exists", () => { 68 | const rmap = {} 69 | mock_req.connection.encrypted = true 70 | request.protocol(mock_req, rmap) 71 | expect(rmap.protocol).toBe("https") 72 | }) 73 | }) 74 | 75 | describe("create", () => { 76 | it("request map", () => { 77 | let mock_req = { 78 | connection: { remoteAddress: "74.125.127.100" }, 79 | headers: { 80 | host: "localhost:3009" 81 | }, 82 | httpVersion: "1.1", 83 | method: "POST", 84 | url: "/hello?a=1" 85 | } 86 | 87 | const result = request.create(mock_req) 88 | expect(result.port).toBe(3009) 89 | expect(result.host).toBe("localhost") 90 | expect(result.ip).toBe("74.125.127.100") 91 | expect(result.url).toBe("/hello") 92 | expect(result.pathname).toBe(result.url) 93 | expect(result.path).toBe("/hello?a=1") 94 | expect(result.method).toBe("POST") 95 | expect(result.scheme).toBe("1.1") 96 | expect(result.protocol).toBe("http") 97 | expect(result.headers).toBe(mock_req.headers) 98 | //expect(result.body).toBe(mock_req) 99 | expect(result.req()).toBe(mock_req) 100 | expect(result.query.a).toBe("1") 101 | 102 | expect(Object.keys(result).length).toBe(12) 103 | }) 104 | 105 | it("passes method, httpVersion, headers of req", () => { 106 | const result = request.create(mock_req) 107 | expect(result.headers).toBe(mock_req.headers) 108 | expect(result.scheme).toBe("1.1") 109 | expect(result.method).toBe("GET") 110 | }) 111 | 112 | }) 113 | 114 | }) 115 | -------------------------------------------------------------------------------- /spec/http/response-spec.js: -------------------------------------------------------------------------------- 1 | const response = require("../../index").node 2 | const Response = require("../../lib/http/response-class").Response 3 | const fs = require("fs") 4 | 5 | describe("http.response", () => { 6 | 7 | describe("is_response", () => { 8 | it("returns true for valid response maps", () => { 9 | const valid = [ 10 | { status: 123, headers: {} }, 11 | { status: 123, headers: {}, body: "" }, 12 | { status: 123, headers: {"Content-Type": "hi"} }, 13 | ] 14 | 15 | valid.forEach((v) => { 16 | expect(response.is_response(v)).toBe(true) 17 | }) 18 | }) 19 | 20 | it("returns false for invalid response maps", () => { 21 | const invalid = [ 22 | "", 123, {}, [], null, 23 | { status: "hi", headers: {} }, 24 | { status: 123, body: 123 }, 25 | { status: 123, headers: [] } 26 | ] 27 | 28 | invalid.forEach((v) => { 29 | expect(response.is_response(v)).toBe(false) 30 | }) 31 | }) 32 | 33 | it("alias", () => { 34 | expect(response.is_response).toBe(response.isResponse) 35 | }) 36 | }) 37 | 38 | const stream = require("stream") 39 | describe("make_stream", () => { 40 | it("returns a writable stream", (done) => { 41 | const buf = [] 42 | const t = new stream.Transform({ 43 | transform(chunk, enc, cb) { 44 | buf.push(chunk.toString()) 45 | if (buf.join("") === "test123") done() 46 | cb() 47 | } 48 | }) 49 | 50 | const s = response.make_stream() 51 | s.write("test") 52 | s.write("1") 53 | s.write("2") 54 | s.end("3") 55 | s.pipe(t) 56 | }) 57 | 58 | it("using with a response is ok", () => { 59 | const s = response.make_stream() 60 | const resp = response.response(s) 61 | expect(resp.status).toBe(200) 62 | expect(resp.headers).toEqual({}) 63 | expect(resp.body).toBe(s) 64 | }) 65 | 66 | it("alias", () => { 67 | expect(response.make_stream).toBe(response.makeStream) 68 | }) 69 | }) 70 | 71 | describe("response", () => { 72 | it("returns a response map from value", () => { 73 | const r = response.response("hey") 74 | expect(r).toEqual(jasmine.objectContaining({ 75 | status: 200, 76 | headers: { 77 | "Content-Type": "text/html; charset=utf-8" 78 | }, 79 | body: "hey" 80 | })) 81 | expect(r instanceof Response).toBe(true) 82 | }) 83 | 84 | it("returns a response map from {}response map", () => { 85 | const r = response.response({ 86 | status: 123, 87 | headers: { a: 1 } 88 | }) 89 | expect(r).toEqual(jasmine.objectContaining({ 90 | status: 123, 91 | headers: { a: 1 }, 92 | body: undefined 93 | })) 94 | expect(r instanceof Response).toBe(true) 95 | }) 96 | 97 | it("returns a new Response if already Response", () => { 98 | const t = new Response("hi") 99 | const r = response.response(t) 100 | expect(r instanceof Response).toBe(true) 101 | expect(r).not.toBe(t) 102 | expect(r).toEqual(jasmine.objectContaining({ 103 | status: 200, 104 | headers: {}, 105 | body: "hi" 106 | })) 107 | }) 108 | }) 109 | 110 | describe("file_response", () => { 111 | 112 | const test_file = __dirname + "/../../package.json" 113 | 114 | const test_expect = (resp) => { 115 | expect(resp.status).toBe(200) 116 | // file is probably greater than 500 bytes 117 | expect(resp.headers["Content-Length"] > 500).toBe(true) 118 | expect(resp.headers["Content-Type"]).toBe("application/json; charset=utf-8") 119 | expect(Object.keys(resp.headers).length).toBe(3) 120 | expect(typeof resp.body.pipe).toBe("function") 121 | 122 | // also attaches file information to ._file 123 | expect(resp._file.filepath).toMatch(/package.json$/) 124 | expect(resp._file instanceof fs.Stats).toBe(true) 125 | } 126 | 127 | it("creates response from file path string with a body stream", (done) => { 128 | response.file_response(test_file) 129 | .then((resp) => { 130 | test_expect(resp) 131 | done() 132 | }) 133 | }) 134 | 135 | it("creates response from a file stream", (done) => { 136 | const f = fs.createReadStream(test_file) 137 | response.file_response(f).then((resp) => { 138 | test_expect(resp) 139 | done() 140 | }) 141 | }) 142 | 143 | it("errors are catchable", (done) => { 144 | response.file_response("no_exist.txt").catch((err) => { 145 | expect(err).toMatch(/no_exist.txt/) 146 | done() 147 | }) 148 | }) 149 | 150 | it("sets Last-Modified headers to the file's mtime", (done) => { 151 | fs.stat(test_file, (err, stat) => { 152 | response.file_response(test_file) 153 | .then((resp) => { 154 | test_expect(resp) 155 | expect(resp.headers["Last-Modified"]).toBe(stat.mtime.toUTCString()) 156 | done() 157 | }) 158 | }) 159 | }) 160 | 161 | it("alias", () => { 162 | expect(response.file_response).toBe(response.fileResponse) 163 | }) 164 | 165 | it("throws when called with non-string or non-file stream argument", () => { 166 | expect(() => { 167 | response.file_response(123) 168 | }).toThrowError(/Expected a file/) 169 | 170 | expect(() => { 171 | response.file_response({}) 172 | }).toThrowError(/Expected a file/) 173 | 174 | expect(() => { 175 | response.file_response({ path: "hi" }) 176 | }).toThrowError(/Expected a file/) 177 | }) 178 | 179 | it("throws when argument is not a file", (done) => { 180 | response.file_response("lib/").catch((err) => { 181 | expect(err).toMatch(/not a file/) 182 | done() 183 | }) 184 | }) 185 | }) 186 | 187 | describe("redirect", () => { 188 | it("generates a response map for redirecting", () => { 189 | let rmap = response.redirect(123, "google") 190 | expect(rmap).toEqual(jasmine.objectContaining({ 191 | status: 123, 192 | body: undefined, 193 | headers: { "Location": "google" } 194 | })) 195 | expect(rmap instanceof Response).toBe(true) 196 | 197 | // defaults status to 302 198 | rmap = response.redirect("blah") 199 | expect(rmap).toEqual(jasmine.objectContaining({ 200 | status: 302, 201 | body: undefined, 202 | headers: { "Location": "blah" } 203 | })) 204 | expect(rmap instanceof Response).toBe(true) 205 | }) 206 | 207 | it("throws an error for invalid arguments", () => { 208 | const test = (status, url) => { 209 | expect(() => { 210 | response.redirect(status, url) 211 | }).toThrowError(/invalid arguments/) 212 | } 213 | 214 | test(123) 215 | test("blah", 123) 216 | test("hi", "blah") 217 | }) 218 | }) 219 | 220 | describe("err_response", () => { 221 | it("returns a response map with status 500", () => { 222 | expect(response.err_response("test 123")).toEqual(jasmine.objectContaining({ 223 | status: 500, 224 | headers: {}, 225 | body: "test 123" 226 | })) 227 | }) 228 | 229 | it("returns a default message if no body", () => { 230 | const resp = response.err_response() 231 | expect(resp).toEqual(jasmine.objectContaining({ 232 | status: 500, 233 | headers: {} 234 | })) 235 | expect(resp.body).toMatch(/no error message/) 236 | }) 237 | 238 | it("accepts a Error and will output the err.stack", () => { 239 | const err = new Error("err") 240 | expect(response.err_response(err).body).toMatch(/response-spec/) 241 | }) 242 | 243 | it("alias", () => { 244 | expect(response.err_response).toBe(response.errResponse) 245 | }) 246 | }) 247 | 248 | }) 249 | -------------------------------------------------------------------------------- /spec/http/response_class-spec.js: -------------------------------------------------------------------------------- 1 | const spirit = require("../../index") 2 | const Response = spirit.node.Response 3 | const is_Response = spirit.node.utils.is_Response 4 | 5 | describe("response-class", () => { 6 | describe("is_Response", () => { 7 | it("returns true if response map", () => { 8 | const t = new Response() 9 | const r = is_Response(t) 10 | expect(r).toBe(true) 11 | }) 12 | 13 | it("returns false for {}response map", () => { 14 | const t = { status: 200, headers: {}, body: "" } 15 | const r = is_Response(t) 16 | expect(r).toBe(false) 17 | }) 18 | 19 | it("returns false for invalid types", () => { 20 | const invalid = [null, undefined, "", 123, {}] 21 | invalid.forEach((notok) => { 22 | expect(is_Response(notok)).toBe(false) 23 | }) 24 | }) 25 | }) 26 | 27 | describe("Response", () => { 28 | it("initializes with a default response map", () => { 29 | let r = new Response() 30 | expect(r).toEqual(jasmine.objectContaining({ 31 | status: 200, 32 | headers: {}, 33 | body: undefined 34 | })) 35 | 36 | r = new Response(123) 37 | expect(r).toEqual(jasmine.objectContaining({ 38 | status: 200, 39 | headers: {}, 40 | body: 123 41 | })) 42 | }) 43 | 44 | describe("status_ , code", () => { 45 | it("sets the status code and returns this", () => { 46 | const r = new Response() 47 | const result = r.status_(78) 48 | expect(r.status).toBe(78) 49 | expect(result).toBe(r) 50 | }) 51 | 52 | it("converts non-number arguments to number", () => { 53 | const r = new Response().status_("123") 54 | expect(r.status).toBe(123) 55 | }) 56 | }) 57 | 58 | describe("body_", () => { 59 | it("sets the body of the response, but also adjusts the content length when it is a string or buffer", () => { 60 | const r = new Response("hello") 61 | expect(r.headers["Content-Length"]).toBe(undefined) 62 | 63 | let result = r.body_("hello world") 64 | r.len(11) 65 | expect(r.body).toBe("hello world") 66 | 67 | const buf = new Buffer("hello") 68 | result = r.body_(buf) 69 | expect(r.headers["Content-Length"]).toBe(undefined) 70 | expect(r.body).toBe(buf) 71 | 72 | expect(result).toBe(r) // returns this 73 | }) 74 | 75 | it("for undefined or a stream (or non string / buffer), removes previous content-length",() => { 76 | const r = new Response("hello") 77 | r.headers["Content-Length"] = 5 78 | expect(r.body).toBe("hello") 79 | r.body_(undefined) 80 | expect(r.body).toBe(undefined) 81 | expect(r.headers["Content-Length"]).toBe(undefined) 82 | 83 | r.body_("hello") 84 | expect(r.headers["Content-Length"]).toBe(undefined) 85 | 86 | r.len(100) 87 | r.body_({}) // pretend this object is stream 88 | expect(r.headers["Content-Length"]).toBe(undefined) 89 | }) 90 | }) 91 | 92 | describe("get", () => { 93 | it("returns value of the passed in key from headers", () => { 94 | const r = new Response() 95 | expect(r.get("BlAh")).toBe(undefined) 96 | r.headers["blah"] = 4 97 | expect(r.get("BlAh")).toBe(4) 98 | }) 99 | }) 100 | 101 | describe("set", () => { 102 | it("will correct the field name on possible duplicate", () => { 103 | const r = new Response() 104 | 105 | r.set("content-length", 100) 106 | expect(r.headers["content-length"]).toBe(100) 107 | r.set("content-length", 10) 108 | expect(r.headers["content-length"]).toBe(10) 109 | 110 | r.set("conTent-length", 900) 111 | expect(r.headers["Content-Length"]).toBe(900) 112 | }) 113 | 114 | it("replaces field names with proper one", () => { 115 | const r = new Response() 116 | r.headers["content-length"] = 1 117 | expect(r.headers["content-length"]).toBe(1) 118 | r.set("contenT-length", 100) 119 | expect(r.headers["Content-Length"]).toBe(100) 120 | expect(r.headers["content-length"]).toBe(undefined) 121 | expect(Object.keys(r.headers).length).toBe(1) 122 | }) 123 | 124 | it("will overwrite an existing key (case-insensitive)", () => { 125 | const r = new Response() 126 | r.headers["Content-Length"] = 200 127 | r.set("content-length", 1) 128 | expect(r.headers["Content-Length"]).toBe(1) 129 | expect(r.headers["content-length"]).toBe(undefined) 130 | }) 131 | 132 | it("avoids writing a header if it doesn't exist and the value to be set is undefined", () => { 133 | const r = new Response() 134 | r.set("Content-Length", undefined) 135 | expect(r.headers).toEqual({}) 136 | }) 137 | 138 | it("ETag has special handling to correct it's case", () => { 139 | const r = new Response() 140 | r.set("Etag", 123) 141 | expect(r.headers).toEqual({ 142 | "Etag": 123 143 | }) 144 | 145 | r.set("ETAG", 321) 146 | expect(r.headers).toEqual({ 147 | "ETag": 321 148 | }) 149 | }) 150 | }) 151 | 152 | describe("type", () => { 153 | it("sets the content type from known types and returns this", () => { 154 | const r = new Response() 155 | const result = r.type("json") 156 | expect(r.headers["Content-Type"]).toBe("application/json; charset=utf-8") 157 | expect(result).toBe(r) 158 | }) 159 | 160 | it("if content type is unknown sets the content type to be argument passed in", () => { 161 | const r = new Response().type("json123") 162 | expect(r.headers["Content-Type"]).toBe("json123") 163 | }) 164 | 165 | it("sets utf-8 charset by default for text/* content types", () => { 166 | const r = new Response().type("text") 167 | expect(r.headers["Content-Type"]).toBe("text/plain; charset=utf-8") 168 | }) 169 | 170 | const stream = require("stream") 171 | const fs = require("fs") 172 | 173 | it("if type is set to 'json', the body will automatically be converted to JSON", () => { 174 | const tester = (arr, expect_json) => { 175 | arr.forEach((t) => { 176 | const r = new Response(t).type("json") 177 | expect(r.headers["Content-Type"]).toBe("application/json; charset=utf-8") 178 | if (expect_json) { 179 | expect(r.body).toBe(JSON.stringify(t)) 180 | } else { 181 | expect(r.body).toBe(t) 182 | } 183 | }) 184 | } 185 | // not ok -> string, buffer, stream, file-stream 186 | // will not convert even if "json" set 187 | tester([ 188 | "hi", 189 | new Buffer([1, 2, 3]), 190 | new stream.Readable(), 191 | new fs.ReadStream("./package.json") 192 | ], false) 193 | // ok -> array, null, undefined, object (that doesn't 194 | // match the above) 195 | tester([[1,2,3], null, undefined, { a: 1, b: 2 }], true) 196 | 197 | // doesn't trigger conversion 198 | const r = new Response([1,2,3]).type("JS ON") 199 | expect(r.body).toEqual([1,2,3]) 200 | }) 201 | }) 202 | 203 | describe("len", () => { 204 | it("sets the 'Content-Length'", () => { 205 | const r = new Response() 206 | r.len(154) 207 | expect(r.headers).toEqual({ 208 | "Content-Length": 154 209 | }) 210 | }) 211 | 212 | it("size of 0 is considered undefined", () => { 213 | const r = new Response() 214 | r.len(0) 215 | expect(r.headers).toEqual({}) 216 | 217 | r.len(123) 218 | expect(r.headers).toEqual({ 219 | "Content-Length": 123 220 | }) 221 | 222 | r.len(0) 223 | expect(r.headers).toEqual({ 224 | "Content-Length": undefined 225 | }) 226 | 227 | r.len(123).len() 228 | expect(r.headers).toEqual({ 229 | "Content-Length": undefined 230 | }) 231 | }) 232 | 233 | it("will throw for non-number", () => { 234 | const r = new Response() 235 | expect(() => { 236 | r.len(null) 237 | }).toThrowError(/Expected number/) 238 | 239 | // but undefined is ok 240 | r.len(undefined) 241 | }) 242 | }) 243 | 244 | describe("attachment", () => { 245 | it("sets 'Content-Disposition'", () => { 246 | const r = new Response() 247 | r.attachment("") 248 | expect(r.headers).toEqual({ 249 | "Content-Disposition": "attachment" 250 | }) 251 | 252 | r.attachment("blah.txt") 253 | expect(r.headers).toEqual({ 254 | "Content-Disposition": "attachment; filename=blah.txt" 255 | }) 256 | 257 | const ret = r.attachment() 258 | expect(r.headers).toEqual({ 259 | "Content-Disposition": undefined 260 | }) 261 | 262 | expect(ret).toBe(r) 263 | }) 264 | }) 265 | 266 | describe("cookie", () => { 267 | it("sets a cookie in headers", () => { 268 | const r = new Response() 269 | r.cookie("test", "123") 270 | expect(r.headers["Set-Cookie"]).toEqual(["test=123"]) 271 | 272 | const t = r.cookie("another", "hi") 273 | expect(r.headers["Set-Cookie"]).toEqual([ 274 | "test=123", 275 | "another=hi" 276 | ]) 277 | 278 | expect(t).toBe(r) // returns this 279 | }) 280 | 281 | it("non-string values are converted to string", () => { 282 | const r = new Response() 283 | r.cookie("test", 123) 284 | expect(r.headers["Set-Cookie"]).toEqual(["test=123"]) 285 | }) 286 | 287 | it("values are always encoded with encodeURIComponent", () => { 288 | const r = new Response() 289 | r.cookie("test", "hi ! á") 290 | expect(r.headers["Set-Cookie"]).toEqual(["test=hi%20!%20%C3%A1"]) 291 | }) 292 | 293 | it("duplicates are not handled in any way", () => { 294 | const r = new Response() 295 | r.cookie("test", 123) 296 | expect(r.headers["Set-Cookie"]).toEqual(["test=123"]) 297 | r.cookie("test", 123) 298 | expect(r.headers["Set-Cookie"]).toEqual(["test=123", "test=123"]) 299 | }) 300 | 301 | it("path option", () => { 302 | const r = new Response() 303 | r.cookie("test", "123", { path: "/" }) 304 | expect(r.headers["Set-Cookie"]).toEqual(["test=123;Path=/"]) 305 | r.cookie("aBc", "123", { path: "/test" }) 306 | expect(r.headers["Set-Cookie"]).toEqual([ 307 | "test=123;Path=/", 308 | "aBc=123;Path=/test" 309 | ]) 310 | }) 311 | 312 | it("domain, httponly, maxage, secure options", () => { 313 | const r = new Response() 314 | // domain 315 | r.cookie("test", "123", { domain: "test.com" }) 316 | expect(r.headers["Set-Cookie"]).toEqual(["test=123;Domain=test.com"]) 317 | // httponly 318 | r.cookie("a", "b", { httponly: true }) 319 | expect(r.headers["Set-Cookie"][1]).toBe("a=b;HttpOnly") 320 | // maxage 321 | r.cookie("a", "b", { maxage: "2000" }) 322 | expect(r.headers["Set-Cookie"][2]).toBe("a=b;Max-Age=2000") 323 | // secure 324 | r.cookie("a", "b", { secure: true }) 325 | expect(r.headers["Set-Cookie"][3]).toBe("a=b;Secure") 326 | 327 | // all together 328 | r.cookie("a", "b", { 329 | httponly: true, 330 | secure: true, 331 | maxage: 4000, 332 | domain: "test.com" 333 | }) 334 | expect(r.headers["Set-Cookie"][4]).toBe("a=b;Domain=test.com;Max-Age=4000;Secure;HttpOnly") 335 | }) 336 | 337 | it("expires option", () => { 338 | // date object ok 339 | const date = new Date() 340 | const r = new Response() 341 | r.cookie("c", "d", { expires: date }) 342 | expect(r.headers["Set-Cookie"][0]).toBe("c=d;Expires=" + date.toUTCString()) 343 | 344 | // string ok 345 | r.cookie("c", "d", { expires: "hihihi" }) 346 | expect(r.headers["Set-Cookie"][1]).toBe("c=d;Expires=hihihi") 347 | }) 348 | 349 | describe("deleting cookies", () => { 350 | it("setting an undefined value means to delete any previous cookie matching the same name & path", () => { 351 | const r = new Response() 352 | r.cookie("test", "123", { path: "/" }) 353 | expect(r.headers["Set-Cookie"]).toEqual(["test=123;Path=/"]) 354 | r.cookie("test", undefined, { path: "/" }) 355 | expect(r.headers["Set-Cookie"]).toEqual([]) 356 | 357 | r.cookie("test", "123", { path: "/" }) 358 | r.cookie("test", "123", { path: "/" }) 359 | r.cookie("test", "123", { path: "/" }) 360 | expect(r.headers["Set-Cookie"].length).toBe(3) 361 | const t = r.cookie("test", { path: "/" }) 362 | expect(r.headers["Set-Cookie"]).toEqual([]) 363 | 364 | expect(t).toBe(r) // returns this 365 | }) 366 | }) 367 | 368 | it("path always defaults to '/' and options do not affect matching", () => { 369 | const r = new Response() 370 | r.cookie("test", "123", { path: "/", httponly: true }) 371 | r.cookie("test", "123") 372 | r.cookie("test", "123", { path: "/", domain: "hi.com" }) 373 | expect(r.headers["Set-Cookie"].length).toBe(3) 374 | r.cookie("test") 375 | expect(r.headers["Set-Cookie"]).toEqual([]) 376 | }) 377 | 378 | it("doesn't touch non-matching cookies", () => { 379 | const r = new Response() 380 | r.cookie("test", "123", { path: "/" }) 381 | r.cookie("test", "123", { path: "/test" }) 382 | r.cookie("tesT", "123", { path: "/" }) 383 | expect(r.headers["Set-Cookie"].length).toBe(3) 384 | r.cookie("test", undefined) 385 | expect(r.headers["Set-Cookie"].length).toBe(2) 386 | expect(r.headers["Set-Cookie"]).toEqual([ 387 | "test=123;Path=/test", 388 | "tesT=123;Path=/" 389 | ]) 390 | }) 391 | }) 392 | 393 | }) 394 | 395 | }) 396 | -------------------------------------------------------------------------------- /spec/http/utils-spec.js: -------------------------------------------------------------------------------- 1 | const utils = require("../../index").node.utils 2 | 3 | const stream = require("stream") 4 | const fs = require("fs") 5 | const Promise = require("bluebird") 6 | 7 | describe("callp_response", () => { 8 | const resolve = utils.callp_response 9 | 10 | it("like callp, will return primitive values wrapped as a Promise", (done) => { 11 | resolve(123).then((v) => { 12 | expect(v).toBe(123) 13 | done() 14 | }) 15 | }) 16 | 17 | it("returns the value when giving a response map with a promise as it's body", (done) => { 18 | const f = () => { 19 | return { 20 | status: 123, 21 | headers: {}, 22 | body: new Promise((resolve, reject) => { 23 | resolve("hi!") 24 | }) 25 | } 26 | } 27 | 28 | resolve(f).then((result) => { 29 | expect(result).toEqual({ 30 | status: 123, 31 | headers: {}, 32 | body: "hi!" 33 | }) 34 | done() 35 | }) 36 | }) 37 | 38 | it("returns the promise passed in, if it's resolved value is a response map but non-promise body", (done) => { 39 | const p = Promise.resolve({ 40 | status: 123, 41 | headers: {a:1}, 42 | body: "yay" 43 | }) 44 | 45 | resolve(p).then((result) => { 46 | expect(result).toEqual({ 47 | status: 123, 48 | headers: {a:1}, 49 | body: "yay" 50 | }) 51 | done() 52 | }) 53 | }) 54 | 55 | it("returns the promise passed in, if it's resolved value is not a response map", (done) => { 56 | const p = Promise.resolve(123) 57 | resolve(p).then((result) => { 58 | expect(result).toBe(123) 59 | done() 60 | }) 61 | }) 62 | 63 | it("if the response body is a rejected promise, it ignores the responses and returns the rejected promise", (done) => { 64 | const f = () => { 65 | const p = Promise.reject("error") 66 | return { 67 | status: 123, 68 | headers: {a:1}, 69 | body: p 70 | } 71 | } 72 | 73 | resolve(f).catch((err) => { 74 | expect(err).toBe("error") 75 | done() 76 | }) 77 | }) 78 | 79 | it("response body is rejected async", (done) => { 80 | const f = () => { 81 | const p = new Promise((resolve, reject) => setTimeout(() => reject("async reject"), 10)) 82 | 83 | return { 84 | status: 123, 85 | headers: {a:1}, 86 | body: p 87 | } 88 | } 89 | 90 | resolve(f).catch((err) => { 91 | expect(err).toBe("async reject") 92 | done() 93 | }) 94 | }) 95 | 96 | }) 97 | 98 | describe("size_of", () => { 99 | it("returns the size in bytes of a string", () => { 100 | let size = utils.size_of("1abcdé") 101 | expect(size).toBe(7) 102 | size = utils.size_of("") 103 | expect(size).toBe(0) 104 | }) 105 | 106 | it("returns the size in bytes of a Buffer", () => { 107 | const size = utils.size_of(new Buffer("123")) 108 | expect(size).toBe(3) 109 | }) 110 | 111 | it("returns undefined if unable to determine size", () => { 112 | let size = utils.size_of([1,2,3,4,5]) 113 | expect(size).toBe(undefined) 114 | size = utils.size_of() 115 | expect(size).toBe(undefined) 116 | size = utils.size_of(0) 117 | expect(size).toBe(undefined) 118 | }) 119 | 120 | }) 121 | 122 | describe("type_of", () => { 123 | it("returns the type of obj as string (just like `typeof`)", () => { 124 | const test = ["string", 123, true, undefined, () => {}] 125 | test.forEach((t) => { 126 | expect(typeof t).toBe(utils.type_of(t)) 127 | }) 128 | }) 129 | 130 | it("null", () => { 131 | expect(utils.type_of(null)).toBe("null") 132 | }) 133 | 134 | it("array", () => { 135 | expect(utils.type_of([1,2,3])).toBe("array") 136 | }) 137 | 138 | it("buffer", () => { 139 | expect(utils.type_of(new Buffer(""))).toBe("buffer") 140 | }) 141 | 142 | it("stream", () => { 143 | const s = new stream.Writable() 144 | expect(utils.type_of(s)).toBe("stream") 145 | }) 146 | 147 | it("file-stream", () => { 148 | const f = fs.createReadStream(__dirname + "/node_adapter-spec.js") 149 | expect(utils.type_of(f)).toBe("file-stream") 150 | }) 151 | 152 | }) 153 | -------------------------------------------------------------------------------- /spec/spirit-spec.js: -------------------------------------------------------------------------------- 1 | const spirit = require("../index") 2 | 3 | describe("spirit spec", () => { 4 | 5 | it("it wraps a handler and middlewares into a reducer function which reduces through the middlewares and handler and 'rewinds' flowing backwards returning the result; the order -> last middleware is the first input, last output. the first middleware is the last input, first output", (done) => { 6 | const handler = (input) => { 7 | expect(input).toBe("abc") 8 | return "out" 9 | } 10 | 11 | const middlewares = [ 12 | (handler) => { 13 | return (input) => { 14 | input += "b" 15 | return handler(input).then((result) => { 16 | return result + "1" 17 | }) 18 | } 19 | }, 20 | (handler) => { 21 | return (input) => { 22 | input += "c" 23 | return handler(input).then((result) => { 24 | return result + "2" 25 | }) 26 | } 27 | } 28 | ] 29 | 30 | const reducer = spirit.compose(handler, middlewares) 31 | reducer("a").then((result) => { 32 | expect(result).toBe("out21") 33 | done() 34 | }) 35 | }) 36 | 37 | it("the handler can return a Promise, or plain values will be converted to a Promise", (done) => { 38 | const handler = (input) => { 39 | return new Promise((resolve, reject) => { 40 | resolve("ok") 41 | }) 42 | } 43 | 44 | const middleware = [ 45 | (handler) => { 46 | return (input) => { 47 | return handler(input).then((result) => { 48 | return result + "2" 49 | }) 50 | } 51 | } 52 | ] 53 | 54 | const reducer = spirit.compose(handler, middleware) 55 | reducer("a").then((result) => { 56 | expect(result).toBe("ok2") 57 | done() 58 | }) 59 | }) 60 | 61 | it("middlewares can decide to stop passing data forward (input), data 'rewinds' and flows back out through all middleware called along the way", (done) => { 62 | const handler = (input) => { 63 | throw "never gets run" 64 | } 65 | const middlewares = [ 66 | (handler) => { 67 | return (input) => { 68 | input += "b" 69 | return handler(input).then((result) => { 70 | return result + "2" 71 | }) 72 | } 73 | }, 74 | (handler) => { 75 | return (input) => { 76 | expect(input).toBe("ab") 77 | return Promise.resolve("stop") 78 | } 79 | } 80 | ] 81 | const reducer = spirit.compose(handler, middlewares) 82 | reducer("a").then((result) => { 83 | expect(result).toBe("stop2") 84 | done() 85 | }) 86 | }) 87 | 88 | it("a adapter transforms data between spirit and another interface. an adapter controls the first entry point and last exit point, it can also catch any uncaught errors along the way", (done) => { 89 | const handler = (input) => { 90 | throw "catch" 91 | } 92 | 93 | const middlewares = [ 94 | (handler) => { 95 | return (input) => { 96 | return handler(input) 97 | } 98 | } 99 | ] 100 | 101 | const reducer = spirit.compose(handler, middlewares) 102 | const adapter = () => { 103 | reducer("hi").catch((err) => { 104 | expect(err).toBe("catch") 105 | done() 106 | }) 107 | } 108 | 109 | adapter() 110 | }) 111 | 112 | }) 113 | 114 | describe("spirit exported API", () => { 115 | it("core", () => { 116 | const api = Object.keys(spirit) 117 | expect(api).toEqual([ 118 | "compose", 119 | "callp", 120 | "is_promise", // not documented as exported 121 | "node" 122 | ]) 123 | expect(api.length).toEqual(4) 124 | api.forEach((f) => { 125 | if (f === "node") { 126 | return expect(typeof spirit[f]).toBe("object") 127 | } 128 | expect(typeof spirit[f]).toBe("function") 129 | }) 130 | }) 131 | 132 | it("node", () => { 133 | const api = Object.keys(spirit.node).sort() 134 | const test_api = [ 135 | "adapter", 136 | "response", 137 | "redirect", 138 | "make_stream", 139 | "is_response", 140 | "file_response", 141 | "err_response", 142 | "Response", 143 | "utils" 144 | ] 145 | 146 | // add in camelCase aliases 147 | const test_aliases = test_api.reduce((ta, api_name) => { 148 | const t = api_name.split("_") 149 | if (t.length === 2) { 150 | const alias = t[0] + t[1][0].toUpperCase() + t[1].slice(1) 151 | ta.push(alias) 152 | } 153 | return ta 154 | }, []) 155 | 156 | expect(api).toEqual(test_api.concat(test_aliases).sort()) 157 | 158 | api.forEach((f) => { 159 | if (f === "utils") { 160 | return expect(typeof spirit.node[f]).toBe("object") 161 | } 162 | expect(typeof spirit.node[f]).toBe("function") 163 | }) 164 | }) 165 | 166 | it("node.utils", () => { 167 | const api = Object.keys(spirit.node.utils).sort() 168 | expect(api).toEqual([ 169 | "callp_response", 170 | "is_Response", 171 | "size_of", 172 | "type_of" 173 | ]) 174 | 175 | api.forEach((f) => { 176 | expect(typeof spirit.node.utils[f]).toBe("function") 177 | }) 178 | }) 179 | }) 180 | -------------------------------------------------------------------------------- /spec/support/custom-errors.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Custom error types for tests 3 | */ 4 | 5 | 6 | // When a function was not expected to be called 7 | class CallError extends Error { 8 | constructor(message) { 9 | super() 10 | this.name = "CallError" 11 | this.message = message 12 | } 13 | } 14 | 15 | module.exports = CallError 16 | -------------------------------------------------------------------------------- /spec/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "spec", 3 | "spec_files": [ 4 | "**/*[sS]pec.js" 5 | ], 6 | "helpers": [ 7 | "../node_modules/babel-register/lib/node.js", 8 | "helpers/**/*.js" 9 | ], 10 | "stopSpecOnExpectationFailure": false, 11 | "random": false 12 | } 13 | -------------------------------------------------------------------------------- /spec/support/mock-response.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Helper for setting up a mock http Response object 3 | */ 4 | 5 | const stream = require("stream") 6 | 7 | module.exports = (done) => { 8 | const result = {} 9 | 10 | const ws = new stream.Writable({ 11 | write(chunk, encoding, next) { 12 | const c = chunk.toString() 13 | if (typeof result.body === "undefined") { 14 | result.body = "" 15 | } 16 | result.body = result.body + c 17 | next() 18 | } 19 | }) 20 | 21 | ws.writeHead = (status, headers) => { 22 | result.status = status 23 | result.headers = headers 24 | } 25 | 26 | ws.on("finish", () => { 27 | ws._result = result 28 | if (done) done(result) 29 | }) 30 | 31 | return ws 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/core/core.js: -------------------------------------------------------------------------------- 1 | /* 2 | * main spirit file 3 | */ 4 | 5 | const p_utils = require("./promise_utils") 6 | 7 | 8 | /** 9 | * A reducer function, for reducing over a handler 10 | * and middlewares 11 | * 12 | * All functions are expected to return a Promise 13 | * It is wrapped to ensure a Promise is returned 14 | * 15 | * @param {function} handler - a handler function 16 | * @param {array} middleware - an array of middleware function 17 | * @return {function} returns a function that takes a input and returns a output as a (promise) 18 | */ 19 | const compose = (handler, middleware) => { 20 | // wrap `fn` to always returns a promise 21 | const wrap = function(fn) { 22 | return function() { 23 | return p_utils.callp(fn, arguments) 24 | } 25 | } 26 | 27 | let accum = wrap(handler) 28 | 29 | for (let i = middleware.length - 1; i >= 0; i--) { 30 | accum = wrap(middleware[i](accum)) 31 | } 32 | 33 | return accum 34 | } 35 | 36 | module.exports = { 37 | compose 38 | } 39 | -------------------------------------------------------------------------------- /src/core/promise_utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * utility functions for working with Promises 3 | */ 4 | 5 | const Promise = require("bluebird") 6 | 7 | /** 8 | * checks if `p` is a promise, returning true or false 9 | * 10 | * @param {*} p - argument to check if promise 11 | * @return {Boolean} 12 | */ 13 | const is_promise = (p) => { 14 | if (p && p.then && typeof p.then === "function") { 15 | return true 16 | } 17 | return false 18 | } 19 | 20 | /** 21 | * calls a function with `args` (array of arguments) 22 | * wraps the result of `fn` as a Promise 23 | * 24 | * if it's not a function, it returns the value wrapped 25 | * as a Promise 26 | * 27 | * @param {function} fn - the function as defined by the user 28 | * @param {*[]} args - an array of arguments to `fn` 29 | * @return {Promise} 30 | */ 31 | const callp = (fn, args) => { 32 | if (typeof fn === "function") { 33 | return new Promise((resolve, reject) => { 34 | resolve(fn.apply(undefined, args)) 35 | }) 36 | } 37 | 38 | return Promise.resolve(fn) 39 | } 40 | 41 | const {is_response} = require("../http/response") 42 | /** 43 | * Similar to `callp` with handling specific 44 | * to spirit http response maps that have a Promise as a body 45 | * 46 | * Special handling needs to be done to resolve the body first 47 | * and avoid passing along a Promise of a response map 48 | * which holds a Promise as it's body 49 | * 50 | * Additionally a empty catch is added to surpress 51 | * Promise warnings in node v7.x regarding async error handling 52 | * 53 | * @param {function} fn - function to call 54 | * @param {*[]} args - array of arguments to `fn` 55 | * @returns {Promise} 56 | */ 57 | const callp_response = (fn, args) => { 58 | if (typeof fn === "function") { 59 | return new Promise((resolve, reject) => { 60 | const v = fn.apply(undefined, args) 61 | if (is_response(v) 62 | && is_promise(v.body)) { 63 | return v.body.then((bd) => { 64 | v.body = bd 65 | resolve(v) 66 | }).catch((err) => reject(err)) 67 | } 68 | resolve(v) 69 | }) 70 | } 71 | 72 | return Promise.resolve(fn) 73 | } 74 | 75 | module.exports = { 76 | callp, 77 | is_promise, 78 | callp_response 79 | } 80 | -------------------------------------------------------------------------------- /src/http/node_adapter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * node adapter for spirit 3 | */ 4 | const core = require("../core/core") 5 | const request = require("./request") 6 | const response = require("./response") 7 | 8 | const Response = require("./response-class.js").Response 9 | const size_of = require("./utils.js").size_of 10 | /** 11 | * Detects if 'Content-Length' headers are set 12 | * If not set, it sets the header 13 | * Only possible for string, buffer 14 | * stream type will usually not have a length and need 15 | * to be closed manually with `.end()` 16 | * 17 | * For file-stream, it's possible to fs.stat for a length 18 | * But this function is more of a simple fail-safe 19 | * 20 | * @param {response} resp - response 21 | * @return {object} response headers 22 | */ 23 | const content_length = (resp) => { 24 | if (resp.body === undefined) { 25 | resp.headers["Content-Length"] = 0 26 | return resp.headers 27 | } 28 | const h = Response.get(resp, "Content-Length") 29 | if (h === undefined) resp.headers["Content-Length"] = size_of(resp.body) 30 | return resp.headers 31 | } 32 | 33 | /** 34 | * strips all undefined values from `headers` 35 | * 36 | * this is needed because node `res.writeHead` will still write 37 | * headers with undefined values 38 | * 39 | * @param {object} headers - response headers 40 | * @return {object} response headers 41 | */ 42 | const strip = (headers) => { 43 | const keys = Object.keys(headers) 44 | for (var i = 0; i < keys.length; i++) { 45 | if (typeof headers[keys[i]] === "undefined") { 46 | delete headers[keys[i]] 47 | } 48 | } 49 | return headers 50 | } 51 | 52 | /** 53 | * writes a http response map to a node HTTP response object 54 | * 55 | * It only knows how to write string/buffer and of course stream 56 | * 57 | * NOTE: There is no guards or type checking 58 | * 59 | * @param {http.Response} res - node http response object 60 | * @param {response-map} resp - response map 61 | */ 62 | const send = (res, resp) => { 63 | res.writeHead(resp.status, strip(content_length(resp))) 64 | if (resp.body === undefined) { 65 | return res.end() 66 | } 67 | 68 | // resp body is a stream 69 | if (typeof resp.body.pipe === "function") { 70 | resp.body.pipe(res) 71 | // resp body is a string or buffer 72 | } else { 73 | res.write(resp.body) 74 | res.end() 75 | } 76 | } 77 | 78 | const adapter = (handler, middleware) => { 79 | if (middleware === undefined) { 80 | middleware = [] 81 | } else if (typeof middleware === "function") { 82 | middleware = [middleware] 83 | } 84 | const adp = core.compose(handler, middleware) 85 | 86 | return (req, res) => { 87 | const request_map = request.create(req) 88 | adp(request_map) 89 | .then((resp) => { 90 | if (!response.is_response(resp)) { 91 | throw new Error("node.js adapter did not receive a proper response (response map). Got: " + JSON.stringify(resp)) 92 | } 93 | send(res, resp) 94 | }) 95 | .catch((err) => { 96 | const resp = response.err_response(err) 97 | if (process.env.NODE_ENV === "production") { 98 | resp.body_() 99 | } 100 | send(res, resp) 101 | }) 102 | } 103 | } 104 | 105 | module.exports = { 106 | adapter, 107 | send 108 | } 109 | -------------------------------------------------------------------------------- /src/http/request.js: -------------------------------------------------------------------------------- 1 | const url = require("url") 2 | 3 | const urlquery = (req, request) => { 4 | if (!req.url) return 5 | const result = url.parse(req.url, true) 6 | request.url = result.pathname 7 | request.pathname = result.pathname // alias to url for those who are use to node.js url api 8 | request.query = result.query 9 | } 10 | 11 | const hostport = (req, request) => { 12 | if (req.headers && req.headers.host) { 13 | const host = req.headers.host 14 | // ipv6 15 | let offset = 0 16 | if (host[0] === "[") { 17 | offset = host.indexOf("]") + 1 18 | } 19 | const index = host.indexOf(":", offset); 20 | request.host = host 21 | if (index !== -1) { 22 | request.host = host.substring(0, index) 23 | request.port = parseInt(host.substring(index + 1, host.length)) 24 | } 25 | } 26 | } 27 | 28 | const protocol = (req, request) => { 29 | request.protocol = "http" 30 | if (req.connection && req.connection.encrypted) { 31 | request.protocol = "https" 32 | } 33 | } 34 | 35 | /** 36 | * create a request map 37 | * 38 | * A request map most likely contains the following: 39 | * - port {number} the port the request was made on 40 | * - host {string} either a hostname or ip of the server 41 | * - ip {string} the requesting client's ip 42 | * - url {string} the request URI (excluding query string) 43 | * - path {string} the request URI (including query string) 44 | * - method {string} the request method 45 | * - protocol {string} transport protocol, either "http" or "https" 46 | * - scheme {string} the protocol version ex: "1.1" 47 | * - headers {object} the request headers (as node delivers it) 48 | * - query {object} query string of request URI parsed as object (defaults to {}) 49 | * 50 | * - req {function} returns the node IncomingRequest object 51 | * 52 | *** TODO, add a body if possible 53 | * - body {Stream} raw unparsed request body, this is just `req`, as it is the easiest way to pass the 'raw' body, which would be a node stream 54 | * NOTE: this may change, treat it as a stream only 55 | * 56 | * @param {http.Request} req - a node http IncomingRequest object 57 | * @return {request-map} 58 | */ 59 | const create = (req) => { 60 | const request = { 61 | method: req.method, 62 | headers: req.headers, 63 | scheme: req.httpVersion, 64 | path: req.url, 65 | //body: req 66 | req: function() { 67 | return req 68 | } 69 | } 70 | 71 | if (req.connection) { 72 | request.ip = req.connection.remoteAddress 73 | } 74 | 75 | if (typeof request.method === "string") request.method = request.method.toUpperCase() 76 | 77 | protocol(req, request) 78 | hostport(req, request) 79 | urlquery(req, request) 80 | return request 81 | } 82 | 83 | module.exports = { 84 | hostport, 85 | protocol, 86 | urlquery, 87 | create 88 | } 89 | -------------------------------------------------------------------------------- /src/http/response-class.js: -------------------------------------------------------------------------------- 1 | const mime = require("mime") 2 | mime.default_type = undefined 3 | 4 | const utils = require("../http/utils") 5 | 6 | const is_Response = (obj) => { 7 | if (obj !== null && typeof obj === "object") { 8 | return obj instanceof Response 9 | } 10 | return false 11 | } 12 | 13 | /* 14 | * Response is for making response maps 15 | * with chainable helper functions 16 | */ 17 | 18 | class Response { 19 | constructor(body) { 20 | this.status = 200 21 | this.headers = {} 22 | this.body = body 23 | } 24 | 25 | /** 26 | * Looks up `k` from `response`'s headers 27 | * 28 | * Where `response` is a response map or Response 29 | * `k` is a case insensitive look up 30 | * 31 | * Returns back the header field matching `k` in it's original case 32 | * 33 | * @param {response} response - a response map or object that conforms to one 34 | * @param {string} k - the header field to look up (case insensitive) 35 | * @return {string|undefined} the header field that matches `k` or undefined if it doesn't exist 36 | */ 37 | static field(response, k) { 38 | // if k exists, then just return 39 | if (response[k] !== undefined) { 40 | return k 41 | } 42 | 43 | k = k.toLowerCase() 44 | const keys = Object.keys(response.headers) 45 | for (let i = 0; i < keys.length; i++) { 46 | if (keys[i].toLowerCase() === k) { 47 | return keys[i] 48 | } 49 | } 50 | } 51 | 52 | static get(response, k) { 53 | return response.headers[Response.field(response, k)] 54 | } 55 | 56 | static set(response, k, v) { 57 | const existk = Response.field(response, k) 58 | 59 | if (existk !== undefined) { 60 | // if the header already exists, and does not match 61 | // in case, resolve the duplicate headers by correcting 62 | // the case 63 | if (existk !== k) { 64 | k = k.split("-").map((p) => { 65 | const c = p[0].toUpperCase() + p.substr(1).toLowerCase() 66 | if (c === "Etag") return "ETag" // special handling for ETag 67 | return c 68 | }).join("-") 69 | 70 | // if existk is not the correct case compared to k 71 | // then delete existk and use k instead 72 | if (existk !== k) delete response.headers[existk] 73 | } 74 | } else { 75 | // if header doesnt exist & the value is empty 76 | // then there is nothing to do 77 | if (v === undefined) return response 78 | } 79 | 80 | response.headers[k] = v 81 | return response 82 | } 83 | 84 | set(k, v) { 85 | Response.set(this, k, v) 86 | return this 87 | } 88 | 89 | get(k) { 90 | return Response.get(this, k) 91 | } 92 | 93 | status_(n) { 94 | this.status = parseInt(n) 95 | return this 96 | } 97 | 98 | body_(body) { 99 | this.body = body 100 | this.len(undefined) // clear any previous Content-Length 101 | return this 102 | } 103 | 104 | type(content_type) { 105 | let t = mime.lookup(content_type) 106 | if (!t) t = content_type 107 | 108 | // auto convert body to JSON as a convienance 109 | if (t === "application/json") { 110 | const typ_body = utils.type_of(this.body) 111 | if (typ_body !== "string" 112 | && typ_body !== "stream" 113 | && typ_body !== "buffer" 114 | && typ_body !== "file-stream") { 115 | this.body = JSON.stringify(this.body) 116 | } 117 | } 118 | 119 | let charset = "" 120 | if (mime.charsets.lookup(t)) charset = "; charset=utf-8" 121 | 122 | return this.set("Content-Type", t + charset) 123 | } 124 | 125 | _clear_cookie(cookies, name, path) { 126 | path = path || "/" 127 | return cookies.filter((ck) => { 128 | // get cookie name 129 | const _name = ck.slice(0, ck.indexOf("=")) 130 | let _path = "/" 131 | 132 | if (_name === name) { 133 | // if name matches, check path 134 | const ck_lower = ck.toLowerCase() 135 | const _begin = ck_lower.indexOf("path=") 136 | 137 | if (_begin !== -1) { 138 | ck = ck.slice(_begin + 5) 139 | const _end = ck.indexOf(";") 140 | 141 | if (_end === -1) { 142 | _path = ck 143 | } else { 144 | _path = ck.slice(0, _end) 145 | } 146 | } 147 | 148 | return _path !== path 149 | } 150 | 151 | return true 152 | }) 153 | } 154 | 155 | /** 156 | * Sets a cookie to headers, if the header already exists 157 | * It will append to the array (and be converted to one, if 158 | * it isn't already one) 159 | * 160 | * encodeURIComponent() is used to encode the value by default 161 | * 162 | * If value is undefined, then the cookie will not be set 163 | * And if it already exists, then all instances of it 164 | * will be removed 165 | * 166 | * Possible duplicate cookies of the same name & path 167 | * are not handled 168 | * NOTE: cookies are considered unique to it's name & path 169 | * 170 | * Options: { 171 | * path {string} 172 | * domain {string} 173 | * httponly {boolean} 174 | * maxage {string} 175 | * secure {boolean} 176 | * expires {Date} 177 | * encode {function} defaults to encodeURIComponent 178 | * } 179 | * 180 | * @param {string} name - cookie name 181 | * @param {string} value - cookie value 182 | * @param {object} opts - an object of options 183 | * @return {this} 184 | */ 185 | cookie(name, value, opts) { 186 | // get current cookies (as an array) 187 | let curr_cookies = this.get("Set-Cookie") 188 | if (curr_cookies === undefined) { 189 | curr_cookies = [] 190 | } else { 191 | if (Array.isArray(curr_cookies) === false) { 192 | curr_cookies = [curr_cookies] 193 | } 194 | } 195 | 196 | // optional arguments & default values 197 | if (typeof value === "object") { 198 | opts = value 199 | value = undefined 200 | } else if (opts === undefined) { 201 | opts = {} 202 | } 203 | if (typeof opts.encode !== "function") opts.encode = encodeURIComponent 204 | 205 | // is this for deletion? 206 | if (value === undefined) { 207 | const _filtered_cookies = this._clear_cookie(curr_cookies, name, opts.path) 208 | return this.set("Set-Cookie", _filtered_cookies) 209 | } 210 | 211 | // begin constructing cookie string 212 | value = [opts.encode(value)] 213 | // * set optional values * 214 | if (opts.path !== undefined) value.push("Path=" + opts.path) 215 | if (opts.domain !== undefined) value.push("Domain=" + opts.domain) 216 | if (opts.maxage !== undefined) value.push("Max-Age=" + opts.maxage) 217 | if (opts.secure === true) value.push("Secure") 218 | if (opts.expires !== undefined) { 219 | if (typeof opts.expires.toUTCString === "function") { 220 | value.push("Expires=" + opts.expires.toUTCString()) 221 | } else { 222 | value.push("Expires=" + opts.expires) 223 | } 224 | } 225 | if (opts.httponly === true) value.push("HttpOnly") 226 | 227 | curr_cookies.push(name + "=" + value.join(";")) 228 | return this.set("Set-Cookie", curr_cookies) 229 | } 230 | 231 | len(size) { 232 | const typ_size = typeof size 233 | if (typ_size !== "undefined" && typ_size !== "number") { 234 | throw new TypeError("Expected number for Response len() instead got: " + typ_size) 235 | } 236 | if (size === 0) size = undefined 237 | return this.set("Content-Length", size) 238 | } 239 | 240 | attachment(filename) { 241 | let v 242 | if (typeof filename === "string") { 243 | v = "attachment" 244 | if (filename !== "") v = v + "; filename=" + filename 245 | } 246 | return this.set("Content-Disposition", v) 247 | } 248 | 249 | } 250 | 251 | module.exports = { 252 | Response, 253 | is_Response 254 | } 255 | -------------------------------------------------------------------------------- /src/http/response.js: -------------------------------------------------------------------------------- 1 | const {Response} = require("./response-class") 2 | 3 | const fs = require("fs") 4 | const path = require("path") 5 | const Promise = require("bluebird") 6 | 7 | const stream = require("stream") // for make_stream 8 | 9 | /** 10 | * checks if `resp` is a valid response map 11 | * this test will return true for a Response too 12 | * 13 | * @param {*} resp - object to check 14 | * @return {boolean} 15 | */ 16 | const is_response = (resp) => { 17 | if (typeof resp === "object" 18 | && resp !== null 19 | && typeof resp.status === "number" 20 | && typeof resp.headers === "object" 21 | && !Array.isArray(resp.headers)) { 22 | return true 23 | } 24 | return false 25 | } 26 | 27 | /** 28 | * Returns a new Response with `body` as it's body 29 | * and by default a 200 status code 30 | * Additionally if `body` is a (non-empty) string, it will 31 | * set content type to html and it's content length 32 | * 33 | * If it's already a Response it will recreate it. 34 | * If it's a response map, it is converted into a Response. 35 | * Leaving the status code and headers untouched. 36 | * 37 | * @param {*} body - the body for a response, or a response map 38 | * @return {Response} 39 | */ 40 | const response = (body) => { 41 | let rmap 42 | if (is_response(body)) { 43 | // just convert or recreate but don't touch! 44 | rmap = new Response(body.body) 45 | rmap.status = body.status 46 | rmap.headers = body.headers 47 | } else { 48 | rmap = new Response(body) 49 | if (typeof body === "string" && body !== "") { 50 | rmap.type("html") 51 | } 52 | } 53 | return rmap 54 | } 55 | 56 | /** 57 | * Creates a writable stream that can be used with response() 58 | * 59 | * @return {stream.Transform} 60 | */ 61 | const make_stream = () => { 62 | return new stream.Transform({ 63 | transform(data, encoding, callback) { 64 | this.push(data) 65 | callback() 66 | } 67 | }) 68 | } 69 | 70 | /** 71 | * Returns a Promise of a Response of the file 72 | * Where the body of the Response is a stream 73 | * 74 | * A Content-Type is guessed from the file extension. 75 | * 76 | * It will set a Content-Length header of the size 77 | * of the file. As well as the Last-Modified header. 78 | * NOTE that it is susceptible to time for both fields, 79 | * so it can be inaccurate. That is, since it is a 80 | * file stream, there is a moment in time (nanoseconds) 81 | * where the file is being modified but after the headers 82 | * are set and before the response is sent. 83 | * For volatile files, it's recommended to: 84 | * - strip the Content-Length header and/or switch 85 | * to chunk transfer 86 | * - not use this and instead create a Response with 87 | * the file data as a Buffer 88 | * 89 | * @param {string} file_path path to file 90 | * @return {Promise} Promise of a Response 91 | */ 92 | const file_response = (file) => { 93 | const resp = response() 94 | 95 | if (typeof file !== "string") { 96 | if (typeof file.path !== "string" || typeof file.pipe !== "function") { 97 | throw new TypeError("Expected a file path (string) or a file stream for `file_response`") 98 | } 99 | resp.body = file 100 | file = file.path 101 | } 102 | 103 | return new Promise((resolve, reject) => { 104 | fs.stat(file, (err, fdata) => { 105 | if (err || !fdata.isFile()) { 106 | if (!err) err = new TypeError(file + " is not a file.") 107 | return reject(err) 108 | } 109 | 110 | resp._file = fdata 111 | resp._file.filepath = file 112 | if (!resp.body) { 113 | resp.body = fs.createReadStream(file) 114 | } 115 | resp.type(path.extname(file)) 116 | .len(fdata.size) 117 | .set("Last-Modified", fdata.mtime.toUTCString()) 118 | resolve(resp) 119 | }) 120 | }) 121 | } 122 | 123 | /** 124 | * returns a Response for a http redirect based 125 | * on status code and url, default status code is 302 126 | * 127 | * moved-permanently 301 128 | * found 302 129 | * see-other 303 130 | * temporary-redirect 307 131 | * permanent-redirect 308 132 | * 133 | * @param {number} status - http status code 134 | * @param {string} url - url to redirect to 135 | * @return {Response} 136 | */ 137 | const redirect = (status, url) => { 138 | if (!url) { 139 | url = status 140 | status = 302 141 | } 142 | 143 | if (typeof status !== "number" || typeof url !== "string") { 144 | throw TypeError("invalid arguments to `redirect`, need (number, string) or (string). number is a optional argument for a valid redirect status code, string is required for the URL to redirect") 145 | } 146 | 147 | return new Response() 148 | .status_(status) 149 | .set("Location", url) 150 | } 151 | 152 | /** 153 | * returns a 500 Response with `err` 154 | * 155 | * @param {*} err - typically a Error 156 | * @return {Response} 157 | */ 158 | const err_response = (err) => { 159 | if (!err) { 160 | err = "" 161 | } 162 | let body = err.toString() 163 | if (err instanceof Error) { 164 | body += "\n\n" + err.stack 165 | } 166 | if (!body) { 167 | body = "An error occured, but there was no error message given." 168 | } 169 | return new Response(body) 170 | .status_(500) 171 | } 172 | 173 | module.exports = { 174 | is_response, 175 | make_stream, 176 | response, 177 | file_response, 178 | redirect, 179 | err_response 180 | } 181 | -------------------------------------------------------------------------------- /src/http/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * the size of in bytes of a string or buffer 3 | * utf-8 assumed 4 | * 5 | * @param {string|Buffer} v - a string or buffer to check 6 | * @return {number|undefined} size in bytes 7 | */ 8 | const size_of = (v) => { 9 | if (typeof v === "string") { 10 | if (v === "") return 0 11 | return Buffer.byteLength(v) 12 | } 13 | 14 | if (Buffer.isBuffer(v)) return v.length 15 | return undefined 16 | } 17 | 18 | /** 19 | * function to get better type information than typeof 20 | * 21 | * As with common primitive types: 22 | * undefined, string, number, symbol, function, boolean 23 | * 24 | * It will also identify correctly: 25 | * null, array, buffer, stream, file-stream 26 | * 27 | * And of course, "object" when all else fails 28 | * 29 | * @param {*} v - value to extract type information from 30 | * @return {string} type represented as string 31 | */ 32 | const type_of = (v) => { 33 | let t = typeof v 34 | if (t === "object") { 35 | if (v === null) return "null" 36 | 37 | if (Buffer.isBuffer(v)) return "buffer" 38 | 39 | if (typeof v.pipe === "function") { 40 | if (typeof v.path === "string") return "file-stream" 41 | return "stream" 42 | } 43 | 44 | if (Array.isArray(v)) return "array" 45 | } 46 | return t 47 | } 48 | 49 | module.exports = { 50 | type_of, 51 | size_of 52 | } 53 | --------------------------------------------------------------------------------