Source

services/ApiManager.tsx

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>

	)
}