diff --git a/frontend/services/mock_service/README.md b/frontend/services/mock_service/README.md new file mode 100644 index 0000000000..c29a68f324 --- /dev/null +++ b/frontend/services/mock_service/README.md @@ -0,0 +1,92 @@ +# How to use mock service + +The mock service implements the `sendRequest` interface and enables you to more easily develop +frontend features that make use of spec API endpoints without waiting for backend development to be +completed. + +You configure the mock service by mapping the API request paths and expected JSON responses +based on the API specification for your feature. Then, you simply import the mock service +in place of `sendRequest` when you build out the `/services/entities` methods for your feature. + +The mock service simulates async network requests and responses for the API. The mock service tries +to match incoming request URLs against the paths declared in `REQUEST_RESPONSE_MAPPINGS`. +If a match is found, the mock service returns the expected JSON response with `Promise.resolve`. +If no match is found, the mock service returns an error with `Promise.reject`. + +## Importing the mock service + +To use the mock service in development, import the following in the `/services/entities` file +for your API service. +```js +import { sendRequest } from "services/mock_service/service/service"; +``` +When the real API is ready, swap out the mock service import with the normal `sendRequest` import. +```js +import { sendRequest } from "services"; +``` + +## Configuring the mock service + +Configuration consists of two files: `config` and `responses`. + +### Responses file +Declare your static JSON responses as constants in the `responses` file inside the `./mocks` folder. +Each JSON response should be given its own unique name and added to the default export for the file. +These responses will be imported as `STATIC` in the `config` file where you will map the responses +to the the request paths for your API service. + +### Config file +Declare your endpoint and each of your API request paths to its expected JSON response in the +`config` file inside the `./mocks` folder. + +Set the `DELAY` constant (in milliseconds) if you want to simulate a delayed API response. + +Set the `ENDPOINT` constant to the base route for your API endpoint (for example, `/v1/fleet`). + +Use the `REQUEST_RESPONSE_MAPPINGS` dictionary to declare your request-responses mappings. For example, +here's how you might configure the `GET hosts/manage` and `GET hosts/count`endpoints: +```js +const REQUEST_RESPONSE_MAPPINGS = { + GET: { + "/hosts/manage": STATIC.MANAGE, + "/hosts/count": STATIC.COUNT + } +}; +``` + +You can declare different responses for specific route parameters or query parameters. Alteratively, +you can use wildcards if you don't particularly care what parameter value is contained in the request. +The example below shows how you might use wildcards or specific values as the `id` route parameter +to get a host's device mapping by host id and how you might use wildcards or specific values +for the `team_id` query parameter to get hosts filtered by team id. +```js +const REQUEST_RESPONSE_MAPPINGS = { + GET: { + "/hosts/1/device_mapping": STATIC.HOST_1_DEVICE_MAPPING, // specific route param value + "/hosts/:id/device_mapping": STATIC.HOST_ID_DEVICE_MAPPING, // wildcard route param value + "/hosts/manage?team_id=1": STATIC.HOSTS_TEAM_1, // specific query param value + "/hosts/manage?team_id={id}": STATIC.HOSTS_TEAM_ID // wildcard query param value + + } +}; +``` +For purposes of illustration, the example above uses ":" as well as "{" and "}" for wildcard +characters. You can set the `WILDCARDS` constant in the `config` file to define any number +of wildcards to suit the conventions of the API spec for your feature. + +The mock service evaluates URLs part-by-part. Each URL is split at the "?" character. If more than +one "?" is present, the mock service throws an error. If this happens in the context of an API request, the +mock service returns with `Promise.reject`. Assuming only one "?" is present, the first half is +split into parts at each "/" and the second half is split into parts at each "&". The substring parts are +evaluated for matching purposes in the order they appeared in the URL string. If no "?" is present, +the URL will only be split by "/". + +The presence of one or more wildcard characters anywhere in a substring part of the URL path +declared in `REQUEST_RESPONSE_MAPPINGS` triggers a match against the corresponding part of the +request URL. More precise handling of wildcard parameters is something that may be added in the +future. In the meantime, you should take care that the path you declare in `REQUEST_RESPONSE_MAPPINGS` +follows the order of the params in the request made by your `/services/entities` methods. + +# Examples +Example `config` and `response` files are included in `./examples`. Please be sure to copy these +files into `./mocks` before making any changes. \ No newline at end of file diff --git a/frontend/services/mock_service/examples/config.ts b/frontend/services/mock_service/examples/config.ts new file mode 100644 index 0000000000..735c1c6571 --- /dev/null +++ b/frontend/services/mock_service/examples/config.ts @@ -0,0 +1,54 @@ +/* + * NOTE: This is an example of how to configure your mock service. + * Be sure to copy this file into `../mocks` and only edit that copy! + * Also please check the README for how to use the mock service :) + */ + +import RESPONSES from "./responses"; + +type IResponses = Record>>; + +const DELAY = 1000; // modify the DELAY value (in milliseconds) to simulate a delayed async response + +const ENDPOINT = "/v1/fleet"; // modify the ENDPOINT string to correspond to your API spec + +// WILDCARDS can be used to represent URL parameters in any combination as illustrated below +// modify the WILDCARDS array if you prefer to use different characters +const WILDCARDS: string[] = [":", "*", "{", "}"]; + +// REQUEST_RESPONSE_MAPPINGS dictionary maps your static responses to the specified API request path +const REQUEST_RESPONSE_MAPPINGS: IResponses = { + GET: { + // this is a basic path with no wildcards + "/hosts?page=0&per_page=100&order_key=hostname&order_direction=asc": + RESPONSES.ALL_HOSTS, + // this basic path only matches with '1337' as the value for the team id query param + "/hosts?page=0&per_page=100&order_key=hostname&order_direction=asc&team_id=1337": + RESPONSES.HOSTS_TEAM_1337, + // this wildcard path matches with any other value for the team id query param + "/hosts?page=0&per_page=100&order_key=hostname&order_direction=asc&team_id={team_id}": + RESPONSES.HOSTS_TEAM_ID, + // this basic path only matches with '1337' as the value for the host id route param + "/hosts/1337": RESPONSES.HOST_1337, + // this wildcard path matches with any other value for the host id route param + "/hosts/*id": RESPONSES.HOST_ID, + // this wildcard path matches with any value for the host id route param + "/hosts/:id/device_mapping": RESPONSES.DEVICE_MAPPING, + // this wildcard path matches with any value for the host id route param + "hosts/{*}/macadmins": RESPONSES.MACADMINS, + // this is a basic path with no wildcards + "hosts/count": { + count: 1, + }, + // this wildcard path matches with any value for the team id route param + "hosts/count?team_id={*}": { + count: 1, + }, + }, + // additional mappings can be specified for other HTTP request types (POST, PATCH, DELETE, etc.) + POST: { + "/:id/refetch": {}, // this wildcard route returns empty JSON + }, +} as IResponses; + +export default { DELAY, ENDPOINT, WILDCARDS, REQUEST_RESPONSE_MAPPINGS }; diff --git a/frontend/services/mock_service/examples/responses.ts b/frontend/services/mock_service/examples/responses.ts new file mode 100644 index 0000000000..c42fe49d08 --- /dev/null +++ b/frontend/services/mock_service/examples/responses.ts @@ -0,0 +1,96 @@ +/* + * NOTE: This is an example of how to define data for your mock responses. + * Be sure to copy this file into `../mocks` and only edit that copy! + * Also please check the README for how to use the mock service :) + */ + +const HOST_ID = { + host: { + created_at: "2021-03-31T00:00:00Z", + updated_at: "2021-03-31T00:00:00Z", + software: [], + id: 1337, + detail_updated_at: "2021-03-31T00:00:00Z", + label_updated_at: "2021-03-31T00:00:00Z", + policy_updated_at: "2021-03-31T00:00:00Z", + last_enrolled_at: "2021-03-31T00:00:00Z", + seen_time: "2021-03-31T00:00:00ZZ", + refetch_requested: false, + hostname: "myf1337d3v1c3", + uuid: "13371337-0000-0000-1337-133713371337", + platform: "rhel", + osquery_version: "5.1.0", + os_version: "Ubuntu 20.4.0", + build: "", + platform_like: "deb", + code_name: "", + uptime: 13371337133713371337, + memory: 143593800000, + cpu_type: "1337", + cpu_subtype: "1337", + cpu_brand: "Intel(R) Core(TM) i3-37k CPU @ 13.37GHz", + cpu_physical_cores: 8, + cpu_logical_cores: 8, + hardware_vendor: "", + hardware_model: "", + hardware_version: "", + hardware_serial: "", + computer_name: "myf1337d3v1c3", + primary_ip: "133.7.133.7", + primary_mac: "13:37:13:37:13:37", + distributed_interval: 1337, + config_tls_refresh: 1337, + logger_tls_period: 1337, + team_id: null, + pack_stats: [], + team_name: null, + users: [ + { + uid: 1337, + username: "root", + type: "", + groupname: "root", + shell: "/bin/bash", + }, + ], + gigs_disk_space_available: 13.37, + percent_disk_space_available: 13.37, + issues: { + total_issues_count: 1337, + failing_policies_count: 1337, + }, + labels: [], + packs: [], + policies: [], + status: "online", + display_text: "myf1337d3v1c3", + }, +}; +const HOST_1337 = { + ...HOST_ID, + team_id: 1337, + team_name: "h4x0r", +}; + +export default { + ALL_HOSTS: { + hosts: [HOST_ID.host], + }, + HOSTS_TEAM_ID: { + hosts: [{ ...HOST_ID.host, team_id: 2, team_name: "n00bz" }], + }, + HOSTS_TEAM_1337: { + hosts: [HOST_1337.host], + }, + HOST_ID, + HOST_1337, + DEVICE_MAPPING: { + host_id: 1337, + device_mapping: null, + foo: "bar", + }, + MACADMINS: { + macadmins: null, + foo: "bar", + }, +}; diff --git a/frontend/services/mock_service/index.ts b/frontend/services/mock_service/index.ts new file mode 100644 index 0000000000..a45eaf96ab --- /dev/null +++ b/frontend/services/mock_service/index.ts @@ -0,0 +1 @@ +export { default } from "./service/service"; diff --git a/frontend/services/mock_service/mocks/config.ts b/frontend/services/mock_service/mocks/config.ts new file mode 100644 index 0000000000..735c1c6571 --- /dev/null +++ b/frontend/services/mock_service/mocks/config.ts @@ -0,0 +1,54 @@ +/* + * NOTE: This is an example of how to configure your mock service. + * Be sure to copy this file into `../mocks` and only edit that copy! + * Also please check the README for how to use the mock service :) + */ + +import RESPONSES from "./responses"; + +type IResponses = Record>>; + +const DELAY = 1000; // modify the DELAY value (in milliseconds) to simulate a delayed async response + +const ENDPOINT = "/v1/fleet"; // modify the ENDPOINT string to correspond to your API spec + +// WILDCARDS can be used to represent URL parameters in any combination as illustrated below +// modify the WILDCARDS array if you prefer to use different characters +const WILDCARDS: string[] = [":", "*", "{", "}"]; + +// REQUEST_RESPONSE_MAPPINGS dictionary maps your static responses to the specified API request path +const REQUEST_RESPONSE_MAPPINGS: IResponses = { + GET: { + // this is a basic path with no wildcards + "/hosts?page=0&per_page=100&order_key=hostname&order_direction=asc": + RESPONSES.ALL_HOSTS, + // this basic path only matches with '1337' as the value for the team id query param + "/hosts?page=0&per_page=100&order_key=hostname&order_direction=asc&team_id=1337": + RESPONSES.HOSTS_TEAM_1337, + // this wildcard path matches with any other value for the team id query param + "/hosts?page=0&per_page=100&order_key=hostname&order_direction=asc&team_id={team_id}": + RESPONSES.HOSTS_TEAM_ID, + // this basic path only matches with '1337' as the value for the host id route param + "/hosts/1337": RESPONSES.HOST_1337, + // this wildcard path matches with any other value for the host id route param + "/hosts/*id": RESPONSES.HOST_ID, + // this wildcard path matches with any value for the host id route param + "/hosts/:id/device_mapping": RESPONSES.DEVICE_MAPPING, + // this wildcard path matches with any value for the host id route param + "hosts/{*}/macadmins": RESPONSES.MACADMINS, + // this is a basic path with no wildcards + "hosts/count": { + count: 1, + }, + // this wildcard path matches with any value for the team id route param + "hosts/count?team_id={*}": { + count: 1, + }, + }, + // additional mappings can be specified for other HTTP request types (POST, PATCH, DELETE, etc.) + POST: { + "/:id/refetch": {}, // this wildcard route returns empty JSON + }, +} as IResponses; + +export default { DELAY, ENDPOINT, WILDCARDS, REQUEST_RESPONSE_MAPPINGS }; diff --git a/frontend/services/mock_service/mocks/responses.ts b/frontend/services/mock_service/mocks/responses.ts new file mode 100644 index 0000000000..c42fe49d08 --- /dev/null +++ b/frontend/services/mock_service/mocks/responses.ts @@ -0,0 +1,96 @@ +/* + * NOTE: This is an example of how to define data for your mock responses. + * Be sure to copy this file into `../mocks` and only edit that copy! + * Also please check the README for how to use the mock service :) + */ + +const HOST_ID = { + host: { + created_at: "2021-03-31T00:00:00Z", + updated_at: "2021-03-31T00:00:00Z", + software: [], + id: 1337, + detail_updated_at: "2021-03-31T00:00:00Z", + label_updated_at: "2021-03-31T00:00:00Z", + policy_updated_at: "2021-03-31T00:00:00Z", + last_enrolled_at: "2021-03-31T00:00:00Z", + seen_time: "2021-03-31T00:00:00ZZ", + refetch_requested: false, + hostname: "myf1337d3v1c3", + uuid: "13371337-0000-0000-1337-133713371337", + platform: "rhel", + osquery_version: "5.1.0", + os_version: "Ubuntu 20.4.0", + build: "", + platform_like: "deb", + code_name: "", + uptime: 13371337133713371337, + memory: 143593800000, + cpu_type: "1337", + cpu_subtype: "1337", + cpu_brand: "Intel(R) Core(TM) i3-37k CPU @ 13.37GHz", + cpu_physical_cores: 8, + cpu_logical_cores: 8, + hardware_vendor: "", + hardware_model: "", + hardware_version: "", + hardware_serial: "", + computer_name: "myf1337d3v1c3", + primary_ip: "133.7.133.7", + primary_mac: "13:37:13:37:13:37", + distributed_interval: 1337, + config_tls_refresh: 1337, + logger_tls_period: 1337, + team_id: null, + pack_stats: [], + team_name: null, + users: [ + { + uid: 1337, + username: "root", + type: "", + groupname: "root", + shell: "/bin/bash", + }, + ], + gigs_disk_space_available: 13.37, + percent_disk_space_available: 13.37, + issues: { + total_issues_count: 1337, + failing_policies_count: 1337, + }, + labels: [], + packs: [], + policies: [], + status: "online", + display_text: "myf1337d3v1c3", + }, +}; +const HOST_1337 = { + ...HOST_ID, + team_id: 1337, + team_name: "h4x0r", +}; + +export default { + ALL_HOSTS: { + hosts: [HOST_ID.host], + }, + HOSTS_TEAM_ID: { + hosts: [{ ...HOST_ID.host, team_id: 2, team_name: "n00bz" }], + }, + HOSTS_TEAM_1337: { + hosts: [HOST_1337.host], + }, + HOST_ID, + HOST_1337, + DEVICE_MAPPING: { + host_id: 1337, + device_mapping: null, + foo: "bar", + }, + MACADMINS: { + macadmins: null, + foo: "bar", + }, +}; diff --git a/frontend/services/mock_service/service/service.ts b/frontend/services/mock_service/service/service.ts new file mode 100644 index 0000000000..4e44fbad54 --- /dev/null +++ b/frontend/services/mock_service/service/service.ts @@ -0,0 +1,117 @@ +/* + * NOTE: Do not make changes to this file! + * Also please check the README for how to use the mock service :) + */ + +import { trim } from "lodash"; + +import CONFIG from "../mocks/config"; + +const { + DELAY, + ENDPOINT, + REQUEST_RESPONSE_MAPPINGS: RESPONSES, + WILDCARDS, +} = CONFIG; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const splitRouteAndQueryString = (path: string) => { + path = trim(path, "/").replace(trim(ENDPOINT, "/"), ""); + const strings = trim(path, "/").split("?"); + + if (strings.length > 2) { + throw new Error( + "Invalid usage: URL cannot contain more than one `?` and query string must follow format `?key=value&another_key=another_value`" + ); + } + if (strings.length === 1) { + return path.includes("?") + ? [undefined, strings[0]] + : [strings[0], undefined]; + } + + return strings; +}; + +const getParts = (pathString: string) => { + const [routeString, queryString] = splitRouteAndQueryString(pathString); + const routeParts = routeString ? routeString.split("/") : []; + const queryParts = queryString ? queryString.split("&") : []; + + return routeParts.concat(queryParts); +}; + +const partsByPathByMethod = {} as Record>; +Object.entries(RESPONSES).forEach(([method, paths]) => { + Object.keys(paths).forEach((pathString) => { + if (!partsByPathByMethod[method]) { + partsByPathByMethod[method] = {} as Record; + } + partsByPathByMethod[method][pathString] = getParts(pathString); + }); +}); + +const isPartMatch = ( + partToMatch: string, + configPart: string, + wildcards: string[] = [] +) => { + return ( + partToMatch === configPart || wildcards.some((w) => configPart.includes(w)) // if a config part includes any wildcards, it matches with any value + ); +}; + +const matchPathToResponse = (method: string, requestPath: string) => { + const results = Object.entries(partsByPathByMethod[method]).filter( + ([, configParts]) => { + const requestParts = getParts(requestPath); + return ( + requestParts.length === configParts.length && + requestParts.every((p, i) => isPartMatch(p, configParts[i], WILDCARDS)) + ); + } + ); + + return results; +}; + +export const sendRequest = async ( + method = "GET", + requestPath: string, + data?: Record +): Promise => { + console.log("Mock service request URL: ", requestPath); + console.log("Mock service request body: ", data); + + requestPath = trim(requestPath, "/").replace(ENDPOINT, ""); + let response: Record | undefined; + let responseKey: string | undefined; + + try { + const matches = matchPathToResponse(method, requestPath) || []; + if (matches.length > 1) { + [responseKey] = + matches.find(([key]) => !WILDCARDS.some((w) => key.includes(w))) || []; + } else { + responseKey = matches?.[0]?.[0]; + } + } catch (err) { + return Promise.reject(err); + } + + if (responseKey) { + response = RESPONSES?.[method]?.[responseKey]; + } + + if (!responseKey || !response) { + return Promise.reject(`Mock service error: 404 ${requestPath} not found`); + } + + await sleep(DELAY); + console.log("Mock service response: ", response); + + return Promise.resolve(response); +}; + +export default sendRequest;