├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── lib ├── commands │ ├── curl.js │ ├── index.js │ ├── repl.js │ └── routes.js ├── helpers.js └── index.js ├── package.json └── test ├── closet ├── curl │ ├── package.json │ └── server.js ├── repl │ ├── main │ │ ├── package.json │ │ └── server.js │ └── pal-plugins │ │ ├── package.json │ │ └── server.js └── routes │ ├── cors-ignore │ ├── package.json │ └── server.js │ ├── main │ ├── package.json │ └── server.js │ └── plugin-groups │ ├── package.json │ └── server.js ├── index.js └── run-util.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | .vscode 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !lib/** 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | import: 2 | - hapipal/ci-config-travis:node_js.yml@hapi-v21 3 | - hapipal/ci-config-travis:hapi_all.yml@hapi-v21 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-2022, Devin Ivy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hpal-debug 2 | 3 | hapijs debugging tools for the [hpal CLI](https://github.com/hapipal/hpal) 4 | 5 | [![Build Status](https://travis-ci.com/hapipal/hpal-debug.svg?branch=main)](https://travis-ci.com/hapipal/hpal-debug) [![Coverage Status](https://coveralls.io/repos/hapipal/hpal-debug/badge.svg?branch=main&service=github)](https://coveralls.io/github/hapipal/hpal-debug?branch=main) 6 | 7 | Lead Maintainer - [Devin Ivy](https://github.com/devinivy) 8 | 9 | `hpal-debug` was designed to help you, 10 | - :ant: display information about your routes in a neat, customizable table. 11 | > `hpal run debug:routes --show cors` 12 | - :beetle: use your hapi server, [models](https://github.com/hapipal/schwifty), [services](https://github.com/hapipal/schmervice), etc. interactively through a REPL. 13 | > `hpal run debug:repl` 14 | - :bug: hit your routes from the command line without having to restart your server. 15 | > `hpal run debug:curl post /user --name Pal -v` 16 | 17 | ## Installation 18 | > If you're getting started with [the pal boilerplate](https://github.com/hapipal/boilerplate), then your project is already setup with hpal-debug! 19 | 20 | 1. Install the hpal-debug package from npm as a dev dependency. 21 | 22 | ```sh 23 | npm install --save-dev @hapipal/hpal-debug 24 | ``` 25 | 26 | 2. Register hpal-debug on your server as a hapi plugin. 27 | 28 | ```js 29 | await server.register(require('@hapipal/hpal-debug')); 30 | ``` 31 | 32 | 3. Ensure `server.js` or `server/index.js` exports a function named `deployment` that returns your configured hapi server. 33 | 34 | Below is a very simple example of boilerplate code to configure a hapi server server, and is not necessarily "production-ready." For a more complete setup, consider using [the pal boilerplate](https://github.com/hapipal/boilerplate), or check-out its approach as seen [here](https://github.com/hapipal/boilerplate/blob/pal/server/index.js). 35 | 36 | ```js 37 | // server.js 38 | 39 | 'use strict'; 40 | 41 | const Hapi = require('hapi'); 42 | const AppPlugin = require('./app'); 43 | 44 | // hpal will look for and use exports.deployment() 45 | // as defined below to obtain a hapi server 46 | 47 | exports.deployment = async ({ start } = {}) => { 48 | 49 | const server = Hapi.server(); 50 | 51 | // Assuming your application (its routes, etc.) live in a plugin 52 | await server.register(AppPlugin); 53 | 54 | if (process.env.NODE_ENV !== 'production') { 55 | await server.register(require('@hapipal/hpal-debug')); 56 | } 57 | 58 | if (start) { 59 | await server.start(); 60 | console.log(`Server started at ${server.info.uri}`); 61 | } 62 | 63 | return server; 64 | }; 65 | 66 | // Start the server only when this file is 67 | // run directly from the CLI, i.e. "node ./server" 68 | 69 | if (!module.parent) { 70 | exports.deployment({ start: true }); 71 | } 72 | ``` 73 | 74 | And that's it! Now the hpal-debug commands should be available through the [hpal CLI](https://github.com/hapipal/hpal). A simple way to check that hpal-debug is setup correctly is to output a pretty display of your route table, 75 | 76 | ```sh 77 | npx hpal run debug:routes 78 | ``` 79 | 80 | ## Usage 81 | > hpal-debug is intended for use with hapi v19+ and nodejs v12+ (_see v1 for lower support_). 82 | 83 | ### Commands 84 | #### `hpal run debug:routes` 85 | > ``` 86 | > hpal run debug:routes [] --hide --show 87 | > e.g. hpal run debug:routes --show cors 88 | > ``` 89 | 90 | This command outputs a neat display of your server's route table. 91 | 92 | In order to display a single route, you may specify `` as a route id (e.g. `user-create`), route method and path (e.g. `post /users`), or route path (e.g. `/users`, method defaulting to `get`). 93 | 94 | Columns may be hidden or shown using the `-H` `--hide` and `-s` `--show` flags respectively. Use the flag multiple times to hide or show multiple columns. Below is the list of available columns. 95 | 96 | `method` `path` `id` `plugin` `vhost` `auth` `cors` `tags` `description` 97 | 98 | The `-r` `--raw` flag will output a minimally formatted table with columns separated by tab characters. Non-TTY usage automatically defaults to raw output. 99 | 100 | A summary of these options can be displayed with the `-h` `--help` flag. 101 | 102 | #### `hpal run debug:repl` 103 | > ``` 104 | > hpal run debug:repl 105 | > ``` 106 | 107 | This command starts a fully-featured interactive REPL with your initialized `server` in context. Each of your server's methods, properties, [schwifty](https://github.com/hapipal/schwifty) models, and [schmervice](https://github.com/hapipal/schmervice) services are also made directly available for convenience. Under [hpal](https://github.com/hapipal/hpal) v2 you can use top-level `await`. You may also call this command using `hpal run debug`. 108 | 109 | ##### Example 110 | ```js 111 | $ hpal run debug:repl 112 | 113 | hpal> server.info // you can always use the server directly 114 | { created: 1527567336111, 115 | started: 0, 116 | host: 'your-computer.local', 117 | ... 118 | hpal> // or you can omit the "server." for public properties and methods... 119 | hpal> 120 | hpal> info.uri // at what URI would I access my server? 121 | 'http://your-computer.local' 122 | hpal> Object.keys(registrations) // what plugins are registered? 123 | [ '@hapipal/hpal-debug', 'my-app' ] 124 | hpal> table().length // how many routes are defined? 125 | 12 126 | hpal> !!match('get', '/my/user') // does this route exist? 127 | true 128 | hpal> .exit 129 | ``` 130 | 131 | #### `hpal run debug:curl` 132 | > ``` 133 | > hpal run debug:curl [] --data --header --raw --verbose 134 | > e.g. hpal run debug:curl post /users --firstName Paldo -v 135 | > ``` 136 | 137 | This command makes a request to a route and displays the result. Notably, you don't need a running server in order to test your route using `hpal run debug:curl`! 138 | 139 | It's required that you determine which route to hit by specifying a `` as a route id (e.g. `user-create`), route method and path (e.g. `post /users`), or route path (e.g. `/users`, method defaulting to `get`). 140 | 141 | You may specify any payload, query, or path params as `` flags or in the ``. Any parameter that utilizes Joi validation through [`route.options.validate`](https://hapi.dev/api/#route.options.validate) has a command line flag. For example, a route with id `user-update`, method `patch`, and path `/user/{id}` that validates the `id` path parameter and a `hometown` payload parameter might be hit using the following commands, 142 | ```sh 143 | hpal run debug:curl patch /user/42 --hometown "Buenos Aires" 144 | 145 | # or 146 | 147 | hpal run debug:curl user-update --id 42 --hometown "Buenos Aires" 148 | ``` 149 | 150 | Nested parameters may also be specified. If the route in the previous example validated payloads of the form `{ user: { hometown } }`, one might use one of the following commands instead, 151 | ```sh 152 | hpal run debug:curl user-update --id 42 --user-hometown "Buenos Aires" 153 | 154 | # or 155 | 156 | hpal run debug:curl user-update --id 42 --user '{ "hometown": "Buenos Aires" }' 157 | ``` 158 | 159 | The `-d` `--data` flag may be used to specify a request payload as a raw string. 160 | 161 | The `-H` `--header` flag may be used to specify a request header in the format `header-name: header value`. This flag may be used multiple times to set multiple headers. 162 | 163 | The `-r` `--raw` and `-v` `--verbose` flags affect the command's output, and may be used in tandem with each other or separately. The `-r` `--raw` flag ensures all output is unformatted, while the `-v` `--verbose` flag shows information about the request and response including timing, the request payload, request headers, response headers, status code, and response payload. Non-TTY usage automatically defaults to raw output. 164 | 165 | A summary of these options can be displayed with the `-h` `--help` flag. 166 | 167 | ##### Example 168 | 169 | ``` 170 | $ hpal run debug:curl /user -v 171 | 172 | get /user (30ms) 173 | 174 | request headers 175 | ─────────────────────────────────────────────────────────────────── 176 | user-agent shot 177 | host your-computer.local:0 178 | 179 | response headers 180 | ─────────────────────────────────────────────────────────────────── 181 | content-type application/json; charset=utf-8 182 | vary origin 183 | cache-control no-cache 184 | content-length 55 185 | accept-ranges bytes 186 | 187 | result (200 ok) 188 | ─────────────────────────────────────────────────────────────────── 189 | { 190 | id: 42, 191 | firstName: 'Paldo', 192 | hometown: 'Buenos Aires' 193 | } 194 | ``` 195 | -------------------------------------------------------------------------------- /lib/commands/curl.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Util = require('util'); 4 | const Http = require('http'); 5 | const Querystring = require('querystring'); 6 | const Bossy = require('@hapi/bossy'); 7 | const WordWrap = require('word-wrap'); 8 | const Helpers = require('../helpers'); 9 | 10 | const internals = {}; 11 | 12 | module.exports = async (server, args, root, ctx) => { 13 | 14 | const { options, output, colors, DisplayError } = ctx; 15 | const { route, id, method, path, query: baseQuery, matchOn } = Helpers.getRouteInfo(server, args[0], args[1]); 16 | const { params, query, payload } = route ? route.settings.validate : {}; 17 | const paramsInfo = (matchOn[0] === 'id') && internals.getValidationDescription(params); 18 | const queryInfo = internals.getValidationDescription(query); 19 | const payloadInfo = internals.getValidationDescription(payload); 20 | 21 | const parameters = internals.parameters({ route, matchOn, paramsInfo, queryInfo, payloadInfo }, args, ctx); 22 | 23 | if (parameters === null) { 24 | return; 25 | } 26 | 27 | if (!route) { // Case that matchOn.length === 0 handled in internals.parameters() 28 | 29 | if (matchOn[0] === 'id') { 30 | throw new DisplayError(colors.yellow(`Route "${id}" not found`)); 31 | } 32 | 33 | throw new DisplayError(colors.yellow(`Route "${method} ${path}" not found`)); 34 | } 35 | 36 | // Collect raw payload if we'll want to display it 37 | 38 | if (parameters.raw && parameters.verbose) { 39 | server.ext('onRequest', (request, h) => { 40 | 41 | request.plugins['hpal-debug'].rawPayload = ''; 42 | 43 | request.events.on('peek', (chunk) => { 44 | 45 | request.plugins['hpal-debug'].rawPayload += chunk.toString(); 46 | }); 47 | 48 | return h.continue; 49 | }); 50 | } 51 | 52 | // Make request and time it 53 | 54 | const paramValues = internals.pick(parameters, paramsInfo); 55 | const queryValues = Object.assign( 56 | Querystring.parse(baseQuery), 57 | internals.pick(parameters, queryInfo) 58 | ); 59 | const payloadValues = parameters.data || internals.pick(parameters, payloadInfo); 60 | const headerValues = internals.headersFromArray(parameters.header || []); 61 | const pathname = internals.setParams(path, paramValues); 62 | const querystring = Querystring.stringify(queryValues); 63 | const url = pathname + (querystring && '?') + querystring; 64 | 65 | const timingStart = Date.now(); 66 | 67 | const { request, result, rawPayload } = await server.inject({ 68 | method, 69 | url, 70 | payload: payloadValues, 71 | headers: headerValues, 72 | allowInternals: true, 73 | plugins: { 'hpal-debug': {} } 74 | }); 75 | 76 | const timingEnd = Date.now(); 77 | 78 | // Output below 79 | 80 | // Handle "null prototype" output from inspect() 81 | const normalizedResult = (result && typeof result === 'object') ? Object.assign({}, result) : result; 82 | 83 | if (!parameters.verbose) { 84 | 85 | if (parameters.raw) { 86 | return options.out.write(rawPayload); 87 | } 88 | 89 | output(''); // Make a little room 90 | 91 | return output(Util.inspect(normalizedResult, { 92 | depth: null, 93 | compact: false, 94 | colors: options.colors, 95 | breakLength: options.out.columns 96 | })); 97 | } 98 | 99 | // Verbose 100 | 101 | const display = new internals.Display(ctx, parameters.raw); 102 | 103 | output(''); // Make a little room 104 | output(display.title(`${method} ${url}`) + ' ' + display.subheader(`(${timingEnd - timingStart}ms)`)); 105 | 106 | if (request.payload || request.payload === '') { 107 | 108 | // Payload was set as a string or object. When an object, is never empty because a key must have been set. 109 | 110 | output(''); 111 | output(display.header('payload')); 112 | output(display.hr()); 113 | if (display.raw) { 114 | output(request.plugins['hpal-debug'].rawPayload); 115 | } 116 | else { 117 | if (typeof request.payload === 'object') { 118 | output(display.twoColumn(request.payload)); 119 | } 120 | else { 121 | output(display.inspect(request.payload, { 122 | depth: null, 123 | compact: false 124 | })); 125 | } 126 | } 127 | } 128 | 129 | output(''); 130 | output(display.header('request headers')); 131 | output(display.hr()); 132 | output(display.twoColumn(request.headers)); 133 | 134 | output(''); 135 | output(display.header('response headers')); 136 | output(display.hr()); 137 | output(display.twoColumn(request.response.headers)); 138 | 139 | output(''); 140 | const { statusCode } = request.response; 141 | const statusText = (Http.STATUS_CODES[statusCode] || 'Unknown').toLowerCase(); 142 | output(display.header('result') + ' ' + display.subheader(`(${statusCode} ${statusText})`)); 143 | output(display.hr()); 144 | if (display.raw) { 145 | output(rawPayload); 146 | } 147 | else { 148 | output(display.inspect(normalizedResult, { 149 | depth: null, 150 | compact: false, 151 | breakLength: options.out.columns 152 | })); 153 | } 154 | }; 155 | 156 | internals.parameters = (info, argv, ctx) => { 157 | 158 | const { options, output, colors, DisplayError } = ctx; 159 | const { route, matchOn, paramsInfo, queryInfo, payloadInfo } = info; 160 | 161 | const definition = Object.assign( 162 | internals.makeDefinition(paramsInfo, 'params'), 163 | internals.makeDefinition(queryInfo, 'query'), 164 | internals.makeDefinition(payloadInfo, 'payload'), 165 | { 166 | help: { 167 | type: 'boolean', 168 | alias: 'h', 169 | description: 'Show usage options.' 170 | }, 171 | header: { 172 | type: 'string', 173 | alias: 'H', 174 | multiple: true, 175 | description: 'Request headers. Should be specified once per header, e.g -H "content-type: text/plain" -H "user-agent: hpal-cli".' 176 | }, 177 | data: { 178 | type: 'string', 179 | alias: 'd', 180 | description: 'Raw payload data. Should not be used in conjunction with route-specific payload options. Note that the default content-type remains "application/json".', 181 | default: null 182 | }, 183 | verbose: { 184 | type: 'boolean', 185 | alias: 'v', 186 | description: 'Show timing and headers in addition to response.', 187 | default: null 188 | }, 189 | raw: { 190 | type: 'boolean', 191 | alias: 'r', 192 | description: 'Output only the unformatted response payload.', 193 | default: !ctx.options.out.isTTY || null 194 | } 195 | } 196 | ); 197 | 198 | const getUsage = () => { 199 | 200 | let usage = [ 201 | 'hpal run debug:curl [options]', 202 | 'hpal run debug:curl [] [options]' 203 | ].join('\n '); 204 | 205 | const actualArgs = argv.slice(0, matchOn.length).join(' '); 206 | 207 | // Ensuring a route avoids a case like "hpal run debug:curl -h" displaying here, 208 | // registering the help flag as a potential route id. And other bad/odd cases 209 | // where the user is asking for help about a specific route, and the route didn't exist. 210 | 211 | if (route) { 212 | // If there's a route, then there must have been actualArgs that specified it 213 | usage += '\n\n' + colors.bold(`hpal run debug:curl ${actualArgs} [options]`); 214 | } 215 | 216 | return Bossy.usage(definition, usage, { colors: options.colors }); 217 | }; 218 | 219 | const parameters = Bossy.parse(definition, { argv }); 220 | 221 | if (parameters instanceof Error) { 222 | throw new DisplayError(getUsage() + '\n\n' + colors.red(parameters.message)); 223 | } 224 | 225 | if (parameters.help) { 226 | output(getUsage()); 227 | return null; 228 | } 229 | 230 | if (matchOn.length === 0) { 231 | throw new DisplayError(getUsage() + '\n\n' + colors.red('No route specified')); 232 | } 233 | 234 | // Bossy gives false specifically for missing boolean 235 | // params— we'll choose to just omit those instead. 236 | // Setting to undefined for parity with the other params. 237 | 238 | Object.keys(parameters).forEach((key) => { 239 | 240 | if (parameters[key] === false) { 241 | parameters[key] = undefined; 242 | } 243 | }); 244 | 245 | return parameters; 246 | }; 247 | 248 | internals.makeDefinition = (info, input) => { 249 | 250 | const getBossyType = (type) => { 251 | 252 | if (type === 'boolean') { 253 | return 'boolean'; 254 | } 255 | 256 | return 'string'; 257 | }; 258 | 259 | const transformItem = ({ flags: { description } = {}, type, items }) => ({ 260 | description: ((input === 'params') ? 'Route path param' : `Route ${input} param`) + (description ? `: ${description}` : ''), 261 | multiple: type === 'array', 262 | type: (items && items.length === 1) ? getBossyType(items[0].type) : getBossyType(type) 263 | }); 264 | 265 | const makeDefinition = (subinfo, path) => { 266 | 267 | return Object.keys(subinfo).reduce((collect, key) => { 268 | 269 | const { keys } = subinfo[key]; 270 | const fullPath = path.concat(key); 271 | const fullKey = path.concat(key).join('-'); 272 | 273 | return { 274 | ...collect, 275 | ...(keys && makeDefinition(keys, fullPath)), 276 | [fullKey]: transformItem(subinfo[key], input) 277 | }; 278 | }, {}); 279 | }; 280 | 281 | return makeDefinition(info, []); 282 | }; 283 | 284 | internals.pick = (parameters, info) => { 285 | 286 | const pick = (subinfo, path) => { 287 | 288 | const picked = Object.keys(subinfo).reduce((collect, key) => { 289 | 290 | const { keys } = subinfo[key]; 291 | const value = parameters[path.concat(key).join('-')]; 292 | 293 | if (value !== undefined) { 294 | return { 295 | ...collect, 296 | [key]: value 297 | }; 298 | } 299 | 300 | if (keys) { 301 | const subpicked = pick(keys, path.concat(key)); 302 | if (subpicked !== undefined) { 303 | return { 304 | ...collect, 305 | [key]: subpicked 306 | }; 307 | } 308 | } 309 | 310 | return collect; 311 | }, {}); 312 | 313 | return Object.keys(picked).length > 0 ? picked : undefined; 314 | }; 315 | 316 | return pick(info, []); 317 | }; 318 | 319 | internals.setParams = (path, params = {}) => { 320 | 321 | return path.replace(/(\/)?\{(.+?)(\?|\*[\d]*)?\}/g, (...args) => { 322 | 323 | const [, slash, name, mod] = args; 324 | const param = params.hasOwnProperty(name) ? params[name] : ''; 325 | 326 | if (mod === '?' && param === '') { // Avoid trailing slash 327 | return ''; 328 | } 329 | 330 | return `${slash}${param}`; 331 | }); 332 | }; 333 | 334 | internals.headersFromArray = (headerLines) => { 335 | 336 | return headerLines.reduce((headers, headerLine) => { 337 | 338 | headerLine = headerLine.trim(); 339 | 340 | const separator = headerLine.match(/:\s*/); 341 | const name = separator ? headerLine.slice(0, separator.index).toLowerCase() : headerLine.toLowerCase(); 342 | const value = separator ? headerLine.slice(separator.index + separator[0].length) : ''; 343 | 344 | return { 345 | ...headers, 346 | [name]: headers[name] ? [].concat(headers[name], value) : value 347 | }; 348 | }, {}); 349 | }; 350 | 351 | internals.Display = class Display { 352 | 353 | constructor(ctx, raw) { 354 | 355 | this.width = ctx.options.out.columns; 356 | this.ctx = ctx; 357 | this.raw = raw; 358 | } 359 | 360 | title(str) { 361 | 362 | return this.ctx.colors.bold(this.ctx.colors.green(str)); 363 | } 364 | 365 | header(str) { 366 | 367 | return this.ctx.colors.bold(this.ctx.colors.yellow(str)); 368 | } 369 | 370 | subheader(str) { 371 | 372 | return this.ctx.colors.grey(str); 373 | } 374 | 375 | hr() { 376 | 377 | const len = Math.round((this.width || 16) * (2 / 3)); 378 | 379 | return ('─').repeat(len); 380 | } 381 | 382 | twoColumn(dict) { 383 | 384 | const rows = Object.keys(dict).reduce((collect, key) => { 385 | 386 | const values = [].concat(dict[key]); 387 | 388 | return collect.concat( 389 | values.map((value) => ([key, this.inspect(value)])) 390 | ); 391 | }, []); 392 | 393 | if (this.raw) { 394 | return rows 395 | .map(([header, value]) => this.ctx.colors.bold(`${header}:`) + ' ' + value) 396 | .join('\n'); 397 | } 398 | 399 | const leftColWidth = Math.max(...rows.map(([header]) => header.length)) + 2; 400 | const rightColWidth = this.width - leftColWidth; 401 | 402 | const table = new Helpers.InvisibleTable({ 403 | truncate: false, 404 | colWidths: [leftColWidth, rightColWidth] 405 | }); 406 | 407 | const wordWrap = (str) => WordWrap(str, { width: rightColWidth - 4, cut: true, trim: true }); 408 | 409 | table.push( 410 | ...rows.map(([header, value]) => ([ 411 | this.ctx.colors.bold(header), 412 | wordWrap(value) 413 | ])) 414 | ); 415 | 416 | return table.toString(); 417 | } 418 | 419 | inspect(value, options = {}) { 420 | 421 | return (typeof value === 'string') ? value : Util.inspect(value, { 422 | colors: this.ctx.options.colors, 423 | ...options 424 | }); 425 | } 426 | }; 427 | 428 | internals.getValidationDescription = (value) => { 429 | 430 | return (value && typeof value.describe === 'function' && value.describe().keys) || {}; 431 | }; 432 | -------------------------------------------------------------------------------- /lib/commands/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.curl = require('./curl'); 4 | 5 | exports.repl = require('./repl'); 6 | 7 | exports.routes = require('./routes'); 8 | -------------------------------------------------------------------------------- /lib/commands/repl.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Repl = require('repl'); 4 | const Util = require('util'); 5 | const Toys = require('@hapipal/toys'); 6 | 7 | const internals = {}; 8 | 9 | module.exports = async (server, args, root, ctx) => { 10 | 11 | ctx.output(''); // Make a little room 12 | 13 | const repl = Repl.start({ 14 | prompt: 'hpal> ', 15 | input: ctx.options.in, 16 | output: ctx.options.out, 17 | useColors: ctx.options.colors 18 | }); 19 | 20 | internals.defineReadOnly(repl.context, 'server', server); 21 | 22 | let prototype = server; 23 | 24 | while (prototype) { 25 | 26 | const props = Object.getOwnPropertyNames(prototype); 27 | 28 | for (let i = 0; i < props.length; ++i) { 29 | 30 | const prop = props[i]; 31 | const value = prototype[prop]; 32 | 33 | if (prop.charAt(0) === '_' || (repl.context[prop] !== undefined)) { 34 | continue; 35 | } 36 | 37 | repl.context[prop] = (typeof value === 'function') ? value.bind(server) : value; 38 | } 39 | 40 | prototype = Object.getPrototypeOf(prototype); 41 | } 42 | 43 | if (typeof server.models === 'function') { 44 | for (const [name, Model] of Object.entries(server.models())) { 45 | 46 | if (repl.context[name] !== undefined) { 47 | continue; 48 | } 49 | 50 | repl.context[name] = Model; 51 | } 52 | } 53 | 54 | if (typeof server.services === 'function') { 55 | for (const [name, service] of Object.entries(server.services())) { 56 | 57 | if (repl.context[name] !== undefined) { 58 | continue; 59 | } 60 | 61 | repl.context[name] = service; 62 | } 63 | } 64 | 65 | const { env = {} } = ctx.options; 66 | const setupHistory = Util.promisify(repl.setupHistory.bind(repl)); 67 | await setupHistory(env.NODE_REPL_HISTORY); 68 | 69 | await Toys.event(repl, 'exit'); 70 | }; 71 | 72 | internals.defineReadOnly = (obj, prop, value) => { 73 | 74 | Object.defineProperty(obj, prop, { 75 | configurable: false, 76 | enumerable: true, 77 | value 78 | }); 79 | }; 80 | -------------------------------------------------------------------------------- /lib/commands/routes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Bossy = require('@hapi/bossy'); 4 | const CliTable = require('cli-table'); 5 | const Helpers = require('../helpers'); 6 | 7 | const internals = {}; 8 | 9 | module.exports = (server, argv, root, ctx) => { 10 | 11 | const { options, output, colors, DisplayError } = ctx; 12 | const width = options.out.columns; 13 | const parameters = internals.parameters(argv, ctx); 14 | 15 | if (parameters === null) { 16 | return; 17 | } 18 | 19 | const { route, matchOn, id, method, path } = Helpers.getRouteInfo(server, ...parameters._); 20 | 21 | if (!route && matchOn.length !== 0) { 22 | 23 | if (matchOn[0] === 'id') { 24 | throw new DisplayError(colors.yellow(`Route "${id}" not found`)); 25 | } 26 | 27 | throw new DisplayError(colors.yellow(`Route "${method} ${path}" not found`)); 28 | } 29 | 30 | const hide = (name) => parameters.hide.includes(name); 31 | const show = (name) => parameters.show.includes(name); 32 | const shouldDisplay = ({ name, display }) => (display && !hide(name)) || show(name); 33 | const displayColumns = internals.columns.filter(shouldDisplay); 34 | const routes = server.table().filter((r) => !route || r.public === route); 35 | 36 | const head = (col) => colors.bold(colors.yellow(col.name)); 37 | const colWidth = (col) => internals.colWidth(col.name, routes, (r) => col.get(r, server, parameters)); 38 | const adjustLastColumn = (widths) => { 39 | 40 | const dividerWidths = widths.length + 1; 41 | const sum = (vals) => vals.reduce((total, val) => total + val, 0); 42 | 43 | if (sum(widths) + dividerWidths < width) { 44 | return widths; 45 | } 46 | 47 | const lastCol = displayColumns[displayColumns.length - 1]; 48 | const allButLastColWidths = widths.slice(0, -1); 49 | const lastColWidth = Math.max(lastCol.name.length + 2, width - (sum(allButLastColWidths) + dividerWidths)); // 2 for cell padding 50 | 51 | return allButLastColWidths.concat(lastColWidth); 52 | }; 53 | 54 | const pluginOrder = Object.keys(server.registrations) 55 | .reduce((collect, name, index) => ({ 56 | ...collect, 57 | [name]: index 58 | }), {}); 59 | 60 | const rows = routes 61 | .sort((r1, r2) => { // Group routes by plugin 62 | 63 | const plugin1 = r1.public.realm.plugin; 64 | const plugin2 = r2.public.realm.plugin; 65 | 66 | const order1 = plugin1 ? pluginOrder[plugin1] : -Infinity; 67 | const order2 = plugin2 ? pluginOrder[plugin2] : -Infinity; 68 | 69 | return order1 - order2; 70 | }) 71 | .map((r) => displayColumns.map((col) => col.get(r, server, parameters))); 72 | 73 | const tableDisplay = { 74 | head: displayColumns.map(head), 75 | style: { 76 | head: [], 77 | border: options.colors ? ['grey'] : [] 78 | } 79 | }; 80 | 81 | const table = parameters.raw ? 82 | new Helpers.InvisibleTable({ 83 | ...tableDisplay, 84 | chars: { 85 | middle: '\t' // Delimeter allows cell content to contain spaces 86 | } 87 | }) : 88 | new CliTable({ 89 | ...tableDisplay, 90 | colWidths: adjustLastColumn(displayColumns.map(colWidth)) 91 | }); 92 | 93 | table.push(...rows); 94 | 95 | output(''); // Make a little room 96 | output(table.toString()); 97 | }; 98 | 99 | internals.parameters = (argv, ctx) => { 100 | 101 | const { options, output, colors, DisplayError } = ctx; 102 | const colNames = internals.columns.map((col) => col.name); 103 | 104 | const definition = { 105 | help: { 106 | type: 'boolean', 107 | alias: 'h', 108 | description: 'Show usage options.' 109 | }, 110 | hide: { 111 | type: 'string', 112 | alias: 'H', 113 | multiple: true, 114 | description: 'Hide specific columns. May be listed multiple times.', 115 | valid: colNames 116 | }, 117 | show: { 118 | type: 'string', 119 | alias: 's', 120 | multiple: true, 121 | description: 'Show specific columns. May be listed multiple times.', 122 | valid: colNames 123 | }, 124 | raw: { 125 | type: 'boolean', 126 | alias: 'r', 127 | description: 'Output unformatted route table.', 128 | default: !ctx.options.out.isTTY || null 129 | } 130 | }; 131 | 132 | const parameters = Bossy.parse(definition, { argv }); 133 | const getUsage = () => { 134 | 135 | const usage = [ 136 | 'hpal run debug:routes [options]', 137 | 'hpal run debug:routes [options]', 138 | 'hpal run debug:routes [] [options]' 139 | ].join('\n '); 140 | 141 | return Bossy.usage(definition, usage, { colors: options.colors }); 142 | }; 143 | 144 | if (parameters instanceof Error) { 145 | throw new DisplayError(getUsage() + '\n\n' + colors.red(parameters.message)); 146 | } 147 | 148 | if (parameters.help) { 149 | output(getUsage()); 150 | return null; 151 | } 152 | 153 | const { 154 | _ = [], 155 | show = [], 156 | hide = [] 157 | } = parameters; 158 | 159 | return { ...parameters, _, show, hide }; 160 | }; 161 | 162 | internals.colWidth = (name, routes, getValue) => { 163 | 164 | const valueLengths = routes.map(getValue) // ['dogs\nkitties'] 165 | .map((val) => val.split('\n')) // [['dogs', 'kitties']] 166 | .reduce((collect, items) => collect.concat(items), []) // Flatten to ['dogs', 'kitties'] 167 | .map((val) => val.length); // [4, 7] 168 | 169 | return Math.max(name.length, ...valueLengths) + 2; // 2 for cell padding 170 | }; 171 | 172 | internals.columns = [ 173 | { 174 | name: 'method', 175 | display: true, 176 | get: (r) => r.method 177 | }, 178 | { 179 | name: 'path', 180 | display: true, 181 | get: (r) => r.path 182 | }, 183 | { 184 | name: 'id', 185 | display: true, 186 | get: (r) => r.settings.id || '' 187 | }, 188 | { 189 | name: 'plugin', 190 | display: true, 191 | get: (r) => r.public.realm.plugin || '(root)' 192 | }, 193 | { 194 | name: 'vhost', 195 | display: false, 196 | get: (r) => r.settings.vhost || '' 197 | }, 198 | { 199 | name: 'auth', 200 | display: false, 201 | get: (r, srv, params) => { 202 | 203 | const auth = srv.auth.lookup(r); 204 | 205 | if (!auth) { 206 | return '(none)'; 207 | } 208 | 209 | const mode = (auth.mode === 'required') ? '' : `(${auth.mode})`; 210 | 211 | if (mode) { 212 | return mode + (params.raw ? ' ' : '\n') + internals.listToString(auth.strategies, params); 213 | } 214 | 215 | return internals.listToString(auth.strategies, params); 216 | } 217 | }, 218 | { 219 | name: 'cors', 220 | display: false, 221 | get: (r, _, params) => { 222 | 223 | if (!r.settings.cors) { 224 | return '(off)'; 225 | } 226 | 227 | if (r.settings.cors.origin === 'ignore') { 228 | return '(ignore)'; 229 | } 230 | 231 | return internals.listToString(r.settings.cors.origin, params); 232 | } 233 | }, 234 | { 235 | name: 'tags', 236 | display: false, 237 | get: (r, _, params) => internals.listToString(r.settings.tags, params) 238 | }, 239 | { 240 | name: 'description', 241 | display: true, 242 | get: (r) => r.settings.description || '' 243 | } 244 | ]; 245 | 246 | internals.listToString = (list, { raw }) => { 247 | 248 | const delimeter = raw ? ', ' : '\n'; 249 | 250 | return [].concat(list || []).join(delimeter); 251 | }; 252 | -------------------------------------------------------------------------------- /lib/helpers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Url = require('url'); 4 | const CliTable = require('cli-table'); 5 | 6 | const internals = {}; 7 | 8 | exports.getRouteInfo = (server, method, path, ...others) => { 9 | 10 | if (method && !internals.isMethod(method)) { 11 | path = method; 12 | method = null; 13 | } 14 | 15 | const id = (!method && path && path[0] !== '/') && path; 16 | 17 | if (id) { 18 | 19 | const matchOn = ['id']; 20 | const route = server.lookup(id); 21 | 22 | return { 23 | route, 24 | id, 25 | method: route && route.method, 26 | path: route && route.path, 27 | query: null, 28 | matchOn 29 | }; 30 | } 31 | else if (path) { 32 | 33 | const matchOn = method ? ['method', 'path'] : ['path']; 34 | const { query, pathname } = Url.parse(path); 35 | path = pathname; 36 | method = (method || 'get').toLowerCase(); 37 | 38 | const route = server.match(method, path); 39 | 40 | return { 41 | route, 42 | id: route && route.settings.id, 43 | method, 44 | path, 45 | query, 46 | matchOn 47 | }; 48 | } 49 | 50 | return { 51 | route: null, 52 | id: null, 53 | method: null, 54 | path: null, 55 | query: null, 56 | matchOn: [] 57 | }; 58 | }; 59 | 60 | internals.isMethod = (str) => { 61 | 62 | return ['get', 'post', 'patch', 'put', 'delete', 'options', 'head'].includes(str.toLowerCase()); 63 | }; 64 | 65 | exports.InvisibleTable = class InvisibleTable extends CliTable { 66 | constructor(options = {}) { 67 | 68 | super({ 69 | ...options, 70 | style: { 'padding-left': 0, 'padding-right': 0, ...options.style }, 71 | chars: { 72 | top: '', 73 | 'top-mid': '', 74 | 'top-left': '', 75 | 'top-right': '', 76 | bottom: '', 77 | 'bottom-mid': '', 78 | 'bottom-left': '', 79 | 'bottom-right': '', 80 | left: '', 81 | 'left-mid': '', 82 | mid: '', 83 | 'mid-mid': '', 84 | right: '', 85 | 'right-mid': '', 86 | middle: '', 87 | ...options.chars 88 | } 89 | }); 90 | } 91 | }; 92 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Commands = require('./commands'); 4 | const Package = require('../package.json'); 5 | 6 | module.exports = { 7 | pkg: Package, 8 | once: true, 9 | requirements: { 10 | hapi: '>=19' 11 | }, 12 | register(server) { 13 | 14 | server.expose('commands', { 15 | default: { 16 | command: Commands.repl, 17 | description: 'Alias for `hpal run debug:repl`', 18 | noDefaultOutput: true 19 | }, 20 | repl: { 21 | command: Commands.repl, 22 | description: 'Run your server interactively as a read-eval-print loop (REPL)', 23 | noDefaultOutput: true 24 | }, 25 | curl: { 26 | command: Commands.curl, 27 | description: 'Make requests to the routes on your server', 28 | noDefaultOutput: true 29 | }, 30 | routes: { 31 | command: Commands.routes, 32 | description: 'List the routes on your server', 33 | noDefaultOutput: true 34 | } 35 | }); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hapipal/hpal-debug", 3 | "version": "2.1.0", 4 | "description": "hapijs debugging tools for the hpal CLI", 5 | "main": "lib/index.js", 6 | "engines": { 7 | "node": ">=12" 8 | }, 9 | "directories": { 10 | "test": "test" 11 | }, 12 | "scripts": { 13 | "test": "lab -a @hapi/code -t 100 -L", 14 | "coveralls": "lab -r lcov | coveralls" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/hapipal/hpal-debug.git" 19 | }, 20 | "keywords": [ 21 | "hapi", 22 | "pal", 23 | "hpal", 24 | "debug", 25 | "cli", 26 | "curl" 27 | ], 28 | "author": "Devin Ivy ", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/hapipal/hpal-debug/issues" 32 | }, 33 | "homepage": "https://github.com/hapipal/hpal-debug#readme", 34 | "dependencies": { 35 | "@hapi/bossy": "^5.0.0", 36 | "@hapipal/toys": "^3.2.0", 37 | "cli-table": "^0.3.0", 38 | "word-wrap": "^1.0.0" 39 | }, 40 | "peerDependencies": { 41 | "@hapi/hapi": ">=19 <22", 42 | "joi": ">=17 <18" 43 | }, 44 | "peerDependenciesMeta": { 45 | "joi": { 46 | "optional": true 47 | } 48 | }, 49 | "devDependencies": { 50 | "@hapi/code": "^8.0.0", 51 | "@hapi/hapi": "^20.0.0", 52 | "@hapi/lab": "^24.0.0", 53 | "@hapi/somever": "^3.0.0", 54 | "@hapipal/hpal": "^3.0.0", 55 | "@hapipal/schmervice": "^2.0.0", 56 | "@hapipal/schwifty": "^6.0.0", 57 | "coveralls": "^3.0.0", 58 | "joi": "^17.0.0", 59 | "knex": "^0.21.0", 60 | "objection": "^2.0.0", 61 | "strip-ansi": "^6.0.0", 62 | "uuid": "^8.0.0" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /test/closet/curl/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "curl" 3 | } 4 | -------------------------------------------------------------------------------- /test/closet/curl/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Hapi = require('@hapi/hapi'); 4 | const { Joi } = require('../../run-util'); 5 | const HpalDebug = require('../../..'); 6 | 7 | exports.deployment = async () => { 8 | 9 | const server = Hapi.server({ 10 | host: 'hapipal' 11 | }); 12 | 13 | await server.register(HpalDebug); 14 | 15 | server.route([ 16 | { 17 | method: 'get', 18 | path: '/basic', 19 | options: { 20 | id: 'get-basic', 21 | handler: () => 'get-basic-result' 22 | } 23 | }, 24 | { 25 | method: 'post', 26 | path: '/basic', 27 | options: { 28 | id: 'post-basic', 29 | handler: () => 'post-basic-result' 30 | } 31 | }, 32 | { 33 | method: 'get', 34 | path: '/by-id', 35 | options: { 36 | id: 'get-by-id', 37 | handler: () => 'get-by-id-result' 38 | } 39 | }, 40 | { 41 | method: 'get', 42 | path: '/first/{one}/second/{two*2}/third/{three?}', 43 | options: { 44 | id: 'use-params', 45 | validate: { 46 | params: Joi.object({ 47 | one: Joi.number(), 48 | two: Joi.string(), 49 | three: Joi.string() 50 | }) 51 | }, 52 | handler: ({ params }) => params 53 | } 54 | }, 55 | { 56 | method: 'get', 57 | path: '/query', 58 | options: { 59 | id: 'use-query', 60 | validate: { 61 | query: Joi.object({ 62 | isOne: Joi.boolean().truthy('true'), 63 | two: Joi.number(), 64 | three: Joi.string() 65 | }) 66 | }, 67 | handler: ({ query }) => query 68 | } 69 | }, 70 | { 71 | method: 'post', 72 | path: '/payload', 73 | options: { 74 | id: 'use-payload', 75 | validate: { 76 | payload: Joi.object({ 77 | isOne: Joi.boolean().truthy('true'), 78 | two: Joi.number() 79 | }) 80 | }, 81 | handler: ({ payload }) => payload 82 | } 83 | }, 84 | { 85 | method: 'post', 86 | path: '/deep-payload', 87 | options: { 88 | id: 'use-deep-payload', 89 | validate: { 90 | payload: Joi.object({ 91 | isOne: Joi.boolean().truthy('true'), 92 | objOne: { 93 | two: Joi.number(), 94 | objTwo: { 95 | isFour: Joi.boolean().truthy('true'), 96 | five: Joi.string() 97 | }, 98 | objThree: { 99 | six: Joi.number() 100 | }, 101 | objFour: { 102 | seven: Joi.string() 103 | } 104 | } 105 | }) 106 | }, 107 | handler: ({ payload }) => payload 108 | } 109 | }, 110 | { 111 | method: 'post', 112 | path: '/no-joi-validation/{param?}', 113 | options: { 114 | id: 'use-no-joi-validation', 115 | validate: { 116 | params: (value) => value, 117 | payload: (value) => value, 118 | query: (value) => value 119 | }, 120 | handler: () => 'use-no-joi-validation-result' 121 | } 122 | }, 123 | { 124 | method: 'post', 125 | path: '/non-obj-joi-validation/{param?}', 126 | options: { 127 | id: 'use-non-obj-joi-validation', 128 | validate: { 129 | params: Joi.any(), 130 | payload: Joi.any(), 131 | query: Joi.any() 132 | }, 133 | handler: () => 'use-non-obj-joi-validation-result' 134 | } 135 | }, 136 | { 137 | method: 'post', 138 | path: '/use-joi-array-validation', 139 | options: { 140 | id: 'use-joi-array-validation', 141 | validate: { 142 | payload: Joi.object({ 143 | single: Joi.array().items(Joi.number()), 144 | mixed: Joi.array().items(Joi.number(), Joi.string()) 145 | }) 146 | }, 147 | handler: ({ payload }) => payload 148 | } 149 | }, 150 | { 151 | method: 'post', 152 | path: '/usage/{one?}', 153 | options: { 154 | id: 'usage', 155 | validate: { 156 | params: Joi.object({ 157 | one: Joi.any() 158 | }), 159 | query: Joi.object({ 160 | two: Joi.any().description('Two things to know') 161 | }), 162 | payload: Joi.object({ 163 | three: Joi.any() 164 | }) 165 | }, 166 | handler: () => 'usage-result' 167 | } 168 | }, 169 | { 170 | method: 'get', 171 | path: '/headers', 172 | options: { 173 | id: 'use-headers', 174 | handler: ({ headers }) => headers 175 | } 176 | }, 177 | { 178 | method: 'post', 179 | path: '/non-obj-payload', 180 | options: { 181 | id: 'use-non-obj-payload', 182 | // Avoid inconsistencies across hapi versions in empty responses 183 | handler: ({ payload }) => payload || '[empty]' 184 | } 185 | }, 186 | { 187 | method: 'get', 188 | path: '/unknown-status-code', 189 | options: { 190 | id: 'unknown-status-code', 191 | handler: (request, h) => h.response({ unknown: 'code' }).code(420) 192 | } 193 | }, 194 | { 195 | method: 'get', 196 | path: '/null-response', 197 | options: { 198 | id: 'null-response', 199 | handler: () => null 200 | } 201 | } 202 | ]); 203 | 204 | return server; 205 | }; 206 | -------------------------------------------------------------------------------- /test/closet/repl/main/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "repl-main" 3 | } 4 | -------------------------------------------------------------------------------- /test/closet/repl/main/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Hapi = require('@hapi/hapi'); 4 | const HpalDebug = require('../../../..'); 5 | 6 | exports.deployment = async () => { 7 | 8 | const server = Hapi.server({ 9 | app: { 10 | configItem: 'config-item' 11 | } 12 | }); 13 | 14 | await server.register(HpalDebug); 15 | 16 | server.decorate('server', 'myDecoration', () => 'my-decoration'); 17 | 18 | server.route({ 19 | method: 'get', 20 | path: '/my-route', 21 | options: { 22 | id: 'my-route', 23 | handler: () => 'my-route' 24 | } 25 | }); 26 | 27 | return server; 28 | }; 29 | -------------------------------------------------------------------------------- /test/closet/repl/pal-plugins/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "repl-pal-plugins" 3 | } 4 | -------------------------------------------------------------------------------- /test/closet/repl/pal-plugins/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Hapi = require('@hapi/hapi'); 4 | const Schwifty = require('@hapipal/schwifty'); 5 | const Schmervice = require('@hapipal/schmervice'); 6 | const HpalDebug = require('../../../..'); 7 | 8 | exports.deployment = async () => { 9 | 10 | const server = Hapi.server(); 11 | 12 | await server.register([HpalDebug, Schwifty, Schmervice]); 13 | 14 | server.registerModel(class MyModel extends Schwifty.Model { 15 | static get exists() { 16 | 17 | return 'indeedy'; 18 | } 19 | }); 20 | 21 | // Clobberer 22 | server.registerModel(class Buffer extends Schwifty.Model { 23 | static get exists() { 24 | 25 | return 'indeedy'; 26 | } 27 | }); 28 | 29 | server.registerService(class MyService extends Schmervice.Service { 30 | get exists() { 31 | 32 | return 'indeedy'; 33 | } 34 | }); 35 | 36 | // Clobberer 37 | server.registerService(class Events extends Schmervice.Service { 38 | get exists() { 39 | 40 | return 'indeedy'; 41 | } 42 | }); 43 | 44 | return server; 45 | }; 46 | -------------------------------------------------------------------------------- /test/closet/routes/cors-ignore/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "routes-cors-ignore" 3 | } 4 | -------------------------------------------------------------------------------- /test/closet/routes/cors-ignore/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Hapi = require('@hapi/hapi'); 4 | const HpalDebug = require('../../../..'); 5 | 6 | exports.deployment = async () => { 7 | 8 | const server = Hapi.server(); 9 | 10 | await server.register(HpalDebug); 11 | 12 | server.route([ 13 | { 14 | method: 'post', 15 | path: '/cors-ignore', 16 | options: { 17 | cors: { origin: 'ignore' }, 18 | handler: () => 'cors-ignore' 19 | } 20 | } 21 | ]); 22 | 23 | return server; 24 | }; 25 | -------------------------------------------------------------------------------- /test/closet/routes/main/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "routes-main" 3 | } 4 | -------------------------------------------------------------------------------- /test/closet/routes/main/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Hapi = require('@hapi/hapi'); 4 | const Toys = require('@hapipal/toys'); 5 | const HpalDebug = require('../../../..'); 6 | 7 | exports.deployment = async () => { 8 | 9 | const server = Hapi.server(); 10 | 11 | await server.register(HpalDebug); 12 | 13 | server.route([ 14 | { 15 | method: 'get', 16 | path: '/empty', 17 | options: { 18 | handler: () => 'empty' 19 | } 20 | } 21 | ]); 22 | 23 | await server.register({ 24 | name: 'my-plugin', 25 | register(srv) { 26 | 27 | Toys.auth.strategy(srv, 'first-strategy', (request, h) => h.authenticated({ credentials: {} })); 28 | Toys.auth.strategy(srv, 'second-strategy', (request, h) => h.authenticated({ credentials: {} })); 29 | 30 | srv.route([ 31 | { 32 | method: 'put', 33 | path: '/shorthand', 34 | options: { 35 | id: 'shorthand', 36 | tags: 'my-tag', 37 | description: 'Shorthand config', 38 | cors: true, 39 | auth: { strategy: 'first-strategy' }, 40 | handler: () => 'shorthand' 41 | } 42 | }, 43 | { 44 | method: 'patch', 45 | path: '/longhand', 46 | vhost: 'hapipal.com', 47 | options: { 48 | id: 'longhand', 49 | tags: ['my-tag', 'my-other-tag'], 50 | description: 'Instead, a longhand config', 51 | cors: { 52 | origin: [ 53 | 'hapipal.com', 54 | 'www.hapipal.com' 55 | ] 56 | }, 57 | auth: { 58 | mode: 'try', 59 | strategies: [ 60 | 'first-strategy', 61 | 'second-strategy' 62 | ] 63 | }, 64 | handler: () => 'longhand' 65 | } 66 | } 67 | ]); 68 | } 69 | }); 70 | 71 | return server; 72 | }; 73 | -------------------------------------------------------------------------------- /test/closet/routes/plugin-groups/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "routes-plugin-groups" 3 | } 4 | -------------------------------------------------------------------------------- /test/closet/routes/plugin-groups/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Hapi = require('@hapi/hapi'); 4 | const HpalDebug = require('../../../..'); 5 | 6 | exports.deployment = async () => { 7 | 8 | const server = Hapi.server(); 9 | 10 | await server.register(HpalDebug); 11 | 12 | server.route([ 13 | { 14 | method: 'get', 15 | path: '/one', 16 | options: { 17 | handler: () => 'one' 18 | } 19 | }, 20 | { 21 | method: 'post', 22 | path: '/two', 23 | options: { 24 | handler: () => 'two' 25 | } 26 | } 27 | ]); 28 | 29 | await server.register({ 30 | name: 'my-plugin-a', 31 | register(srv) { 32 | 33 | srv.route([ 34 | { 35 | method: 'get', 36 | path: '/one-a', 37 | options: { 38 | handler: () => 'one-a' 39 | } 40 | }, 41 | { 42 | method: 'post', 43 | path: '/two-a', 44 | options: { 45 | handler: () => 'two-a' 46 | } 47 | } 48 | ]); 49 | } 50 | }); 51 | 52 | await server.register({ 53 | name: 'my-plugin-b', 54 | register(srv) { 55 | 56 | srv.route([ 57 | { 58 | method: 'get', 59 | path: '/one-b', 60 | options: { 61 | handler: () => 'one-b' 62 | } 63 | }, 64 | { 65 | method: 'post', 66 | path: '/two-b', 67 | options: { 68 | handler: () => 'two-b' 69 | } 70 | } 71 | ]); 72 | } 73 | }); 74 | 75 | return server; 76 | }; 77 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Fs = require('fs'); 6 | const Os = require('os'); 7 | const Path = require('path'); 8 | const Util = require('util'); 9 | const Lab = require('@hapi/lab'); 10 | const Code = require('@hapi/code'); 11 | const Uuid = require('uuid'); 12 | const StripAnsi = require('strip-ansi'); 13 | const RunUtil = require('./run-util'); 14 | 15 | // Test shortcuts 16 | 17 | const { describe, it } = exports.lab = Lab.script(); 18 | const { expect } = Code; 19 | 20 | const internals = {}; 21 | 22 | describe('hpal-debug', () => { 23 | 24 | describe('repl command', () => { 25 | 26 | const waitForPrompt = (cli) => { 27 | 28 | return new Promise((resolve) => { 29 | 30 | let last; 31 | 32 | const onData = (data) => { 33 | 34 | data = data.toString(); 35 | 36 | if (data === 'hpal> ') { 37 | cli.options.out.removeListener('data', onData); 38 | return resolve(last.trim()); 39 | } 40 | 41 | last = data; 42 | }; 43 | 44 | cli.options.out.on('data', onData); 45 | }); 46 | }; 47 | 48 | const evaluate = async (cli, expression) => { 49 | 50 | const output = waitForPrompt(cli); 51 | 52 | cli.options.in.write(`${expression}\n`); 53 | 54 | return await output; 55 | }; 56 | 57 | const exit = async (cli) => { 58 | 59 | cli.options.in.write(`.exit\n`); 60 | 61 | await cli; 62 | }; 63 | 64 | const repl = (args = [], dir = 'main', opts) => { 65 | 66 | return RunUtil.cli(['run', 'debug:repl', ...args], `repl/${dir}`, { 67 | isTTY: false, // Disables colors 68 | ...opts 69 | }); 70 | }; 71 | 72 | const readFile = Util.promisify(Fs.readFile); 73 | 74 | it('has server in context.', async () => { 75 | 76 | const cli = repl(); 77 | 78 | await waitForPrompt(cli); 79 | 80 | const output1 = await evaluate(cli, 'server.settings.app.configItem'); 81 | expect(output1).to.equal('\'config-item\''); 82 | 83 | const output2 = await evaluate(cli, 'server.registrations'); 84 | expect(output2).to.contain('name: \'@hapipal/hpal-debug\''); 85 | 86 | const output3 = await evaluate(cli, 'server.myDecoration()'); 87 | expect(output3).to.equal('\'my-decoration\''); 88 | 89 | await exit(cli); 90 | }); 91 | 92 | it('has server\'s own properties in context.', async () => { 93 | 94 | const cli = repl(); 95 | 96 | await waitForPrompt(cli); 97 | 98 | const output1 = await evaluate(cli, 'settings.app.configItem'); 99 | expect(output1).to.equal('\'config-item\''); 100 | 101 | const output2 = await evaluate(cli, 'registrations'); 102 | expect(output2).to.contain('name: \'@hapipal/hpal-debug\''); 103 | 104 | const output3 = await evaluate(cli, 'myDecoration()'); 105 | expect(output3).to.equal('\'my-decoration\''); 106 | 107 | await exit(cli); 108 | }); 109 | 110 | it('has server\'s prototype chain\'s properties in context.', async () => { 111 | 112 | const cli = repl(); 113 | 114 | await waitForPrompt(cli); 115 | 116 | const output = await evaluate(cli, 'lookup(\'my-route\').path'); 117 | expect(output).to.equal('\'/my-route\''); 118 | 119 | await exit(cli); 120 | }); 121 | 122 | it('does not have private server properties in context.', async () => { 123 | 124 | const cli = repl(); 125 | 126 | await waitForPrompt(cli); 127 | 128 | const output = await evaluate(cli, '_core'); 129 | expect(output).to.contain('_core is not defined'); 130 | 131 | await exit(cli); 132 | }); 133 | 134 | it('does not clobber props on standard REPL context.', async () => { 135 | 136 | const cli = repl(); 137 | 138 | await waitForPrompt(cli); 139 | 140 | const output1 = await evaluate(cli, 'server.events === require(\'events\')'); 141 | expect(output1).to.equal('false'); 142 | 143 | const output2 = await evaluate(cli, 'events === require(\'events\')'); 144 | expect(output2).to.equal('true'); 145 | 146 | await exit(cli); 147 | }); 148 | 149 | it('server prop is defined as read-only.', async () => { 150 | 151 | const cli = repl(); 152 | 153 | await waitForPrompt(cli); 154 | 155 | const output1 = await evaluate(cli, 'server = null, server === null'); 156 | expect(output1).to.equal('false'); 157 | 158 | const output2 = await evaluate(cli, 'lookup = null, lookup === null'); 159 | expect(output2).to.equal('true'); 160 | 161 | await exit(cli); 162 | }); 163 | 164 | it('has schwifty models and schmervice services in context, without clobbering existing context.', async () => { 165 | 166 | const cli = repl([], 'pal-plugins'); 167 | 168 | await waitForPrompt(cli); 169 | 170 | const output1 = await evaluate(cli, 'MyModel.exists === \'indeedy\''); 171 | expect(output1).to.equal('true'); 172 | 173 | const output2 = await evaluate(cli, 'myService.exists === \'indeedy\''); 174 | expect(output2).to.equal('true'); 175 | 176 | const output3 = await evaluate(cli, 'Buffer.exists === \'indeedy\''); 177 | expect(output3).to.equal('false'); 178 | 179 | const output4 = await evaluate(cli, 'events.exists === \'indeedy\''); 180 | expect(output4).to.equal('false'); 181 | 182 | await exit(cli); 183 | }); 184 | 185 | it('sets-up history when applicable.', async () => { 186 | 187 | const uuid = Uuid.v4(); 188 | const historyFile = Path.join(Os.tmpdir(), uuid); 189 | 190 | const cli = RunUtil.cli(['run', 'debug'], 'repl/main', { 191 | env: { 192 | NODE_REPL_HISTORY: historyFile 193 | } 194 | }); 195 | 196 | await waitForPrompt(cli); 197 | await evaluate(cli, `'${uuid}'`); 198 | await exit(cli); 199 | 200 | const historyContents = await readFile(historyFile); 201 | expect(historyContents.toString()).to.contain(uuid); 202 | }); 203 | 204 | it('is the default debug command.', async () => { 205 | 206 | const cli = RunUtil.cli(['run', 'debug'], 'repl/main', { isTTY: false }); 207 | 208 | await waitForPrompt(cli); 209 | 210 | const output = await evaluate(cli, 'server.settings.app.configItem'); 211 | expect(output).to.equal('\'config-item\''); 212 | 213 | await exit(cli); 214 | }); 215 | }); 216 | 217 | describe('curl command', () => { 218 | 219 | const normalize = (str) => { 220 | 221 | return str.trim(); 222 | }; 223 | 224 | const ignoreNewlines = (str) => str.replace(/\s*\n\s*/g, ' '); 225 | 226 | const validateVerboseOutput = (actual, expected) => { 227 | 228 | actual = actual.replace(/\(\d+ms\)/g, '(?ms)'); // unknown timing 229 | actual = actual.replace(/[^\S\r\n]+$/gm, ''); // remove trailing spaces 230 | 231 | const actualLines = actual.split('\n'); 232 | const indexOfPayload = actualLines.length - actualLines.slice().reverse().findIndex((line) => line.match(/^─+$/)); 233 | 234 | actual = actualLines.slice(0, indexOfPayload).join('\n') + '\n' + 235 | ignoreNewlines(actualLines.slice(indexOfPayload).join('\n')); 236 | 237 | // unknown indentation in test 238 | 239 | const expectedLines = expected.split('\n'); 240 | const [indent] = expectedLines[1].match(/^\s*/); 241 | const indentRegex = new RegExp(`^${indent}`); 242 | 243 | expected = expectedLines.map((line) => line.replace(indentRegex, '')).join('\n'); 244 | expected = expected.trim(); 245 | 246 | expect(actual).to.equal(expected); 247 | }; 248 | 249 | const curl = (args, opts) => RunUtil.cli(['run', 'debug:curl', ...args], 'curl', opts); 250 | 251 | it('outputs help [-h, --help].', async () => { 252 | 253 | const { output: output1, err: err1, errorOutput: errorOutput1 } = await curl(['-h']); 254 | 255 | expect(err1).to.not.exist(); 256 | expect(errorOutput1).to.equal(''); 257 | expect(normalize(output1)).to.contain('Usage: hpal run debug:curl [options]'); 258 | 259 | const { output: output2, err: err2, errorOutput: errorOutput2 } = await curl(['--help']); 260 | 261 | expect(err2).to.not.exist(); 262 | expect(errorOutput2).to.equal(''); 263 | expect(normalize(output2)).to.contain('Usage: hpal run debug:curl [options]'); 264 | }); 265 | 266 | it('hits route from its method and path.', async () => { 267 | 268 | const { output, err, errorOutput } = await curl(['post', '/basic']); 269 | 270 | expect(err).to.not.exist(); 271 | expect(errorOutput).to.equal(''); 272 | expect(normalize(output)).to.equal('\'post-basic-result\''); 273 | }); 274 | 275 | it('hits route from its path, defaulting method to get.', async () => { 276 | 277 | const { output, err, errorOutput } = await curl(['/basic']); 278 | 279 | expect(err).to.not.exist(); 280 | expect(errorOutput).to.equal(''); 281 | expect(normalize(output)).to.equal('\'get-basic-result\''); 282 | }); 283 | 284 | it('errors when route is not found by method and path.', async () => { 285 | 286 | const { output, err, errorOutput } = await curl(['post', '/does-not-exist']); 287 | 288 | expect(err).to.exist(); 289 | expect(errorOutput).to.equal('Route "post /does-not-exist" not found'); 290 | expect(normalize(output)).to.equal(''); 291 | }); 292 | 293 | it('hits route from its id.', async () => { 294 | 295 | const { output, err, errorOutput } = await curl(['get-by-id']); 296 | 297 | expect(err).to.not.exist(); 298 | expect(errorOutput).to.equal(''); 299 | expect(normalize(output)).to.equal('\'get-by-id-result\''); 300 | }); 301 | 302 | it('errors when route is not found by id.', async () => { 303 | 304 | const { output, err, errorOutput } = await curl(['does-not-exist']); 305 | 306 | expect(err).to.exist(); 307 | expect(errorOutput).to.equal('Route "does-not-exist" not found'); 308 | expect(normalize(output)).to.equal(''); 309 | }); 310 | 311 | it('errors when no path or id are specified.', async () => { 312 | 313 | const { output, err, errorOutput } = await curl([]); 314 | 315 | expect(err).to.exist(); 316 | expect(errorOutput).to.endWith('No route specified'); 317 | expect(normalize(output)).to.equal(''); 318 | }); 319 | 320 | it('can specify path params as flags (with optional param).', async () => { 321 | 322 | const { output, err, errorOutput } = await curl(['use-params', '--one', '1', '--two', '2/2', '--three', '3']); 323 | 324 | expect(err).to.not.exist(); 325 | expect(errorOutput).to.equal(''); 326 | expect(ignoreNewlines(normalize(output))).to.equal('{ one: 1, two: \'2/2\', three: \'3\' }'); 327 | }); 328 | 329 | it('can specify path params as flags (without optional param).', async () => { 330 | 331 | const { output, err, errorOutput } = await curl(['use-params', '--one', '1', '--two', '2/2']); 332 | 333 | expect(err).to.not.exist(); 334 | expect(errorOutput).to.equal(''); 335 | expect(ignoreNewlines(normalize(output))).to.equal('{ one: 1, two: \'2/2\' }'); 336 | }); 337 | 338 | it('can specify query params as flags.', async () => { 339 | 340 | const { output, err, errorOutput } = await curl(['use-query', '--isOne', '--two', '2']); 341 | 342 | expect(err).to.not.exist(); 343 | expect(errorOutput).to.equal(''); 344 | expect(ignoreNewlines(normalize(output))).to.equal('{ isOne: true, two: 2 }'); 345 | }); 346 | 347 | it('can specify query params in the path and as flags.', async () => { 348 | 349 | const { output, err, errorOutput } = await curl(['/query?three=3', '--two', '2']); 350 | 351 | expect(err).to.not.exist(); 352 | expect(errorOutput).to.equal(''); 353 | expect(ignoreNewlines(normalize(output))).to.equal('{ three: \'3\', two: 2 }'); 354 | }); 355 | 356 | it('can specify payload params as flags.', async () => { 357 | 358 | const { output, err, errorOutput } = await curl(['use-payload', '--isOne', '--two', '2']); 359 | 360 | expect(err).to.not.exist(); 361 | expect(errorOutput).to.equal(''); 362 | expect(ignoreNewlines(normalize(output))).to.equal('{ isOne: true, two: 2 }'); 363 | }); 364 | 365 | it('displays null response.', async () => { 366 | 367 | const { output, err, errorOutput } = await curl(['null-response']); 368 | 369 | expect(err).to.not.exist(); 370 | expect(errorOutput).to.equal(''); 371 | expect(ignoreNewlines(normalize(output))).to.equal('null'); 372 | }); 373 | 374 | it('can specify deep payload params as flags.', async () => { 375 | 376 | const { output, err, errorOutput } = await curl([ 377 | 'use-deep-payload', 378 | '--isOne', 379 | '--objOne-objTwo-isFour', 380 | '--objOne-objThree', '{"six":6}' 381 | ]); 382 | 383 | expect(err).to.not.exist(); 384 | expect(errorOutput).to.equal(''); 385 | expect(ignoreNewlines(normalize(output))).to.equal('{ isOne: true, objOne: { objTwo: { isFour: true }, objThree: { six: 6 } } }'); 386 | }); 387 | 388 | it('ignores validation when exists but is not a Joi schema.', async () => { 389 | 390 | const { output, err, errorOutput } = await curl(['use-no-joi-validation']); 391 | 392 | expect(err).to.not.exist(); 393 | expect(errorOutput).to.equal(''); 394 | expect(normalize(output)).to.equal('\'use-no-joi-validation-result\''); 395 | }); 396 | 397 | it('ignores validation when exists but is not a Joi schema.', async () => { 398 | 399 | const { output, err, errorOutput } = await curl(['use-non-obj-joi-validation']); 400 | 401 | expect(err).to.not.exist(); 402 | expect(errorOutput).to.equal(''); 403 | expect(normalize(output)).to.equal('\'use-non-obj-joi-validation-result\''); 404 | }); 405 | 406 | it('supports multiple CLI args for Joi arrays.', async () => { 407 | 408 | const { output, err, errorOutput } = await curl(['use-joi-array-validation', '--single', '1', '--single', '2', '--mixed', 'one', '--mixed', '2']); 409 | 410 | expect(err).to.not.exist(); 411 | expect(errorOutput).to.equal(''); 412 | expect(ignoreNewlines(normalize(output))).to.equal('{ single: [ 1, 2 ], mixed: [ \'one\', 2 ] }'); 413 | }); 414 | 415 | it('fails when specifying an invalid flag, shows params and descriptions in usage.', async () => { 416 | 417 | const { output, err, errorOutput } = await curl(['usage', '--badParam']); 418 | 419 | expect(err).to.exist(); 420 | expect(normalize(output)).to.equal(''); 421 | 422 | const matches = []; 423 | const regex = /--(\w+)\s+(.*)/g; 424 | 425 | let match; 426 | while ((match = regex.exec(errorOutput)) !== null) { 427 | const [, name, description] = match; 428 | matches.push({ name, description }); 429 | } 430 | 431 | const [one, two, three, help, header, data, verbose, raw, ...others] = matches; 432 | 433 | expect(others).to.have.length(0); 434 | expect(one).to.equal({ name: 'one', description: 'Route path param' }); 435 | expect(two).to.equal({ name: 'two', description: 'Route query param: Two things to know' }); 436 | expect(three).to.equal({ name: 'three', description: 'Route payload param' }); 437 | expect(help).to.contain({ name: 'help' }); 438 | expect(header).to.contain({ name: 'header' }); 439 | expect(data).to.contain({ name: 'data' }); 440 | expect(verbose).to.contain({ name: 'verbose' }); 441 | expect(raw).to.contain({ name: 'raw' }); 442 | }); 443 | 444 | it('can specify headers with -H, --header flag.', async () => { 445 | 446 | const { output, err, errorOutput } = await curl(['/headers', '-H', 'my-header: one', '-H', 'my-header: two', '--header', 'my-other-header:three', '--header', 'my-last-header']); 447 | 448 | expect(err).to.not.exist(); 449 | expect(errorOutput).to.equal(''); 450 | expect(ignoreNewlines(normalize(output))).to.contain('\'my-header\': [ \'one\', \'two\' ]'); 451 | expect(ignoreNewlines(normalize(output))).to.contain('\'my-other-header\': \'three\''); 452 | expect(ignoreNewlines(normalize(output))).to.contain('\'my-last-header\': \'\''); 453 | }); 454 | 455 | it('can specify payload with -d, --data flag.', async () => { 456 | 457 | const { 458 | output: output1, 459 | err: err1, 460 | errorOutput: errorOutput1 461 | } = await curl(['use-payload', '-d', '{"isOne":true,"two":2}']); 462 | 463 | expect(err1).to.not.exist(); 464 | expect(errorOutput1).to.equal(''); 465 | expect(ignoreNewlines(normalize(output1))).to.equal('{ isOne: true, two: 2 }'); 466 | 467 | const { 468 | output: output2, 469 | err: err2, 470 | errorOutput: errorOutput2 471 | } = await curl(['use-payload', '--data', '{"isOne":true,"two":2}']); 472 | 473 | expect(err2).to.not.exist(); 474 | expect(errorOutput2).to.equal(''); 475 | expect(ignoreNewlines(normalize(output2))).to.equal('{ isOne: true, two: 2 }'); 476 | }); 477 | 478 | it('can specify raw mode with -r, --raw flag.', async () => { 479 | 480 | const { 481 | output: output1, 482 | err: err1, 483 | errorOutput: errorOutput1 484 | } = await curl(['use-payload', '-r', '--isOne', '--two', '2']); 485 | 486 | expect(err1).to.not.exist(); 487 | expect(errorOutput1).to.equal(''); 488 | expect(normalize(output1)).to.equal('{"isOne":true,"two":2}'); 489 | 490 | const { 491 | output: output2, 492 | err: err2, 493 | errorOutput: errorOutput2 494 | } = await curl(['use-payload', '--raw', '--isOne', '--two', '2']); 495 | 496 | expect(err2).to.not.exist(); 497 | expect(errorOutput2).to.equal(''); 498 | expect(normalize(output2)).to.equal('{"isOne":true,"two":2}'); 499 | }); 500 | 501 | it('defaults to raw output when out is not a terminal.', async () => { 502 | 503 | const { 504 | output: output, 505 | err: err, 506 | errorOutput: errorOutput 507 | } = await curl(['use-payload', '--isOne', '--two', '2'], { 508 | isTTY: false 509 | }); 510 | 511 | expect(err).to.not.exist(); 512 | expect(errorOutput).to.equal(''); 513 | expect(normalize(output)).to.equal('{"isOne":true,"two":2}'); 514 | }); 515 | 516 | it('defaults to raw output when out is not a terminal with -v, --verbose flag.', async () => { 517 | 518 | const { 519 | output: output, 520 | err: err, 521 | errorOutput: errorOutput 522 | } = await curl(['use-payload', '-v', '--isOne', '--two', '2'], { 523 | isTTY: false 524 | }); 525 | 526 | // Ensuring lack of output columns is okay 527 | 528 | expect(err).to.not.exist(); 529 | expect(errorOutput).to.equal(''); 530 | validateVerboseOutput(normalize(output), ` 531 | post /payload (?ms) 532 | 533 | payload 534 | ─────────── 535 | {"isOne":true,"two":"2"} 536 | 537 | request headers 538 | ─────────── 539 | user-agent: shot 540 | host: hapipal:0 541 | content-type: application/json 542 | content-length: 24 543 | 544 | response headers 545 | ─────────── 546 | content-type: application/json; charset=utf-8 547 | cache-control: no-cache 548 | content-length: 22 549 | 550 | result (200 ok) 551 | ─────────── 552 | {"isOne":true,"two":2} 553 | `); 554 | }); 555 | 556 | it('can specify verbose mode with -v, --verbose flag (without payload).', async () => { 557 | 558 | const { 559 | output: output1, 560 | err: err1, 561 | errorOutput: errorOutput1 562 | } = await curl(['use-query', '-v', '--isOne', '--two', '2'], { columns: 60 }); 563 | 564 | expect(err1).to.not.exist(); 565 | expect(errorOutput1).to.equal(''); 566 | validateVerboseOutput(normalize(output1), ` 567 | get /query?isOne=true&two=2 (?ms) 568 | 569 | request headers 570 | ──────────────────────────────────────── 571 | user-agent shot 572 | host hapipal:0 573 | 574 | response headers 575 | ──────────────────────────────────────── 576 | content-type application/json; charset=utf-8 577 | cache-control no-cache 578 | content-length 22 579 | accept-ranges bytes 580 | 581 | result (200 ok) 582 | ──────────────────────────────────────── 583 | { isOne: true, two: 2 } 584 | `); 585 | 586 | const { 587 | output: output2, 588 | err: err2, 589 | errorOutput: errorOutput2 590 | } = await curl(['use-query', '--verbose', '--isOne', '--two', '2'], { columns: 60 }); 591 | 592 | expect(err2).to.not.exist(); 593 | expect(errorOutput2).to.equal(''); 594 | validateVerboseOutput(normalize(output2), ` 595 | get /query?isOne=true&two=2 (?ms) 596 | 597 | request headers 598 | ──────────────────────────────────────── 599 | user-agent shot 600 | host hapipal:0 601 | 602 | response headers 603 | ──────────────────────────────────────── 604 | content-type application/json; charset=utf-8 605 | cache-control no-cache 606 | content-length 22 607 | accept-ranges bytes 608 | 609 | result (200 ok) 610 | ──────────────────────────────────────── 611 | { isOne: true, two: 2 } 612 | `); 613 | }); 614 | 615 | it('can specify verbose mode with -v (with payload object).', async () => { 616 | 617 | const { output, err, errorOutput } = await curl(['use-payload', '-v', '-d', '{"isOne":true,"two":2}'], { columns: 60 }); 618 | 619 | expect(err).to.not.exist(); 620 | expect(errorOutput).to.equal(''); 621 | validateVerboseOutput(normalize(output), ` 622 | post /payload (?ms) 623 | 624 | payload 625 | ──────────────────────────────────────── 626 | isOne true 627 | two 2 628 | 629 | request headers 630 | ──────────────────────────────────────── 631 | user-agent shot 632 | host hapipal:0 633 | content-length 22 634 | 635 | response headers 636 | ──────────────────────────────────────── 637 | content-type application/json; charset=utf-8 638 | cache-control no-cache 639 | content-length 22 640 | 641 | result (200 ok) 642 | ──────────────────────────────────────── 643 | { isOne: true, two: 2 } 644 | `); 645 | }); 646 | 647 | it('can specify verbose mode with -v (with payload non-object).', async () => { 648 | 649 | const { output, err, errorOutput } = await curl(['use-non-obj-payload', '-v', '-d', 'some text', '-H', 'content-type: text/plain'], { columns: 60 }); 650 | 651 | expect(err).to.not.exist(); 652 | expect(errorOutput).to.equal(''); 653 | validateVerboseOutput(normalize(output), ` 654 | post /non-obj-payload (?ms) 655 | 656 | payload 657 | ──────────────────────────────────────── 658 | some text 659 | 660 | request headers 661 | ──────────────────────────────────────── 662 | content-type text/plain 663 | user-agent shot 664 | host hapipal:0 665 | content-length 9 666 | 667 | response headers 668 | ──────────────────────────────────────── 669 | content-type text/html; charset=utf-8 670 | cache-control no-cache 671 | content-length 9 672 | 673 | result (200 ok) 674 | ──────────────────────────────────────── 675 | some text 676 | `); 677 | }); 678 | 679 | it('can specify verbose mode with -v (with payload empty, non-object).', async () => { 680 | 681 | const { output, err, errorOutput } = await curl(['use-non-obj-payload', '-v', '-H', 'content-type: text/plain'], { columns: 60 }); 682 | 683 | expect(err).to.not.exist(); 684 | expect(errorOutput).to.equal(''); 685 | 686 | validateVerboseOutput(normalize(output), ` 687 | post /non-obj-payload (?ms) 688 | 689 | payload 690 | ──────────────────────────────────────── 691 | 692 | 693 | request headers 694 | ──────────────────────────────────────── 695 | content-type text/plain 696 | user-agent shot 697 | host hapipal:0 698 | 699 | response headers 700 | ──────────────────────────────────────── 701 | content-type text/html; charset=utf-8 702 | cache-control no-cache 703 | content-length 7 704 | 705 | result (200 ok) 706 | ──────────────────────────────────────── 707 | [empty] 708 | `); 709 | }); 710 | 711 | it('can specify raw, verbose mode with -rv (without payload).', async () => { 712 | 713 | const { output, err, errorOutput } = await curl(['use-query', '-v', '-r', '--isOne', '--two', '2'], { columns: 60 }); 714 | 715 | expect(err).to.not.exist(); 716 | expect(errorOutput).to.equal(''); 717 | validateVerboseOutput(normalize(output), ` 718 | get /query?isOne=true&two=2 (?ms) 719 | 720 | request headers 721 | ──────────────────────────────────────── 722 | user-agent: shot 723 | host: hapipal:0 724 | 725 | response headers 726 | ──────────────────────────────────────── 727 | content-type: application/json; charset=utf-8 728 | cache-control: no-cache 729 | content-length: 22 730 | accept-ranges: bytes 731 | 732 | result (200 ok) 733 | ──────────────────────────────────────── 734 | {"isOne":true,"two":2} 735 | `); 736 | }); 737 | 738 | it('can specify raw, verbose mode with -rv (with payload).', async () => { 739 | 740 | const { output, err, errorOutput } = await curl(['use-payload', '-v', '-r', '-d', '{"isOne":true,"two":2}'], { columns: 60 }); 741 | 742 | expect(err).to.not.exist(); 743 | expect(errorOutput).to.equal(''); 744 | validateVerboseOutput(normalize(output), ` 745 | post /payload (?ms) 746 | 747 | payload 748 | ──────────────────────────────────────── 749 | {"isOne":true,"two":2} 750 | 751 | request headers 752 | ──────────────────────────────────────── 753 | user-agent: shot 754 | host: hapipal:0 755 | content-length: 22 756 | 757 | response headers 758 | ──────────────────────────────────────── 759 | content-type: application/json; charset=utf-8 760 | cache-control: no-cache 761 | content-length: 22 762 | 763 | result (200 ok) 764 | ──────────────────────────────────────── 765 | {"isOne":true,"two":2} 766 | `); 767 | }); 768 | 769 | it('handles unknown status code in verbose mode.', async () => { 770 | 771 | const { output, err, errorOutput } = await curl(['unknown-status-code', '-v'], { columns: 60 }); 772 | 773 | expect(err).to.not.exist(); 774 | expect(errorOutput).to.equal(''); 775 | validateVerboseOutput(normalize(output), ` 776 | get /unknown-status-code (?ms) 777 | 778 | request headers 779 | ──────────────────────────────────────── 780 | user-agent shot 781 | host hapipal:0 782 | 783 | response headers 784 | ──────────────────────────────────────── 785 | content-type application/json; charset=utf-8 786 | cache-control no-cache 787 | content-length 18 788 | 789 | result (420 unknown) 790 | ──────────────────────────────────────── 791 | { unknown: 'code' } 792 | `); 793 | }); 794 | }); 795 | 796 | describe('routes command', () => { 797 | 798 | const normalize = (str, { multiline } = {}) => { 799 | 800 | if (multiline) { 801 | return str.trim().split('\n').map((s) => s.trim()).join('\n'); 802 | } 803 | 804 | return str.trim(); 805 | }; 806 | 807 | const unindent = (str) => { 808 | 809 | const lines = str.split('\n'); 810 | const [indent] = lines[1].match(/^\s*/); 811 | const indentRegex = new RegExp(`^${indent}`); 812 | 813 | str = lines.map((line) => line.replace(indentRegex, '')).join('\n'); 814 | str = str.trim(); 815 | 816 | return str; 817 | }; 818 | 819 | const routes = (args, dir, opts) => RunUtil.cli(['run', 'debug:routes', ...args], `routes/${dir}`, opts); 820 | 821 | it('outputs help [-h, --help].', async () => { 822 | 823 | const { output: output1, err: err1, errorOutput: errorOutput1 } = await routes(['-h'], 'main'); 824 | 825 | expect(err1).to.not.exist(); 826 | expect(errorOutput1).to.equal(''); 827 | expect(normalize(output1)).to.contain('Usage: hpal run debug:routes [options]'); 828 | 829 | const { output: output2, err: err2, errorOutput: errorOutput2 } = await routes(['--help'], 'main'); 830 | 831 | expect(err2).to.not.exist(); 832 | expect(errorOutput2).to.equal(''); 833 | expect(normalize(output2)).to.contain('Usage: hpal run debug:routes [options]'); 834 | }); 835 | 836 | it('fails when specifying an invalid flag.', async () => { 837 | 838 | const { output, err, errorOutput } = await routes(['--badFlag'], 'main'); 839 | 840 | expect(err).to.exist(); 841 | expect(errorOutput).to.contain('Usage: hpal run debug:routes [options]'); 842 | expect(errorOutput).to.contain('Unknown option: badFlag'); 843 | expect(normalize(output)).to.equal(''); 844 | }); 845 | 846 | it('errors when route is not found by id.', async () => { 847 | 848 | const { output, err, errorOutput } = await routes(['does-not-exist'], 'main'); 849 | 850 | expect(err).to.exist(); 851 | expect(errorOutput).to.equal('Route "does-not-exist" not found'); 852 | expect(normalize(output)).to.equal(''); 853 | }); 854 | 855 | it('errors when route is not found by method and path.', async () => { 856 | 857 | const { output, err, errorOutput } = await routes(['post', '/does-not-exist'], 'main'); 858 | 859 | expect(err).to.exist(); 860 | expect(errorOutput).to.equal('Route "post /does-not-exist" not found'); 861 | expect(normalize(output)).to.equal(''); 862 | }); 863 | 864 | it('outputs default display columns, with less content than space.', async () => { 865 | 866 | const { output, err, errorOutput } = await routes([], 'main', { columns: 100 }); 867 | 868 | expect(err).to.not.exist(); 869 | expect(errorOutput).to.equal(''); 870 | expect(normalize(output)).to.equal(unindent(` 871 | ┌────────┬────────────┬───────────┬───────────┬────────────────────────────┐ 872 | │ method │ path │ id │ plugin │ description │ 873 | ├────────┼────────────┼───────────┼───────────┼────────────────────────────┤ 874 | │ get │ /empty │ │ (root) │ │ 875 | ├────────┼────────────┼───────────┼───────────┼────────────────────────────┤ 876 | │ patch │ /longhand │ longhand │ my-plugin │ Instead, a longhand config │ 877 | ├────────┼────────────┼───────────┼───────────┼────────────────────────────┤ 878 | │ put │ /shorthand │ shorthand │ my-plugin │ Shorthand config │ 879 | └────────┴────────────┴───────────┴───────────┴────────────────────────────┘ 880 | `)); 881 | }); 882 | 883 | it('outputs default display columns, with more content than space.', async () => { 884 | 885 | const { output, err, errorOutput } = await routes([], 'main', { columns: 60 }); 886 | 887 | expect(err).to.not.exist(); 888 | expect(errorOutput).to.equal(''); 889 | expect(normalize(output)).to.equal(unindent(` 890 | ┌────────┬────────────┬───────────┬───────────┬─────────────┐ 891 | │ method │ path │ id │ plugin │ description │ 892 | ├────────┼────────────┼───────────┼───────────┼─────────────┤ 893 | │ get │ /empty │ │ (root) │ │ 894 | ├────────┼────────────┼───────────┼───────────┼─────────────┤ 895 | │ patch │ /longhand │ longhand │ my-plugin │ Instead, a… │ 896 | ├────────┼────────────┼───────────┼───────────┼─────────────┤ 897 | │ put │ /shorthand │ shorthand │ my-plugin │ Shorthand … │ 898 | └────────┴────────────┴───────────┴───────────┴─────────────┘ 899 | `)); 900 | }); 901 | 902 | it('outputs info for a single route by its id.', async () => { 903 | 904 | const { output, err, errorOutput } = await routes(['shorthand'], 'main', { columns: 100 }); 905 | 906 | expect(err).to.not.exist(); 907 | expect(errorOutput).to.equal(''); 908 | expect(normalize(output)).to.equal(unindent(` 909 | ┌────────┬────────────┬───────────┬───────────┬──────────────────┐ 910 | │ method │ path │ id │ plugin │ description │ 911 | ├────────┼────────────┼───────────┼───────────┼──────────────────┤ 912 | │ put │ /shorthand │ shorthand │ my-plugin │ Shorthand config │ 913 | └────────┴────────────┴───────────┴───────────┴──────────────────┘ 914 | `)); 915 | }); 916 | 917 | it('outputs info for a single route by its method and path.', async () => { 918 | 919 | const { output, err, errorOutput } = await routes(['put', '/shorthand'], 'main', { columns: 100 }); 920 | 921 | expect(err).to.not.exist(); 922 | expect(errorOutput).to.equal(''); 923 | expect(normalize(output)).to.equal(unindent(` 924 | ┌────────┬────────────┬───────────┬───────────┬──────────────────┐ 925 | │ method │ path │ id │ plugin │ description │ 926 | ├────────┼────────────┼───────────┼───────────┼──────────────────┤ 927 | │ put │ /shorthand │ shorthand │ my-plugin │ Shorthand config │ 928 | └────────┴────────────┴───────────┴───────────┴──────────────────┘ 929 | `)); 930 | }); 931 | 932 | it('outputs info for a single route by its path, defaulting method to "get".', async () => { 933 | 934 | const { output, err, errorOutput } = await routes(['/empty'], 'main', { columns: 100 }); 935 | 936 | expect(err).to.not.exist(); 937 | expect(errorOutput).to.equal(''); 938 | expect(normalize(output)).to.equal(unindent(` 939 | ┌────────┬────────┬────┬────────┬─────────────┐ 940 | │ method │ path │ id │ plugin │ description │ 941 | ├────────┼────────┼────┼────────┼─────────────┤ 942 | │ get │ /empty │ │ (root) │ │ 943 | └────────┴────────┴────┴────────┴─────────────┘ 944 | `)); 945 | }); 946 | 947 | it('can show and hide columns with [-s, --show] and [-H, --hide].', async () => { 948 | 949 | const args = [ 950 | '-H', 'method', 951 | '-H', 'path', 952 | '--hide', 'plugin', 953 | '--hide', 'description', 954 | '-s', 'vhost', 955 | '-s', 'auth', 956 | '--show', 'cors', 957 | '--show', 'tags' 958 | ]; 959 | 960 | const { output, err, errorOutput } = await routes(args, 'main', { columns: 100 }); 961 | 962 | expect(err).to.not.exist(); 963 | expect(errorOutput).to.equal(''); 964 | expect(normalize(output)).to.equal(unindent(` 965 | ┌───────────┬─────────────┬─────────────────┬─────────────────┬──────────────┐ 966 | │ id │ vhost │ auth │ cors │ tags │ 967 | ├───────────┼─────────────┼─────────────────┼─────────────────┼──────────────┤ 968 | │ │ │ (none) │ (off) │ │ 969 | ├───────────┼─────────────┼─────────────────┼─────────────────┼──────────────┤ 970 | │ longhand │ hapipal.com │ (try) │ hapipal.com │ my-tag │ 971 | │ │ │ first-strategy │ www.hapipal.com │ my-other-tag │ 972 | │ │ │ second-strategy │ │ │ 973 | ├───────────┼─────────────┼─────────────────┼─────────────────┼──────────────┤ 974 | │ shorthand │ │ first-strategy │ * │ my-tag │ 975 | └───────────┴─────────────┴─────────────────┴─────────────────┴──────────────┘ 976 | `)); 977 | }); 978 | 979 | it('can specify raw mode with -r, --raw flag.', async () => { 980 | 981 | const args = [ 982 | '--raw', 983 | '--show', 'auth' 984 | ]; 985 | 986 | const { output, err, errorOutput } = await routes(args, 'main'); 987 | 988 | expect(err).to.not.exist(); 989 | expect(errorOutput).to.equal(''); 990 | expect(normalize(output.replace(/\t/g, ' '), { multiline: true })).to.equal(unindent(` 991 | method path id plugin auth description 992 | get /empty (root) (none) 993 | patch /longhand longhand my-plugin (try) first-strategy, second-strategy Instead, a longhand config 994 | put /shorthand shorthand my-plugin first-strategy Shorthand config 995 | `)); 996 | }); 997 | 998 | it('defaults to raw output when out is not a terminal.', async () => { 999 | 1000 | const args = ['--show', 'auth']; 1001 | 1002 | const { output, err, errorOutput } = await routes(args, 'main', { isTTY: false }); 1003 | 1004 | expect(err).to.not.exist(); 1005 | expect(errorOutput).to.equal(''); 1006 | expect(normalize(output.replace(/\t/g, ' '), { multiline: true })).to.equal(unindent(` 1007 | method path id plugin auth description 1008 | get /empty (root) (none) 1009 | patch /longhand longhand my-plugin (try) first-strategy, second-strategy Instead, a longhand config 1010 | put /shorthand shorthand my-plugin first-strategy Shorthand config 1011 | `)); 1012 | }); 1013 | 1014 | it('displays cors "ignore" setting correctly.', async () => { 1015 | 1016 | const { output, err, errorOutput } = await routes(['-s', 'cors', '-H', 'description'], 'cors-ignore', { columns: 60 }); 1017 | 1018 | expect(err).to.not.exist(); 1019 | expect(errorOutput).to.equal(''); 1020 | expect(normalize(output)).to.equal(unindent(` 1021 | ┌────────┬──────────────┬────┬────────┬──────────┐ 1022 | │ method │ path │ id │ plugin │ cors │ 1023 | ├────────┼──────────────┼────┼────────┼──────────┤ 1024 | │ post │ /cors-ignore │ │ (root) │ (ignore) │ 1025 | └────────┴──────────────┴────┴────────┴──────────┘ 1026 | `)); 1027 | }); 1028 | 1029 | it('displays routes grouped by plugin.', async () => { 1030 | 1031 | const { output, err, errorOutput } = await routes([], 'plugin-groups', { columns: 100 }); 1032 | 1033 | expect(err).to.not.exist(); 1034 | expect(errorOutput).to.equal(''); 1035 | expect(normalize(output)).to.equal(unindent(` 1036 | ┌────────┬────────┬────┬─────────────┬─────────────┐ 1037 | │ method │ path │ id │ plugin │ description │ 1038 | ├────────┼────────┼────┼─────────────┼─────────────┤ 1039 | │ get │ /one │ │ (root) │ │ 1040 | ├────────┼────────┼────┼─────────────┼─────────────┤ 1041 | │ post │ /two │ │ (root) │ │ 1042 | ├────────┼────────┼────┼─────────────┼─────────────┤ 1043 | │ get │ /one-a │ │ my-plugin-a │ │ 1044 | ├────────┼────────┼────┼─────────────┼─────────────┤ 1045 | │ post │ /two-a │ │ my-plugin-a │ │ 1046 | ├────────┼────────┼────┼─────────────┼─────────────┤ 1047 | │ get │ /one-b │ │ my-plugin-b │ │ 1048 | ├────────┼────────┼────┼─────────────┼─────────────┤ 1049 | │ post │ /two-b │ │ my-plugin-b │ │ 1050 | └────────┴────────┴────┴─────────────┴─────────────┘ 1051 | `)); 1052 | }); 1053 | 1054 | it('displays table in color when supported.', async () => { 1055 | 1056 | const { output, err, errorOutput } = await routes(['get', '/empty'], 'main', { colors: true, columns: 100 }); 1057 | 1058 | expect(err).to.not.exist(); 1059 | expect(errorOutput).to.equal(''); 1060 | expect(normalize(output)).to.not.equal(unindent(` 1061 | ┌────────┬────────┬────┬────────┬─────────────┐ 1062 | │ method │ path │ id │ plugin │ description │ 1063 | ├────────┼────────┼────┼────────┼─────────────┤ 1064 | │ get │ /empty │ │ (root) │ │ 1065 | └────────┴────────┴────┴────────┴─────────────┘ 1066 | `)); 1067 | expect(normalize(StripAnsi(output))).to.equal(unindent(` 1068 | ┌────────┬────────┬────┬────────┬─────────────┐ 1069 | │ method │ path │ id │ plugin │ description │ 1070 | ├────────┼────────┼────┼────────┼─────────────┤ 1071 | │ get │ /empty │ │ (root) │ │ 1072 | └────────┴────────┴────┴────────┴─────────────┘ 1073 | `)); 1074 | }); 1075 | }); 1076 | }); 1077 | -------------------------------------------------------------------------------- /test/run-util.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Path = require('path'); 4 | const Stream = require('stream'); 5 | const Joi = require('joi'); 6 | const Hpal = require('@hapipal/hpal'); 7 | const DisplayError = require('@hapipal/hpal/lib/display-error'); 8 | 9 | exports.Joi = Joi.extend({ 10 | type: 'object', 11 | base: Joi.object(), 12 | coerce: { 13 | from: 'string', 14 | method(value) { 15 | 16 | if (value[0] !== '{' && 17 | !/^\s*\{/.test(value)) { 18 | return; 19 | } 20 | 21 | try { 22 | return { value: JSON.parse(value) }; 23 | } 24 | catch (ignoreErr) {} 25 | } 26 | } 27 | }); 28 | 29 | exports.cli = (argv, cwd, { colors, columns, isTTY = true, env = {} } = {}) => { 30 | 31 | argv = ['x', 'x'].concat(argv); // [node, script, ...args] 32 | cwd = cwd ? (Path.isAbsolute(cwd) ? cwd : `${__dirname}/closet/${cwd}`) : __dirname; 33 | 34 | const stdin = new Stream.PassThrough(); 35 | const stdout = new Stream.PassThrough(); 36 | const stderr = new Stream.PassThrough(); 37 | 38 | let output = ''; 39 | 40 | stdout.columns = columns; 41 | stdout.isTTY = isTTY; 42 | stdout.on('data', (data) => { 43 | 44 | output += data; 45 | }); 46 | 47 | let errorOutput = ''; 48 | 49 | stderr.on('data', (data) => { 50 | 51 | errorOutput += data; 52 | }); 53 | 54 | const options = { 55 | argv, 56 | env, 57 | cwd, 58 | in: stdin, 59 | out: stdout, 60 | err: stderr, 61 | colors: !!colors 62 | }; 63 | 64 | const cli = Promise.resolve() 65 | .then(() => Hpal.start(options)) 66 | .then(() => ({ err: null, output, errorOutput, options })) 67 | .catch((err) => { 68 | 69 | if (!(err instanceof DisplayError)) { 70 | err.output = output; 71 | err.options = options; 72 | throw err; 73 | } 74 | 75 | return { err, output, errorOutput: err.message, options }; 76 | }); 77 | 78 | return Object.assign(cli, { options }); 79 | }; 80 | --------------------------------------------------------------------------------