├── .eslintignore ├── .eslintrc ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── circle.yml ├── dist ├── modules │ ├── stubby-chaos-monkey-bundle.js │ ├── stubby-chaos-monkey-bundle.min.js │ ├── stubby-schema-validator-bundle.js │ └── stubby-schema-validator-bundle.min.js ├── stubby-bundle.js └── stubby-bundle.min.js ├── example ├── demo.html └── demo.js ├── helpers └── protractor │ └── create-stubby.js ├── index.js ├── modules ├── chaos-monkey.js └── schema-validator.js ├── package-lock.json ├── package.json ├── runner.html ├── script ├── build └── demo ├── setupTests.js ├── spec ├── modules │ ├── chaos-monkey.spec.js │ ├── schema-validator.spec.js │ └── test-schema.json ├── spec-helper.js └── stubby.spec.js └── stubby.js /.eslintignore: -------------------------------------------------------------------------------- 1 | bower_components/ 2 | node_modules/ 3 | dist/ 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "browser": true, 5 | "node": true, 6 | "jest": true, 7 | }, 8 | "globals": { 9 | "_": true, 10 | "describe": true, 11 | "it": true, 12 | "beforeEach": true, 13 | "afterEach": true, 14 | "expect": true, 15 | "jasmine": true 16 | }, 17 | "rules": { 18 | "brace-style": [2, "1tbs", { "allowSingleLine": true }], 19 | "camelcase": [2, { "properties": "always" }], 20 | "comma-dangle": [2, "never"], 21 | "comma-spacing": 0, 22 | "comma-style": [2, "last"], 23 | "consistent-return": 2, 24 | "max-len": [2, 140, 4], 25 | "consistent-this": [0, "self"], 26 | "indent": [2, 2], 27 | "key-spacing": [2, { 28 | "beforeColon": false, 29 | "afterColon": true 30 | }], 31 | "new-parens": 2, 32 | "no-array-constructor": 2, 33 | "no-constant-condition": 2, 34 | "no-continue": 2, 35 | "no-debugger": 2, 36 | "no-dupe-args": 2, 37 | "no-dupe-keys": 2, 38 | "no-duplicate-case": 2, 39 | "new-cap": 2, 40 | "no-empty": 2, 41 | "no-ex-assign": 2, 42 | "no-extra-boolean-cast": 2, 43 | "no-extra-semi": 2, 44 | "no-func-assign": 2, 45 | "no-inline-comments": 2, 46 | "no-inner-declarations": 2, 47 | "no-invalid-regexp": 2, 48 | "no-irregular-whitespace": 2, 49 | "no-label-var": 2, 50 | "no-lonely-if": 2, 51 | "no-mixed-spaces-and-tabs": 2, 52 | "no-multiple-empty-lines": 2, 53 | "no-negated-in-lhs": 2, 54 | "no-nested-ternary": 2, 55 | "no-new-object": 2, 56 | "no-obj-calls": 2, 57 | "no-shadow": 2, 58 | "no-shadow-restricted-names": 2, 59 | "no-spaced-func": 2, 60 | "no-sparse-arrays": 2, 61 | "no-trailing-spaces": 2, 62 | "no-undef": 2, 63 | "no-undef-init": 2, 64 | "no-unreachable": 2, 65 | "no-unused-vars": 2, 66 | "no-extra-parens": 2, 67 | "one-var": [2, "never"], 68 | "operator-assignment": [2, "always"], 69 | "operator-linebreak": [2, "after"], 70 | "padded-blocks": [0, "never"], 71 | "quote-props": [2, "as-needed"], 72 | "quotes": [2, "single"], 73 | "semi": [2, "always"], 74 | "keyword-spacing": [2, {"after": true}], 75 | "space-before-blocks": [2, "always"], 76 | "space-before-function-paren": [2, "never"], 77 | "space-in-parens": [2, "never"], 78 | "space-infix-ops": 2, 79 | "use-isnan": 2, 80 | "valid-typeof": 2, 81 | "wrap-iife": 2, 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v10.6.0 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 GoCardless 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This library is legacy and will be modified to help us move away from it, and will not be maintained with the plan to deprecate this library later this year (2019). 2 | ## If you require a stubbing library see: [Pretender](https://github.com/trek/pretender) 3 | 4 | # Stubby 5 | 6 | ## Continuous Integration 7 | Currently this project is tested in Circle CI v1 which is past it's sunset and could be switched off at any time. There are currently no plans to migrate this project as no major changes are being made, and no plans to support this longer term. If you plan on making major changes to this project we recommend you [migrate it to use Circle CI V2](https://circleci.com/docs/2.0/migrating-from-1-2/) first. 8 | 9 | ### AJAX Testing Stub Library 10 | 11 | Stubby is a rich API on top of [Pretender](https://github.com/trek/pretender) to allow for browser integration testing and AJAX endpoint mocking. 12 | 13 | Stubby will also validate any stubs given to it. URL parameters are treated as significant along with request parameters. You can stub request HTTP headers, url parameters, the response code and body. Stubby will match incoming requests against the stubs that have been defined. The library is primarily built for testing JSON APIs; JSON requests and responses are decoded for you. 14 | 15 | Stubby allows for features to be added as plugins, such as JSON schema validation of requests and mocks, along with potential additional verifications for mocks preventing invalid mocks from being created. The library comes with a module for validating stubs against a [JSON Schema](http://json-schema.org/). 16 | 17 | ## Installing Stubby 18 | 19 | There are two ways you can use Stubby. The first is to simply add `dist/stubby-bundle.min.js` to your page. This is a Browserify compiled build that includes Stubby and all its dependencies. 20 | 21 | If you'd rather deal with loading the dependencies yourself, you should include `stubby.js` along with its dependencies, which are: 22 | 23 | - [RouteRecognizer](https://github.com/tildeio/route-recognizer) 24 | - [Pretender](https://github.com/trek/pretender) 25 | - [LoDash](https://github.com/lodash/lodash) 26 | 27 | If you find any bugs or issues with Stubby, please feel free to raise an issue on this repository. 28 | 29 | ## Usage Examples: 30 | 31 | See the demo in `example/demo.html` by running `npm run demo`. 32 | 33 | Server code: 34 | 35 | ```js 36 | stubby.stub({ 37 | url: '/foo', 38 | params: { b: 1 }, 39 | }).respondWith(200, { b: 1}); 40 | ``` 41 | 42 | This code stubs out a server at that responds to `GET /foo?b=1` with `{b:1}`. 43 | 44 | Client-side code: 45 | 46 | ```js 47 | $.get('/foo?b=1', function(response) { 48 | if (response.body['b'] === 1) { 49 | alert('ok :)'); 50 | } else { 51 | alert('fail'); 52 | } 53 | }) 54 | ``` 55 | 56 | ### Stubbing a URL 57 | 58 | Given an instance of stubby, you can call `stub` to stub a URL. This returns a `Stubby.StubInternal` object, that you can then call `respondWith` to define what Stubby should return: 59 | 60 | ```js 61 | stubby.stub(options).respondWith(status, data, responseOptions) 62 | ``` 63 | 64 | The allowed options for a stub are: 65 | 66 | - `url`: The URL of the request. Can include query params, Stubby will strip them for you and match against them too. 67 | - `params`: An object of query parameters that should be matched against. If these are given, Stubby will use these over any query parameters in the URL. 68 | - `headers`: a list of headers for Stubby to match against 69 | - `data`: an object that should be present in the request data. 70 | - `method`: the type of request. Defaults to `GET`. 71 | - `overrideStub`: pass `true` here to state that this stub should override a matching stub if one exists. Defaults to `false`. 72 | 73 | If you try to stub a request that is already stubbed, Stubby will error. You should first call `stubby.remove(options)` to remove the stub, and then restub it. 74 | 75 | You should also [consult the Stubby specs](https://github.com/gocardless/stubby/blob/master/spec/stubby.spec.js) for many examples of how to stub requests. 76 | 77 | `respondWith` takes three arguments: 78 | 79 | - `status`: the status code that will be returned 80 | - `data`: the body that will be returned as JSON 81 | - `responseOptions`: an optional third argument that can set extra options. You can pass in a `headers` object here to set response headers. 82 | 83 | An instance of `Stubby` also has the following methods that can be called: 84 | 85 | ##### `addModule(module)` 86 | 87 | Adds a module to Stubby. See below for what these modules can do and how to write them. 88 | 89 | ##### `passthrough(url)` 90 | 91 | By default Stubby blocks all requests, and will error if it gets a request that it can't match to a Stub. You can use this method to tell Stubby that it's fine to let requests matching this URL to hit the network. `stubby.passthrough('/foo')` would mean any `GET` request to `/foo` hits the network. It is currently only possible to passthrough on `GET` requests. 92 | 93 | ##### `verifyNoOutstandingRequests()` 94 | 95 | When called, Stubby will check that every stub that it has been given has been called at least once, and error if it hasn't. This is useful to perform at the end of a test, to ensure all stubs were matched. 96 | 97 | ### Modules: 98 | 99 | Stubby provides an API to add functionality through modules. 100 | 101 | #### How to setup a module: 102 | 103 | The included modules in the `modules/` folder are optional, officially supported parts of this project to supplement the functionality of Stubby but also to keep the core lightweight. Modules are registered by passing an instance of a module to `addModule`: 104 | 105 | ```js 106 | var addonModule = new RandomAddonModule(); 107 | var stubby = new window.Stubby(); 108 | stubby.addModule(addonModule); 109 | ``` 110 | 111 | #### Modules API: 112 | 113 | A module needs to expose an javascript object or function with a prototype or method called register. 114 | The register method is passed an object that listeners within that class can be registered with: 115 | ```js 116 | function DemoModule(api_version){ 117 | // Method to register the module as a handler. 118 | this.register = function(handler) { 119 | // handler.on(event, callback(request, stub), callback context); 120 | // Handler called when the route is setup calling `.stub`. 121 | handler.on('setup', function(req, stub) { }, this); 122 | // Handler called when a route is being matched (allows for changes or checks before matching routes). 123 | handler.on('routesetup', function(req, stub) { }, this); 124 | // Handler is called when a request is about to be fuffiled by the passed in matching route 125 | handler.on('request', function(req, stub) { this.inject_api_version(stub); }, this); 126 | }; 127 | this.inject_api_version = function(stub) { 128 | stub.response.headers['api-version'] = api_version; 129 | }; 130 | } 131 | ``` 132 | 133 | #### Included Modules: 134 | 135 | - **schema-validator**: 136 | 137 | While Stubby doesn't support JSON Schema validation out of the box, the included schema-validator module validates stubs against a JSON hyperschema. 138 | 139 | To setup the validator, add a schema like so: 140 | 141 | ```js 142 | var stubby = new Stubby(); 143 | var validator = new window.StubbySchemaValidator(); 144 | validator.addSchema('/', schemaJSON); 145 | stubby.addModule(validator); 146 | ``` 147 | 148 | - **chaos-monkey**: 149 | 150 | A demo module that responds with random http status codes instead of the ones specified with the stub with the option `{chaos: true}` set in the stub. 151 | It also verifies that the response http status is equal to 42 in order to allow for chaos. 152 | 153 | This module demonstrates how to write a simple module to integrate within the stubby framework, for usage examples, see the definitions file and corresponding spec. 154 | 155 | ### Development: 156 | 157 | When you clone the repo, you should ensure all dependencies are installed: 158 | 159 | ``` 160 | npm install 161 | bower install 162 | ``` 163 | 164 | You can run `npm test` to run all the tests. 165 | 166 | In order to recompile the browserify modules, run `./script/build` to rebuild. To force a build, run `./script/build -f`. 167 | 168 | If you don't want to use the Browserify build, manually take `stubby.js`, its dependencies and any modules from the `modules` folder. 169 | 170 | ### Limitations: 171 | 172 | Since stubby was created for JSON APIs, it might be better to use the raw pretender API instead of stubby for other types of data. 173 | 174 | Additionally, Pretender doesn't allow for mocking JSONP or cross-origin ajax requests, so stubby only works for the same hostname/protocol/port ajax requests. 175 | 176 | 177 | ### Changelog 178 | 179 | ##### V0.0.6 180 | - fix schema validation for non-object request payloads 181 | 182 | ##### V0.0.5 183 | - print expected stub when no stubs where found 184 | 185 | ##### V0.0.4 186 | - allow stubs to have response headers specified 187 | 188 | ##### V0.0.3 189 | - allow stubs to override existing match with `overrideStub` option 190 | 191 | ##### V0.0.2 192 | - stop skipping data matches in schema validation plugin 193 | - if a request has data but the stub has none defined, it will now match that request to that stub 194 | 195 | ##### V0.0.1 196 | - initial release 197 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 10.6.0 4 | -------------------------------------------------------------------------------- /dist/modules/stubby-chaos-monkey-bundle.js: -------------------------------------------------------------------------------- 1 | (function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.StubbyChaosMonkey = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o 2 | 3 | 4 | 5 | 6 | 7 | 8 | 40 | 41 | 42 | 45 |
46 |

Favourite internet widgets:

47 | 50 |
51 | 52 | 53 | -------------------------------------------------------------------------------- /example/demo.js: -------------------------------------------------------------------------------- 1 | /* global Stubby */ 2 | 'use strict'; 3 | 4 | var stubby = new Stubby(); 5 | 6 | stubby.stub({ url: '/widgets' }).respondWith(200, [ 7 | { id: 1, colour: '#245454', title: 'iPad', company: 'apple' }, 8 | { id: 2, colour: '#225222', title: 'moto 360º', company: 'motorola' }, 9 | { id: 3, colour: '#000000', title: 'black whole', company: 'hollywood' }, 10 | { id: 4, colour: '#000000', title: 'dont click', company: 'undefinedd' } 11 | ]); 12 | 13 | stubby.stub({ 14 | url: '/widget', params: { id: 3 }, method: 'POST' 15 | }).respondWith(404, {error: 'Item not Found'}); 16 | 17 | stubby.stub({ 18 | url: '/widget?id=2', method: 'POST' 19 | }).respondWith(200, {message: 'Shiny :)'}); 20 | 21 | stubby.stub({ 22 | url: '/widget?id=1', method: 'POST', headers: {'X-Action-Method': 'click'} 23 | }).respondWith(412, {error: 'Insufficient funds :('}); 24 | 25 | function showNotification(message, colour) { 26 | if (!colour) { colour = 'black'; } 27 | var modalContainer = document.getElementById('modal-container'); 28 | var modal = document.createElement('code'); 29 | modal.style.color = colour; 30 | modal.className = 'modal'; 31 | modal.innerHTML = message; 32 | modalContainer.appendChild(modal); 33 | setTimeout(function() { 34 | modalContainer.removeChild(modal); 35 | }, 3000); 36 | } 37 | 38 | window.onerror = function(err) { 39 | showNotification(err, 'maroon'); 40 | }; 41 | 42 | window.loadWidget = function(id) { 43 | window.post({url: '/widget?id=' + id, headers: {'X-Action-Method': 'click'}}, function(res) { 44 | var body = JSON.parse(res.responseText); 45 | if (res.status !== 200) { 46 | showNotification('Error: ' + body.error, 'maroon'); 47 | } else { 48 | showNotification('Success: ' + body.message, 'black'); 49 | } 50 | }); 51 | }; 52 | 53 | document.addEventListener('DOMContentLoaded', function() { 54 | var widgetList = document.getElementById('widget-list'); 55 | window.get('/widgets', function(res) { 56 | try { 57 | var widgets = JSON.parse(res.responseText); 58 | document.getElementById('widget-list').innerHTML = ''; 59 | widgets.map(function(widget) { 60 | var newEl = document.createElement('li'); 61 | newEl.innerHTML = widget.title; 62 | newEl.style.color = widget.colour; 63 | newEl.addEventListener('click', function() { 64 | window.loadWidget(widget.id); 65 | }); 66 | widgetList.appendChild(newEl); 67 | }); 68 | } catch (e) { 69 | showNotification(e, 'maroon'); 70 | } 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /helpers/protractor/create-stubby.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Stubby Protractor helper 3 | * 4 | * Usage example: 5 | * 6 | * // Example Protractor test with helper: 7 | * var createStubby = require('../helpers/create-stubby'); 8 | * var userFixture = require('../fixtures/user.json'); 9 | * describe('exampletest', function() { 10 | * var stubby = crateStubby(browser); 11 | * 12 | * beforeEach(function() { 13 | * stubby.withModule('testModule', function(userFixture) { 14 | * stubby.stub({ 15 | * url: '/users' 16 | * }).respondWith(200, userFixture); 17 | * }, userFixture); 18 | * }); 19 | * 20 | * afterEach(function() { 21 | * stubby.verifyNoOutstandingRequests(); 22 | * }); 23 | * 24 | * it('tests stubby', function() { 25 | * browser.get('/userlist.html'); 26 | * expect(element(by.binding('.users').getText()).toContain(userFixture[0].name); 27 | * }); 28 | * }); 29 | * 30 | */ 31 | 32 | 'use strict'; 33 | 34 | /*global beforeEach,angular*/ 35 | /*eslint no-eval:0*/ 36 | 37 | module.exports = function(browser) { 38 | 39 | var schemaURL = '/schema/schema-latest.json'; 40 | var passthroughURLs = [ 41 | '/asserts/svgs/:type/:icon.svg', 42 | '/assets/svgs/:icon.svg' 43 | ]; 44 | 45 | beforeEach(function() { 46 | function createStubby() { 47 | return angular.module('createStubby', []).run([ 48 | function() { 49 | 50 | var xmlHTTPReq = new XMLHttpRequest(); 51 | xmlHTTPReq.open('GET', schemaURL, false); 52 | xmlHTTPReq.send(); 53 | var schema = JSON.parse(xmlHTTPReq.responseText); 54 | 55 | var stubby = new window.Stubby(); 56 | 57 | var validator = new window.StubbySchemaValidator(); 58 | validator.addSchema('/', schema); 59 | stubby.addModule(validator); 60 | 61 | passthroughURLs.forEach(function(url) { 62 | stubby.passthrough(url); 63 | }); 64 | 65 | window.stubbyInstance = stubby; 66 | } 67 | ]); 68 | } 69 | 70 | browser.addMockModule('createStubby', createStubby); 71 | }); 72 | 73 | afterEach(function() { 74 | browser.clearMockModules(); 75 | }); 76 | 77 | return { 78 | verifyNoOutstandingRequests: function() { 79 | browser.executeScript(function() { 80 | window.stubbyInstance.verifyNoOutstandingRequest(); 81 | }); 82 | }, 83 | passthrough: function(url) { 84 | browser.executeScript(function() { 85 | window.stubbyInstance.passthrough(url); 86 | }); 87 | }, 88 | stub: function(stubOptions) { 89 | return { 90 | respondWith: function(responseStatus, responseData) { 91 | browser.executeScript(function(innerStubOptions, innerResponseStatus, innerResponseData) { 92 | window.stubbyInstance.stub(innerStubOptions).respondWith(innerResponseStatus, innerResponseData); 93 | }, stubOptions, responseStatus, responseData); 94 | } 95 | }; 96 | }, 97 | withModule: function() { 98 | var args = Array.prototype.slice.call(arguments, 0); 99 | var innerModName = args.shift(); 100 | var fn = args.shift(); 101 | 102 | if (typeof innerModName !== 'string') { 103 | throw new Error('Module Name (arg0) needs to be a string'); 104 | } 105 | if (typeof fn !== 'function') { 106 | throw new Error('Function (arg1) needs to be a function'); 107 | } 108 | 109 | function angularModule(passedInnerModName, passedFn, passedArgs) { 110 | return angular.module(passedInnerModName, []).run([function() { 111 | eval('(' + passedFn.toString() + ').apply(window, ' + JSON.stringify(passedArgs) + ');'); 112 | }]); 113 | } 114 | browser.addMockModule(innerModName.toString(), angularModule, innerModName, fn, args); 115 | } 116 | }; 117 | }; 118 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Stubby = require('./stubby')({ 4 | lodash: require('lodash'), 5 | pretender: require('pretender'), 6 | querystring: require('query-string') 7 | }); 8 | 9 | module.exports = Stubby; 10 | 11 | -------------------------------------------------------------------------------- /modules/chaos-monkey.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Stubby Chaos Money Demo Module 5 | */ 6 | 7 | var StubbyChaosMonkey = function() { 8 | 9 | /* min is inclusive, and max is exclusive */ 10 | var getRandomArbitrary = function(min, max) { 11 | return Math.random() * (max - min) + min; 12 | }; 13 | 14 | var getRandomHTTPStatus = function() { 15 | return getRandomArbitrary(100, 600); 16 | }; 17 | 18 | this.register = function(handler) { 19 | // Called before a request and response are matched 20 | handler.on('routesetup', this.onRouteSetup, this); 21 | 22 | // Called after a request and response are matched 23 | handler.on('request', this.onRequestExecute, this); 24 | }; 25 | 26 | this.onRouteSetup = function(request, stub) { 27 | if (!stub.internal.options.chaos) { 28 | return; 29 | } 30 | if (stub.response.status !== 43) { 31 | throw new Error('Response status needs to be `43` for a valid chaos response'); 32 | } 33 | }; 34 | 35 | this.onRequestExecute = function(request, stub) { 36 | if (stub.internal.options.chaos) { 37 | stub.response.status = getRandomHTTPStatus(); 38 | } 39 | }; 40 | }; 41 | 42 | 43 | if (typeof module === 'undefined') { 44 | window.StubbyChaosMonkey = StubbyChaosMonkey; 45 | } else { 46 | module.exports = StubbyChaosMonkey; 47 | } 48 | -------------------------------------------------------------------------------- /modules/schema-validator.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Stubby JSON Schema Validator 5 | * Depends on tv4, lodash, and RouteRecognizer 6 | */ 7 | 8 | var stubbySchemaValidatorModule = function(deps) { 9 | return function() { 10 | 11 | var _ = deps.lodash; 12 | this.validator = deps.tv4.freshApi(); 13 | this.schemaCount = 0; 14 | this.hyperschemaUrls = {}; 15 | var RouteRecognizer = deps.routerecognizer; 16 | this.router = new RouteRecognizer(); 17 | 18 | this.addSchema = function(uri, schema) { 19 | this.validator.addSchema(uri, schema); 20 | this.processNewHyperschema(schema); 21 | this.schemaCount += 1; 22 | }; 23 | 24 | this.register = function(handler) { 25 | handler.on('request', this.onRequestExecute, this); 26 | handler.on('routesetup', this.onRequestExecute, this); 27 | }; 28 | 29 | this.onRequestExecute = function(request, stub) { 30 | if (stub.internal.options.validateSchema === false) { 31 | return null; 32 | } 33 | return this.validate(request, stub); 34 | }; 35 | 36 | this.parseQueryParamsSchema = function(params) { 37 | var paramsParsed = {}; 38 | Object.keys(params).forEach(function(paramName) { 39 | var paramMatch = paramName.match(/^(.+)\[(.+)\]$/); 40 | var paramValue = params[paramName]; 41 | if (paramMatch) { 42 | var matchPath = paramsParsed[paramMatch[1]]; 43 | if (!matchPath) { 44 | matchPath = {}; 45 | } 46 | matchPath[paramMatch[2]] = paramValue; 47 | } else { 48 | paramsParsed[paramName] = paramValue; 49 | } 50 | }); 51 | return paramsParsed; 52 | }; 53 | 54 | this.processNewHyperschema = function(rawSchema) { 55 | var self = this; 56 | Object.keys(rawSchema.definitions).forEach(function(descKey) { 57 | var val = rawSchema.definitions[descKey]; 58 | val.links.forEach(function(linkSchema) { 59 | var urlToAdd = linkSchema.href; 60 | 61 | if (!self.hyperschemaUrls[urlToAdd]) { 62 | self.hyperschemaUrls[urlToAdd] = []; 63 | } 64 | 65 | self.hyperschemaUrls[urlToAdd].push(linkSchema); 66 | if (urlToAdd.match(/\{(.+)\}/)) { 67 | var paramsToMatch = decodeURIComponent(urlToAdd); 68 | var doReplaceParam = function(match, param) { 69 | linkSchema.baseUrlToAdd = param; 70 | return ':id'; 71 | }; 72 | urlToAdd = paramsToMatch.replace(/\{\(([^)]+)\)\}/, doReplaceParam); 73 | } 74 | self.router.add([{path: urlToAdd, handler: linkSchema.href}]); 75 | }); 76 | }); 77 | }; 78 | 79 | this.getSchemaForRoute = function(stub, routeRef) { 80 | return _.find(this.hyperschemaUrls[routeRef], function(schema) { 81 | return schema.method === stub.request.method; 82 | }); 83 | }; 84 | 85 | this.validateRequestSchema = function(stub, request, schema) { 86 | var keyTraverse = _.find(Object.keys(request.data), function(key) { 87 | return stub.url.indexOf(key) !== -1; 88 | }); 89 | var requestData = request.data; 90 | if (keyTraverse) { 91 | requestData = requestData[keyTraverse]; 92 | } 93 | 94 | var queryParams = this.parseQueryParamsSchema(stub.queryParams); 95 | 96 | // An empty request is valid. (in the case of gocardless' schema) 97 | if (_.isEmpty(queryParams) && _.isEmpty(requestData)) { return; } 98 | 99 | function validateReq(validator, req, type) { 100 | var valResponse = validator.validateMultiple(req, schema, true, false); 101 | if (valResponse.errors.length > 0 || valResponse.missing.length > 0) { 102 | var stubInfo = stub.request.method + ' ' + stub.url + ' (' + JSON.stringify(req) + ')'; 103 | throw new Error('Schema validation failed for ' + type + ': ' + stubInfo + ' ' + JSON.stringify(valResponse)); 104 | } 105 | } 106 | 107 | if (!_.isEmpty(queryParams)) { validateReq(this.validator, queryParams, 'query params'); } 108 | if (!_.isEmpty(requestData)) { validateReq(this.validator, requestData, 'request data'); } 109 | }; 110 | 111 | this.validateEmptyQueryParams = function(stub) { 112 | if (!_.isEmpty(stub.request.data) && !_.isEmpty(stub.queryParams)) { 113 | throw new Error('Parameters provided to a parameterless route (' + 114 | JSON.stringify([stub.request, stub.queryParams, stub.url]) + ')'); 115 | } 116 | }; 117 | 118 | this.validate = function(request, stub) { 119 | request.data = {}; 120 | if (request.requestBody) { 121 | request.data = JSON.parse(request.requestBody) || {}; 122 | } 123 | 124 | var routes = this.router.recognize(stub.url); 125 | if (!routes) { 126 | throw new Error('URL (' + stub.url + ') is undefined for the API'); 127 | } 128 | var routeSchema = this.getSchemaForRoute(stub, routes[0].handler); 129 | if (routeSchema) { 130 | this.validateRequestSchema(stub, request, routeSchema.schema); 131 | } else { 132 | this.validateEmptyQueryParams(stub); 133 | } 134 | }; 135 | }; 136 | }; 137 | 138 | 139 | if (typeof module === 'undefined') { 140 | var dependencies = { 141 | lodash: window._, 142 | routerecognizer: window.RouteRecognizer, 143 | tv4: window.tv4 144 | }; 145 | Object.keys(dependencies).forEach(function(dependency) { 146 | if (typeof dependencies[dependency] === 'undefined') { 147 | throw new Error(['[stubby schema-validator] Missing ', dependency, ' library.'].join(' ')); 148 | } 149 | }); 150 | window.StubbySchemaValidator = stubbySchemaValidatorModule(dependencies); 151 | } else { 152 | module.exports = stubbySchemaValidatorModule({ 153 | lodash: require('lodash'), 154 | routerecognizer: require('route-recognizer'), 155 | tv4: require('tv4') 156 | }); 157 | } 158 | 159 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gocardless/stubby", 3 | "description": "AJAX Testing Stub Library", 4 | "version": "0.0.10", 5 | "main": "index.js", 6 | "directories": { 7 | "example": "example" 8 | }, 9 | "scripts": { 10 | "test": "jest", 11 | "build": "./script/build", 12 | "demo": "./script/demo", 13 | "lint": "eslint ." 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/gocardless/stubby.git" 18 | }, 19 | "keywords": [ 20 | "stubby", 21 | "pretender", 22 | "ajax", 23 | "testing", 24 | "mocking" 25 | ], 26 | "contributors": [ 27 | { 28 | "name": "Iain Nash" 29 | }, 30 | { 31 | "name": "Jack Franklin", 32 | "email": "jack@jackfranklin.net" 33 | }, 34 | { 35 | "name": "Walter Carvalho", 36 | "email": "waltervascarvalho@gmail.com" 37 | }, 38 | { 39 | "name": "Philip Harrison", 40 | "email": "philip@mailharrison.com" 41 | } 42 | ], 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/gocardless/stubby/issues" 46 | }, 47 | "homepage": "https://github.com/gocardless/stubby", 48 | "dependencies": { 49 | "lodash": "^4.17.11", 50 | "pretender": "^3.0.1", 51 | "query-string": "^6.8.1", 52 | "route-recognizer": "^0.3.4", 53 | "tv4": "^1.2.7" 54 | }, 55 | "devDependencies": { 56 | "babel-eslint": "^10.0.1", 57 | "browserify": "^16.2.3", 58 | "eslint": "^6.0.1", 59 | "fake-xml-http-request": "^2.0.0", 60 | "jest": "^24.8.0", 61 | "uglifyjs": "^2.4.10", 62 | "url-parse": "^1.4.1" 63 | }, 64 | "jest": { 65 | "verbose": true, 66 | "setupFiles": [ 67 | "/setupTests.js" 68 | ] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /runner.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Jasmine Test Runner 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /script/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 4 | # Stubby Build Script 5 | # Author: iain nash 6 | # Todo: Replace with make? 7 | # 8 | 9 | PATH=$PATH:./node_modules/.bin 10 | 11 | if [[ "$@" == "-f" ]] || [ "node_modules" -nt "dist/stubby-bundle.js" ] 12 | then 13 | FORCE_COMPILE=1 14 | else 15 | FORCE_COMPILE=0 16 | fi 17 | 18 | # Compile stubby 19 | 20 | if [ $FORCE_COMPILE -eq 1 ] || [ "dist/stubby-bundle.js" -ot "dist/stubby-bundle.js" ] 21 | then 22 | printf 'Browserifying base stubby module: ' 23 | browserify index.js --standalone Stubby > dist/stubby-bundle.js 24 | uglifyjs dist/stubby-bundle.js > dist/stubby-bundle.min.js 25 | echo 'done' 26 | else 27 | echo 'Skipping browserifying stubby output newer than input [pass -f to skip check]' 28 | fi 29 | 30 | # Compile modules 31 | 32 | MODULES=($(ls -d ./modules/*.js)) 33 | 34 | for module_raw in "${MODULES[@]}" 35 | do 36 | module=$(basename $module_raw) 37 | MODULE_NAME=Stubby-${module%.*} 38 | OUTPUT_FILENAME_BASE="dist/modules/${MODULE_NAME}-bundle" 39 | if [ $FORCE_COMPILE -eq 1 ] || [ $module_raw -nt "${OUTPUT_FILENAME_BASE}.js" ] 40 | then 41 | printf "Browserifying stubby module ${MODULE_NAME}: " 42 | browserify "modules/${module}" --standalone $MODULE_NAME > "${OUTPUT_FILENAME_BASE}.js" 43 | uglifyjs ${OUTPUT_FILENAME_BASE}.js > ${OUTPUT_FILENAME_BASE}.min.js 44 | echo 'done' 45 | else 46 | echo "Output file for ${MODULE_NAME} newer than input, ignoring [pass -f to skip check]." 47 | fi 48 | done 49 | 50 | -------------------------------------------------------------------------------- /script/demo: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 4 | # Stubby Demo Script 5 | # 6 | 7 | python -m SimpleHTTPServer 5565 & 8 | 9 | URL=http://localhost:5565/example/demo.html 10 | 11 | if [[ "$OSTYPE" == "darwin"* ]]; then 12 | open $URL 13 | else 14 | echo "Go to $URL in your browser to see the demo" 15 | fi 16 | 17 | trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT 18 | 19 | read 20 | -------------------------------------------------------------------------------- /setupTests.js: -------------------------------------------------------------------------------- 1 | const stubbyFactory = require('./stubby'); 2 | const StubbyChaosMonkey = require('./modules/chaos-monkey'); 3 | const _ = require('lodash'); 4 | const URL = require('url-parse'); 5 | const queryString = require('query-string'); 6 | 7 | require('./spec/spec-helper'); 8 | 9 | global.StubbySchemaValidator = require('./modules/schema-validator'); 10 | 11 | // We need to mock Object.prototype.toString so that pretender 12 | // thinks its in a test enviorment 13 | // Start mock 14 | const toString = Object.prototype.toString; 15 | Object.prototype.toString = jest.fn().mockImplementation(() => '[object Object]'); 16 | const originalDocumentCreateElement = document.createElement; 17 | 18 | document.createElement = jest.fn().mockImplementation((tagName) => { 19 | // WHY? This is here because pretender utilises an anchor tag to parse their urls. 20 | if (tagName === 'a') { 21 | return { 22 | set href(path) { 23 | const url = new URL(path); 24 | this.pathname = url.pathname; 25 | this.search = url.query; 26 | this.hash = url.hash; 27 | this.fullpath = path; 28 | } 29 | }; 30 | } 31 | return originalDocumentCreateElement(tagName); 32 | }); 33 | 34 | const Pretender = require('pretender'); 35 | 36 | // End mock 37 | Object.prototype.toString = toString; 38 | 39 | global.Stubby = stubbyFactory({ 40 | lodash: _, 41 | pretender: Pretender, 42 | querystring: queryString 43 | }); 44 | 45 | global.StubbyChaosMonkey = StubbyChaosMonkey; 46 | -------------------------------------------------------------------------------- /spec/modules/chaos-monkey.spec.js: -------------------------------------------------------------------------------- 1 | describe('uses chaos money to randomise response status codes', () => { 2 | var stubby; 3 | 4 | beforeEach(() => { 5 | const xhrMockClass = () => ({ 6 | open: jest.fn(), 7 | send: jest.fn(), 8 | setRequestHeader: jest.fn() 9 | }); 10 | 11 | global.XMLHttpRequest = jest.fn().mockImplementation(xhrMockClass); 12 | 13 | stubby = new global.Stubby(); 14 | stubby.addModule(new global.StubbyChaosMonkey()); 15 | }); 16 | 17 | describe('stubbing out normal requests', () => { 18 | it('will ignore requests with no chaos option', (done) => { 19 | stubby.stub({ 20 | url: '/test' 21 | }).respondWith(200, { ok: true }); 22 | 23 | window.get('/test', (xhr) => { 24 | expect(xhr.status).toEqual(200); 25 | expect(JSON.parse(xhr.responseText)).toEqual({ ok: true }); 26 | done(); 27 | }); 28 | }); 29 | 30 | it('will explode if the response port is not 43', () => { 31 | expect(() => { 32 | stubby.stub({ 33 | url: '/test', 34 | options: { 35 | chaos: true 36 | } 37 | }).respondWith(200, []); 38 | }).toThrowError(); 39 | }); 40 | 41 | }); 42 | 43 | describe('stubbing out chaotic requests', () => { 44 | it('will give a random response within a status range for a proper chaotic request', (done) => { 45 | stubby.stub({ 46 | url: '/test', 47 | options: { 48 | chaos: true 49 | } 50 | }).respondWith(43, { ok: false }); 51 | 52 | for (var i = 0; i < 100; i++) { 53 | 54 | window.get('/test', (xhr) => { 55 | expect(xhr.status).toBeGreaterThan(99); 56 | expect(xhr.status).toBeLessThan(600); 57 | expect(JSON.parse(xhr.responseText)).toEqual({ ok: false }); 58 | }); 59 | 60 | if (i === 99) { 61 | setTimeout(done, 1); 62 | } 63 | } 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /spec/modules/schema-validator.spec.js: -------------------------------------------------------------------------------- 1 | const schema = require('./test-schema.json'); 2 | 3 | describe('uses modules to validate json schema', () => { 4 | var stubby; 5 | beforeEach(() => { 6 | const xhrMockClass = () => ({ 7 | open: jest.fn(), 8 | send: jest.fn(), 9 | setRequestHeader: jest.fn() 10 | }); 11 | 12 | global.XMLHttpRequest = jest.fn().mockImplementation(xhrMockClass); 13 | 14 | stubby = new global.Stubby(); 15 | 16 | var validator = new global.StubbySchemaValidator(); 17 | validator.addSchema('/', schema); 18 | stubby.addModule(validator); 19 | return stubby; 20 | }); 21 | 22 | describe('stubbing out validated api queries', () => { 23 | it('can send a valid customers list request', (done) => { 24 | stubby.stub({ 25 | url: '/customers', 26 | params: { 27 | limit: 11 28 | }, 29 | method: 'GET' 30 | }).respondWith(200, { meta: {}, customers: [] }); 31 | 32 | window.get('/customers?limit=11', function(xhr) { 33 | expect(JSON.parse(xhr.responseText)).toEqual({ meta: {}, customers: [] }); 34 | done(); 35 | }); 36 | }); 37 | 38 | it('can match on GET data', () => { 39 | expect(() => { 40 | stubby.stub({ 41 | url: '/payments?undefined=blah&fubar', 42 | method: 'GET' 43 | }).respondWith(200, { }); 44 | }).toThrowError(); 45 | }); 46 | 47 | it('throws with an invalid request', () => { 48 | expect(() => { 49 | stubby.stub({ 50 | url: '/payments?age=1,2,3,4', 51 | method: 'GET' 52 | }).respondWith(200, []); 53 | }).toThrowError(); 54 | }); 55 | 56 | it('throws an invalid post request', () => { 57 | stubby.stub({ 58 | url: '/customers', 59 | method: 'POST' 60 | }).respondWith(422, {}); 61 | 62 | expect(() => { 63 | window.post({ 64 | url: '/customers', data: { invalid: 'data' } 65 | }, () => { 66 | // Should throw. 67 | }); 68 | }).toThrowError(); 69 | }); 70 | }); 71 | 72 | describe('stubbing out wildcard routed urls', () => { 73 | it('can validate a wildcard url', (done) => { 74 | stubby.stub({ 75 | url: '/customers/234235', 76 | method: 'GET' 77 | }).respondWith(200, {name: 'hi'}); 78 | 79 | window.get('/customers/234235', function(xhr) { 80 | expect(JSON.parse(xhr.responseText)).toEqual({name: 'hi'}); 81 | done(); 82 | }); 83 | }); 84 | 85 | it('fails when not given a valid wildcard url', () => { 86 | expect(() => { 87 | stubby.stub({ 88 | url: '/customers/asdf/asfd/a//a', 89 | method: 'GET' 90 | }).respondWith(200, {customers: []}); 91 | }).toThrow(); 92 | }); 93 | 94 | it('fails when parameters are given to a parameterless request', () => { 95 | expect(() => { 96 | stubby.stub({ 97 | url: '/customers/123?param=fail', 98 | method: 'GET' 99 | }).respondWith(200, {name: 'hi'}); 100 | }).toThrowError(); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /spec/modules/test-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "definitions": { 3 | "customer": { 4 | "$schema": "https://developer.gocardless.com/hyper-schema.json", 5 | "title": "Customers", 6 | "envelope": "customers", 7 | "description": "Customer objects hold the contact details for a customer. A customer can have several [customer bank accounts](https://developer.gocardless.com/pro/#api-endpoints-customer-bank-accounts), which in turn can have several Direct Debit [mandates](https://developer.gocardless.com/pro/#api-endpoints-mandates).", 8 | "definitions": { 9 | "id": { 10 | "description": "Unique identifier, beginning with \"CU\".", 11 | "example": "CU123", 12 | "type": [ 13 | "string" 14 | ] 15 | }, 16 | "identity": { 17 | "$ref": "#/definitions/customer/definitions/id" 18 | }, 19 | "created_at": { 20 | "description": "Fixed [timestamp](https://developer.gocardless.com/pro/#overview-time-zones-dates), recording when this resource was created.", 21 | "example": "2014-01-01T12:00:00.000Z", 22 | "format": "date-time", 23 | "type": [ 24 | "string" 25 | ] 26 | }, 27 | "email": { 28 | "description": "Customer's email address.", 29 | "example": "user@example.com", 30 | "type": [ 31 | "string", 32 | "null" 33 | ] 34 | }, 35 | "given_name": { 36 | "description": "Customer's first name.", 37 | "example": "Frank", 38 | "type": [ 39 | "string" 40 | ] 41 | }, 42 | "family_name": { 43 | "description": "Customer's surname.", 44 | "example": "Osborne", 45 | "type": [ 46 | "string" 47 | ] 48 | }, 49 | "address_line1": { 50 | "description": "The first line of the customer's address.", 51 | "example": "1 Example House", 52 | "type": [ 53 | "string" 54 | ] 55 | }, 56 | "address_line2": { 57 | "description": "The second line of the customer's address.", 58 | "example": "17 Example Street", 59 | "type": [ 60 | "string", 61 | "null" 62 | ] 63 | }, 64 | "address_line3": { 65 | "description": "The third line of the customer's address.", 66 | "example": null, 67 | "type": [ 68 | "string", 69 | "null" 70 | ] 71 | }, 72 | "city": { 73 | "description": "The city of the customer's address.", 74 | "example": "London", 75 | "type": [ 76 | "string" 77 | ] 78 | }, 79 | "region": { 80 | "description": "The customer's address region, county or department.", 81 | "type": [ 82 | "string", 83 | "null" 84 | ] 85 | }, 86 | "postal_code": { 87 | "description": "The customer's postal code.", 88 | "type": [ 89 | "string" 90 | ] 91 | }, 92 | "country_code": { 93 | "description": "[ISO 3166-1](http://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements) alpha-2 code.", 94 | "type": [ 95 | "string" 96 | ] 97 | }, 98 | "links": { 99 | "description": "", 100 | "type": [ 101 | "object" 102 | ], 103 | "properties": {}, 104 | "additionalProperties": false 105 | } 106 | }, 107 | "links": [ 108 | { 109 | "title": "Create a customer", 110 | "description": "Creates a new customer object.", 111 | "href": "/customers", 112 | "method": "POST", 113 | "rel": "create", 114 | "schema": { 115 | "type": [ 116 | "object" 117 | ], 118 | "properties": { 119 | "email": { 120 | "$ref": "#/definitions/customer/definitions/email" 121 | }, 122 | "given_name": { 123 | "$ref": "#/definitions/customer/definitions/given_name" 124 | }, 125 | "family_name": { 126 | "$ref": "#/definitions/customer/definitions/family_name" 127 | }, 128 | "address_line1": { 129 | "$ref": "#/definitions/customer/definitions/address_line1" 130 | }, 131 | "address_line2": { 132 | "$ref": "#/definitions/customer/definitions/address_line2" 133 | }, 134 | "address_line3": { 135 | "$ref": "#/definitions/customer/definitions/address_line3" 136 | }, 137 | "city": { 138 | "$ref": "#/definitions/customer/definitions/city" 139 | }, 140 | "region": { 141 | "$ref": "#/definitions/customer/definitions/region" 142 | }, 143 | "postal_code": { 144 | "$ref": "#/definitions/customer/definitions/postal_code" 145 | }, 146 | "country_code": { 147 | "$ref": "#/definitions/customer/definitions/country_code" 148 | }, 149 | "metadata": { 150 | "$ref": "#/definitions/helper/definitions/metadata" 151 | }, 152 | "links": { 153 | "$ref": "#/definitions/customer/definitions/links" 154 | } 155 | }, 156 | "required": [ 157 | "country_code", 158 | "city", 159 | "postal_code", 160 | "given_name", 161 | "family_name", 162 | "address_line1" 163 | ], 164 | "additionalProperties": false 165 | }, 166 | "example": "POST https://api.gocardless.com/customers HTTP/1.1\n{\n \"customers\": {\n \"email\": \"user@example.com\",\n \"given_name\": \"Frank\",\n \"family_name\": \"Osborne\",\n \"address_line1\": \"27 Acer Road\",\n \"address_line2\": \"Apt 2\",\n \"city\": \"London\",\n \"postal_code\": \"E8 3GX\",\n \"country_code\": \"GB\",\n \"metadata\": {\n \"salesforce_id\": \"ABCD1234\"\n }\n }\n}\n\nHTTP/1.1 201 (Created)\nLocation: /customers/CU123\n{\n \"customers\": {\n \"id\": \"CU123\",\n \"created_at\": \"2014-05-08T17:01:06.000Z\",\n \"email\": \"user@example.com\",\n \"given_name\": \"Frank\",\n \"family_name\": \"Osborne\",\n \"address_line1\": \"27 Acer Road\",\n \"address_line2\": \"Apt 2\",\n \"address_line3\": null,\n \"city\": \"London\",\n \"region\": null,\n \"postal_code\": \"E8 3GX\",\n \"country_code\": \"GB\",\n \"metadata\": {\n \"salesforce_id\": \"ABCD1234\"\n }\n }\n}\n" 167 | }, 168 | { 169 | "title": "List customers", 170 | "description": "Returns a [cursor-paginated](https://developer.gocardless.com/pro/#overview-cursor-pagination) list of your customers.", 171 | "href": "/customers", 172 | "method": "GET", 173 | "rel": "instances", 174 | "schema": { 175 | "type": [ 176 | "object" 177 | ], 178 | "properties": { 179 | "before": { 180 | "$ref": "#/definitions/helper/definitions/instances_before" 181 | }, 182 | "after": { 183 | "$ref": "#/definitions/helper/definitions/instances_after" 184 | }, 185 | "limit": { 186 | "$ref": "#/definitions/helper/definitions/instances_limit" 187 | }, 188 | "created_at": { 189 | "$ref": "#/definitions/helper/definitions/instances_created_at" 190 | } 191 | }, 192 | "additionalProperties": false 193 | }, 194 | "example": "GET https://api.gocardless.com/customers?after=CU123 HTTP/1.1\n\nHTTP/1.1 200 (OK)\n{\n \"meta\": {\n \"cursors\": {\n \"before\": \"CU000\",\n \"after\": \"CU456\",\n },\n \"limit\": 50\n },\n \"customers\": [{\n \"id\": \"CU123\",\n \"created_at\": \"2014-05-08T17:01:06.000Z\",\n \"email\": \"user@example.com\",\n \"given_name\": \"Frank\",\n \"family_name\": \"Osborne\",\n \"address_line1\": \"27 Acer Road\",\n \"address_line2\": \"Apt 2\",\n \"address_line3\": null,\n \"city\": \"London\",\n \"region\": null,\n \"postal_code\": \"E8 3GX\",\n \"country_code\": \"GB\",\n \"metadata\": {\n \"salesforce_id\": \"ABCD1234\"\n }\n }, {\n ...\n }]\n}\n" 195 | }, 196 | { 197 | "title": "Get a single customer", 198 | "description": "Retrieves the details of an existing customer.", 199 | "href": "/customers/{(%23%2Fdefinitions%2Fcustomer%2Fdefinitions%2Fidentity)}", 200 | "method": "GET", 201 | "rel": "self", 202 | "example": "GET https://api.gocardless.com/customers/CU123 HTTP/1.1\n\nHTTP/1.1 200 (OK)\n{\n \"customers\": {\n \"id\": \"CU123\",\n \"created_at\": \"2014-05-08T17:01:06.000Z\",\n \"email\": \"user@example.com\",\n \"given_name\": \"Frank\",\n \"family_name\": \"Osborne\",\n \"address_line1\": \"27 Acer Road\",\n \"address_line2\": \"Apt 2\",\n \"address_line3\": null,\n \"city\": \"London\",\n \"region\": null,\n \"postal_code\": \"E8 3GX\",\n \"country_code\": \"GB\",\n \"metadata\": {\n \"salesforce_id\": \"ABCD1234\"\n }\n }\n}\n" 203 | }, 204 | { 205 | "title": "Update a customer", 206 | "description": "Updates a customer object. Supports all of the fields supported when creating a customer.", 207 | "href": "/customers/{(%23%2Fdefinitions%2Fcustomer%2Fdefinitions%2Fidentity)}", 208 | "method": "PUT", 209 | "rel": "update", 210 | "schema": { 211 | "type": [ 212 | "object" 213 | ], 214 | "properties": { 215 | "email": { 216 | "$ref": "#/definitions/customer/definitions/email" 217 | }, 218 | "given_name": { 219 | "$ref": "#/definitions/customer/definitions/given_name" 220 | }, 221 | "family_name": { 222 | "$ref": "#/definitions/customer/definitions/family_name" 223 | }, 224 | "address_line1": { 225 | "$ref": "#/definitions/customer/definitions/address_line1" 226 | }, 227 | "address_line2": { 228 | "$ref": "#/definitions/customer/definitions/address_line2" 229 | }, 230 | "address_line3": { 231 | "$ref": "#/definitions/customer/definitions/address_line3" 232 | }, 233 | "city": { 234 | "$ref": "#/definitions/customer/definitions/city" 235 | }, 236 | "region": { 237 | "$ref": "#/definitions/customer/definitions/region" 238 | }, 239 | "postal_code": { 240 | "$ref": "#/definitions/customer/definitions/postal_code" 241 | }, 242 | "country_code": { 243 | "$ref": "#/definitions/customer/definitions/country_code" 244 | }, 245 | "metadata": { 246 | "$ref": "#/definitions/helper/definitions/metadata" 247 | }, 248 | "links": { 249 | "$ref": "#/definitions/customer/definitions/links" 250 | } 251 | }, 252 | "additionalProperties": false 253 | }, 254 | "example": "PUT https://api.gocardless.com/customers/CU123 HTTP/1.1\n{\n \"customers\": {\n \"email\": \"updated_user@example.com\",\n \"given_name\": \"Frank\",\n \"family_name\": \"Osborne\",\n \"address_line1\": \"29 Acer Road\",\n \"address_line2\": \"Apt 3\",\n \"address_line3\": \"Block 4\",\n \"city\": \"London\",\n \"metadata\": {\n \"salesforce_id\": \"EFGH5678\"\n }\n }\n}\n\nHTTP/1.1 200 (OK)\n{\n \"customers\": {\n \"id\": \"CU123\",\n \"created_at\": \"2014-05-08T17:01:06.000Z\",\n \"email\": \"updated_user@example.com\",\n \"given_name\": \"Frank\",\n \"family_name\": \"Osborne\",\n \"address_line1\": \"29 Acer Road\",\n \"address_line2\": \"Apt 3\",\n \"address_line3\": \"Block 4\",\n \"city\": \"London\",\n \"region\": null,\n \"postal_code\": \"E8 3GX\",\n \"country_code\": \"GB\",\n \"metadata\": {\n \"salesforce_id\": \"EFGH5678\"\n }\n }\n}\n" 255 | } 256 | ], 257 | "properties": { 258 | "id": { 259 | "$ref": "#/definitions/customer/definitions/id" 260 | }, 261 | "created_at": { 262 | "$ref": "#/definitions/customer/definitions/created_at" 263 | }, 264 | "email": { 265 | "$ref": "#/definitions/customer/definitions/email" 266 | }, 267 | "given_name": { 268 | "$ref": "#/definitions/customer/definitions/given_name" 269 | }, 270 | "family_name": { 271 | "$ref": "#/definitions/customer/definitions/family_name" 272 | }, 273 | "address_line1": { 274 | "$ref": "#/definitions/customer/definitions/address_line1" 275 | }, 276 | "address_line2": { 277 | "$ref": "#/definitions/customer/definitions/address_line2" 278 | }, 279 | "address_line3": { 280 | "$ref": "#/definitions/customer/definitions/address_line3" 281 | }, 282 | "city": { 283 | "$ref": "#/definitions/customer/definitions/city" 284 | }, 285 | "region": { 286 | "$ref": "#/definitions/customer/definitions/region" 287 | }, 288 | "postal_code": { 289 | "$ref": "#/definitions/customer/definitions/postal_code" 290 | }, 291 | "country_code": { 292 | "$ref": "#/definitions/customer/definitions/country_code" 293 | }, 294 | "metadata": { 295 | "$ref": "#/definitions/helper/definitions/metadata" 296 | }, 297 | "links": { 298 | "$ref": "#/definitions/customer/definitions/links" 299 | } 300 | }, 301 | "type": [ 302 | "object" 303 | ] 304 | }, 305 | "payment": { 306 | "$schema": "https://developer.gocardless.com/hyper-schema.json", 307 | "title": "Payments", 308 | "envelope": "payments", 309 | "description": "Payment objects represent payments from a [customer](https://developer.gocardless.com/pro/#api-endpoints-customers) to a [creditor](https://developer.gocardless.com/pro/#api-endpoints-creditors), taken against a Direct Debit [mandate](https://developer.gocardless.com/pro/#api-endpoints-mandates).\n\nGoCardless will notify you via a [webhook](https://developer.gocardless.com/pro/#webhooks) whenever the state of a payment changes.", 310 | "definitions": { 311 | "id": { 312 | "description": "Unique identifier, beginning with \"PM\"", 313 | "example": "PM123", 314 | "type": [ 315 | "string" 316 | ] 317 | }, 318 | "identity": { 319 | "$ref": "#/definitions/payment/definitions/id" 320 | }, 321 | "created_at": { 322 | "description": "Fixed [timestamp](https://developer.gocardless.com/pro/#overview-time-zones-dates), recording when this resource was created.", 323 | "example": "2014-01-01T12:00:00.000Z", 324 | "format": "date-time", 325 | "type": [ 326 | "string" 327 | ] 328 | }, 329 | "amount": { 330 | "description": "Amount in pence or cents.", 331 | "example": "1000", 332 | "type": [ 333 | "string", 334 | "integer" 335 | ] 336 | }, 337 | "currency": { 338 | "description": "[ISO 4217](http://en.wikipedia.org/wiki/ISO_4217#Active_codes) currency code, currently only \"GBP\" and \"EUR\" are supported.", 339 | "example": "EUR", 340 | "type": [ 341 | "string" 342 | ] 343 | }, 344 | "charge_date": { 345 | "description": "A future date on which the payment should be collected. If not specified, the payment will be collected as soon as possible. This must be on or after the [mandate](https://developer.gocardless.com/pro/#api-endpoints-mandates)'s `next_possible_charge_date`, and will be rolled-forwards by GoCardless if it is not a working day.", 346 | "example": "2014-05-21", 347 | "type": [ 348 | "string", 349 | "null" 350 | ] 351 | }, 352 | "reference": { 353 | "description": "An optional payment reference. This will be appended to the mandate reference on your customer's bank statement. For Bacs payments this can be up to 10 characters, for SEPA Core payments the limit is 140 characters.", 354 | "example": "WINEBOX001", 355 | "type": [ 356 | "string", 357 | "null" 358 | ] 359 | }, 360 | "amount_refunded": { 361 | "description": "Amount refunded in pence or cents.", 362 | "example": "150", 363 | "type": [ 364 | "string", 365 | "integer" 366 | ] 367 | }, 368 | "status": { 369 | "description": "One of:\n", 370 | "example": "submitted", 371 | "type": [ 372 | "string" 373 | ] 374 | }, 375 | "description": { 376 | "description": "A human readable description of the payment.", 377 | "example": "One-off upgrade fee", 378 | "type": [ 379 | "string", 380 | "null" 381 | ] 382 | }, 383 | "links": { 384 | "description": "", 385 | "type": [ 386 | "object" 387 | ], 388 | "properties": { 389 | "mandate": { 390 | "type": [ 391 | "string" 392 | ], 393 | "description": "ID of the [mandate](https://developer.gocardless.com/pro/#api-endpoints-mandates) against which this payment should be collected.", 394 | "example": "MD123" 395 | }, 396 | "creditor": { 397 | "type": [ 398 | "string" 399 | ], 400 | "description": "ID of [creditor](https://developer.gocardless.com/pro/#api-endpoints-creditors) to which the collected payment will be sent.", 401 | "example": "CR123" 402 | }, 403 | "payout": { 404 | "type": [ 405 | "string" 406 | ], 407 | "description": "ID of [payout](https://developer.gocardless.com/pro/#api-endpoints-payouts) which contains the funds from this payment.
**Note**: this property will not be present until the payment has been successfully collected.", 408 | "example": "PO123" 409 | }, 410 | "subscription": { 411 | "type": [ 412 | "string" 413 | ], 414 | "description": "ID of [subscription](https://developer.gocardless.com/pro/#api-endpoints-subscriptions) from which this payment was created.
**Note**: this property will only be present if this payment is part of a subscription.", 415 | "example": "SU123" 416 | } 417 | }, 418 | "additionalProperties": false 419 | } 420 | }, 421 | "links": [ 422 | { 423 | "description": "Creates a new payment object.\n\nThis fails with a `mandate_is_inactive` error if the linked [mandate](https://developer.gocardless.com/pro/#api-endpoints-mandates) is cancelled. Payments can be created against `pending_submission` mandates, but they will not be submitted until the mandate becomes active.", 424 | "title": "Create a payment", 425 | "href": "/payments", 426 | "method": "POST", 427 | "rel": "create", 428 | "schema": { 429 | "type": [ 430 | "object" 431 | ], 432 | "properties": { 433 | "amount": { 434 | "$ref": "#/definitions/payment/definitions/amount" 435 | }, 436 | "currency": { 437 | "$ref": "#/definitions/payment/definitions/currency" 438 | }, 439 | "description": { 440 | "$ref": "#/definitions/payment/definitions/description" 441 | }, 442 | "charge_date": { 443 | "$ref": "#/definitions/payment/definitions/charge_date" 444 | }, 445 | "reference": { 446 | "$ref": "#/definitions/payment/definitions/reference" 447 | }, 448 | "metadata": { 449 | "$ref": "#/definitions/helper/definitions/metadata" 450 | }, 451 | "links": { 452 | "description": "", 453 | "type": [ 454 | "object" 455 | ], 456 | "properties": { 457 | "mandate": { 458 | "type": [ 459 | "string" 460 | ], 461 | "description": "ID of the [mandate](https://developer.gocardless.com/pro/#api-endpoints-mandates) against which this payment should be collected.", 462 | "example": "MD123" 463 | } 464 | }, 465 | "required": [ 466 | "mandate" 467 | ], 468 | "additionalProperties": false 469 | } 470 | }, 471 | "additionalProperties": false, 472 | "required": [ 473 | "amount", 474 | "currency", 475 | "links" 476 | ] 477 | }, 478 | "notes": { 479 | "warning": "by default, you have the option to provide a payment reference up to 10 characters in length. However, if you are providing custom mandate references (which can only be enabled by contacting support), the combined length of the two references separated by a dash character must not exceed 18 characters." 480 | }, 481 | "example": "POST https://api.gocardless.com/payments HTTP/1.1\n{\n \"payments\": {\n \"amount\": 100,\n \"currency\": \"GBP\",\n \"charge_date\": \"2014-05-19\",\n \"reference\": \"WINEBOX001\",\n \"metadata\": {\n \"order_dispatch_date\": \"2014-05-22\"\n },\n \"links\": {\n \"mandate\": \"MD123\"\n }\n }\n}\n\nHTTP/1.1 201 (Created)\nLocation: /payments/PM123\n{\n \"payments\": {\n \"id\": \"PM123\",\n \"created_at\": \"2014-05-08T17:01:06.000Z\",\n \"charge_date\": \"2014-05-21\",\n \"amount\": 100,\n \"description\": null,\n \"currency\": \"GBP\",\n \"status\": \"pending_submission\",\n \"reference\": \"WINEBOX001\",\n \"metadata\": {\n \"order_dispatch_date\": \"2014-05-22\"\n },\n \"amount_refunded\": 0,\n \"links\": {\n \"mandate\": \"MD123\",\n \"creditor\": \"CR123\"\n }\n }\n}\n" 482 | }, 483 | { 484 | "title": "List payments", 485 | "description": "Returns a [cursor-paginated](https://developer.gocardless.com/pro/#overview-cursor-pagination) list of your payments.", 486 | "href": "/payments", 487 | "method": "GET", 488 | "rel": "instances", 489 | "schema": { 490 | "type": [ 491 | "object" 492 | ], 493 | "properties": { 494 | "before": { 495 | "$ref": "#/definitions/helper/definitions/instances_before" 496 | }, 497 | "after": { 498 | "$ref": "#/definitions/helper/definitions/instances_after" 499 | }, 500 | "limit": { 501 | "$ref": "#/definitions/helper/definitions/instances_limit" 502 | }, 503 | "created_at": { 504 | "$ref": "#/definitions/helper/definitions/instances_created_at" 505 | }, 506 | "customer": { 507 | "$ref": "#/definitions/customer/definitions/identity" 508 | }, 509 | "creditor": { 510 | "$ref": "#/definitions/creditor/definitions/identity" 511 | }, 512 | "subscription": { 513 | "$ref": "#/definitions/subscription/definitions/identity" 514 | }, 515 | "mandate": { 516 | "$ref": "#/definitions/mandate/definitions/identity" 517 | }, 518 | "status": { 519 | "$ref": "#/definitions/payment/definitions/status" 520 | } 521 | }, 522 | "additionalProperties": false 523 | }, 524 | "example": "GET https://api.gocardless.com/payments HTTP/1.1\n\nHTTP/1.1 200 (OK)\n{\n \"meta\": {\n \"cursors\": {\n \"before\": null,\n \"after\": null\n },\n \"limit\": 50\n },\n \"payments\": [{\n \"id\": \"PM123\",\n \"created_at\": \"2014-05-08T17:01:06.000Z\",\n \"charge_date\": \"2014-05-15\",\n \"amount\": 100,\n \"description\": null,\n \"currency\": \"GBP\",\n \"status\": \"pending_submission\",\n \"reference\": \"WINEBOX001\",\n \"metadata\": {\n \"order_dispatch_date\": \"2014-05-22\"\n },\n \"amount_refunded\": 0,\n \"links\": {\n \"mandate\": \"MD123\",\n \"creditor\": \"CR123\"\n }\n }]\n}\n" 525 | }, 526 | { 527 | "title": "Get a single payment", 528 | "description": "Retrieves the details of a single existing payment.", 529 | "href": "/payments/{(%23%2Fdefinitions%2Fpayment%2Fdefinitions%2Fidentity)}", 530 | "method": "GET", 531 | "rel": "self", 532 | "example": "GET https://api.gocardless.com/payments/PM123 HTTP/1.1\n\nHTTP/1.1 200 (OK)\n{\n \"payments\": {\n \"id\": \"PM123\",\n \"created_at\": \"2014-05-08T17:01:06.000Z\",\n \"charge_date\": \"2014-05-15\",\n \"amount\": 100,\n \"description\": null,\n \"currency\": \"GBP\",\n \"status\": \"pending_submission\",\n \"reference\": \"WINEBOX001\",\n \"metadata\": {\n \"order_dispatch_date\": \"2014-05-22\"\n },\n \"amount_refunded\": 0,\n \"links\": {\n \"mandate\": \"MD123\",\n \"creditor\": \"CR123\"\n }\n }\n}\n" 533 | }, 534 | { 535 | "title": "Update a payment", 536 | "description": "Updates a payment object. This accepts only the metadata parameter.", 537 | "href": "/payments/{(%23%2Fdefinitions%2Fpayment%2Fdefinitions%2Fidentity)}", 538 | "method": "PUT", 539 | "rel": "update", 540 | "schema": { 541 | "type": [ 542 | "object" 543 | ], 544 | "properties": { 545 | "metadata": { 546 | "$ref": "#/definitions/helper/definitions/metadata" 547 | } 548 | }, 549 | "additionalProperties": false 550 | }, 551 | "example": "PUT https://api.gocardless.com/payments/PM123 HTTP/1.1\n{\n \"payments\": {\n \"metadata\": {\n \"key\": \"value\"\n }\n }\n}\n\nHTTP/1.1 200 (OK)\n{\n \"payments\": {\n \"id\": \"PM123\",\n \"created_at\": \"2014-05-08T17:01:06.000Z\",\n \"charge_date\": \"2014-05-15\",\n \"amount\": 100,\n \"description\": null,\n \"currency\": \"GBP\",\n \"status\": \"pending_submission\",\n \"reference\": \"WINEBOX001\",\n \"metadata\": {\n \"key\": \"value\"\n },\n \"amount_refunded\": 0,\n \"links\": {\n \"mandate\": \"MD123\",\n \"creditor\": \"CR123\"\n }\n }\n}\n" 552 | }, 553 | { 554 | "title": "Cancel a payment", 555 | "description": "Cancels the payment if it has not already been submitted to the banks. Any metadata supplied to this endpoint will be stored on the payment cancellation event it causes.\n\nThis will fail with a `cancellation_failed` error unless the payment's status is `pending_submission`.", 556 | "href": "/payments/{(%23%2Fdefinitions%2Fpayment%2Fdefinitions%2Fidentity)}/actions/cancel", 557 | "method": "POST", 558 | "rel": "cancel", 559 | "schema": { 560 | "type": [ 561 | "object" 562 | ], 563 | "properties": { 564 | "metadata": { 565 | "$ref": "#/definitions/helper/definitions/metadata" 566 | } 567 | }, 568 | "additionalProperties": false 569 | }, 570 | "example": "POST https://api.gocardless.com/payments/PM123/actions/cancel HTTP/1.1\n{\n \"data\": {\n \"metadata\": {\n \"ticket_id\": \"TK123\"\n }\n }\n}\n\nHTTP/1.1 200 (OK)\n{\n \"payments\": {\n \"id\": \"PM123\",\n \"created_at\": \"2014-05-08T17:01:06.000Z\",\n \"charge_date\": \"2014-05-21\",\n \"amount\": 100,\n \"description\": null,\n \"currency\": \"GBP\",\n \"status\": \"cancelled\",\n \"reference\": \"WINEBOX001\",\n \"metadata\": {\n \"order_dispatch_date\": \"2014-05-22\"\n },\n \"amount_refunded\": 0,\n \"links\": {\n \"mandate\": \"MD123\",\n \"creditor\": \"CR123\"\n }\n }\n}\n" 571 | }, 572 | { 573 | "title": "Retry a payment", 574 | "description": "Retries a failed payment if the underlying mandate is active. You will receive a `resubmission_requested` webhook, but after that retrying the payment follows the same process as its initial creation, so you will receive a `submitted` webhook, followed by a `confirmed` or `failed` event. Any metadata supplied to this endpoint will be stored against the payment submission event it causes.\n\nThis will return a `retry_failed` error if the payment has not failed.", 575 | "href": "/payments/{(%23%2Fdefinitions%2Fpayment%2Fdefinitions%2Fidentity)}/actions/retry", 576 | "method": "POST", 577 | "rel": "retry", 578 | "schema": { 579 | "type": [ 580 | "object" 581 | ], 582 | "properties": { 583 | "metadata": { 584 | "$ref": "#/definitions/helper/definitions/metadata" 585 | } 586 | }, 587 | "additionalProperties": false 588 | }, 589 | "example": "POST https://api.gocardless.com/payments/PM123/actions/retry HTTP/1.1\n\n{\n \"data\": {\n \"metadata\": {\n \"reason\": \"Customer request\"\n }\n }\n}\n\nHTTP/1.1 200 (OK)\n{\n \"payments\": {\n \"id\": \"PM123\",\n \"created_at\": \"2014-05-08T17:01:06.000Z\",\n \"charge_date\": \"2014-05-21\",\n \"amount\": 100,\n \"description\": null,\n \"currency\": \"GBP\",\n \"status\": \"submitted\",\n \"reference\": \"WINEBOX001\",\n \"metadata\": {\n \"order_dispatch_date\": \"2014-05-22\"\n },\n \"amount_refunded\": 0,\n \"links\": {\n \"mandate\": \"MD123\",\n \"creditor\": \"CR123\"\n }\n }\n}\n" 590 | } 591 | ], 592 | "properties": { 593 | "id": { 594 | "$ref": "#/definitions/payment/definitions/id" 595 | }, 596 | "created_at": { 597 | "$ref": "#/definitions/payment/definitions/created_at" 598 | }, 599 | "amount": { 600 | "$ref": "#/definitions/payment/definitions/amount" 601 | }, 602 | "amount_refunded": { 603 | "$ref": "#/definitions/payment/definitions/amount_refunded" 604 | }, 605 | "currency": { 606 | "$ref": "#/definitions/payment/definitions/currency" 607 | }, 608 | "description": { 609 | "$ref": "#/definitions/payment/definitions/description" 610 | }, 611 | "charge_date": { 612 | "$ref": "#/definitions/payment/definitions/charge_date" 613 | }, 614 | "reference": { 615 | "$ref": "#/definitions/payment/definitions/reference" 616 | }, 617 | "metadata": { 618 | "$ref": "#/definitions/helper/definitions/metadata" 619 | }, 620 | "status": { 621 | "$ref": "#/definitions/payment/definitions/status" 622 | }, 623 | "links": { 624 | "$ref": "#/definitions/payment/definitions/links" 625 | } 626 | }, 627 | "type": [ 628 | "object" 629 | ] 630 | }, 631 | "helper": { 632 | "$schema": "https://developer.gocardless.com/hyper-schema.json", 633 | "title": "Helpers", 634 | "description": "", 635 | "envelope": "data", 636 | "definitions": { 637 | "metadata": { 638 | "description": "Key-value store of custom data. Up to 3 keys are permitted, with key names up to 50 characters and values up to 200 characters.", 639 | "type": [ 640 | "object" 641 | ], 642 | "maxProperties": 3, 643 | "patternProperties": { 644 | "^(.){1,50}$": { 645 | "type": [ 646 | "string" 647 | ], 648 | "maxLength": 200 649 | } 650 | } 651 | }, 652 | "instances_before": { 653 | "type": [ 654 | "string" 655 | ], 656 | "description": "Cursor pointing to the end of the desired set." 657 | }, 658 | "instances_after": { 659 | "type": [ 660 | "string" 661 | ], 662 | "description": "Cursor pointing to the start of the desired set." 663 | }, 664 | "instances_limit": { 665 | "type": [ 666 | "number", 667 | "string" 668 | ], 669 | "description": "Number of records to return." 670 | }, 671 | "instances_created_at": { 672 | "description": "", 673 | "type": [ 674 | "object" 675 | ], 676 | "properties": { 677 | "gt": { 678 | "type": [ 679 | "string" 680 | ], 681 | "format": "date-time", 682 | "description": "Limit to records created after the specified date-time." 683 | }, 684 | "lt": { 685 | "type": [ 686 | "string" 687 | ], 688 | "format": "date-time", 689 | "description": "Limit to records created before the specified date-time." 690 | }, 691 | "gte": { 692 | "type": [ 693 | "string" 694 | ], 695 | "format": "date-time", 696 | "description": "Limit to records created on or after the specified date-time." 697 | }, 698 | "lte": { 699 | "type": [ 700 | "string" 701 | ], 702 | "format": "date-time", 703 | "description": "Limit to records created on or before the specified date-time." 704 | } 705 | } 706 | }, 707 | "list_basic": { 708 | "description": "", 709 | "type": [ 710 | "object" 711 | ], 712 | "properties": { 713 | "before": { 714 | "$ref": "#/definitions/helper/definitions/instances_before" 715 | }, 716 | "after": { 717 | "$ref": "#/definitions/helper/definitions/instances_after" 718 | }, 719 | "limit": { 720 | "$ref": "#/definitions/helper/definitions/instances_limit" 721 | } 722 | }, 723 | "additionalProperties": false 724 | }, 725 | "list_extended": { 726 | "description": "", 727 | "type": [ 728 | "object" 729 | ], 730 | "properties": { 731 | "before": { 732 | "$ref": "#/definitions/helper/definitions/instances_before" 733 | }, 734 | "after": { 735 | "$ref": "#/definitions/helper/definitions/instances_after" 736 | }, 737 | "limit": { 738 | "$ref": "#/definitions/helper/definitions/instances_limit" 739 | }, 740 | "created_at": { 741 | "$ref": "#/definitions/helper/definitions/instances_created_at" 742 | } 743 | }, 744 | "additionalProperties": false 745 | }, 746 | "scheme": { 747 | "description": "Direct Debit scheme", 748 | "type": [ 749 | "string" 750 | ] 751 | }, 752 | "mandate_account_number": { 753 | "description": "8 digit, valid UK bank account number.", 754 | "example": "55779912", 755 | "type": [ 756 | "string", 757 | "null" 758 | ] 759 | }, 760 | "mandate_sort_code": { 761 | "description": "6 digit, valid UK sort code.", 762 | "example": "200000", 763 | "type": [ 764 | "string", 765 | "null" 766 | ] 767 | }, 768 | "mandate_bank_code": { 769 | "description": "Bank identifier code.", 770 | "example": "20041", 771 | "type": [ 772 | "string", 773 | "null" 774 | ] 775 | }, 776 | "mandate_branch_code": { 777 | "description": "Branch identifier code.", 778 | "example": "01005", 779 | "type": [ 780 | "string", 781 | "null" 782 | ] 783 | }, 784 | "iban": { 785 | "description": "International Bank Account Number", 786 | "type": [ 787 | "string", 788 | "null" 789 | ] 790 | }, 791 | "bic": { 792 | "description": "Bank Identifier Code", 793 | "type": [ 794 | "string", 795 | "null" 796 | ] 797 | }, 798 | "account_holder_address": { 799 | "description": "The address of the account holder.", 800 | "type": [ 801 | "string", 802 | "null" 803 | ] 804 | }, 805 | "account_number": { 806 | "description": "8 digit, valid UK bank account number.", 807 | "example": "55779912", 808 | "type": [ 809 | "string" 810 | ] 811 | }, 812 | "sort_code": { 813 | "description": "6 digit, valid UK sort code.", 814 | "example": "200000", 815 | "type": [ 816 | "string" 817 | ] 818 | }, 819 | "bank_code": { 820 | "description": "Bank identifier code.", 821 | "example": "20041", 822 | "type": [ 823 | "string" 824 | ] 825 | }, 826 | "branch_code": { 827 | "description": "Branch identifier code.", 828 | "example": "01005", 829 | "type": [ 830 | "string" 831 | ] 832 | }, 833 | "country_code": { 834 | "description": "[ISO 3166-1](http://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements) alpha-2 code. Defaults to the country code of the `iban` if supplied, otherwise is required.", 835 | "type": [ 836 | "string" 837 | ] 838 | }, 839 | "account_holder_name": { 840 | "description": "Name of the account holder, as known by the bank. Usually this matches the name of the linked [customer](https://developer.gocardless.com/pro/#api-endpoints-customers). This field cannot exceed 18 characters.", 841 | "example": "Lando Calrissian", 842 | "type": [ 843 | "string", 844 | "null" 845 | ] 846 | }, 847 | "mandate_reference": { 848 | "description": "Mandate reference (normally set by GoCardless)", 849 | "example": "Foo Subscription", 850 | "type": [ 851 | "string", 852 | "null" 853 | ] 854 | }, 855 | "signed_at": { 856 | "description": "Will render a form with this date and no signature field.", 857 | "example": "2014-01-18", 858 | "type": [ 859 | "string", 860 | "null" 861 | ] 862 | }, 863 | "links": { 864 | "description": "", 865 | "type": [ 866 | "object" 867 | ], 868 | "sun": { 869 | "type": [ 870 | "string" 871 | ], 872 | "description": "If you have multiple SUNs for the a scheme you can specify which will be displayed on the mandate by specifying an ID of one here." 873 | } 874 | } 875 | }, 876 | "links": [ 877 | { 878 | "title": "Mandate PDF", 879 | "description": "Returns a PDF mandate form with a signature field, ready to be signed by your customer. May be fully or partially pre-filled.\n\nYou must specify `Accept: application/pdf` on requests to this endpoint.\n\nBank account details may either be supplied using the IBAN (international bank account number), or [local details](https://developer.gocardless.com/pro/#ui-compliance-local-bank-details). For more information on the different fields required in each country, please see the [local bank details](https://developer.gocardless.com/pro/#ui-compliance-local-bank-details) section.\n\nTo generate a mandate in a foreign language, set your `Accept-Language` header to the relevant [ISO 639-1](http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes#Partial_ISO_639_table) language code. Currently Dutch, English, French, German, Italian, Portuguese and Spanish are supported.\n\n_Note:_ If you want to render a PDF of an existing mandate you can also do so using the [mandate show endpoint](https://developer.gocardless.com/pro/#mandates-get-a-single-mandate).", 880 | "href": "/helpers/mandate", 881 | "method": "POST", 882 | "rel": "mandate", 883 | "schema": { 884 | "type": [ 885 | "object" 886 | ], 887 | "properties": { 888 | "scheme": { 889 | "$ref": "#/definitions/helper/definitions/scheme" 890 | }, 891 | "country_code": { 892 | "$ref": "#/definitions/helper/definitions/country_code" 893 | }, 894 | "account_number": { 895 | "$ref": "#/definitions/helper/definitions/mandate_account_number" 896 | }, 897 | "bank_code": { 898 | "$ref": "#/definitions/helper/definitions/mandate_bank_code" 899 | }, 900 | "branch_code": { 901 | "$ref": "#/definitions/helper/definitions/mandate_branch_code" 902 | }, 903 | "sort_code": { 904 | "$ref": "#/definitions/helper/definitions/mandate_sort_code" 905 | }, 906 | "account_holder_name": { 907 | "$ref": "#/definitions/helper/definitions/account_holder_name" 908 | }, 909 | "account_holder_address": { 910 | "$ref": "#/definitions/helper/definitions/account_holder_address" 911 | }, 912 | "mandate_reference": { 913 | "$ref": "#/definitions/helper/definitions/mandate_reference" 914 | }, 915 | "signed_at": { 916 | "$ref": "#/definitions/helper/definitions/signed_at" 917 | }, 918 | "iban": { 919 | "$ref": "#/definitions/helper/definitions/iban" 920 | }, 921 | "bic": { 922 | "$ref": "#/definitions/helper/definitions/bic" 923 | }, 924 | "links": { 925 | "$ref": "#/definitions/helper/definitions/links" 926 | } 927 | }, 928 | "additionalProperties": false 929 | } 930 | }, 931 | { 932 | "title": "Modulus checking", 933 | "description": "Check whether an account number and bank / branch code combination are compatible.\n\nBank account details may either be supplied using the IBAN (international bank account number), or [local details](https://developer.gocardless.com/pro/#ui-compliance-local-bank-details). For more information on the different fields required in each country, please see the [local bank details](https://developer.gocardless.com/pro/#ui-compliance-local-bank-details) section.", 934 | "href": "/helpers/modulus_check", 935 | "method": "POST", 936 | "rel": "modulus_check", 937 | "schema": { 938 | "type": [ 939 | "object" 940 | ], 941 | "properties": { 942 | "iban": { 943 | "$ref": "#/definitions/helper/definitions/iban" 944 | }, 945 | "account_number": { 946 | "$ref": "#/definitions/helper/definitions/account_number" 947 | }, 948 | "bank_code": { 949 | "$ref": "#/definitions/helper/definitions/bank_code" 950 | }, 951 | "branch_code": { 952 | "$ref": "#/definitions/helper/definitions/branch_code" 953 | }, 954 | "sort_code": { 955 | "$ref": "#/definitions/helper/definitions/sort_code" 956 | }, 957 | "country_code": { 958 | "$ref": "#/definitions/helper/definitions/country_code" 959 | } 960 | }, 961 | "additionalProperties": false 962 | } 963 | } 964 | ], 965 | "properties": {}, 966 | "type": [ 967 | "object" 968 | ] 969 | } 970 | }, 971 | "$schema": "https://developer.gocardless.com/hyper-schema.json", 972 | "properties": { 973 | "api_key": { 974 | "$ref": "#/definitions/api_key" 975 | }, 976 | "creditor": { 977 | "$ref": "#/definitions/creditor" 978 | }, 979 | "creditor_bank_account": { 980 | "$ref": "#/definitions/creditor_bank_account" 981 | }, 982 | "customer": { 983 | "$ref": "#/definitions/customer" 984 | }, 985 | "customer_bank_account": { 986 | "$ref": "#/definitions/customer_bank_account" 987 | }, 988 | "event": { 989 | "$ref": "#/definitions/event" 990 | }, 991 | "helper": { 992 | "$ref": "#/definitions/helper" 993 | }, 994 | "mandate": { 995 | "$ref": "#/definitions/mandate" 996 | }, 997 | "payment": { 998 | "$ref": "#/definitions/payment" 999 | }, 1000 | "payout": { 1001 | "$ref": "#/definitions/payout" 1002 | }, 1003 | "publishable_api_key": { 1004 | "$ref": "#/definitions/publishable_api_key" 1005 | }, 1006 | "redirect_flow": { 1007 | "$ref": "#/definitions/redirect_flow" 1008 | }, 1009 | "refund": { 1010 | "$ref": "#/definitions/refund" 1011 | }, 1012 | "role": { 1013 | "$ref": "#/definitions/role" 1014 | }, 1015 | "subscription": { 1016 | "$ref": "#/definitions/subscription" 1017 | }, 1018 | "user": { 1019 | "$ref": "#/definitions/user" 1020 | } 1021 | }, 1022 | "type": [ 1023 | "object" 1024 | ], 1025 | "description": "GoCardless Enterprise API", 1026 | "id": "gocardless-enterprise", 1027 | "links": [ 1028 | { 1029 | "href": "https://api.gocardless.com", 1030 | "rel": "self" 1031 | }, 1032 | { 1033 | "href": "https://api-sandbox.gocardless.com", 1034 | "rel": "self" 1035 | } 1036 | ], 1037 | "title": "GoCardless Enterprise API" 1038 | } -------------------------------------------------------------------------------- /spec/spec-helper.js: -------------------------------------------------------------------------------- 1 | function _specHelper(scope = {}) { 2 | return ['get', 'post', 'put', 'delete'].forEach(method => { 3 | scope[method] = function(url, cb) { 4 | var options = {}; 5 | if (typeof url === 'string') { 6 | options.url = url; 7 | } else { 8 | options = url; 9 | } 10 | 11 | if (!options.headers) { 12 | options.headers = {}; 13 | } 14 | if (options.async === undefined) { 15 | options.async = true; 16 | } 17 | 18 | var xhr = new XMLHttpRequest(); 19 | 20 | xhr.open(method.toUpperCase(), options.url, !!options.async); 21 | 22 | xhr.setRequestHeader('Content-Type', 'application/json'); 23 | Object.keys(options.headers).forEach(function(header) { 24 | xhr.setRequestHeader(header, options.headers[header]); 25 | }); 26 | 27 | var postBody = options.data ? JSON.stringify(options.data) : null; 28 | 29 | if (typeof cb === 'function') { 30 | xhr.onreadystatechange = function() { 31 | if (xhr.readyState === 4) { 32 | cb(xhr); 33 | } 34 | }; 35 | } 36 | 37 | xhr.send(postBody); 38 | 39 | return xhr; 40 | }; 41 | }); 42 | } 43 | 44 | if (typeof global !== 'undefined') { 45 | _specHelper(global); 46 | } 47 | 48 | if (typeof window !== 'undefined') { 49 | _specHelper(window); 50 | } 51 | -------------------------------------------------------------------------------- /spec/stubby.spec.js: -------------------------------------------------------------------------------- 1 | describe('create stubby', () => { 2 | var stubby; 3 | 4 | beforeEach(() => { 5 | stubby = new global.Stubby(); 6 | }); 7 | 8 | describe('stubbing a URL', () => { 9 | it('lets a URL be stubbed', done => { 10 | stubby 11 | .stub({ 12 | url: '/foo' 13 | }) 14 | .respondWith(200, { foo: 2 }); 15 | 16 | global.get('/foo', function(xhr) { 17 | expect(JSON.parse(xhr.responseText)).toEqual({ foo: 2 }); 18 | expect(xhr.status).toEqual(200); 19 | done(); 20 | }); 21 | }); 22 | 23 | it('differentiates on query params', done => { 24 | stubby 25 | .stub({ 26 | url: '/foo?a=1' 27 | }) 28 | .respondWith(200, { a: 1 }); 29 | 30 | stubby 31 | .stub({ 32 | url: '/foo?b=2', 33 | params: { b: 1 } 34 | }) 35 | .respondWith(200, { b: 1 }); 36 | 37 | global.get('/foo?a=1', function(xhr) { 38 | expect(JSON.parse(xhr.responseText)).toEqual({ a: 1 }); 39 | done(); 40 | }); 41 | }); 42 | 43 | it('doesn\'t match a stub with query params against a URL without', () => { 44 | stubby 45 | .stub({ 46 | url: '/foo?a=1' 47 | }) 48 | .respondWith(200); 49 | 50 | expect(() => { 51 | global.get('/foo', () => {}); 52 | }).toThrowError(); 53 | }); 54 | 55 | it('works with query params in both orders', done => { 56 | stubby 57 | .stub({ 58 | url: '/foo?a=1&b=2' 59 | }) 60 | .respondWith(200, { a: 1, b: 2 }); 61 | 62 | stubby 63 | .stub({ 64 | url: '/foo?b=3' 65 | }) 66 | .respondWith(200, { b: 3 }); 67 | 68 | global.get('/foo?b=2&a=1', function(xhr) { 69 | expect(JSON.parse(xhr.responseText)).toEqual({ a: 1, b: 2 }); 70 | done(); 71 | }); 72 | }); 73 | 74 | it('lets you define query params', done => { 75 | stubby 76 | .stub({ 77 | url: '/foo', 78 | params: { a: 1 } 79 | }) 80 | .respondWith(200, { a: 1 }); 81 | 82 | global.get('/foo?a=1', function(xhr) { 83 | expect(JSON.parse(xhr.responseText)).toEqual({ a: 1 }); 84 | done(); 85 | }); 86 | }); 87 | 88 | it('lets you match on headers', done => { 89 | stubby 90 | .stub({ 91 | url: '/foo', 92 | headers: { 93 | foo: 'bar' 94 | } 95 | }) 96 | .respondWith(200, { a: 1 }); 97 | 98 | global.get( 99 | { 100 | url: '/foo', 101 | headers: { 102 | foo: 'bar' 103 | } 104 | }, 105 | function(xhr) { 106 | expect(JSON.parse(xhr.responseText)).toEqual({ a: 1 }); 107 | done(); 108 | } 109 | ); 110 | }); 111 | 112 | it('lets you stub response headers', done => { 113 | stubby 114 | .stub({ 115 | url: '/foo' 116 | }) 117 | .respondWith( 118 | 200, 119 | { a: 1 }, 120 | { 121 | headers: { foo: 'bar' } 122 | } 123 | ); 124 | 125 | global.get( 126 | { 127 | url: '/foo' 128 | }, 129 | function(xhr) { 130 | expect(xhr.getResponseHeader('foo')).toEqual('bar'); 131 | done(); 132 | } 133 | ); 134 | }); 135 | 136 | it('lets you match on regex headers', done => { 137 | stubby 138 | .stub({ 139 | url: '/foo', 140 | headers: { a: '/\\w|\\d/g' } 141 | }) 142 | .respondWith(200, { a: 1 }); 143 | 144 | global.get( 145 | { 146 | url: '/foo', 147 | headers: { 148 | a: 1 149 | } 150 | }, 151 | function(xhr) { 152 | expect(JSON.parse(xhr.responseText)).toEqual({ a: 1 }); 153 | done(); 154 | } 155 | ); 156 | }); 157 | 158 | it('ignores headers in the request not present in the stub', done => { 159 | stubby 160 | .stub({ 161 | url: '/foo', 162 | headers: { a: 1 } 163 | }) 164 | .respondWith(200, { a: 1 }); 165 | 166 | global.get( 167 | { 168 | url: '/foo', 169 | headers: { 170 | a: 1, 171 | b: 2 172 | } 173 | }, 174 | function(xhr) { 175 | expect(JSON.parse(xhr.responseText)).toEqual({ a: 1 }); 176 | done(); 177 | } 178 | ); 179 | }); 180 | 181 | it('matches on regex query param values', done => { 182 | stubby 183 | .stub({ 184 | url: '/foo', 185 | params: { a: '/\\w|\\d/g' } 186 | }) 187 | .respondWith(200, { a: 1 }); 188 | 189 | global.get('/foo?a=1', function(xhr) { 190 | expect(JSON.parse(xhr.responseText)).toEqual({ a: 1 }); 191 | done(); 192 | }); 193 | }); 194 | 195 | it('all params are matched', done => { 196 | stubby 197 | .stub({ 198 | url: '/foo', 199 | params: { a: '/\\w|\\d/g', c: 1 } 200 | }) 201 | .respondWith(200, { a: 1 }); 202 | 203 | stubby 204 | .stub({ 205 | url: '/foo', 206 | params: { b: 1, c: 1, a: '/\\w|\\d/g' } 207 | }) 208 | .respondWith(200, { is: 'not matched' }); 209 | 210 | global.get('/foo?a=1&c=1', function(xhr) { 211 | expect(JSON.parse(xhr.responseText)).toEqual({ a: 1 }); 212 | done(); 213 | }); 214 | }); 215 | }); 216 | 217 | describe('allows plugins on setup and on request', () => { 218 | describe('creates a new stub', () => { 219 | var spies; 220 | beforeEach(() => { 221 | spies = { 222 | setup: jasmine.createSpy('setup'), 223 | request: jasmine.createSpy('request'), 224 | routesetup: jasmine.createSpy('routesetup') 225 | }; 226 | stubby.addModule({ 227 | register: function(stubbyInstance) { 228 | stubbyInstance.on('setup', spies.setup); 229 | stubbyInstance.on('routesetup', spies.routesetup); 230 | stubbyInstance.on('request', spies.request); 231 | } 232 | }); 233 | stubby 234 | .stub({ 235 | url: '/test' 236 | }) 237 | .respondWith(200, {}); 238 | }); 239 | 240 | it('makes one request when a stub is made for setup', () => { 241 | expect(spies.routesetup).toHaveBeenCalled(); 242 | expect(spies.setup).not.toHaveBeenCalled(); 243 | expect(spies.request).not.toHaveBeenCalled(); 244 | }); 245 | it('makes one request to setup and one request to request when set', done => { 246 | expect(spies.routesetup).toHaveBeenCalled(); 247 | global.get('/test', () => { 248 | expect(spies.setup).toHaveBeenCalled(); 249 | expect(spies.request).toHaveBeenCalled(); 250 | done(); 251 | }); 252 | }); 253 | }); 254 | }); 255 | 256 | describe('verifiying that stubs have been used', () => { 257 | it('errors if a stub is not used', () => { 258 | stubby.stub({ url: '/foo' }).respondWith(200, {}); 259 | 260 | expect(() => { 261 | stubby.verifyNoOutstandingRequest(); 262 | }).toThrowError(); 263 | }); 264 | 265 | it('doesn\'t error when all stubs are satisfied', done => { 266 | stubby.stub({ url: '/foo' }).respondWith(200, {}); 267 | stubby.stub({ url: '/bar' }).respondWith(200, {}); 268 | 269 | global.get('/foo', () => { 270 | global.get('/bar', () => { 271 | try { 272 | expect(() => { 273 | stubby.verifyNoOutstandingRequest(); 274 | }).not.toThrow(); 275 | done(); 276 | } catch (e) { 277 | done(e); 278 | } 279 | }); 280 | }); 281 | }); 282 | 283 | it('errors if multiple stubs aren\'t used', () => { 284 | stubby.stub({ url: '/foo' }).respondWith(200, {}); 285 | stubby.stub({ url: '/foo', method: 'POST' }).respondWith(200, {}); 286 | 287 | expect(() => { 288 | stubby.verifyNoOutstandingRequest(); 289 | }).toThrowError('Stub(s) were not called: GET /foo/, POST /foo/'); 290 | }); 291 | 292 | it('can deal with multiple stubs', done => { 293 | stubby.stub({ url: '/foo' }).respondWith(200, {}); 294 | stubby.stub({ url: '/bar' }).respondWith(200, {}); 295 | 296 | global.get('/foo', () => { 297 | expect(() => { 298 | stubby.verifyNoOutstandingRequest(); 299 | }).toThrowError(); 300 | 301 | done(); 302 | }); 303 | }); 304 | 305 | it('can deal with query params', done => { 306 | stubby.stub({ url: '/foo', params: { a: 1 } }).respondWith(200, {}); 307 | stubby.stub({ url: '/foo', params: { b: 1 } }).respondWith(200, {}); 308 | 309 | global.get('/foo?a=1', () => { 310 | expect(() => { 311 | stubby.verifyNoOutstandingRequest(); 312 | }).toThrowError(); 313 | 314 | done(); 315 | }); 316 | }); 317 | }); 318 | 319 | describe('stubbing a POST url', () => { 320 | it('stubs a post URL', done => { 321 | stubby 322 | .stub({ 323 | url: '/foo', 324 | method: 'POST' 325 | }) 326 | .respondWith(200, { a: 1 }); 327 | 328 | global.post('/foo', function(xhr) { 329 | expect(JSON.parse(xhr.responseText)).toEqual({ a: 1 }); 330 | done(); 331 | }); 332 | }); 333 | 334 | describe('when stubbing POST data', () => { 335 | it('can match stub data to POST data', done => { 336 | stubby 337 | .stub({ 338 | url: '/foo', 339 | data: { b: 2 }, 340 | method: 'POST' 341 | }) 342 | .respondWith(200, { a: 1 }); 343 | 344 | global.post({ url: '/foo', data: { b: 2 } }, function(xhr) { 345 | expect(JSON.parse(xhr.responseText)).toEqual({ a: 1 }); 346 | done(); 347 | }); 348 | }); 349 | 350 | describe('when POST data is stringified object', () => { 351 | it('can match on POST body', done => { 352 | stubby 353 | .stub({ 354 | url: '/foo', 355 | data: { b: 2 }, 356 | method: 'POST' 357 | }) 358 | .respondWith(200, { a: 1 }); 359 | 360 | global.post({ url: '/foo', data: JSON.stringify({ b: 2 }) }, function(xhr) { 361 | expect(JSON.parse(xhr.responseText)).toEqual({ a: 1 }); 362 | done(); 363 | }); 364 | }); 365 | }); 366 | 367 | describe('when POST data is a string', () => { 368 | it('should throw an error as it can not match', () => { 369 | stubby 370 | .stub({ 371 | url: '/foo', 372 | data: JSON.stringify({ b: 2 }), 373 | method: 'POST' 374 | }) 375 | .respondWith(200, { a: 1 }); 376 | 377 | expect(() => { 378 | global.post({ url: '/foo', data: { b: 2 } }, () => {}); 379 | }).toThrow(); 380 | }); 381 | }); 382 | }); 383 | 384 | it('matches a stub if the stub has no data but the request does', done => { 385 | stubby 386 | .stub({ 387 | url: '/foo', 388 | method: 'POST' 389 | }) 390 | .respondWith(200, { a: 1 }); 391 | 392 | global.post({ url: '/foo', data: { b: 2 } }, function(xhr) { 393 | expect(JSON.parse(xhr.responseText)).toEqual({ a: 1 }); 394 | done(); 395 | }); 396 | }); 397 | 398 | it('can differentiate between POST and PUT data', done => { 399 | stubby 400 | .stub({ 401 | url: '/foobar', 402 | method: 'POST' 403 | }) 404 | .respondWith(200, { method: 'get' }); 405 | 406 | stubby 407 | .stub({ 408 | url: '/foobar', 409 | method: 'PUT' 410 | }) 411 | .respondWith(200, { method: 'put' }); 412 | 413 | global.put('/foobar', function(xhr) { 414 | expect(JSON.parse(xhr.responseText)).toEqual({ method: 'put' }); 415 | done(); 416 | }); 417 | }); 418 | 419 | it('can differentiate between a GET and PUT', done => { 420 | stubby 421 | .stub({ 422 | url: '/foobar', 423 | method: 'PUT' 424 | }) 425 | .respondWith(200, { method: 'put' }); 426 | stubby 427 | .stub({ 428 | url: '/foobar', 429 | method: 'GET' 430 | }) 431 | .respondWith(200, { method: 'get' }); 432 | 433 | global.get('/foobar', function(xhr) { 434 | expect(JSON.parse(xhr.responseText)).toEqual({ method: 'get' }); 435 | done(); 436 | }); 437 | }); 438 | }); 439 | 440 | describe('stubbing the same URL twice', () => { 441 | it('fails when a matching stub is redeclared', () => { 442 | stubby.stub({ url: '/foo' }).respondWith(200, { first: true }); 443 | 444 | expect(() => { 445 | stubby.stub({ url: '/foo' }).respondWith(200, { first: false }); 446 | }).toThrow(); 447 | }); 448 | 449 | it('lets you override if you pass the overrideStub param', done => { 450 | stubby.stub({ url: '/foo' }).respondWith(200, { first: true }); 451 | 452 | expect(() => { 453 | stubby.stub({ url: '/foo', overrideStub: true }).respondWith(200, { first: false }); 454 | }).not.toThrow(); 455 | 456 | global.get('/foo', function(xhr) { 457 | expect(JSON.parse(xhr.responseText)).toEqual({ first: false }); 458 | done(); 459 | }); 460 | }); 461 | }); 462 | }); 463 | -------------------------------------------------------------------------------- /stubby.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Depends on: 5 | * - lodash [bower_components/lodash/lodash.js] 6 | * - pretender [bower_components/pretender/pretender.js] 7 | * - route-recognizer [bower_components/route-recognizer/dist/route-recognizer.js] 8 | */ 9 | 10 | var stubbyFactory = function(deps) { 11 | 12 | var Pretender = deps.pretender; 13 | var _ = deps.lodash; 14 | var queryString = deps.querystring; 15 | 16 | var Stubby = function() { 17 | this.stubs = {}; 18 | this.pretender = new Pretender(); 19 | 20 | this.events = { 21 | handlers: {}, 22 | whitelist: ['setup', 'routesetup', 'request'] 23 | }; 24 | }; 25 | 26 | Stubby.prototype.addModule = function(module) { 27 | if (!('register' in module)) { 28 | throw new Error('Valid modules need to have a .register method.'); 29 | } 30 | module.register(this); 31 | }; 32 | 33 | Stubby.prototype.emit = function(name) { 34 | if (!this.events.handlers[name]) { return; } 35 | var args = [].slice.call(arguments, 1); 36 | this.events.handlers[name].forEach(function(hook) { 37 | hook.apply(null, args); 38 | }); 39 | }; 40 | 41 | Stubby.prototype.on = function(name, handler, thisArg) { 42 | if (this.events.whitelist && !_.includes(this.events.whitelist, name)) { 43 | throw new Error('"' + name + '" is not a valid event handler'); 44 | } 45 | this.events.handlers[name] = this.events.handlers[name] || []; 46 | if (thisArg) { handler = _.bind(handler, thisArg); } 47 | this.events.handlers[name].push(handler); 48 | }; 49 | 50 | Stubby.prototype.passthrough = function(url) { 51 | this.pretender.get(url, Pretender.prototype.passthrough); 52 | }; 53 | 54 | Stubby.prototype.findStubForRequest = function(req) { 55 | var stubs = this.stubs[req.url.split('?')[0]]; 56 | var data = req.requestBody; 57 | var contentType = 'requestHeaders' in req && req.requestHeaders['Content-Type'] || ''; 58 | if (contentType.match('application/json')) { 59 | data = JSON.parse(req.requestBody) || {}; 60 | } 61 | 62 | if (!data) { data = {}; } 63 | 64 | return _.find(stubs, function(stub) { 65 | return this.stubMatchesRequest(stub, { 66 | data: data, 67 | method: req.method, 68 | requestHeaders: req.requestHeaders, 69 | queryParams: req.queryParams 70 | }); 71 | }.bind(this)); 72 | }; 73 | 74 | Stubby.prototype.stubMatchesRequest = function(stub, request) { 75 | var queryParams = request.queryParams; 76 | var method = request.method; 77 | 78 | this.emit('setup', stub, request); 79 | 80 | function isRegex(regex) { 81 | return regex && regex.match && regex.match(/^\/(.+)\/([gimy])?$/); 82 | } 83 | 84 | function testRegex(regex, test) { 85 | var match = isRegex(regex); 86 | return match && new RegExp(match[1], match[2]).test(test); 87 | } 88 | 89 | var methodsMatch = stub.request.method === method; 90 | 91 | var paramKeys = _.uniq(_.keys(stub.queryParams).concat(_.keys(queryParams))); 92 | var queryParamsMatch = paramKeys.every(function(key) { 93 | if (!(key in queryParams) || !(key in stub.queryParams)) { return false; } 94 | 95 | if (isRegex(stub.queryParams[key])) { 96 | return testRegex(stub.queryParams[key], queryParams[key]); 97 | } else { 98 | return stub.queryParams[key] === queryParams[key]; 99 | } 100 | }); 101 | 102 | var dataRequestMatch; 103 | 104 | var stubbedRequestData = stub.request.data; 105 | var requestData = request.data; 106 | 107 | // if no stub data was given, we just say that we matched 108 | if (!_.isEmpty(stubbedRequestData)) { 109 | // if the data is a string we assume it is JSON string 110 | if (typeof requestData === 'string') { 111 | try { 112 | var parsedRequestData = JSON.parse(requestData); 113 | dataRequestMatch = _.isEqual(stubbedRequestData, parsedRequestData); 114 | } catch (e) { 115 | dataRequestMatch = _.isEqual(stubbedRequestData, requestData); 116 | } 117 | } else { 118 | dataRequestMatch = _.isEqual(stubbedRequestData, requestData); 119 | } 120 | } else { 121 | dataRequestMatch = true; 122 | } 123 | 124 | var headersMatch = _.every(Object.keys(request.requestHeaders || {}), function(requestHeader) { 125 | var stubHeaderValue = stub.request.headers[requestHeader]; 126 | var requestHeaderValue = request.requestHeaders[requestHeader]; 127 | 128 | if (!_.includes(Object.keys(stub.request.headers), requestHeader)) { 129 | // if the request header wasn't in the stub, then just 130 | // ignore it and don't match against it 131 | return true; 132 | } 133 | 134 | if (isRegex(stubHeaderValue) && testRegex(stubHeaderValue, requestHeaderValue)) { 135 | return true; 136 | } 137 | 138 | return _.isEqual(stubHeaderValue, requestHeaderValue); 139 | }); 140 | 141 | // Request data doesn't need to match if we're validating. 142 | if (stub.internal.skipDataMatch) { dataRequestMatch = true; } 143 | 144 | return methodsMatch && queryParamsMatch && dataRequestMatch && headersMatch; 145 | }; 146 | 147 | Stubby.StubInternal = function(stubby, options) { 148 | var urlsplit = options.url.split('?'); 149 | this.url = urlsplit[0]; 150 | this.internal = {options: options.options || {}}; 151 | this.queryParams = options.params || queryString.parse(urlsplit[1]); 152 | this.overrideStub = options.overrideStub || false; 153 | 154 | // convert all queryParam values to string 155 | // this means we don't support nested query params 156 | // we do this because later we compare to the query params in the body 157 | // where everything is kept as a string 158 | Object.keys(this.queryParams).forEach(function(p) { 159 | if (this.queryParams[p] == null) { this.queryParams[p] = ''; } 160 | this.queryParams[p] = this.queryParams[p].toString(); 161 | }, this); 162 | 163 | this.requestCount = 0; 164 | 165 | this.setupRequest = function(requestOptions) { 166 | this.request = { 167 | headers: requestOptions.headers || {}, 168 | data: requestOptions.data || {}, 169 | method: requestOptions.method || 'GET' 170 | }; 171 | }; 172 | 173 | this.setupRequest(options); 174 | 175 | this.stubMatcher = function(stubbyInstance) { 176 | var self = this; 177 | return function(stubToMatch) { 178 | return stubbyInstance.stubMatchesRequest(self, { 179 | data: stubToMatch.request.data, 180 | queryParams: stubToMatch.queryParams, 181 | headers: stubToMatch.request.headers, 182 | method: stubToMatch.request.method 183 | }); 184 | }; 185 | }; 186 | 187 | this.respondWith = function(status, data, responseOptions) { 188 | var url = this.url; 189 | 190 | if (typeof status !== 'number') { 191 | throw new Error('Status (' + JSON.stringify(status) + ') is invalid.'); 192 | } 193 | 194 | this.response = { 195 | data: data || {}, 196 | status: status 197 | }; 198 | 199 | if (responseOptions && responseOptions.headers) { 200 | this.response.headers = responseOptions.headers; 201 | } 202 | 203 | if (!stubby.stubs[this.url]) { stubby.stubs[this.url] = []; } 204 | 205 | var matchingStub = _.find(stubby.stubs[this.url], this.stubMatcher(stubby)); 206 | 207 | if (matchingStub) { 208 | if (this.overrideStub) { 209 | stubby.remove(options); 210 | } else { 211 | throw new Error('Matching stub found. Cannot override.'); 212 | } 213 | } 214 | 215 | stubby.stubs[this.url].push(this); 216 | 217 | stubby.emit('routesetup', {}, this); 218 | 219 | stubby.pretender[this.request.method.toLowerCase()](this.url, function(req) { 220 | var matchedStub = stubby.findStubForRequest(req); 221 | if (matchedStub) { 222 | stubby.emit('request', req, matchedStub); 223 | ++matchedStub.requestCount; 224 | return stubby.response(matchedStub); 225 | } else { 226 | console.log('Could not match stub for request: ', req); 227 | 228 | var result = { 229 | method: req.method, 230 | url: url 231 | }; 232 | 233 | _.each( 234 | { 235 | params: req.queryParams, 236 | data: req.requestBody, 237 | headers: req.requestHeaders 238 | }, 239 | function appendToResult(value, key) { 240 | var res; 241 | try { 242 | res = JSON.parse(value); 243 | } catch (err) { 244 | res = value; 245 | } finally { 246 | if (!_.isEmpty(res)) { 247 | _.set(result, key, res); 248 | } 249 | } 250 | } 251 | ); 252 | 253 | throw new Error( 254 | 'Stubby: no stub found for this request. ' + 255 | 'You can stub this request with:\n\n' + 256 | 'window.stubby.stub(' + 257 | JSON.stringify(result, null, 2) + 258 | ')\n' + 259 | '.respondWith(' + status + ', ' + JSON.stringify(data, null, 2) + ');' 260 | ); 261 | } 262 | }); 263 | 264 | return this; 265 | }; 266 | }; 267 | 268 | Stubby.prototype.stub = function(options) { 269 | return new Stubby.StubInternal(this, options); 270 | }; 271 | 272 | Stubby.prototype.remove = function(options) { 273 | var stubToMatch = new Stubby.StubInternal(this, options); 274 | var stubsArray = this.stubs[stubToMatch.url]; 275 | if (!stubsArray) { 276 | throw new Error('No stubs exist for this base url'); 277 | } 278 | var stubsArrayOriginalLength = stubsArray.length; 279 | _.remove(stubsArray, stubToMatch.stubMatcher(this)); 280 | if (stubsArrayOriginalLength === stubsArray.length) { 281 | throw new Error('Couldn\'t find the specified stub to remove'); 282 | } 283 | }; 284 | 285 | Stubby.prototype.verifyNoOutstandingRequest = function() { 286 | var outstandingStubs = _.chain(this.stubs) 287 | .values() 288 | .flatten() 289 | .filter(function(stub) { return stub.requestCount === 0; }) 290 | .map(function(stub) { 291 | return stub.request.method + ' ' + stub.url + '/' + 292 | queryString.stringify(stub.queryParams); 293 | }) 294 | .value(); 295 | 296 | if (outstandingStubs.length !== 0) { 297 | throw new Error('Stub(s) were not called: ' + outstandingStubs.join(', ')); 298 | } 299 | }; 300 | 301 | Stubby.prototype.response = function(stub) { 302 | var headers = stub.response.headers || {}; 303 | 304 | if (!('Content-Type' in headers)) { headers['Content-Type'] = 'application/json'; } 305 | 306 | return [stub.response.status, headers, JSON.stringify(stub.response.data)]; 307 | }; 308 | 309 | Stubby.prototype.passthrough = function(url) { 310 | this.pretender.get(url, Pretender.prototype.passthrough); 311 | }; 312 | 313 | 314 | return Stubby; 315 | }; 316 | 317 | if (typeof module === 'undefined') { 318 | var dependencies = { 319 | lodash: window._, 320 | pretender: window.Pretender, 321 | querystring: window.queryString 322 | }; 323 | Object.keys(dependencies).forEach(function(dependencyName) { 324 | if (typeof dependencies[dependencyName] === 'undefined') { 325 | throw new Error(['[stubby] Missing `', dependencyName, '` library.'].join('')); 326 | } 327 | }); 328 | window.Stubby = stubbyFactory(dependencies); 329 | } else { 330 | module.exports = stubbyFactory; 331 | } 332 | --------------------------------------------------------------------------------