diff --git a/README.md b/README.md index df57a7b0..ba4e9729 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ Name | Description `style` | LESS or CSS to control the layout and style of the document using the variables from below. Can be a path to your own file or one of the following presets: `default`. May be an array of paths and/or presets. `template` | Jade template to render HTML. Can be a path to your own file or one of the following presets: `default`. `variables` | LESS variables that control theme colors, fonts, and spacing. Can be a path to your own file or one of the following presets: `default`, `flatly`, `slate`, `cyborg`. May be an array of paths and/or presets. +`forms` | Whether to generate form fields for trying out the API (default is `false`). +`forms-base-uri` | The base URI to prepend to relative action URIs when trying out the API. Default is `auto`, meaning the full URI is calculated from the HOST keyword in the API Blueprint document or when that isn't present, from the host serving the documentation. **Note**: When using this theme programmatically, these options are cased like you would expect in Javascript: `--theme-full-width` becomes `options.themeFullWidth`. diff --git a/src/main.coffee b/src/main.coffee index e2c0bf04..00a164c6 100644 --- a/src/main.coffee +++ b/src/main.coffee @@ -317,11 +317,35 @@ modifyUriTemplate = (templateUri, parameters, colorize) -> uri , []).join('').replace(/\/+/g, '/') -decorate = (api, md, slugCache, verbose) -> +makeFullUriTemplate = (uriTemplate, baseUri) -> + # Change comma-separated params into individual params + while result = /\{([#&\?\+]?)([^,\}]+),/g.exec uriTemplate + operator = (if result[1] is '?' then '&' else result[1]) + uriTemplate = uriTemplate.substring(0, result.index) + + '{' + + result[1] + + result[2] + + '}{' + + operator + + uriTemplate.substring(result.index + result[0].length) + + # Prepend the baseUri if needed + if baseUri and uriTemplate.indexOf('://') is -1 + if baseUri.charAt(baseUri.length-1) is '/' + baseUri = baseUri.substring 0, baseUri.length-1 + if uriTemplate.charAt(0) isnt '/' + uriTemplate = '/' + uriTemplate + uriTemplate = baseUri + uriTemplate + + return uriTemplate + +decorate = (api, md, slugCache, options) -> # Decorate an API Blueprint AST with various pieces of information that # will be useful for the theme. Anything that would significantly # complicate the Jade template should probably live here instead! + verbose = options.verbose + # Use the slug caching mechanism slugify = slug.bind slug, slugCache @@ -394,23 +418,36 @@ decorate = (api, md, slugCache, verbose) -> action.parameters = newParams.reverse() + uriTemplate = (action.attributes or {}).uriTemplate or + resource.uriTemplate or '' + + for parameter in action.parameters + regex = new RegExp( + "\\{\\*#{parameter.name}\\}|\\{[#&\\?\\+]?#{parameter.name}\\*\\}") + parameter.explode = regex.test uriTemplate + # Set up the action's template URI - action.uriTemplate = modifyUriTemplate( - (action.attributes or {}).uriTemplate or resource.uriTemplate or '', - action.parameters) + action.uriTemplate = modifyUriTemplate uriTemplate, action.parameters - action.colorizedUriTemplate = modifyUriTemplate( - (action.attributes or {}).uriTemplate or resource.uriTemplate or '', + action.colorizedUriTemplate = modifyUriTemplate(uriTemplate, action.parameters, true) + host = options.themeFormsBaseUri + if host is 'auto' + host = api.host + action.fullUriTemplate = makeFullUriTemplate action.uriTemplate, host + # Examples have a content section only if they have a # description, headers, body, or schema. - action.hasRequest = false + action.requestCount = 0 + action.hasBody = action.method == 'PUT' or action.method == 'POST' for example in action.examples or [] for name in ['requests', 'responses'] for item in example[name] or [] - if name is 'requests' and not action.hasRequest - action.hasRequest = true + if name is 'requests' + ++action.requestCount + if not action.hasBody and item.body + action.hasBody = true # If there is no schema, but there are MSON attributes, then try # to generate the schema. This will fail sometimes. @@ -475,6 +512,12 @@ exports.getConfig = -> description: 'Layout style name or path to custom stylesheet'}, {name: 'emoji', description: 'Enable support for emoticons', boolean: true, default: true} + {name: 'forms', + description: 'Generate form fields for trying out the API', + boolean: true, default: false}, + {name: 'forms-base-uri', + description: 'A base URI to use for trying out the API', + default: 'auto'} ] # Render the blueprint with the given options using Jade and LESS @@ -496,6 +539,8 @@ exports.render = (input, options, done) -> options.themeTemplate ?= 'default' options.themeCondenseNav ?= true options.themeFullWidth ?= false + options.themeForms ?= false + options.themeFormsBaseUri ?= 'auto' # Transform built-in layout names to paths if options.themeTemplate is 'default' @@ -527,7 +572,7 @@ exports.render = (input, options, done) -> md.renderer.rules.code_block = md.renderer.rules.fence benchmark.start 'decorate' - decorate input, md, slugCache, options.verbose + decorate input, md, slugCache, options benchmark.end 'decorate' benchmark.start 'css-total' @@ -541,6 +586,7 @@ exports.render = (input, options, done) -> condenseNav: options.themeCondenseNav css: css fullWidth: options.themeFullWidth + forms: options.themeForms date: moment hash: (value) -> crypto.createHash('md5').update(value.toString()).digest('hex') diff --git a/styles/layout-default.less b/styles/layout-default.less index 1d957a70..0f6093d9 100644 --- a/styles/layout-default.less +++ b/styles/layout-default.less @@ -121,6 +121,7 @@ pre { border: 1px solid @code-block-border-color; border-radius: @border-radius; overflow: auto; + max-height: 50em; code { color: @code-block-text-color; @@ -139,6 +140,34 @@ code { border-radius: 3px; } +input.parameter, select.parameter, select.request { + width: 200px; +} + +textarea.body { + width: 300px; + height: 100px; +} + +button.tryit { + padding: 1px 4px; + font: inherit; + margin: 12px 0 12px 150px; +} + +.spinner { + margin-left: 4px; + display: none; +} + +.response { + display: none; +} + +.click-to-fill { + cursor: pointer; +} + ul, ol { padding-left: 2em; } @@ -389,6 +418,10 @@ nav { max-height: 0; overflow: hidden; transition: max-height 0.3s ease-in-out; + + dd { + overflow: auto; + } } /* Layout classes */ diff --git a/templates/index.jade b/templates/index.jade index b6c2fe9f..ea303ef0 100644 --- a/templates/index.jade +++ b/templates/index.jade @@ -5,6 +5,7 @@ include mixins.jade html head meta(charset="utf-8") + meta(http-equiv="X-UA-Compatible", content="IE=edge") title= self.api.name || 'API Documentation' link(rel="stylesheet", href="https://maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css") style!= self.css diff --git a/templates/mixins.jade b/templates/mixins.jade index 888811a9..7b551679 100644 --- a/templates/mixins.jade +++ b/templates/mixins.jade @@ -63,7 +63,7 @@ mixin Nav() p(style="text-align: center; word-wrap: break-word;") a(href=meta.value)= meta.value -mixin Parameters(params) +mixin Parameters(action) //- Draw a definition list of parameter names, types, defaults, //- examples and descriptions. .title @@ -72,9 +72,11 @@ mixin Parameters(params) span.close Hide span.open Show .collapse-content - dl.inner: each param in params || [] + dl.inner: each param in action.parameters || [] dt= self.urldec(param.name) dd + if self.forms + +ParameterField(param, action) code= param.type || 'string' |   if param.required @@ -90,16 +92,124 @@ mixin Parameters(params) if param.example span.text-muted.example strong Example:  - span= param.example + span( + class={'click-to-fill': self.forms} + data-fill-target=self.urldec(action.elementId + '-' + param.name) + ) + = param.example != self.markdown(param.description) if param.values.length p.choices strong Choices:  each value in param.values - code= self.urldec(value.value) + code( + class={'click-to-fill': self.forms} + data-fill-target=self.urldec(action.elementId + '-' + param.name) + ) + = self.urldec(value.value) = ' ' -mixin RequestResponse(title, request, collapse) +mixin ParameterField(param, action) + //- Generate an input field for the parameter to use + - var values = param.type === 'boolean' ? [{value: 'true'}, {value: 'false'}] : param.values + - values = (values && values.length) ? values : undefined + if values + select.parameter( + name=self.urldec(param.name) + id=action.elementId + '-' + param.name + required=param.required + multiple=param.explode + ) + unless param.required + option + each option in values || [] + option( + value=option.value + selected=option.value === param.default + ) + = option.value + else + - var type = param.type === 'number' ? 'number' : 'text' + - var value = param.required ? param.default || param.example || '' : '' + input.parameter( + type=type + name=self.urldec(param.name) + id=action.elementId + '-' + param.name + value=value + required=param.required + ) + +mixin FormFields(action) + input( + type='hidden' + name='__method' + value=action.method + ) + input( + type='hidden' + name='__uri' + value=action.fullUriTemplate + ) + - var body + each example in action.examples + each request, index in example.requests + - body = body || request.body + each item in request.headers + input( + type='hidden' + name='__' + index + '-header_' + item.name + value=item.value + ) + +RequestIndexField(action) + if action.hasBody + dl.inner + dt Body + dd + textarea.body( + name='__body' + id=action.elementId + '-__body' + )= body + +mixin RequestIndexField(action) + if action.requestCount <= 1 + input( + type='hidden' + name='__request' + value='0' + ) + else + - var requestIndex = 0 + dl.inner + dt Request + dd + select.request(name='__request') + each example in action.examples + each request in example.requests + option( + value=requestIndex + )= request.name || request.description || 'Request ' + (requestIndex + 1) + - requestIndex++ + +mixin FormAction + button.tryit(name='__tryit', type='button') Try it out! + span.spinner: i.fa.fa-spinner.fa-pulse + + .response.title + strong Server Response + .collapse-button.show + span.close Hide + span.open Show + .response.collapse-content + .inner + +FormResponseSection('Request URI', 'response-request-uri') + +FormResponseSection('Response Body', 'response-body') + +FormResponseSection('Response Headers', 'response-headers') + +mixin FormResponseSection(title, cls) + h5= title + pre: code(class=cls) + +mixin RequestResponse(title, action, request, collapse, clickToFill) .title strong = title @@ -110,9 +220,9 @@ mixin RequestResponse(title, request, collapse) .collapse-button span.close Hide span.open Show - +RequestResponseBody(request, collapse) + +RequestResponseBody(action, request, collapse, clickToFill) -mixin RequestResponseBody(request, collapse, showBlank) +mixin RequestResponseBody(action, request, collapse, clickToFill, showBlank) if request.hasContent || showBlank div(class=collapse ? 'collapse-content' : ''): .inner if request.description @@ -128,8 +238,12 @@ mixin RequestResponseBody(request, collapse, showBlank) div(style="height: 1px;") if request.body h5 Body - pre: code - != self.highlight(request.body, null, ['json', 'yaml', 'xml', 'javascript']) + pre( + class={'click-to-fill': self.forms && clickToFill} + data-fill-target=action.elementId + '-__body' + ) + code + != self.highlight(request.body, null, ['json', 'yaml', 'xml', 'javascript']) div(style="height: 1px;") if request.schema h5 Schema @@ -143,9 +257,9 @@ mixin RequestResponseBody(request, collapse, showBlank) mixin Examples(resourceGroup, resource, action) each example in action.examples each request in example.requests - +RequestResponse('Request', request, true) + +RequestResponse('Request', action, request, true, true) each response in example.responses - +RequestResponse('Response', response, true) + +RequestResponse('Response', action, response, true) mixin Content() //- Page header and API description @@ -195,10 +309,15 @@ mixin Content() //- A list of sub-sections for parameters, requests //- and responses. - if action.parameters.length - +Parameters(action.parameters) - if action.examples - +Examples(resourceGroup, resource, action) + form(accept-charset='UTF-8') + if action.parameters.length + +Parameters(action) + if self.forms + +FormFields(action) + if self.forms + +FormAction + if action.examples + +Examples(resourceGroup, resource, action) mixin ContentTriple() .middle @@ -245,7 +364,7 @@ mixin ContentTriple() span.hostname= self.api.host != action.colorizedUriTemplate .tabs - if action.hasRequest + if action.requestCount > 0 .example-names span Requests - var requestCount = 0 @@ -256,7 +375,7 @@ mixin ContentTriple() each example in action.examples each request in example.requests .tab - +RequestResponseBody(request, false, true) + +RequestResponseBody(action, request, false, true, true) .tabs .example-names span Responses @@ -264,7 +383,7 @@ mixin ContentTriple() span.tab-button= response.name each response in example.responses .tab - +RequestResponseBody(response, false, true) + +RequestResponseBody(action, response, false, false, true) else each example in action.examples .tabs @@ -274,7 +393,7 @@ mixin ContentTriple() span.tab-button= response.name each response in example.responses .tab - +RequestResponseBody(response, false, true) + +RequestResponseBody(action, response, false, false, true) .middle .action(class=action.methodLower, id=action.elementId) h4.action-heading @@ -287,7 +406,11 @@ mixin ContentTriple() //- A list of sub-sections for parameters, requests //- and responses. - if action.parameters.length - +Parameters(action.parameters) + form(accept-charset='UTF-8') + if action.parameters.length + +Parameters(action) + if self.forms + +FormFields(action) + +FormAction hr.split diff --git a/templates/scripts.js b/templates/scripts.js index d105df6c..c320fef8 100644 --- a/templates/scripts.js +++ b/templates/scripts.js @@ -2,6 +2,13 @@ /* eslint quotes: [2, "single"] */ 'use strict'; +/* + Determine if a string starts with another string. +*/ +function startsWith(str, prefix) { + return str.indexOf(prefix) === 0; +} + /* Determine if a string ends with another string. */ @@ -117,6 +124,177 @@ function toggleCollapseNav(event, force) { } } +/* + Get a value for the given field and operator. +*/ +function createFieldValue(field, operator, explosive) { + if (operator === '*') { + explosive = true; + operator = false; + } + var fieldValue, values, i; + if (explosive) { + fieldValue = ''; + if (field.tagName.toLowerCase() === 'select' && field.options) { + values = []; + for (i = 0; i < field.options.length; i++) { + var option = field.options[i]; + if (option.selected) { + values.push(option.value || option.text); + } + } + } else { + values = field.value.split(/\s*,\s*/g); + } + for (i = 0; i < values.length; i++) { + fieldValue += createFieldValue({name: field.name, value: values[i]}, operator); + if (operator === '?') { + operator = '&'; + } + } + return fieldValue; + } + + fieldValue = encodeURIComponent(field.value); + if (operator && (fieldValue || field.required)) { + if (operator === '?' || operator === '&') { + fieldValue = operator + field.name + '=' + fieldValue; + } else if (operator === '#') { + fieldValue = '#' + fieldValue; + } else if (operator === '+') { + fieldValue = field.value; + } + } + return fieldValue; +} + +/* + Send a sandbox request to the server. +*/ +function sendSandboxRequest(event) { + var i, body, regex, regexResult, fieldValue; + var button = event.target; + var fields = button.form.elements; + var method = fields.__method.value; + var uri = fields.__uri.value; + var headerPrefix = '__' + fields.__request.value + '-header_'; + var headers = []; + var xhr = new XMLHttpRequest(); + xhr.withCredentials = true; + + if (uri.indexOf('://') === -1 && window.location.hostname) { + if (uri.charAt(0) !== '/') { + uri = '/' + uri; + } + if (window.location.port) { + uri = window.location.protocol + '//' + window.location.hostname + ':' + window.location.port + uri; + } else { + uri = window.location.protocol + '//' + window.location.hostname + uri; + } + } + + if (fields.__body) { + body = fields.__body.value; + } + + for (i = 0; i < fields.length; i++) { + if (startsWith(fields[i].name, '__')) { + if (startsWith(fields[i].name, headerPrefix)) { + headers.push({ + name: fields[i].name.substring(headerPrefix.length), + value: fields[i].value + }); + } + } else { + regex = new RegExp('\\{([#\\+\\?&]?)' + fields[i].name + '(\\*?)\\}'); + regexResult = regex.exec(uri); + if (!regexResult) { + continue; + } + var operator = regexResult[1]; + if (operator === '&' && uri.indexOf('?') === -1) { + operator = '?'; + } + fieldValue = createFieldValue(fields[i], operator, regexResult[2]); + uri = uri.substring(0, regexResult.index) + fieldValue + uri.substring(regexResult.index + regexResult[0].length); + } + } + button.form.querySelector('.response-request-uri').textContent = uri; + + xhr.onload = handleSandboxResponse.bind(null, xhr, button); + xhr.onerror = xhr.onload; + xhr.open(method, uri); + for (i = 0; i < headers.length; i++) { + xhr.setRequestHeader(headers[i].name, headers[i].value); + } + if (!window.__jwt && storageAvailable('sessionStorage')) { + window.__jwt = sessionStorage.getItem('__jwt'); + } + if (window.__jwt) { + xhr.setRequestHeader('Authorization', 'Bearer ' + window.__jwt); + } + + button.querySelector('.spinner').style.display = 'inline-block'; + xhr.send(body); +} + +/* + Handle a sandbox response from the server, showing its output. +*/ +function handleSandboxResponse(xhr, button) { + var content = button.form.querySelector('.response.collapse-content'); + var inner = content.children[0]; + var responseBody = button.form.querySelector('.response-body'); + var responseHeaders = button.form.querySelector('.response-headers'); + + button.querySelector('.spinner').style.display = 'none'; + button.form.querySelector('.response.title').style.display = 'block'; + content.style.display = 'block'; + + try { + responseBody.textContent = JSON.stringify(JSON.parse(xhr.responseText), null, 2); + } catch (e) { + responseBody.textContent = xhr.responseText; + } + responseHeaders.textContent = xhr.getAllResponseHeaders(); + window.__jwt = xhr.getResponseHeader('X-Set-JWT') || window.__jwt; + if (window.__jwt && storageAvailable('sessionStorage')) { + sessionStorage.setItem('__jwt', window.__jwt); + } + + content.style.maxHeight = inner.offsetHeight + 12 + 'px'; +} + +/* + Detect whether web storage is available. +*/ +function storageAvailable(type) { + try { + var storage = window[type], + x = '__storage_test__'; + storage.setItem(x, x); + storage.removeItem(x); + return true; + } + catch(e) { + return false; + } +} + +/* + Fill a field with the content of a text element. +*/ +function fillField(fieldName, event) { + var element = event.target; + while (element && !element.dataset.fillTarget) { + element = element.parentNode; + } + var field = document.getElementById(fieldName); + if (field && element) { + field.value = element.textContent; + } +} + /* Refresh the page after a live update from the server. This only works in live preview mode (using the `--server` parameter). @@ -211,6 +389,17 @@ function init() { // Show all by default toggleCollapseNav({target: navItems[i].children[0]}); } + + // Set up the sandbox functionality + var tryitButtons = document.querySelectorAll('button.tryit'); + for (i = 0; i < tryitButtons.length; i++) { + tryitButtons[i].onclick = sendSandboxRequest; + } + var clickToFills = document.querySelectorAll('.click-to-fill'); + for (i = 0; i < clickToFills.length; i++) { + clickToFills[i].title = clickToFills[i].title || 'Click to fill the field with this value'; + clickToFills[i].onclick = fillField.bind(null, clickToFills[i].dataset.fillTarget); + } } // Initial call to set up buttons diff --git a/test/forms.coffee b/test/forms.coffee new file mode 100644 index 00000000..5c9fc8b9 --- /dev/null +++ b/test/forms.coffee @@ -0,0 +1,372 @@ +{assert} = require 'chai' +theme = require '../lib/main' + +describe 'Forms', -> + it 'Should not generate form fields when disabled', (done) -> + + theme.render ast, {themeForms: false}, (err, html) -> + if err then return done err + + assert.notInclude html, ' + + theme.render ast, {themeForms: true, themeFormsBaseUri: 'http://example.com/rest/'}, (err, html) -> + if err then return done err + assert.include html, '' + done() + + it 'Should use the HOST metadata when there is no formsBaseUri option', (done) -> + + theme.render ast, {themeForms: true}, (err, html) -> + if err then return done err + assert.include html, '' + done() + + it 'Should generate form fields', (done) -> + + theme.render ast, {themeForms: true}, (err, html) -> + if err then return done err + + # GET + + expectSelect html, 'boolean', true, ['true', 'false'] + expectClickToFill html, 'boolean', 'true' + + expectSelect html, 'optional_boolean', false, ['', 'true', 'false'] + expectClickToFill html, 'optional_boolean', 'true' + + expectInput html, 'number', 'number', true, 123 + expectClickToFill html, 'number', 123 + + expectInput html, 'optional', 'text' + expectClickToFill html, 'optional', 'example' + + expectInput html, 'default', 'text', true, 'default' + expectNoClickToFill html, 'default' + + expectInput html, 'example', 'text', true, 'example' + expectClickToFill html, 'example', 'example' + + expectInput html, 'default_example', 'text', true, 'default' + expectClickToFill html, 'default_example', 'example' + + expectSelect html, 'enum', true, ['one', 'two'] + expectClickToFill html, 'enum', 'one', 'code' + expectClickToFill html, 'enum', 'two', 'code' + expectClickToFill html, 'enum', 'two' + + expectSelect html, 'optional_enum', false, ['', 'one', 'two'] + expectClickToFill html, 'optional_enum', 'one', 'code' + expectClickToFill html, 'optional_enum', 'two', 'code' + expectClickToFill html, 'optional_enum', 'two' + + expectSelect html, 'explode_enum', true, ['one', 'two'], true + expectClickToFill html, 'explode_enum', 'one', 'code' + expectClickToFill html, 'explode_enum', 'two', 'code' + expectClickToFill html, 'explode_enum', 'one' + + # POST + + assert.include html, '' + assert.include html, '' + + done() + + +expectSelect = (html, name, required, options, multiple) -> + required = if required then ' required' else '' + multiple = if multiple then ' multiple' else '' + assert.include html, "" + +createOptions = (options) -> + result = '' + for option in options + if option + result += "" + else + result += "" + return result + +expectInput = (html, name, type, required, value) -> + required = if required then ' required' else '' + assert.include html, "" + +expectClickToFill = (html, name, example, tag) -> + tag ?= 'span' + assert.include html, "<#{tag} data-fill-target=\"test-get-#{name}\" class=\"click-to-fill\">#{example}" + +expectNoClickToFill = (html, name) -> + assert.notInclude html, "