UNPKG

openapi-explorer

Version:

OpenAPI Explorer - API viewer with dynamically generated components, documentation, and interaction console

780 lines (758 loc) 28.4 kB
import { LitElement } from 'lit'; // Styles import FontStyles from './styles/font-styles.js'; import InputStyles from './styles/input-styles.js'; import SchemaStyles from './styles/schema-styles.js'; import FlexStyles from './styles/flex-styles.js'; import TableStyles from './styles/table-styles.js'; import KeyFrameStyles from './styles/key-frame-styles.js'; import EndpointStyles from './styles/endpoint-styles.js'; import PrismStyles from './styles/prism-styles.js'; import TagInputStyles from './styles/tag-input-styles.js'; import TabStyles from './styles/tab-styles.js'; import NavStyles from './styles/nav-styles.js'; import InfoStyles from './styles/info-styles.js'; import advancedSearchStyles from './styles/advanced-search-styles.js'; import MainBodyStyles from './styles/main-body-styles.js'; import { advancedSearch, getCurrentElement, replaceState, sleep } from './utils/common-utils.js'; import { initI18n } from './languages/index.js'; import ProcessSpec from './utils/spec-parser.js'; import mainBodyTemplate from './templates/mainBodyTemplate.js'; import apiRequestStyles from './styles/api-request-styles.js'; import { checkForAuthToken } from './templates/security-scheme-template.js'; import './components/syntax-highlighter.js'; export default class OpenApiExplorer extends LitElement { constructor() { super(); this.loading = true; const intersectionObserverOptions = { root: this.getRootNode().host, rootMargin: '-50px 0px -50px 0px', // when the element is visible 100px from bottom threshold: 0 }; this.isIntersectionObserverActive = true; if (typeof IntersectionObserver !== 'undefined') { this.intersectionObserver = new IntersectionObserver(entries => { this.onIntersect(entries); }, intersectionObserverOptions); } else { this.intersectionObserver = { disconnect() {}, observe() {} }; } } static get properties() { return { // Heading headingText: { type: String, attribute: 'heading-text' }, explorerLocation: { type: String, attribute: 'explorer-location' }, // Spec specUrl: { type: String, attribute: 'spec-url' }, // UI Layouts layout: { type: String }, collapsed: { type: Boolean, attribute: 'collapse', converter(value) { return value !== 'false' && value !== false; } }, operationsCollapsed: { type: Boolean }, componentsCollapsed: { type: Boolean }, defaultSchemaTab: { type: String, attribute: 'default-schema-tab' }, responseAreaHeight: { type: String, attribute: 'response-area-height' }, hideDefaults: { type: Boolean, attribute: 'hide-defaults', converter(value) { return value !== 'false' && value !== false; } }, // Schema Styles displaySchemaAsTree: { type: Boolean, attribute: 'tree', converter(value) { return value !== 'false' && value !== false; } }, schemaExpandLevel: { type: Number, attribute: 'schema-expand-level' }, // API Server serverUrl: { type: String, attribute: 'server-url' }, // Hide/Show Sections & Enable Disable actions hideInfo: { type: Boolean, attribute: 'hide-info', converter(value) { return value !== 'false' && value !== false; } }, hideAuthentication: { type: Boolean, attribute: 'hide-authentication', converter(value) { return value !== 'false' && value !== false; } }, hideExecution: { type: Boolean, attribute: 'hide-console', converter(value) { return value !== 'false' && value !== false; } }, includeNulls: { type: Boolean, attribute: 'display-nulls', converter(value) { return value !== 'false' && value !== false; } }, hideSearch: { type: Boolean, attribute: 'hide-search', converter(value) { return value !== 'false' && value !== false; } }, hideServerSelection: { type: Boolean, attribute: 'hide-server-selection', converter(value) { return value !== 'false' && value !== false; } }, hideComponents: { type: Boolean, attribute: 'hide-components', converter(value) { return value !== 'false' && value !== false; } }, // Main Colors and Font primaryColor: { type: String, attribute: 'primary-color' }, secondaryColor: { type: String, attribute: 'secondary-color' }, bgColor: { type: String, attribute: 'bg-color' }, bgHeaderColor: { type: String, attribute: 'header-bg-color' }, textColor: { type: String, attribute: 'text-color' }, headerColor: { type: String, attribute: 'header-color' }, // Nav Bar Colors navBgColor: { type: String, attribute: 'nav-bg-color' }, navTextColor: { type: String, attribute: 'nav-text-color' }, navHoverBgColor: { type: String, attribute: 'nav-hover-bg-color' }, navHoverTextColor: { type: String, attribute: 'nav-hover-text-color' }, usePathInNavBar: { type: Boolean, attribute: 'use-path-in-nav-bar', converter(value) { return value !== 'false' && value !== false; } }, // Fetch Options fetchCredentials: { type: String, attribute: 'fetch-credentials' }, // Filters matchPaths: { type: String, attribute: 'match-paths' }, // Internal Properties loading: { type: Boolean }, // indicates spec is being loaded showAdvancedSearchDialog: { type: Boolean }, advancedSearchMatches: { type: Object } }; } static finalizeStyles() { return [FontStyles, SchemaStyles, InputStyles, FlexStyles, TableStyles, KeyFrameStyles, EndpointStyles, PrismStyles, TabStyles, NavStyles, InfoStyles, TagInputStyles, advancedSearchStyles, apiRequestStyles, MainBodyStyles]; } // Startup connectedCallback() { super.connectedCallback(); this.handleResize = this.handleResize.bind(this); window.addEventListener('resize', this.handleResize); this.loading = true; const parent = this.parentElement; if (parent) { if (parent.offsetWidth === 0 && parent.style.width === '') { parent.style.width = '100vw'; } if (parent.offsetHeight === 0 && parent.style.height === '') { parent.style.height = '100vh'; } if (parent.tagName === 'BODY') { if (!parent.style.marginTop) { parent.style.marginTop = '0'; } if (!parent.style.marginRight) { parent.style.marginRight = '0'; } if (!parent.style.marginBottom) { parent.style.marginBottom = '0'; } if (!parent.style.marginLeft) { parent.style.marginLeft = '0'; } } } this.renderStyle = 'focused'; this.operationsCollapsed = this.collapsed; this.componentsCollapsed = this.collapsed; this.explorerLocation = this.explorerLocation || getCurrentElement(); if (!this.defaultSchemaTab || !'body, model, form,'.includes(`${this.defaultSchemaTab},`)) { this.defaultSchemaTab = 'model'; } if (!this.schemaExpandLevel || this.schemaExpandLevel < 1) { this.schemaExpandLevel = 99999; } this.schemaHideReadOnly = ['post', 'put', 'patch', 'query'].join(','); this.schemaHideWriteOnly = true; if (!this.responseAreaHeight) { this.responseAreaHeight = '300px'; } if (!this.fetchCredentials || !'omit, same-origin, include,'.includes(`${this.fetchCredentials},`)) { this.fetchCredentials = ''; } if (!this.showAdvancedSearchDialog) { this.showAdvancedSearchDialog = false; } window.addEventListener('hashchange', () => { this.scrollTo(getCurrentElement()); }, true); this.handleResize(); } // Cleanup disconnectedCallback() { this.intersectionObserver.disconnect(); window.removeEventListener('resize', this.handleResize); super.disconnectedCallback(); } render() { return mainBodyTemplate.call(this); } observeExpandedContent() { // Main Container const observeOverviewEls = this.shadowRoot.querySelectorAll('.observe-me'); observeOverviewEls.forEach(targetEl => { this.intersectionObserver.observe(targetEl); }); } handleResize() { const mediaQueryResult = window.matchMedia('(min-width: 768px)'); const newDisplay = mediaQueryResult.matches ? 'focused' : 'view'; if (this.renderStyle !== newDisplay) { this.renderStyle = newDisplay; this.requestUpdate(); } } attributeChangedCallback(name, oldVal, newVal) { if (name === 'spec-url') { if (oldVal !== newVal) { window.setTimeout(async () => { await this.loadSpec(newVal); // If the initial location is set, then attempt to scroll there if (this.explorerLocation) { this.scrollTo(this.explorerLocation); } }, 0); } } if (name === 'server-url' && newVal) { var _this$resolvedSpec; this.selectedServer = ((_this$resolvedSpec = this.resolvedSpec) === null || _this$resolvedSpec === void 0 ? void 0 : _this$resolvedSpec.servers.find(s => s.url === newVal || !newVal)) || { url: newVal, computedUrl: newVal }; } if (name === 'render-style') { if (newVal === 'read') { window.setTimeout(() => { this.observeExpandedContent(); }, 100); } else { this.intersectionObserver.disconnect(); } } if (name === 'explorer-location') { window.setTimeout(() => { this.scrollTo(newVal); }, 0); } if (name === 'collapsed') { this.operationsCollapsed = newVal; this.componentsCollapsed = newVal; } super.attributeChangedCallback(name, oldVal, newVal); } onSearchChange(e) { var _this$matchPaths; this.matchPaths = e.target.value; const expand = !!((_this$matchPaths = this.matchPaths) !== null && _this$matchPaths !== void 0 && _this$matchPaths.trim()); this.operationsCollapsed = !expand; this.componentsCollapsed = !expand; this.resolvedSpec.tags.forEach(tag => { tag.expanded = expand; }); this.resolvedSpec.components.forEach(component => { component.expanded = expand; }); this.requestUpdate(); } onClearSearch() { const searchEl = this.shadowRoot.getElementById('nav-bar-search'); searchEl.value = ''; this.matchPaths = ''; } async onShowSearchModalClicked() { this.showAdvancedSearchDialog = true; // wait for the dialog to render await sleep(10); const inputEl = this.shadowRoot.getElementById('advanced-search-dialog-input'); if (inputEl) { inputEl.focus(); } } // Public Method async loadSpec(specUrlOrObject) { if (!specUrlOrObject) { return; } this.matchPaths = ''; try { var _spec$info; this.resolvedSpec = null; this.loading = true; this.loadingFailedError = null; const spec = await ProcessSpec(specUrlOrObject, this.serverUrl); this.loading = false; if (spec === undefined || spec === null) { console.error('Unable to resolve the API spec. '); // eslint-disable-line no-console return; } initI18n((_spec$info = spec.info) === null || _spec$info === void 0 ? void 0 : _spec$info['x-locale']); if (!this.serverUrl) { var _spec$servers$, _spec$servers$2; this.serverUrl = ((_spec$servers$ = spec.servers[0]) === null || _spec$servers$ === void 0 ? void 0 : _spec$servers$.computedUrl) || ((_spec$servers$2 = spec.servers[0]) === null || _spec$servers$2 === void 0 ? void 0 : _spec$servers$2.url); } this.selectedServer = spec.servers.find(s => s.url === this.serverUrl || !this.serverUrl) || spec.servers[0]; this.afterSpecParsedAndValidated(spec); } catch (err) { this.loading = false; this.loadingFailedError = err.message; this.resolvedSpec = null; console.error('OpenAPI Explorer: Unable to resolve the API spec..', err); // eslint-disable-line no-console } try { await checkForAuthToken.call(this); } catch (error) { // eslint-disable-next-line no-console console.error('Failed to check for authentication token', error); } } // Public Method async setAuthenticationConfiguration(apiKeyId, { token, clientId, clientSecret, redirectUri }) { const securityObj = this.resolvedSpec && this.resolvedSpec.securitySchemes.find(v => v.apiKeyId === apiKeyId); if (!securityObj) { throw Error('SecuritySchemeNotFound'); } let authorizationToken = token && token.replace(/^(Bearer|Basic)\s+/i, '').trim(); if (authorizationToken && securityObj.type && securityObj.type === 'http' && securityObj.scheme && securityObj.scheme.toLowerCase() === 'basic') { authorizationToken = `Basic ${btoa(authorizationToken)}`; } else if (authorizationToken && securityObj.scheme && securityObj.scheme.toLowerCase() === 'bearer') { authorizationToken = `Bearer ${authorizationToken}`; } securityObj.clientId = clientId && clientId.trim(); securityObj.clientSecret = clientSecret && clientSecret.trim(); securityObj.redirectUri = new URL(redirectUri && redirectUri.trim() || '', window.location.href).toString(); securityObj.finalKeyValue = authorizationToken; await checkForAuthToken.call(this); this.requestUpdate(); } afterSpecParsedAndValidated(spec) { this.resolvedSpec = spec; if (this.operationsCollapsed) { this.resolvedSpec.tags.forEach(t => t.expanded = false); } if (this.componentsCollapsed) { this.resolvedSpec.components.forEach(c => c.expanded = false); } this.dispatchEvent(new CustomEvent('spec-loaded', { bubbles: true, detail: spec })); this.requestUpdate(); // Initiate IntersectionObserver and put it at the end of event loop, to allow loading all the child elements (must for larger specs) this.intersectionObserver.disconnect(); if (this.renderStyle === 'focused') { const defaultElementId = !this.hideInfo ? 'overview' : this.resolvedSpec.tags && this.resolvedSpec.tags[0] && this.resolvedSpec.tags[0].paths[0]; this.scrollTo(this.explorerLocation || defaultElementId); } if (this.renderStyle === 'view' && this.explorerLocation) { this.expandAndGotoOperation(this.explorerLocation); } } expandAndGotoOperation(elementId) { var _tag$paths; // Expand full operation and tag let isExpandingNeeded = false; const tag = this.resolvedSpec.tags.find(t => t.paths && t.paths.find(p => p.elementId === elementId)); const path = tag === null || tag === void 0 ? void 0 : (_tag$paths = tag.paths) === null || _tag$paths === void 0 ? void 0 : _tag$paths.find(p => p.elementId === elementId); if (path && (!path.expanded || !tag.expanded)) { isExpandingNeeded = true; path.expanded = true; tag.expanded = true; this.requestUpdate(); } // requestUpdate() and delay required, else we cant find element because it won't exist immediately const tmpElementId = elementId.indexOf('#') === -1 ? elementId : elementId.substring(1); window.setTimeout(() => { const gotoEl = this.shadowRoot.getElementById(tmpElementId); if (gotoEl) { gotoEl.scrollIntoView({ behavior: 'auto', block: 'start' }); replaceState(tmpElementId); } }, isExpandingNeeded ? 150 : 0); } isValidTopId(id) { return id.startsWith('overview') || id === 'servers' || id === 'auth'; } isValidPathId(id) { if (id === 'overview' && !this.hideInfo) { return true; } if (id === 'servers' && !this.hideServerSelection) { return true; } if (id === 'auth' && !this.hideAuthentication) { return true; } if (id.startsWith('tag--')) { return this.resolvedSpec.tags && this.resolvedSpec.tags.find(tag => tag.elementId === id); } return this.resolvedSpec.tags && this.resolvedSpec.tags.find(tag => tag.paths.find(path => path.elementId === id)); } onIntersect(entries) { if (this.isIntersectionObserverActive === false) { return; } entries.forEach(entry => { if (entry.isIntersecting && entry.intersectionRatio > 0) { const oldNavEl = this.shadowRoot.querySelector('.nav-bar-tag.active, .nav-bar-path.active, .nav-bar-info.active, .nav-bar-h1.active, .nav-bar-h2.active'); const newNavEl = this.shadowRoot.getElementById(`link-${entry.target.id}`); // Add active class in the new element if (newNavEl) { replaceState(entry.target.id); newNavEl.scrollIntoView({ behavior: 'auto', block: 'center' }); newNavEl.classList.add('active'); } // Remove active class from previous element if (oldNavEl) { oldNavEl.classList.remove('active'); } } }); } // Called by anchor tags created using markdown handleHref(e) { if (e.target.tagName.toLowerCase() === 'a') { const anchor = e.target.getAttribute('href'); if (anchor && anchor.startsWith('#')) { const gotoEl = this.shadowRoot.getElementById(anchor.replace('#', '')); if (gotoEl) { gotoEl.scrollIntoView({ behavior: 'auto', block: 'start' }); } } } } /** * Called by * - onClick of Navigation Bar * - onClick of Advanced Search items * * Functionality: * 1. First deactivate IntersectionObserver * 2. Scroll to the element * 3. Activate IntersectionObserver (after little delay) * */ scrollToEventTarget(event, scrollNavItemToView = true) { const navEl = event.currentTarget; if (!navEl.dataset.contentId) { return; } this.isIntersectionObserverActive = false; this.scrollTo(navEl.dataset.contentId, scrollNavItemToView); setTimeout(() => { this.isIntersectionObserverActive = true; }, 300); } scrollToCustomNavSectionTarget(event, scrollNavItemToView = true) { const navEl = event.currentTarget; if (!navEl.dataset.contentId) { return; } const navSectionSlot = this.shadowRoot.querySelector('slot.custom-nav-section'); const assignedNodes = navSectionSlot === null || navSectionSlot === void 0 ? void 0 : navSectionSlot.assignedNodes(); // clicked child node could be multiple levels deep in a custom nav const hasChildNode = node => { return node === event.target || node.children && [...node.children].some(c => hasChildNode(c)); }; let repeatedElementIndex = assignedNodes && [].findIndex.call(assignedNodes, slot => hasChildNode(slot)); if (repeatedElementIndex === -1 && navEl.dataset.contentId.match(/^section--\d+/)) { repeatedElementIndex = Number(navEl.dataset.contentId.split('--')[1]) - 1; } this.isIntersectionObserverActive = false; this.scrollTo(navEl.dataset.contentId, scrollNavItemToView, repeatedElementIndex); setTimeout(() => { this.isIntersectionObserverActive = true; }, 300); } async scrollToSchemaComponentByName(schemaComponentNameEvent) { var _this$resolvedSpec2, _this$resolvedSpec2$c, _this$resolvedSpec2$c2, _this$resolvedSpec2$c3; const schemaComponentName = schemaComponentNameEvent.detail; const schemaComponent = (_this$resolvedSpec2 = this.resolvedSpec) === null || _this$resolvedSpec2 === void 0 ? void 0 : (_this$resolvedSpec2$c = _this$resolvedSpec2.components) === null || _this$resolvedSpec2$c === void 0 ? void 0 : (_this$resolvedSpec2$c2 = _this$resolvedSpec2$c.find(c => c.componentKeyId === 'schemas')) === null || _this$resolvedSpec2$c2 === void 0 ? void 0 : (_this$resolvedSpec2$c3 = _this$resolvedSpec2$c2.subComponents) === null || _this$resolvedSpec2$c3 === void 0 ? void 0 : _this$resolvedSpec2$c3.find(s => s.name === schemaComponentName); if (schemaComponent) { await this.scrollTo(`cmp--${schemaComponent.id}`, true); } } // Public Method (scrolls to a given path and highlights the left-nav selection) async scrollTo(elementId, scrollNavItemToView = true, repeatedElementIndex) { try { await this.scrollToOrThrowException(elementId, scrollNavItemToView, repeatedElementIndex); } catch (error) { // There's an issue for lit elements for some browsers which are causing this issue we'll log here and still throw console.error('Failed to scroll to target', elementId, scrollNavItemToView, repeatedElementIndex, error); // eslint-disable-line no-console throw error; } } async scrollToOrThrowException(elementId, scrollNavItemToView = true, forcedRepeatedElementIndex) { if (!this.resolvedSpec) { return; } this.emitOperationChangedEvent(elementId); if (this.renderStyle === 'view') { this.expandAndGotoOperation(elementId); return; } // explorerLocation will get validated in the focused-endpoint-template this.explorerLocation = elementId; const tag = this.resolvedSpec.tags.find(t => t.paths.some(p => p.elementId === elementId)); if (tag) { tag.expanded = true; } // Convert to Async and to the background, so that we can be sure that the operation has been expanded and put into view before trying to directly scroll to it (or it won't be found in the next line and even if it is, it might not be able to be scrolled into view) await sleep(0); // In the case of section scrolling, these are hard swaps, so just load "section". In the case of `tags` the headers have the element html Id in the last `--id`, so split that off and check for it const contentEl = this.shadowRoot.getElementById(elementId !== null && elementId !== void 0 && elementId.startsWith('section') ? 'section' : elementId) || this.shadowRoot.getElementById(elementId.split('--').slice(-1)[0]); if (!contentEl) { return; } // For focused APIs, always scroll to the top of the component let newNavEl; let waitForComponentToExpand = false; const elementIndex = forcedRepeatedElementIndex || forcedRepeatedElementIndex === 0 ? forcedRepeatedElementIndex : Number(elementId.split('--')[1]) - 1; if (elementId.match(/^section/)) { const customSections = this.shadowRoot.querySelector('slot.custom-section'); const assignedNodesToCustomSections = customSections === null || customSections === void 0 ? void 0 : customSections.assignedNodes(); if (assignedNodesToCustomSections) { try { assignedNodesToCustomSections.map(customSection => { customSection.classList.remove('active'); }); const newActiveCustomSection = assignedNodesToCustomSections[elementIndex]; if (newActiveCustomSection && !newActiveCustomSection.classList.contains('active')) { newActiveCustomSection.classList.add('active'); } } catch (error) { // eslint-disable-next-line no-console console.error('Failed to switch between custom sections, usually happens because the DOM is not ready and has not loaded these sections yet.', error); } } const navSectionSlot = this.shadowRoot.querySelector('slot.custom-nav-section'); const assignedNodes = navSectionSlot === null || navSectionSlot === void 0 ? void 0 : navSectionSlot.assignedNodes(); newNavEl = assignedNodes === null || assignedNodes === void 0 ? void 0 : assignedNodes[elementIndex]; // Update Location Hash replaceState(`section--${elementIndex + 1}`); } else if (elementId.match('cmp--')) { const component = this.resolvedSpec.components.find(c => c.subComponents.find(sub => elementId.includes(sub.id))); if (component && !component.expanded) { waitForComponentToExpand = true; component.expanded = true; } contentEl.scrollIntoView({ behavior: 'auto', block: 'start' }); // Update Location Hash replaceState(elementId); newNavEl = this.shadowRoot.getElementById(`link-${elementId}`); } else if (!elementId.match('cmp--') && !elementId.match('tag--')) { this.shadowRoot.getElementById('operations-root').scrollIntoView({ behavior: 'auto', block: 'start' }); // Update Location Hash replaceState(elementId); newNavEl = this.shadowRoot.getElementById(`link-${elementId}`); } else { contentEl.scrollIntoView({ behavior: 'auto', block: 'start' }); // Update Location Hash replaceState(elementId); newNavEl = this.shadowRoot.getElementById(`link-${elementId}`); } // for focused style it is important to reset request-body-selection and response selection which maintains the state for in case of multiple req-body or multiple response mime-type const requestEl = this.shadowRoot.querySelector('api-request'); if (requestEl) { requestEl.resetRequestBodySelection(); } const responseEl = this.shadowRoot.querySelector('api-response'); if (responseEl) { responseEl.resetSelection(); } // Update NavBar View and Styles if (!newNavEl) { return; } if (scrollNavItemToView) { newNavEl.scrollIntoView({ behavior: 'auto', block: 'center' }); // Also force it into view again if for some reason it isn't there if (waitForComponentToExpand) { setTimeout(() => newNavEl.scrollIntoView({ behavior: 'auto', block: 'center' }), 600); } } await sleep(0); const oldNavEl = this.shadowRoot.querySelector('.nav-bar-tag.active, .nav-bar-path.active, .nav-bar-info.active, .nav-bar-h1.active, .nav-bar-h2.active'); if (oldNavEl) { oldNavEl.classList.remove('active'); } const navSectionSlot = this.shadowRoot.querySelector('slot.custom-nav-section'); const assignedNodes = navSectionSlot === null || navSectionSlot === void 0 ? void 0 : navSectionSlot.assignedNodes(); (assignedNodes || []).filter((n, nodeIndex) => isNaN(elementIndex) || nodeIndex !== elementIndex).forEach(node => { node.classList.remove('active'); }); newNavEl.classList.add('active'); // must add the class after scrolling this.requestUpdate(); } // Event handler for Advanced Search text-inputs and checkboxes onAdvancedSearch(ev) { const eventTargetEl = ev.target; clearTimeout(this.timeoutId); this.timeoutId = setTimeout(() => { let searchInputEl; if (eventTargetEl.type === 'text') { searchInputEl = eventTargetEl; } else { searchInputEl = eventTargetEl.closest('.advanced-search-options').querySelector('input[type=text]'); } const searchOptions = [...eventTargetEl.closest('.advanced-search-options').querySelectorAll('input:checked')].map(v => v.id); this.advancedSearchMatches = advancedSearch(searchInputEl.value, this.resolvedSpec.tags, searchOptions); }, 0); } emitOperationChangedEvent(elementId) { const operation = this.resolvedSpec.tags.map(t => t.paths).flat(1).find(p => p.elementId === elementId); const event = { bubbles: true, composed: true, detail: { explorerLocation: elementId, operation, type: 'OperationChanged' } }; this.dispatchEvent(new CustomEvent('event', event)); } } if (!customElements.get('openapi-explorer')) { customElements.define('openapi-explorer', OpenApiExplorer); } import './openapi-explorer-oauth-handler.js';