import VersionedItemCollection from '../tools/VersionedItemCollection'
import {ApiInterface} from '../interfaces'
import * as React from 'react'
import {useContext, useEffect, useLayoutEffect, useState} from "react";
import {ConfigContext} from "./ConfigManager";
import {logger} from "./SparkLogger";
import {useOidc} from "../tools";
export type ApiConfigSource = {
name: string
version: string
url?: string
options?: any
}
/**
* The ApiManager holds configurations and instances of ApiInterfaces. It updates the ApiInterfaces with
* tokens that are used for authorisation.
*
* @category Services
*/
export class ApiManager {
// Static list that apps can load their API interfaces into
static interfacesToLoad: (typeof ApiInterface)[] = []
/**
* Takes an array of scopes and substitutes in template values. Returns new scopes array.
* @param scopes array of scopes to be templated.
* @param data object whose keys are the value to replace, and the value is what to put in place.
* @return Populated map of original scopes, matched to their templated scopes.
*/
static templateScopes(scopes: string[], data: any): Map<string, string> {
let newScopes: Map<string, string> = new Map<string, string>()
scopes.forEach((scope: string) => {
let templatedScope = scope
Object.keys(data).forEach((key: string) => {
let searchVal = '[' + key.toUpperCase() + ']'
templatedScope = templatedScope.replace(searchVal, data[key])
})
newScopes.set(scope, templatedScope)
})
return newScopes
}
/**
* Adds an interface to the list of interfaces to load when this class is initialised.
* @param apiInterface ApiInterface derivative class (not an instance) to add to load when an ApiManager is created
*/
static addInterface(apiInterface: typeof ApiInterface) {
ApiManager.interfacesToLoad.push(apiInterface)
}
/**
* Basic common function for templating API scopes to the correct environment
*
* @param {string} scope The untemplated scope
* @param {string} env The environment string to insert into the template
*
* @returns {string} The new templated scope
*/
static getTemplatedScope = (scope: string, env: string) => {
if (scope) {
return scope.replace('[ENV]', env)
} else {
return undefined
}
}
// -------------------------------------------- Non-static ----------------------------------------------------- //
env: string
interfaceConfig: VersionedItemCollection<ApiConfigSource>
interfaceClasses: VersionedItemCollection<typeof ApiInterface>
scopesToRequest: Map<string, string>
transientHeaders: Map<string, string>
interfaceInstances: ApiInterface[]
constructor(environment: string) {
this.env = environment
this.transientHeaders = new Map<string, string>()
this.scopesToRequest = new Map<string, string>()
this.interfaceInstances = []
// the interface classes we can be asked for
this.interfaceClasses = new VersionedItemCollection()
// the config interfaces need
this.interfaceConfig = new VersionedItemCollection()
// currently open interfaces
for (let i in ApiManager.interfacesToLoad) {
this.addInterface(ApiManager.interfacesToLoad[i])
}
}
getTemplatedScope = (scope: string) => {
return ApiManager.getTemplatedScope(scope, this.env)
}
readScopesFromInterface(apiInterface: typeof ApiInterface, env: string) {
// Example data:
//static scopes = {CRM_DATA: 'urn:x-jcu-api:crm-support:[ENV]:crm-data'};
// [ENV] gets replaced with the environment name
if (!apiInterface.scopes) {
throw new Error('Interface class must have static map of scopes. Please add this.')
}
let templatedScopes = ApiManager.templateScopes(Object.values(apiInterface.scopes), {env: env})
templatedScopes.forEach((value: string, key: string) => {
this.scopesToRequest.set(key, value)
})
}
/**
* Provide a new header to propagate to ApiInterface instances
* @param header Header to set
* @param value if not null, sets the header, otherwise clears it.
*/
updateHeader(header: string, value: string) {
if (value) {
console.log('Updating header ' + header + ' in ApiManager')
this.transientHeaders.set(header, value)
} else {
this.transientHeaders.delete(header)
}
// Trigger update on all interfaces, so they update their headers too
this.interfaceInstances.forEach((apiInterface: ApiInterface) => {
apiInterface.transientHeadersListener(header, value)
})
}
/**
* Helper function for adding an ApiInterface to the ApiManager's library of available APIs. Any API that is
* required will need to be added to the library prior to being usable.
*
* APIs should be added in the base `App.js`, in a place where it will be run BEFORE the app is rendered. This is
* important as the OIDC library queries the ApiManagers library of registered APIs to ask for the correct OIDC scopes.
*
* ```js
* import { ApiManager } from '@jcu/spark'
*
* ...
*
* ApiManager.addInterface(TestInterface)
*
* function App() {
* ...
* ```
*
* @param {ApiInterface} apiInterface The ApiInterface class to add to library
*/
addInterface(apiInterface: typeof ApiInterface) {
this.interfaceClasses.put(apiInterface, apiInterface.interfaceName, apiInterface.interfaceVersion)
this.readScopesFromInterface(apiInterface, this.env)
}
addConfig(config: ApiConfigSource) {
this.interfaceConfig.put(config, config.name, config.version)
}
getInterfaceConfig(apiInterface: string, version: string = 'latest') {
return this.interfaceConfig.get(apiInterface, version)
}
/**
* Load an API interface from the library by name and version. Name and version need to be defined (and matching) in
* two locations: SPA config and the interface class.
*
* Handles instantiating and configuring API interface based on the information defined in the API section of the
* SPA config.
*
* @param {string} apiInterface The name of the API
* @param {string} version The version of the API
*
* @returns {(ApiInterface | undefined)} The requested ApiInterface object or undefined if it doesn't exist
*/
getApiInterface(apiInterface: string, version: string = 'latest'): ApiInterface | undefined {
// get the apiInterface class and the config it needs
const interfaceClass = this.interfaceClasses.get(apiInterface, version)
const config = this.getInterfaceConfig(apiInterface, version)
// if we got them...
if (interfaceClass && config) {
// make the instance and initialise it with the config
const interfaceInstance = new interfaceClass(config)
// Populate with existing headers
this.transientHeaders.forEach((val: any, key: string) => {
interfaceInstance.transientHeadersListener(key, val)
})
// Add to our array of instances so we can call updates when the headers change
this.interfaceInstances.push(interfaceInstance)
return interfaceInstance
} else {
let loadedInterfaces = this.interfaceClasses.listItemVersions().map((loadedInterface) => ` ${loadedInterface.name} :: ${loadedInterface.version} `)
logger.devWarn(
`There is currently no interface loaded with the name ${apiInterface} of version ${version}\n` +
`The list of currently loaded interfaces is: ${loadedInterfaces}\n` +
'This issue is likely caused by a missing ApiManager.addInterface(YourInterface) in your App.js file\n'
)
return undefined
}
}
/**
* Compare the loaded interfaces against the list of APIs that have had configs loaded
*/
checkLoadedInterfaces() {
for (let i of this.interfaceClasses.listItemVersions()) {
if (!this.interfaceConfig.get(i.name, i.version)) {
let storedItems = this.interfaceConfig.listItemVersions().map((storedInterface) => ` ${storedInterface.name} :: ${storedInterface.version} `)
logger.devWarn(
`There is no corresponding API config for loaded interface '${i.name}' version ${i.version}.\n` +
`The list of currently stored API configs is: ${storedItems}\n` +
'This issue is likely caused by:\n' +
' - An incorrectly defined name or version in your ApiInterface\n' +
' - A missing API configuration in your config file\n'
)
}
}
}
}
export const ApiContext = React.createContext<any | null>(null)
/**
* React context that provides app-wide access to the {@link ApiManager}.
*
* @component
* @category Context Providers
*/
export const ApiContextProvider = (props: any) => {
// Load in the context containing all config data
const appConfigContext = useContext(ConfigContext)
const {user} = useOidc()
const [apiManager, setApiManager] = useState(undefined)
// Whenever the config changes, update the API manager
useEffect(() => {
if (appConfigContext) {
let localApiMan = new ApiManager(appConfigContext.env)
appConfigContext.apis.forEach((config: any) => localApiMan.addConfig(config))
// Check if the loaded interfaces the dev defined have also got a config loaded
// Outputs console warnings in non-production builds
localApiMan.checkLoadedInterfaces()
//@ts-ignore
setApiManager(localApiMan)
}
}, [appConfigContext])
// Whenever the user changes (login/logout) change the authorization header
// Attaches users access token when user exists, removes token on log out
useLayoutEffect(() => {
if (apiManager && user) {
//@ts-ignore
apiManager.updateHeader('Authorization', 'Bearer ' + user.access_token)
} else if (apiManager) {
//TODO: This auth header needs to be setup correctly, below is a hack to get it working
//@ts-ignore
apiManager.updateHeader('Authorization', `SPA ${appConfigContext.authentication?.client_id}:1`)
}
}, [apiManager, user])
return (
<ApiContext.Provider value={apiManager}>
{apiManager ? props.children : null}
</ApiContext.Provider>
)
}
Source