├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── circle.yml ├── lib └── nockingbird.js ├── package.json ├── scripts └── prepublish ├── src └── nockingbird.coffee └── test ├── nb ├── comments.nb ├── empty.nb ├── hello-world.nb ├── invalid-chunk.nb ├── invalid-request-method.nb ├── reply-with-file.nb ├── request-bodies.nb ├── request-methods.nb └── response-headers.nb └── nockingbird.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /npm-debug.log 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 1. Update local master branch: 4 | 5 | $ git checkout master 6 | $ git pull origin master 7 | 8 | 2. Create initial-prefixed feature branch: 9 | 10 | $ git checkout -b dc-feature-x 11 | 12 | 3. Make one or more atomic commits, and ensure that each commit has a 13 | descriptive commit message. Commit messages should be line wrapped 14 | at 72 characters. 15 | 16 | 4. Run `make test`, and address any errors. Preferably, fix commits in 17 | place using `git rebase` or `git commit --amend` to make the changes 18 | easier to review. 19 | 20 | 5. Open a pull request from the feature branch to the master branch. 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 David Chambers and Plaid Technologies, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or 8 | sell copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | COFFEE = node_modules/.bin/coffee 2 | MOCHA = node_modules/.bin/mocha --compilers coffee:coffee-script/register 3 | XYZ = node_modules/.bin/xyz --message X.Y.Z --tag X.Y.Z --repo git@github.com:plaid/nockingbird.git --script scripts/prepublish 4 | 5 | SRC = $(shell find src -name '*.coffee') 6 | LIB = $(SRC:src/%.coffee=lib/%.js) 7 | 8 | 9 | .PHONY: all 10 | all: $(LIB) 11 | 12 | lib/%.js: src/%.coffee 13 | $(COFFEE) --compile --output $(@D) -- $< 14 | 15 | 16 | .PHONY: clean 17 | clean: 18 | rm -f -- $(LIB) 19 | 20 | 21 | .PHONY: release-major release-minor release-patch 22 | release-major release-minor release-patch: 23 | @$(XYZ) --increment $(@:release-%=%) 24 | 25 | 26 | .PHONY: setup 27 | setup: 28 | npm install 29 | make clean 30 | git update-index --assume-unchanged -- $(LIB) 31 | 32 | 33 | .PHONY: test 34 | test: all 35 | $(MOCHA) 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nockingbird 2 | 3 | Nockingbird is an interface for [Nock][1]. With Nockingbird, mocks are 4 | specified in straightforward text files, rather than in JavaScript code. 5 | 6 | Example from the Nock [documentation][2]: 7 | 8 | ```javascript 9 | var scope = nock('http://myapp.iriscouch.com') 10 | .get('/users/1') 11 | .reply(404) 12 | .post('/users', { 13 | username: 'pgte', 14 | email: 'pedro.teixeira@gmail.com' 15 | }) 16 | .reply(201, { 17 | ok: true, 18 | id: '123ABC', 19 | rev: '946B7D1C' 20 | }) 21 | .get('/users/123ABC') 22 | .reply(200, { 23 | _id: '123ABC', 24 | _rev: '946B7D1C', 25 | username: 'pgte', 26 | email: 'pedro.teixeira@gmail.com' 27 | }); 28 | ``` 29 | 30 | The equivalent Nockingbird file is as follows: 31 | 32 | -- chaining-example.nb 33 | 34 | >> GET /users/1 35 | << 404 36 | 37 | >> POST /users 38 | >> username=pgte 39 | >> email=pedro.teixeira%40gmail.com 40 | << 201 41 | << content-type: application/json 42 | << ={"ok":true,"id":"123ABC","rev":"946B7D1C"} 43 | 44 | >> GET /users/123ABC 45 | << 200 46 | << content-type: application/json 47 | << ={"_id":"123ABC","_rev":"946B7D1C","username":"pgte","email":"pedro.teixeira@gmail.com"} 48 | 49 | __nockingbird.load__ can be used to apply the declarations in a Nockingbird 50 | file to a Nock scope object: 51 | 52 | ```javascript 53 | var nock = require('nock'); 54 | var nockingbird = require('nockingbird'); 55 | 56 | var scope = nock('http://myapp.iriscouch.com'); 57 | nockingbird.load(scope, __dirname + '/mocks/chaining-example.nb'); 58 | ``` 59 | 60 | ### File format 61 | 62 | Nockingbird files consist of zero or more "chunks". A file's text is broken 63 | into chunks according to the delimiter `\n\n`. Each line within a chunk must 64 | begin with `>>`, `<<`, or `--`. `>>` is for requests; `<<` is for responses. 65 | Lines beginning with `--` are ignored. For example: 66 | 67 | ``` 68 | -- Retrieve John's account details from the /users endpoint. 69 | >> GET /users/1 70 | << 200 71 | << content-type: application/json 72 | << ={"id":"1","username":"jsmith","email":"jsmith@example.com"} 73 | ``` 74 | 75 | The extension for the Nockingbird file format is `.nb`. 76 | 77 | #### Chunks 78 | 79 | Each chunk must conform to the following grammar: 80 | 81 | ```ebnf 82 | chunk = request lines , response lines ; 83 | ``` 84 | 85 | #### Request lines 86 | 87 | Each chunk must contain one or more request lines (lines beginning with `>>`), 88 | in accordance with the following grammar: 89 | 90 | ```ebnf 91 | request lines = main request line , { request body } ; 92 | main request line = request prefix , method name , pathname , "\n" ; 93 | method name = "GET" | "POST" | "PUT" | "HEAD" | "PATCH" | "MERGE" | "DELETE" ; 94 | pathname = { any character } ; 95 | request body = inline body ; 96 | inline body = inline body line , { inline body line } ; 97 | inline body line = request prefix , "=" , { any character } , "\n" ; 98 | request prefix = ">>" , { " " } ; 99 | any character = ? any character except "\n" ? ; 100 | ``` 101 | 102 | #### Response lines 103 | 104 | Each chunk must contain two or more response lines (lines beginning with `<<`), 105 | in accordance with the following grammar: 106 | 107 | ```ebnf 108 | response lines = status code line , { header line } , response body ; 109 | status code line = response prefix , status code , "\n" ; 110 | status code = digit , { digit } ; 111 | digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ; 112 | header line = response prefix , header name , ":" , { " " } , header value , "\n" ; 113 | header name = { any character } ; 114 | header value = { any character } ; 115 | response body = inline body | filename line ; 116 | inline body = inline body line , { inline body line } ; 117 | inline body line = response prefix , "=" , { any character } , "\n" ; 118 | filename line = response prefix , { any character } , "\n" ; 119 | response prefix = "<<" , { " " } ; 120 | any character = ? any character except "\n" ? ; 121 | ``` 122 | 123 | 124 | [1]: https://github.com/pgte/nock 125 | [2]: https://github.com/pgte/nock#chaining 126 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | dependencies: 2 | override: 3 | - make setup 4 | 5 | test: 6 | override: 7 | - make test 8 | -------------------------------------------------------------------------------- /lib/nockingbird.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.8.0 2 | (function() { 3 | var R, fs, is_method_name, path, 4 | __slice = [].slice; 5 | 6 | fs = require('fs'); 7 | 8 | path = require('path'); 9 | 10 | R = require('ramda'); 11 | 12 | is_method_name = R.rPartial(R.contains, ['GET', 'POST', 'PUT', 'HEAD', 'PATCH', 'MERGE', 'DELETE']); 13 | 14 | exports.mock = function(scope, chunk, root) { 15 | var filename, method_name, other_response_lines, pathname, request_lines, response_body_lines, response_header_lines, response_lines, status_code_line, _ref, _ref1; 16 | request_lines = []; 17 | response_lines = []; 18 | chunk.replace(/\n+$/, '').split(/\n/).map(RegExp.prototype.exec.bind(/^(>>|<<)\s*(.*)$/)).forEach(function(match) { 19 | if (match === null) { 20 | throw new SyntaxError('Invalid chunk (lines must begin with ">>" or "<<")'); 21 | } else { 22 | return (match[1] === '>>' ? request_lines : response_lines).push(match[2]); 23 | } 24 | }); 25 | _ref = request_lines[0].split(/[ ]+/), method_name = _ref[0], pathname = _ref[1]; 26 | if (!is_method_name(method_name)) { 27 | throw new Error("Invalid request method \"" + method_name + "\""); 28 | } 29 | status_code_line = response_lines[0], other_response_lines = 2 <= response_lines.length ? __slice.call(response_lines, 1) : []; 30 | _ref1 = other_response_lines.reduce(function(_arg, line, idx) { 31 | var body_lines, header_lines, match; 32 | header_lines = _arg[0], body_lines = _arg[1]; 33 | if (match = /^=(.*)$/.exec(line)) { 34 | return [header_lines, __slice.call(body_lines).concat([match[1]])]; 35 | } else if (idx === other_response_lines.length - 1) { 36 | return [header_lines, body_lines, line]; 37 | } else { 38 | return [__slice.call(header_lines).concat([line]), body_lines]; 39 | } 40 | }, [[], []]), response_header_lines = _ref1[0], response_body_lines = _ref1[1], filename = _ref1[2]; 41 | scope[method_name.toLowerCase()].apply(scope, R.pipe(R.tail, R.map(RegExp.prototype.exec.bind(/^=(.*)$/)), R.pluck('1'), R.join('\n'), R.of, R.reject(R.isEmpty), R.concat([pathname]))(request_lines))[filename != null ? 'replyWithFile' : 'reply'](Number(status_code_line), filename != null ? path.resolve(root, filename) : response_body_lines.join('\n'), R.pipe(R.map(RegExp.prototype.exec.bind(/^([^:]*):[ ]*(.*)$/)), R.map(R.tail), R.fromPairs)(response_header_lines)); 42 | }; 43 | 44 | exports.load = function(scope, filename, root) { 45 | fs.readFileSync(filename, 'utf8').replace(/^\s*--.*$\n?/gm, '').split(/\n{2,}/).filter(Boolean).forEach(function(chunk) { 46 | return exports.mock(scope, chunk, root); 47 | }); 48 | }; 49 | 50 | }).call(this); 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nockingbird", 3 | "version": "0.2.0", 4 | "description": "Declarative HTTP mocking (for use with Nock)", 5 | "tags": [ 6 | "http", 7 | "mock", 8 | "nock", 9 | "test" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "git://github.com/plaid/nockingbird.git" 14 | }, 15 | "main": "./lib/nockingbird", 16 | "dependencies": { 17 | "ramda": "0.19.x" 18 | }, 19 | "devDependencies": { 20 | "coffee-script": "1.8.x", 21 | "mocha": "2.x.x", 22 | "nock": "0.27.x", 23 | "xyz": "1.0.x" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /scripts/prepublish: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | rm -f lib/nockingbird.js 5 | make lib/nockingbird.js 6 | git update-index --no-assume-unchanged lib/nockingbird.js 7 | git add lib/nockingbird.js 8 | git update-index --assume-unchanged lib/nockingbird.js 9 | -------------------------------------------------------------------------------- /src/nockingbird.coffee: -------------------------------------------------------------------------------- 1 | fs = require 'fs' 2 | path = require 'path' 3 | 4 | R = require 'ramda' 5 | 6 | 7 | is_method_name = R.contains R.__, [ 8 | 'GET' 9 | 'POST' 10 | 'PUT' 11 | 'HEAD' 12 | 'PATCH' 13 | 'MERGE' 14 | 'DELETE' 15 | ] 16 | 17 | exports.mock = (scope, chunk, root) -> 18 | request_lines = []; response_lines = [] 19 | chunk 20 | .replace /\n+$/, '' 21 | .split /\n/ 22 | .map R.match /^(>>|<<)\s*(.*)$/ 23 | .forEach (match) -> 24 | if R.isEmpty match 25 | throw new SyntaxError 'Invalid chunk (lines must begin with ">>" or "<<")' 26 | else 27 | (if match[1] is '>>' then request_lines else response_lines).push match[2] 28 | 29 | [method_name, pathname] = request_lines[0].split /[ ]+/ 30 | unless is_method_name method_name 31 | throw new Error "Invalid request method \"#{method_name}\"" 32 | 33 | [status_code_line, other_response_lines...] = response_lines 34 | [response_header_lines, response_body_lines, filename] = 35 | other_response_lines.reduce ([header_lines, body_lines], line, idx) -> 36 | if match = /^=(.*)$/.exec line 37 | [header_lines, [body_lines..., match[1]]] 38 | else if idx is other_response_lines.length - 1 39 | [header_lines, body_lines, line] 40 | else 41 | [[header_lines..., line], body_lines] 42 | , [[], []] 43 | 44 | scope[method_name.toLowerCase()].apply( 45 | scope, 46 | R.pipe( 47 | R.tail 48 | R.map R.match /^=(.*)$/ 49 | R.pluck '1' 50 | R.join '\n' 51 | R.of 52 | R.reject R.isEmpty 53 | R.concat [pathname] 54 | ) request_lines 55 | )[if filename? then 'replyWithFile' else 'reply']( 56 | Number status_code_line 57 | if filename? 58 | path.resolve root, filename 59 | else 60 | response_body_lines.join '\n' 61 | R.pipe( 62 | R.map R.match /^([^:]*):[ ]*(.*)$/ 63 | R.map R.tail 64 | R.fromPairs 65 | ) response_header_lines 66 | ) 67 | return 68 | 69 | 70 | exports.load = (scope, filename, root) -> 71 | fs.readFileSync filename, 'utf8' 72 | .replace /^\s*--.*$\n?/gm, '' 73 | .split /\n{2,}/ 74 | .filter Boolean 75 | .forEach (chunk) -> exports.mock scope, chunk, root 76 | return 77 | -------------------------------------------------------------------------------- /test/nb/comments.nb: -------------------------------------------------------------------------------- 1 | -- prechunk comment 2 | >> GET /1 3 | << 200 4 | << =--one-- 5 | 6 | -- interchunk comment 7 | 8 | >> GET /2 9 | -- intrachunk comment 10 | << 200 11 | << =--two-- 12 | -- postchunk comment 13 | -------------------------------------------------------------------------------- /test/nb/empty.nb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plaid/nockingbird/4824b98776887319031158e21a7c4fa591b0f463/test/nb/empty.nb -------------------------------------------------------------------------------- /test/nb/hello-world.nb: -------------------------------------------------------------------------------- 1 | >> GET / 2 | << 200 3 | << =Hello, world! 4 | -------------------------------------------------------------------------------- /test/nb/invalid-chunk.nb: -------------------------------------------------------------------------------- 1 | >< GET / 2 | << 200 3 | << = 4 | -------------------------------------------------------------------------------- /test/nb/invalid-request-method.nb: -------------------------------------------------------------------------------- 1 | >> get / 2 | << 200 3 | << = 4 | -------------------------------------------------------------------------------- /test/nb/reply-with-file.nb: -------------------------------------------------------------------------------- 1 | >> GET / 2 | << 200 3 | << ./index.html 4 | -------------------------------------------------------------------------------- /test/nb/request-bodies.nb: -------------------------------------------------------------------------------- 1 | >> POST / 2 | >> =username=alice&password=%23%24%25&remember_me=on 3 | << 200 4 | << = 5 | 6 | 7 | >> POST / 8 | >> ={"username":"alice","password":"#$%","remember_me":true} 9 | << 200 10 | << = 11 | 12 | 13 | >> POST / 14 | >> ={ 15 | >> = "username": "alice", 16 | >> = "password": "#$%", 17 | >> = "remember_me": true 18 | >> =} 19 | << 200 20 | << = 21 | -------------------------------------------------------------------------------- /test/nb/request-methods.nb: -------------------------------------------------------------------------------- 1 | >> GET / 2 | << 200 3 | << =GET request successful 4 | 5 | >> POST / 6 | << 200 7 | << =POST request successful 8 | 9 | >> PUT / 10 | << 200 11 | << =PUT request successful 12 | 13 | >> HEAD / 14 | << 200 15 | << = 16 | 17 | >> PATCH / 18 | << 200 19 | << =PATCH request successful 20 | 21 | >> MERGE / 22 | << 200 23 | << =MERGE request successful 24 | 25 | >> DELETE / 26 | << 200 27 | << =DELETE request successful 28 | -------------------------------------------------------------------------------- /test/nb/response-headers.nb: -------------------------------------------------------------------------------- 1 | >> GET / 2 | << 200 3 | << content-type: text/plain 4 | << content-length: 4 5 | << =hai! 6 | -------------------------------------------------------------------------------- /test/nockingbird.coffee: -------------------------------------------------------------------------------- 1 | assert = require 'assert' 2 | 3 | nock = require 'nock' 4 | 5 | nockingbird = require '..' 6 | 7 | 8 | dummy = nock 'http://example.com' 9 | class Scope then constructor: -> @__log__ = [] 10 | [Object.keys(dummy)..., Object.keys(dummy.get('/'))...].forEach (key) -> 11 | Scope.prototype[key] = (args...) -> @__log__.push [key, args...]; this 12 | 13 | 14 | describe 'nockingbird.load', -> 15 | 16 | it 'parses hello-world.nb', -> 17 | scope = new Scope 18 | nockingbird.load scope, "#{__dirname}/nb/hello-world.nb" 19 | assert.deepEqual scope.__log__, [ 20 | ['get', '/'] 21 | ['reply', 200, 'Hello, world!', {}] 22 | ] 23 | 24 | it 'parses response-headers.nb', -> 25 | scope = new Scope 26 | nockingbird.load scope, "#{__dirname}/nb/response-headers.nb" 27 | assert.deepEqual scope.__log__, [ 28 | ['get', '/'] 29 | ['reply', 200, 'hai!', { 30 | 'content-type': 'text/plain' 31 | 'content-length': '4' 32 | }] 33 | ] 34 | 35 | it 'parses request-methods.nb', -> 36 | scope = new Scope 37 | nockingbird.load scope, "#{__dirname}/nb/request-methods.nb" 38 | assert.deepEqual scope.__log__, [ 39 | ['get', '/'] 40 | ['reply', 200, 'GET request successful', {}] 41 | ['post', '/'] 42 | ['reply', 200, 'POST request successful', {}] 43 | ['put', '/'] 44 | ['reply', 200, 'PUT request successful', {}] 45 | ['head', '/'] 46 | ['reply', 200, '', {}] 47 | ['patch', '/'] 48 | ['reply', 200, 'PATCH request successful', {}] 49 | ['merge', '/'] 50 | ['reply', 200, 'MERGE request successful', {}] 51 | ['delete', '/'] 52 | ['reply', 200, 'DELETE request successful', {}] 53 | ] 54 | 55 | it 'parses reply-with-file.nb', -> 56 | scope = new Scope 57 | nockingbird.load scope, "#{__dirname}/nb/reply-with-file.nb", '/tmp' 58 | assert.deepEqual scope.__log__, [ 59 | ['get', '/'] 60 | ['replyWithFile', 200, '/tmp/index.html', {}] 61 | ] 62 | 63 | it 'parses comments.nb', -> 64 | scope = new Scope 65 | nockingbird.load scope, "#{__dirname}/nb/comments.nb" 66 | assert.deepEqual scope.__log__, [ 67 | ['get', '/1'] 68 | ['reply', 200, '--one--', {}] 69 | ['get', '/2'] 70 | ['reply', 200, '--two--', {}] 71 | ] 72 | 73 | it 'parses empty.nb', -> 74 | scope = new Scope 75 | nockingbird.load scope, "#{__dirname}/nb/empty.nb" 76 | assert.deepEqual scope.__log__, [] 77 | 78 | it 'throws while parsing invalid-chunk.nb', -> 79 | scope = new Scope 80 | assert.throws -> 81 | nockingbird.load scope, "#{__dirname}/nb/invalid-chunk.nb" 82 | , (err) -> 83 | err.constructor is SyntaxError and 84 | err.message is 'Invalid chunk (lines must begin with ">>" or "<<")' 85 | 86 | it 'throws while parsing invalid-request-method.nb', -> 87 | scope = new Scope 88 | assert.throws -> 89 | nockingbird.load scope, "#{__dirname}/nb/invalid-request-method.nb" 90 | , (err) -> 91 | err.constructor is Error and 92 | err.message is 'Invalid request method "get"' 93 | 94 | it 'ensures status code is a number', -> 95 | scope = new Scope 96 | nockingbird.load scope, "#{__dirname}/nb/hello-world.nb" 97 | assert.strictEqual scope.__log__[1][1], 200 98 | 99 | it 'parses request-bodies.nb', -> 100 | scope = new Scope 101 | nockingbird.load scope, "#{__dirname}/nb/request-bodies.nb" 102 | assert.deepEqual scope.__log__, [ 103 | ['post', '/', 'username=alice&password=%23%24%25&remember_me=on'] 104 | ['reply', 200, '', {}] 105 | ['post', '/', '{"username":"alice","password":"#$%","remember_me":true}'] 106 | ['reply', 200, '', {}] 107 | ['post', '/', '''{ 108 | "username": "alice", 109 | "password": "#$%", 110 | "remember_me": true 111 | }'''] 112 | ['reply', 200, '', {}] 113 | ] 114 | --------------------------------------------------------------------------------