miragejs
Version:
A client-side server to help you build, test and demo your JavaScript app
598 lines (495 loc) • 17 kB
JavaScript
import "@miragejs/pretender-node-polyfill/before";
import Pretender from "pretender";
import "@miragejs/pretender-node-polyfill/after";
import assert from "../assert";
import assign from "lodash/assign";
/**
Mirage Interceptor Class
urlPrefix;
namespace;
// Creates the interceptor instance
constructor(mirageServer, mirageConfig)
// Allow you to change some of the config options after the server is created
config(mirageConfig)
// These are the equivalent of the functions that were on the Mirage Server.
// Those Mirage Server functions are redirected to the Interceptors functions for
// backward compatibility
get
post
put
delete
del
patch
head
options
// Start the interceptor. (Optional) this happens after the mirage server has been completed configured
// and all the models, routes, etc have been defined.
start
// Shutdown the interceptor instance
shutdown
*/
/**
@hide
*/
const defaultPassthroughs = [
"http://localhost:0/chromecheckurl", // mobile chrome
"http://localhost:30820/socket.io", // electron
(request) => {
return /.+\.hot-update.json$/.test(request.url);
},
];
const defaultRouteOptions = {
coalesce: false,
timing: undefined,
};
/**
* Determine if the object contains a valid option.
*
* @method isOption
* @param {Object} option An object with one option value pair.
* @return {Boolean} True if option is a valid option, false otherwise.
* @private
*/
function isOption(option) {
if (!option || typeof option !== "object") {
return false;
}
let allOptions = Object.keys(defaultRouteOptions);
let optionKeys = Object.keys(option);
for (let i = 0; i < optionKeys.length; i++) {
let key = optionKeys[i];
if (allOptions.indexOf(key) > -1) {
return true;
}
}
return false;
}
/**
@hide
*/
export { defaultPassthroughs };
/**
* Extract arguments for a route.
*
* @method extractRouteArguments
* @param {Array} args Of the form [options], [object, code], [function, code]
* [shorthand, options], [shorthand, code, options]
* @return {Array} [handler (i.e. the function, object or shorthand), code,
* options].
*/
function extractRouteArguments(args) {
let [lastArg] = args.splice(-1);
if (isOption(lastArg)) {
lastArg = assign({}, defaultRouteOptions, lastArg);
} else {
args.push(lastArg);
lastArg = defaultRouteOptions;
}
let t = 2 - args.length;
while (t-- > 0) {
args.push(undefined);
}
args.push(lastArg);
return args;
}
export default class PretenderConfig {
urlPrefix;
namespace;
timing;
passthroughChecks;
pretender;
mirageServer;
trackRequests;
create(mirageServer, config) {
this.mirageServer = mirageServer;
this.pretender = this._create(mirageServer, config);
/**
Mirage uses [pretender.js](https://github.com/trek/pretender) as its xhttp interceptor. In your Mirage config, `this.pretender` refers to the actual Pretender instance, so any config options that work there will work here as well.
```js
createServer({
routes() {
this.pretender.handledRequest = (verb, path, request) => {
console.log(`Your server responded to ${path}`);
}
}
})
```
Refer to [Pretender's docs](https://github.com/pretenderjs/pretender) if you want to change any options on your Pretender instance.
@property pretender
@return {Object} The Pretender instance
@public
*/
mirageServer.pretender = this.pretender;
this.passthroughChecks = this.passthroughChecks || [];
this.config(config);
[
["get"],
["post"],
["put"],
["delete", "del"],
["patch"],
["head"],
["options"],
].forEach(([verb, alias]) => {
this[verb] = (path, ...args) => {
let [rawHandler, customizedCode, options] = extractRouteArguments(args);
let handler = mirageServer.registerRouteHandler(
verb,
path,
rawHandler,
customizedCode,
options
);
let fullPath = this._getFullPath(path);
let timing =
options.timing !== undefined ? options.timing : () => this.timing;
return this.pretender?.[verb](fullPath, handler, timing);
};
mirageServer[verb] = this[verb];
if (alias) {
this[alias] = this[verb];
mirageServer[alias] = this[verb];
}
});
}
config(config) {
let useDefaultPassthroughs =
typeof config.useDefaultPassthroughs !== "undefined"
? config.useDefaultPassthroughs
: true;
if (useDefaultPassthroughs) {
this._configureDefaultPassthroughs();
}
let didOverridePretenderConfig =
config.trackRequests !== undefined &&
config.trackRequests !== this.trackRequests;
assert(
!didOverridePretenderConfig,
"You cannot modify Pretender's request tracking once the server is created"
);
/**
Set the number of milliseconds for the the Server's response time.
By default there's a 400ms delay during development, and 0 delay in testing (so your tests run fast).
```js
createServer({
routes() {
this.timing = 400; // default
}
})
```
To set the timing for individual routes, see the `timing` option for route handlers.
@property timing
@type Number
@public
*/
this.timing = config.timing ?? this.timing ?? 400;
/**
Sets a string to prefix all route handler URLs with.
Useful if your app makes API requests to a different port.
```js
createServer({
routes() {
this.urlPrefix = 'http://localhost:8080'
}
})
```
*/
this.urlPrefix = this.urlPrefix || config.urlPrefix || "";
/**
Set the base namespace used for all routes defined with `get`, `post`, `put` or `del`.
For example,
```js
createServer({
routes() {
this.namespace = '/api';
// this route will handle the URL '/api/contacts'
this.get('/contacts', 'contacts');
}
})
```
Note that only routes defined after `this.namespace` are affected. This is useful if you have a few one-off routes that you don't want under your namespace:
```js
createServer({
routes() {
// this route handles /auth
this.get('/auth', function() { ...});
this.namespace = '/api';
// this route will handle the URL '/api/contacts'
this.get('/contacts', 'contacts');
};
})
```
If your app is loaded from the filesystem vs. a server (e.g. via Cordova or Electron vs. `localhost` or `https://yourhost.com/`), you will need to explicitly define a namespace. Likely values are `/` (if requests are made with relative paths) or `https://yourhost.com/api/...` (if requests are made to a defined server).
For a sample implementation leveraging a configured API host & namespace, check out [this issue comment](https://github.com/miragejs/ember-cli-mirage/issues/497#issuecomment-183458721).
@property namespace
@type String
@public
*/
this.namespace = this.namespace || config.namespace || "";
}
/**
*
* @private
* @hide
*/
_configureDefaultPassthroughs() {
defaultPassthroughs.forEach((passthroughUrl) => {
this.passthrough(passthroughUrl);
});
}
/**
* Creates a new Pretender instance.
*
* @method _create
* @param {Server} server
* @return {Object} A new Pretender instance.
* @public
*/
_create(mirageServer, config) {
if (typeof window !== "undefined") {
this.trackRequests = config.trackRequests || false;
return new Pretender(
function () {
this.passthroughRequest = function (verb, path, request) {
if (mirageServer.shouldLog()) {
console.log(
`Mirage: Passthrough request for ${verb.toUpperCase()} ${
request.url
}`
);
}
};
this.handledRequest = function (verb, path, request) {
if (mirageServer.shouldLog()) {
console.groupCollapsed(
`Mirage: [${request.status}] ${verb.toUpperCase()} ${
request.url
}`
);
let { requestBody, responseText } = request;
let loggedRequest, loggedResponse;
try {
loggedRequest = JSON.parse(requestBody);
} catch (e) {
loggedRequest = requestBody;
}
try {
loggedResponse = JSON.parse(responseText);
} catch (e) {
loggedResponse = responseText;
}
console.groupCollapsed("Response");
console.log(loggedResponse);
console.groupEnd();
console.groupCollapsed("Request (data)");
console.log(loggedRequest);
console.groupEnd();
console.groupCollapsed("Request (raw)");
console.log(request);
console.groupEnd();
console.groupEnd();
}
};
let originalCheckPassthrough = this.checkPassthrough;
this.checkPassthrough = function (request) {
let shouldPassthrough = mirageServer.passthroughChecks.some(
(passthroughCheck) => passthroughCheck(request)
);
if (shouldPassthrough) {
let url = request.url.includes("?")
? request.url.substr(0, request.url.indexOf("?"))
: request.url;
this[request.method.toLowerCase()](url, this.passthrough);
}
return originalCheckPassthrough.apply(this, arguments);
};
this.unhandledRequest = function (verb, path) {
path = decodeURI(path);
let namespaceError = "";
if (this.namespace === "") {
namespaceError =
"There is no existing namespace defined. Please define one";
} else {
namespaceError = `The existing namespace is ${this.namespace}`;
}
assert(
`Your app tried to ${verb} '${path}', but there was no route defined to handle this request. Define a route for this endpoint in your routes() config. Did you forget to define a namespace? ${namespaceError}`
);
};
},
{ trackRequests: this.trackRequests }
);
}
}
/**
By default, if your app makes a request that is not defined in your server config, Mirage will throw an error. You can use `passthrough` to whitelist requests, and allow them to pass through your Mirage server to the actual network layer.
Note: Put all passthrough config at the bottom of your routes, to give your route handlers precedence.
To ignore paths on your current host (as well as configured `namespace`), use a leading `/`:
```js
this.passthrough('/addresses');
```
You can also pass a list of paths, or call `passthrough` multiple times:
```js
this.passthrough('/addresses', '/contacts');
this.passthrough('/something');
this.passthrough('/else');
```
These lines will allow all HTTP verbs to pass through. If you want only certain verbs to pass through, pass an array as the last argument with the specified verbs:
```js
this.passthrough('/addresses', ['post']);
this.passthrough('/contacts', '/photos', ['get']);
```
You can pass a function to `passthrough` to do a runtime check on whether or not the request should be handled by Mirage. If the function returns `true` Mirage will not handle the request and let it pass through.
```js
this.passthrough(request => {
return request.queryParams.skipMirage;
});
```
If you want all requests on the current domain to pass through, simply invoke the method with no arguments:
```js
this.passthrough();
```
Note again that the current namespace (i.e. any `namespace` property defined above this call) will be applied.
You can also allow other-origin hosts to passthrough. If you use a fully-qualified domain name, the `namespace` property will be ignored. Use two * wildcards to match all requests under a path:
```js
this.passthrough('http://api.foo.bar/**');
this.passthrough('http://api.twitter.com/v1/cards/**');
```
In versions of Pretender prior to 0.12, `passthrough` only worked with jQuery >= 2.x. As long as you're on Pretender@0.12 or higher, you should be all set.
@method passthrough
@param {String} [...paths] Any number of paths to whitelist
@param {Array} options Unused
@public
*/
passthrough(...paths) {
// this only works in browser-like environments for now. in node users will have to configure
// their own interceptor if they are using one.
if (typeof window !== "undefined") {
let verbs = ["get", "post", "put", "delete", "patch", "options", "head"];
let lastArg = paths[paths.length - 1];
if (paths.length === 0) {
paths = ["/**", "/"];
} else if (paths.length > 1 && Array.isArray(lastArg)) {
verbs = paths.pop();
}
paths.forEach((path) => {
if (typeof path === "function") {
this.passthroughChecks.push(path);
} else {
verbs.forEach((verb) => {
let fullPath = this._getFullPath(path);
this.pretender[verb](fullPath, this.pretender.passthrough);
});
}
});
}
}
/**
* Builds a full path for Pretender to monitor based on the `path` and
* configured options (`urlPrefix` and `namespace`).
*
* @private
* @hide
*/
_getFullPath(path) {
path = path[0] === "/" ? path.slice(1) : path;
let fullPath = "";
let urlPrefix = this.urlPrefix ? this.urlPrefix.trim() : "";
let namespace = "";
// if there is a urlPrefix and a namespace
if (this.urlPrefix && this.namespace) {
if (
this.namespace[0] === "/" &&
this.namespace[this.namespace.length - 1] === "/"
) {
namespace = this.namespace
.substring(0, this.namespace.length - 1)
.substring(1);
}
if (
this.namespace[0] === "/" &&
this.namespace[this.namespace.length - 1] !== "/"
) {
namespace = this.namespace.substring(1);
}
if (
this.namespace[0] !== "/" &&
this.namespace[this.namespace.length - 1] === "/"
) {
namespace = this.namespace.substring(0, this.namespace.length - 1);
}
if (
this.namespace[0] !== "/" &&
this.namespace[this.namespace.length - 1] !== "/"
) {
namespace = this.namespace;
}
}
// if there is a namespace and no urlPrefix
if (this.namespace && !this.urlPrefix) {
if (
this.namespace[0] === "/" &&
this.namespace[this.namespace.length - 1] === "/"
) {
namespace = this.namespace.substring(0, this.namespace.length - 1);
}
if (
this.namespace[0] === "/" &&
this.namespace[this.namespace.length - 1] !== "/"
) {
namespace = this.namespace;
}
if (
this.namespace[0] !== "/" &&
this.namespace[this.namespace.length - 1] === "/"
) {
let namespaceSub = this.namespace.substring(
0,
this.namespace.length - 1
);
namespace = `/${namespaceSub}`;
}
if (
this.namespace[0] !== "/" &&
this.namespace[this.namespace.length - 1] !== "/"
) {
namespace = `/${this.namespace}`;
}
}
// if no namespace
if (!this.namespace) {
namespace = "";
}
// check to see if path is a FQDN. if so, ignore any urlPrefix/namespace that was set
if (/^https?:\/\//.test(path)) {
fullPath += path;
} else {
// otherwise, if there is a urlPrefix, use that as the beginning of the path
if (urlPrefix.length) {
fullPath +=
urlPrefix[urlPrefix.length - 1] === "/" ? urlPrefix : `${urlPrefix}/`;
}
// add the namespace to the path
fullPath += namespace;
// add a trailing slash to the path if it doesn't already contain one
if (fullPath[fullPath.length - 1] !== "/") {
fullPath += "/";
}
// finally add the configured path
fullPath += path;
// if we're making a same-origin request, ensure a / is prepended and
// dedup any double slashes
if (!/^https?:\/\//.test(fullPath)) {
fullPath = `/${fullPath}`;
fullPath = fullPath.replace(/\/+/g, "/");
}
}
return fullPath;
}
start() {
// unneeded for pretender implementation
}
shutdown() {
this.pretender.shutdown();
}
}