From 4065a77e22249f07399c626e371e39756a6fcac0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 10 Mar 2021 16:37:29 +0100 Subject: [PATCH] add clone of core-api package as app-api Co-authored-by: Juan Lulkin Signed-off-by: Patrik Oldsberg --- packages/app-api/.eslintrc.js | 8 + packages/app-api/README.md | 20 + packages/app-api/package.json | 61 +++ .../app-api/src/apis/definitions/AlertApi.ts | 44 ++ .../src/apis/definitions/AppThemeApi.ts | 83 +++ .../app-api/src/apis/definitions/ConfigApi.ts | 28 + .../src/apis/definitions/DiscoveryApi.ts | 47 ++ .../app-api/src/apis/definitions/ErrorApi.ts | 68 +++ .../src/apis/definitions/FeatureFlagsApi.ts | 86 +++ .../src/apis/definitions/IdentityApi.ts | 56 ++ .../src/apis/definitions/OAuthRequestApi.ts | 133 +++++ .../src/apis/definitions/StorageApi.ts | 71 +++ packages/app-api/src/apis/definitions/auth.ts | 347 ++++++++++++ .../app-api/src/apis/definitions/index.ts | 33 ++ .../AlertApi/AlertApiForwarder.ts | 33 ++ .../apis/implementations/AlertApi/index.ts | 17 + .../AppThemeApi/AppThemeSelector.test.ts | 85 +++ .../AppThemeApi/AppThemeSelector.ts | 75 +++ .../apis/implementations/AppThemeApi/index.ts | 17 + .../apis/implementations/ConfigApi/index.ts | 17 + .../DiscoveryApi/UrlPatternDiscovery.test.ts | 84 +++ .../DiscoveryApi/UrlPatternDiscovery.ts | 58 ++ .../implementations/DiscoveryApi/index.ts | 21 + .../implementations/ErrorApi/ErrorAlerter.ts | 39 ++ .../ErrorApi/ErrorApiForwarder.ts | 36 ++ .../apis/implementations/ErrorApi/index.ts | 18 + .../LocalStorageFeatureFlags.test.tsx | 222 ++++++++ .../LocalStorageFeatureFlags.tsx | 109 ++++ .../implementations/FeatureFlagsApi/index.ts | 17 + .../OAuthRequestApi/MockOAuthApi.test.ts | 101 ++++ .../OAuthRequestApi/MockOAuthApi.ts | 55 ++ .../OAuthPendingRequests.test.ts | 96 ++++ .../OAuthRequestApi/OAuthPendingRequests.ts | 126 +++++ .../OAuthRequestManager.test.ts | 59 +++ .../OAuthRequestApi/OAuthRequestManager.ts | 92 ++++ .../implementations/OAuthRequestApi/index.ts | 17 + .../StorageApi/WebStorage.test.ts | 173 ++++++ .../implementations/StorageApi/WebStorage.ts | 94 ++++ .../apis/implementations/StorageApi/index.ts | 17 + .../implementations/auth/auth0/Auth0Auth.ts | 46 ++ .../apis/implementations/auth/auth0/index.ts | 17 + .../auth/github/GithubAuth.test.ts | 29 + .../implementations/auth/github/GithubAuth.ts | 140 +++++ .../apis/implementations/auth/github/index.ts | 18 + .../apis/implementations/auth/github/types.ts | 28 + .../auth/gitlab/GitlabAuth.test.ts | 50 ++ .../implementations/auth/gitlab/GitlabAuth.ts | 45 ++ .../apis/implementations/auth/gitlab/index.ts | 17 + .../auth/google/GoogleAuth.test.ts | 69 +++ .../implementations/auth/google/GoogleAuth.ts | 68 +++ .../apis/implementations/auth/google/index.ts | 17 + .../src/apis/implementations/auth/index.ts | 25 + .../auth/microsoft/MicrosoftAuth.ts | 52 ++ .../implementations/auth/microsoft/index.ts | 17 + .../auth/oauth2/OAuth2.test.ts | 152 ++++++ .../implementations/auth/oauth2/OAuth2.ts | 179 +++++++ .../apis/implementations/auth/oauth2/index.ts | 18 + .../apis/implementations/auth/oauth2/types.ts | 28 + .../auth/okta/OktaAuth.test.ts | 61 +++ .../implementations/auth/okta/OktaAuth.ts | 71 +++ .../apis/implementations/auth/okta/index.ts | 17 + .../auth/onelogin/OneLoginAuth.ts | 82 +++ .../implementations/auth/onelogin/index.ts | 17 + .../implementations/auth/saml/SamlAuth.ts | 98 ++++ .../apis/implementations/auth/saml/index.ts | 16 + .../apis/implementations/auth/saml/types.ts | 22 + .../src/apis/implementations/auth/types.ts | 28 + .../app-api/src/apis/implementations/index.ts | 30 ++ packages/app-api/src/apis/index.ts | 19 + .../src/apis/system/ApiAggregator.test.ts | 46 ++ .../app-api/src/apis/system/ApiAggregator.ts | 39 ++ .../apis/system/ApiFactoryRegistry.test.ts | 91 ++++ .../src/apis/system/ApiFactoryRegistry.ts | 89 ++++ .../src/apis/system/ApiProvider.test.tsx | 212 ++++++++ .../app-api/src/apis/system/ApiProvider.tsx | 126 +++++ .../app-api/src/apis/system/ApiRef.test.ts | 51 ++ packages/app-api/src/apis/system/ApiRef.ts | 57 ++ .../src/apis/system/ApiRegistry.test.ts | 75 +++ .../app-api/src/apis/system/ApiRegistry.ts | 69 +++ .../src/apis/system/ApiResolver.test.ts | 266 ++++++++++ .../app-api/src/apis/system/ApiResolver.ts | 110 ++++ packages/app-api/src/apis/system/helpers.ts | 49 ++ packages/app-api/src/apis/system/index.ts | 23 + packages/app-api/src/apis/system/types.ts | 57 ++ packages/app-api/src/app/App.test.tsx | 299 +++++++++++ packages/app-api/src/app/App.tsx | 501 ++++++++++++++++++ packages/app-api/src/app/AppContext.test.tsx | 79 +++ packages/app-api/src/app/AppContext.tsx | 60 +++ packages/app-api/src/app/AppIdentity.ts | 85 +++ packages/app-api/src/app/AppThemeProvider.tsx | 97 ++++ packages/app-api/src/app/index.ts | 18 + packages/app-api/src/app/types.ts | 257 +++++++++ .../src/extensions/componentData.test.tsx | 118 +++++ .../app-api/src/extensions/componentData.tsx | 84 +++ .../src/extensions/extensions.test.tsx | 76 +++ .../app-api/src/extensions/extensions.tsx | 109 ++++ packages/app-api/src/extensions/index.ts | 22 + .../app-api/src/extensions/traversal.test.tsx | 104 ++++ packages/app-api/src/extensions/traversal.ts | 138 +++++ packages/app-api/src/icons/icons.tsx | 60 +++ packages/app-api/src/icons/index.ts | 18 + packages/app-api/src/icons/types.ts | 33 ++ packages/app-api/src/index.ts | 20 + .../DefaultAuthConnector.test.ts | 182 +++++++ .../lib/AuthConnector/DefaultAuthConnector.ts | 204 +++++++ .../lib/AuthConnector/DirectAuthConnector.ts | 77 +++ .../AuthConnector/MockAuthConnector.test.ts | 35 ++ .../lib/AuthConnector/MockAuthConnector.ts | 45 ++ .../app-api/src/lib/AuthConnector/index.ts | 19 + .../app-api/src/lib/AuthConnector/types.ts | 30 ++ .../AuthSessionStore.test.ts | 137 +++++ .../AuthSessionManager/AuthSessionStore.ts | 131 +++++ .../RefreshingAuthSessionManager.test.ts | 168 ++++++ .../RefreshingAuthSessionManager.ts | 148 ++++++ .../AuthSessionManager/SessionStateTracker.ts | 40 ++ .../StaticAuthSessionManager.test.ts | 97 ++++ .../StaticAuthSessionManager.ts | 83 +++ .../src/lib/AuthSessionManager/common.ts | 71 +++ .../src/lib/AuthSessionManager/index.ts | 20 + .../src/lib/AuthSessionManager/types.ts | 57 ++ packages/app-api/src/lib/globalObject.test.ts | 90 ++++ packages/app-api/src/lib/globalObject.ts | 73 +++ packages/app-api/src/lib/index.ts | 20 + packages/app-api/src/lib/loginPopup.test.ts | 218 ++++++++ packages/app-api/src/lib/loginPopup.ts | 143 +++++ packages/app-api/src/lib/subjects.test.ts | 178 +++++++ packages/app-api/src/lib/subjects.ts | 202 +++++++ .../app-api/src/lib/versionedValues.test.ts | 40 ++ packages/app-api/src/lib/versionedValues.ts | 40 ++ packages/app-api/src/plugin/Plugin.tsx | 99 ++++ .../app-api/src/plugin/collectors.test.tsx | 106 ++++ packages/app-api/src/plugin/collectors.ts | 47 ++ packages/app-api/src/plugin/index.ts | 33 ++ packages/app-api/src/plugin/types.ts | 121 +++++ packages/app-api/src/private.ts | 17 + packages/app-api/src/public.ts | 23 + .../src/routing/ExternalRouteRef.test.ts | 119 +++++ .../app-api/src/routing/ExternalRouteRef.ts | 84 +++ .../app-api/src/routing/FlatRoutes.test.tsx | 111 ++++ packages/app-api/src/routing/FlatRoutes.tsx | 87 +++ packages/app-api/src/routing/RouteRef.test.ts | 94 ++++ packages/app-api/src/routing/RouteRef.ts | 106 ++++ .../app-api/src/routing/RouteResolver.test.ts | 236 +++++++++ packages/app-api/src/routing/RouteResolver.ts | 223 ++++++++ .../app-api/src/routing/SubRouteRef.test.ts | 134 +++++ packages/app-api/src/routing/SubRouteRef.ts | 128 +++++ .../app-api/src/routing/collectors.test.tsx | 376 +++++++++++++ packages/app-api/src/routing/collectors.tsx | 161 ++++++ packages/app-api/src/routing/hooks.test.tsx | 372 +++++++++++++ packages/app-api/src/routing/hooks.tsx | 110 ++++ packages/app-api/src/routing/index.ts | 29 + packages/app-api/src/routing/types.ts | 97 ++++ packages/app-api/src/routing/validation.ts | 56 ++ packages/app-api/src/setupTests.ts | 18 + packages/app-api/src/types.ts | 63 +++ 155 files changed, 13375 insertions(+) create mode 100644 packages/app-api/.eslintrc.js create mode 100644 packages/app-api/README.md create mode 100644 packages/app-api/package.json create mode 100644 packages/app-api/src/apis/definitions/AlertApi.ts create mode 100644 packages/app-api/src/apis/definitions/AppThemeApi.ts create mode 100644 packages/app-api/src/apis/definitions/ConfigApi.ts create mode 100644 packages/app-api/src/apis/definitions/DiscoveryApi.ts create mode 100644 packages/app-api/src/apis/definitions/ErrorApi.ts create mode 100644 packages/app-api/src/apis/definitions/FeatureFlagsApi.ts create mode 100644 packages/app-api/src/apis/definitions/IdentityApi.ts create mode 100644 packages/app-api/src/apis/definitions/OAuthRequestApi.ts create mode 100644 packages/app-api/src/apis/definitions/StorageApi.ts create mode 100644 packages/app-api/src/apis/definitions/auth.ts create mode 100644 packages/app-api/src/apis/definitions/index.ts create mode 100644 packages/app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts create mode 100644 packages/app-api/src/apis/implementations/AlertApi/index.ts create mode 100644 packages/app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.test.ts create mode 100644 packages/app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts create mode 100644 packages/app-api/src/apis/implementations/AppThemeApi/index.ts create mode 100644 packages/app-api/src/apis/implementations/ConfigApi/index.ts create mode 100644 packages/app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts create mode 100644 packages/app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts create mode 100644 packages/app-api/src/apis/implementations/DiscoveryApi/index.ts create mode 100644 packages/app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts create mode 100644 packages/app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts create mode 100644 packages/app-api/src/apis/implementations/ErrorApi/index.ts create mode 100644 packages/app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx create mode 100644 packages/app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx create mode 100644 packages/app-api/src/apis/implementations/FeatureFlagsApi/index.ts create mode 100644 packages/app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts create mode 100644 packages/app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts create mode 100644 packages/app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts create mode 100644 packages/app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts create mode 100644 packages/app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts create mode 100644 packages/app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts create mode 100644 packages/app-api/src/apis/implementations/OAuthRequestApi/index.ts create mode 100644 packages/app-api/src/apis/implementations/StorageApi/WebStorage.test.ts create mode 100644 packages/app-api/src/apis/implementations/StorageApi/WebStorage.ts create mode 100644 packages/app-api/src/apis/implementations/StorageApi/index.ts create mode 100644 packages/app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts create mode 100644 packages/app-api/src/apis/implementations/auth/auth0/index.ts create mode 100644 packages/app-api/src/apis/implementations/auth/github/GithubAuth.test.ts create mode 100644 packages/app-api/src/apis/implementations/auth/github/GithubAuth.ts create mode 100644 packages/app-api/src/apis/implementations/auth/github/index.ts create mode 100644 packages/app-api/src/apis/implementations/auth/github/types.ts create mode 100644 packages/app-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts create mode 100644 packages/app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts create mode 100644 packages/app-api/src/apis/implementations/auth/gitlab/index.ts create mode 100644 packages/app-api/src/apis/implementations/auth/google/GoogleAuth.test.ts create mode 100644 packages/app-api/src/apis/implementations/auth/google/GoogleAuth.ts create mode 100644 packages/app-api/src/apis/implementations/auth/google/index.ts create mode 100644 packages/app-api/src/apis/implementations/auth/index.ts create mode 100644 packages/app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts create mode 100644 packages/app-api/src/apis/implementations/auth/microsoft/index.ts create mode 100644 packages/app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts create mode 100644 packages/app-api/src/apis/implementations/auth/oauth2/OAuth2.ts create mode 100644 packages/app-api/src/apis/implementations/auth/oauth2/index.ts create mode 100644 packages/app-api/src/apis/implementations/auth/oauth2/types.ts create mode 100644 packages/app-api/src/apis/implementations/auth/okta/OktaAuth.test.ts create mode 100644 packages/app-api/src/apis/implementations/auth/okta/OktaAuth.ts create mode 100644 packages/app-api/src/apis/implementations/auth/okta/index.ts create mode 100644 packages/app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts create mode 100644 packages/app-api/src/apis/implementations/auth/onelogin/index.ts create mode 100644 packages/app-api/src/apis/implementations/auth/saml/SamlAuth.ts create mode 100644 packages/app-api/src/apis/implementations/auth/saml/index.ts create mode 100644 packages/app-api/src/apis/implementations/auth/saml/types.ts create mode 100644 packages/app-api/src/apis/implementations/auth/types.ts create mode 100644 packages/app-api/src/apis/implementations/index.ts create mode 100644 packages/app-api/src/apis/index.ts create mode 100644 packages/app-api/src/apis/system/ApiAggregator.test.ts create mode 100644 packages/app-api/src/apis/system/ApiAggregator.ts create mode 100644 packages/app-api/src/apis/system/ApiFactoryRegistry.test.ts create mode 100644 packages/app-api/src/apis/system/ApiFactoryRegistry.ts create mode 100644 packages/app-api/src/apis/system/ApiProvider.test.tsx create mode 100644 packages/app-api/src/apis/system/ApiProvider.tsx create mode 100644 packages/app-api/src/apis/system/ApiRef.test.ts create mode 100644 packages/app-api/src/apis/system/ApiRef.ts create mode 100644 packages/app-api/src/apis/system/ApiRegistry.test.ts create mode 100644 packages/app-api/src/apis/system/ApiRegistry.ts create mode 100644 packages/app-api/src/apis/system/ApiResolver.test.ts create mode 100644 packages/app-api/src/apis/system/ApiResolver.ts create mode 100644 packages/app-api/src/apis/system/helpers.ts create mode 100644 packages/app-api/src/apis/system/index.ts create mode 100644 packages/app-api/src/apis/system/types.ts create mode 100644 packages/app-api/src/app/App.test.tsx create mode 100644 packages/app-api/src/app/App.tsx create mode 100644 packages/app-api/src/app/AppContext.test.tsx create mode 100644 packages/app-api/src/app/AppContext.tsx create mode 100644 packages/app-api/src/app/AppIdentity.ts create mode 100644 packages/app-api/src/app/AppThemeProvider.tsx create mode 100644 packages/app-api/src/app/index.ts create mode 100644 packages/app-api/src/app/types.ts create mode 100644 packages/app-api/src/extensions/componentData.test.tsx create mode 100644 packages/app-api/src/extensions/componentData.tsx create mode 100644 packages/app-api/src/extensions/extensions.test.tsx create mode 100644 packages/app-api/src/extensions/extensions.tsx create mode 100644 packages/app-api/src/extensions/index.ts create mode 100644 packages/app-api/src/extensions/traversal.test.tsx create mode 100644 packages/app-api/src/extensions/traversal.ts create mode 100644 packages/app-api/src/icons/icons.tsx create mode 100644 packages/app-api/src/icons/index.ts create mode 100644 packages/app-api/src/icons/types.ts create mode 100644 packages/app-api/src/index.ts create mode 100644 packages/app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts create mode 100644 packages/app-api/src/lib/AuthConnector/DefaultAuthConnector.ts create mode 100644 packages/app-api/src/lib/AuthConnector/DirectAuthConnector.ts create mode 100644 packages/app-api/src/lib/AuthConnector/MockAuthConnector.test.ts create mode 100644 packages/app-api/src/lib/AuthConnector/MockAuthConnector.ts create mode 100644 packages/app-api/src/lib/AuthConnector/index.ts create mode 100644 packages/app-api/src/lib/AuthConnector/types.ts create mode 100644 packages/app-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts create mode 100644 packages/app-api/src/lib/AuthSessionManager/AuthSessionStore.ts create mode 100644 packages/app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts create mode 100644 packages/app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts create mode 100644 packages/app-api/src/lib/AuthSessionManager/SessionStateTracker.ts create mode 100644 packages/app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts create mode 100644 packages/app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts create mode 100644 packages/app-api/src/lib/AuthSessionManager/common.ts create mode 100644 packages/app-api/src/lib/AuthSessionManager/index.ts create mode 100644 packages/app-api/src/lib/AuthSessionManager/types.ts create mode 100644 packages/app-api/src/lib/globalObject.test.ts create mode 100644 packages/app-api/src/lib/globalObject.ts create mode 100644 packages/app-api/src/lib/index.ts create mode 100644 packages/app-api/src/lib/loginPopup.test.ts create mode 100644 packages/app-api/src/lib/loginPopup.ts create mode 100644 packages/app-api/src/lib/subjects.test.ts create mode 100644 packages/app-api/src/lib/subjects.ts create mode 100644 packages/app-api/src/lib/versionedValues.test.ts create mode 100644 packages/app-api/src/lib/versionedValues.ts create mode 100644 packages/app-api/src/plugin/Plugin.tsx create mode 100644 packages/app-api/src/plugin/collectors.test.tsx create mode 100644 packages/app-api/src/plugin/collectors.ts create mode 100644 packages/app-api/src/plugin/index.ts create mode 100644 packages/app-api/src/plugin/types.ts create mode 100644 packages/app-api/src/private.ts create mode 100644 packages/app-api/src/public.ts create mode 100644 packages/app-api/src/routing/ExternalRouteRef.test.ts create mode 100644 packages/app-api/src/routing/ExternalRouteRef.ts create mode 100644 packages/app-api/src/routing/FlatRoutes.test.tsx create mode 100644 packages/app-api/src/routing/FlatRoutes.tsx create mode 100644 packages/app-api/src/routing/RouteRef.test.ts create mode 100644 packages/app-api/src/routing/RouteRef.ts create mode 100644 packages/app-api/src/routing/RouteResolver.test.ts create mode 100644 packages/app-api/src/routing/RouteResolver.ts create mode 100644 packages/app-api/src/routing/SubRouteRef.test.ts create mode 100644 packages/app-api/src/routing/SubRouteRef.ts create mode 100644 packages/app-api/src/routing/collectors.test.tsx create mode 100644 packages/app-api/src/routing/collectors.tsx create mode 100644 packages/app-api/src/routing/hooks.test.tsx create mode 100644 packages/app-api/src/routing/hooks.tsx create mode 100644 packages/app-api/src/routing/index.ts create mode 100644 packages/app-api/src/routing/types.ts create mode 100644 packages/app-api/src/routing/validation.ts create mode 100644 packages/app-api/src/setupTests.ts create mode 100644 packages/app-api/src/types.ts diff --git a/packages/app-api/.eslintrc.js b/packages/app-api/.eslintrc.js new file mode 100644 index 0000000000..d592a653c8 --- /dev/null +++ b/packages/app-api/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], + rules: { + // TODO: add prop types to JS and remove + 'react/prop-types': 0, + 'jest/expect-expect': 0, + }, +}; diff --git a/packages/app-api/README.md b/packages/app-api/README.md new file mode 100644 index 0000000000..1adce23b42 --- /dev/null +++ b/packages/app-api/README.md @@ -0,0 +1,20 @@ +# @backstage/app-api + +This package provides the core API used by Backstage plugins and apps. + +## Installation + +```sh +$ npm install --save @backstage/app-api +``` + +or + +```sh +$ yarn add @backstage/app-api +``` + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) diff --git a/packages/app-api/package.json b/packages/app-api/package.json new file mode 100644 index 0000000000..413ec6a73d --- /dev/null +++ b/packages/app-api/package.json @@ -0,0 +1,61 @@ +{ + "name": "@backstage/app-api", + "description": "Core app API used by Backstage plugins and apps", + "version": "0.1.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/app-api" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "backstage-cli build --outputs types,esm", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/config": "^0.1.3", + "@backstage/theme": "^0.2.3", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@types/react": "^16.9", + "@types/prop-types": "^15.7.3", + "prop-types": "^15.7.2", + "react": "^16.12.0", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^15.3.3", + "zen-observable": "^0.8.15" + }, + "devDependencies": { + "@backstage/cli": "^0.6.3", + "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils-core": "^0.1.1", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/react-hooks": "^3.3.0", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "@types/zen-observable": "^0.8.0", + "cross-fetch": "^3.0.6", + "msw": "^0.21.3" + }, + "files": [ + "dist" + ] +} diff --git a/packages/app-api/src/apis/definitions/AlertApi.ts b/packages/app-api/src/apis/definitions/AlertApi.ts new file mode 100644 index 0000000000..91641ac35b --- /dev/null +++ b/packages/app-api/src/apis/definitions/AlertApi.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createApiRef, ApiRef } from '../system'; +import { Observable } from '../../types'; + +export type AlertMessage = { + message: string; + // Severity will default to success since that is what material ui defaults the value to. + severity?: 'success' | 'info' | 'warning' | 'error'; +}; + +/** + * The alert API is used to report alerts to the app, and display them to the user. + */ + +export type AlertApi = { + /** + * Post an alert for handling by the application. + */ + post(alert: AlertMessage): void; + + /** + * Observe alerts posted by other parts of the application. + */ + alert$(): Observable; +}; + +export const alertApiRef: ApiRef = createApiRef({ + id: 'core.alert', + description: 'Used to report alerts and forward them to the app', +}); diff --git a/packages/app-api/src/apis/definitions/AppThemeApi.ts b/packages/app-api/src/apis/definitions/AppThemeApi.ts new file mode 100644 index 0000000000..515e8df082 --- /dev/null +++ b/packages/app-api/src/apis/definitions/AppThemeApi.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiRef, createApiRef } from '../system'; +import { BackstageTheme } from '@backstage/theme'; +import { Observable } from '../../types'; +import { SvgIconProps } from '@material-ui/core'; + +/** + * Describes a theme provided by the app. + */ +export type AppTheme = { + /** + * ID used to remember theme selections. + */ + id: string; + + /** + * Title of the theme + */ + title: string; + + /** + * Theme variant + */ + variant: 'light' | 'dark'; + + /** + * The specialized MaterialUI theme instance. + */ + theme: BackstageTheme; + + /** + * An Icon for the theme mode setting. + */ + icon?: React.ReactElement; +}; + +/** + * The AppThemeApi gives access to the current app theme, and allows switching + * to other options that have been registered as a part of the App. + */ +export type AppThemeApi = { + /** + * Get a list of available themes. + */ + getInstalledThemes(): AppTheme[]; + + /** + * Observe the currently selected theme. A value of undefined means no specific theme has been selected. + */ + activeThemeId$(): Observable; + + /** + * Get the current theme ID. Returns undefined if no specific theme is selected. + */ + getActiveThemeId(): string | undefined; + + /** + * Set a specific theme to use in the app, overriding the default theme selection. + * + * Clear the selection by passing in undefined. + */ + setActiveThemeId(themeId?: string): void; +}; + +export const appThemeApiRef: ApiRef = createApiRef({ + id: 'core.apptheme', + description: 'API Used to configure the app theme, and enumerate options', +}); diff --git a/packages/app-api/src/apis/definitions/ConfigApi.ts b/packages/app-api/src/apis/definitions/ConfigApi.ts new file mode 100644 index 0000000000..459a361cf7 --- /dev/null +++ b/packages/app-api/src/apis/definitions/ConfigApi.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ApiRef, createApiRef } from '../system'; +import { Config } from '@backstage/config'; + +/** + * The Config API is used to provide a mechanism to access the + * runtime configuration of the system. + */ +export type ConfigApi = Config; + +export const configApiRef: ApiRef = createApiRef({ + id: 'core.config', + description: 'Used to access runtime configuration', +}); diff --git a/packages/app-api/src/apis/definitions/DiscoveryApi.ts b/packages/app-api/src/apis/definitions/DiscoveryApi.ts new file mode 100644 index 0000000000..f62a97b61a --- /dev/null +++ b/packages/app-api/src/apis/definitions/DiscoveryApi.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ApiRef, createApiRef } from '../system'; + +/** + * The discovery API is used to provide a mechanism for plugins to + * discover the endpoint to use to talk to their backend counterpart. + * + * The purpose of the discovery API is to allow for many different deployment + * setups and routing methods through a central configuration, instead + * of letting each individual plugin manage that configuration. + * + * Implementations of the discovery API can be a simple as a URL pattern + * using the pluginId, but could also have overrides for individual plugins, + * or query a separate discovery service. + */ +export type DiscoveryApi = { + /** + * Returns the HTTP base backend URL for a given plugin, without a trailing slash. + * + * This method must always be called just before making a request, as opposed to + * fetching the URL when constructing an API client. That is to ensure that more + * flexible routing patterns can be supported. + * + * For example, asking for the URL for `auth` may return something + * like `https://backstage.example.com/api/auth` + */ + getBaseUrl(pluginId: string): Promise; +}; + +export const discoveryApiRef: ApiRef = createApiRef({ + id: 'core.discovery', + description: 'Provides service discovery of backend plugins', +}); diff --git a/packages/app-api/src/apis/definitions/ErrorApi.ts b/packages/app-api/src/apis/definitions/ErrorApi.ts new file mode 100644 index 0000000000..6205f8e058 --- /dev/null +++ b/packages/app-api/src/apis/definitions/ErrorApi.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiRef, createApiRef } from '../system'; +import { Observable } from '../../types'; + +/** + * Mirrors the JavaScript Error class, for the purpose of + * providing documentation and optional fields. + */ +type Error = { + name: string; + message: string; + stack?: string; +}; + +/** + * Provides additional information about an error that was posted to the application. + */ +export type ErrorContext = { + // If set to true, this error should not be displayed to the user. Defaults to false. + hidden?: boolean; +}; + +/** + * The error API is used to report errors to the app, and display them to the user. + * + * Plugins can use this API as a method of displaying errors to the user, but also + * to report errors for collection by error reporting services. + * + * If an error can be displayed inline, e.g. as feedback in a form, that should be + * preferred over relying on this API to display the error. The main use of this API + * for displaying errors should be for asynchronous errors, such as a failing background process. + * + * Even if an error is displayed inline, it should still be reported through this API + * if it would be useful to collect or log it for debugging purposes, but with + * the hidden flag set. For example, an error arising from form field validation + * should probably not be reported, while a failed REST call would be useful to report. + */ +export type ErrorApi = { + /** + * Post an error for handling by the application. + */ + post(error: Error, context?: ErrorContext): void; + + /** + * Observe errors posted by other parts of the application. + */ + error$(): Observable<{ error: Error; context?: ErrorContext }>; +}; + +export const errorApiRef: ApiRef = createApiRef({ + id: 'core.error', + description: 'Used to report errors and forward them to the app', +}); diff --git a/packages/app-api/src/apis/definitions/FeatureFlagsApi.ts b/packages/app-api/src/apis/definitions/FeatureFlagsApi.ts new file mode 100644 index 0000000000..243af562b1 --- /dev/null +++ b/packages/app-api/src/apis/definitions/FeatureFlagsApi.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiRef, createApiRef } from '../system'; + +/** + * The feature flags API is used to toggle functionality to users across plugins and Backstage. + * + * Plugins can use this API to register feature flags that they have available + * for users to enable/disable, and this API will centralize the current user's + * state of which feature flags they would like to enable. + * + * This is ideal for Backstage plugins, as well as your own App, to trial incomplete + * or unstable upcoming features. Although there will be a common interface for users + * to enable and disable feature flags, this API acts as another way to enable/disable. + */ + +export type FeatureFlag = { + name: string; + pluginId: string; +}; + +export enum FeatureFlagState { + None = 0, + Active = 1, +} + +/** + * Options to use when saving feature flags. + */ +export type FeatureFlagsSaveOptions = { + /** + * The new feature flag states to save. + */ + states: Record; + + /** + * Whether the saves states should be merged into the existing ones, or replace them. + * + * Defaults to false. + */ + merge?: boolean; +}; + +export type UserFlags = {}; + +export interface FeatureFlagsApi { + /** + * Registers a new feature flag. Once a feature flag has been registered it + * can be toggled by users, and read back to enable or disable features. + */ + registerFlag(flag: FeatureFlag): void; + + /** + * Get a list of all registered flags. + */ + getRegisteredFlags(): FeatureFlag[]; + + /** + * Whether the feature flag with the given name is currently activated for the user. + */ + isActive(name: string): boolean; + + /** + * Save the user's choice of feature flag states. + */ + save(options: FeatureFlagsSaveOptions): void; +} + +export const featureFlagsApiRef: ApiRef = createApiRef({ + id: 'core.featureflags', + description: 'Used to toggle functionality in features across Backstage', +}); diff --git a/packages/app-api/src/apis/definitions/IdentityApi.ts b/packages/app-api/src/apis/definitions/IdentityApi.ts new file mode 100644 index 0000000000..2d29709b02 --- /dev/null +++ b/packages/app-api/src/apis/definitions/IdentityApi.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ApiRef, createApiRef } from '../system'; +import { ProfileInfo } from './auth'; + +/** + * The Identity API used to identify and get information about the signed in user. + */ +export type IdentityApi = { + /** + * The ID of the signed in user. This ID is not meant to be presented to the user, but used + * as an opaque string to pass on to backends or use in frontend logic. + * + * TODO: The intention of the user ID is to be able to tie the user to an identity + * that is known by the catalog and/or identity backend. It should for example + * be possible to fetch all owned components using this ID. + */ + getUserId(): string; + + // TODO: getProfile(): Promise - We want this to be async when added, but needs more work. + /** + * The profile of the signed in user. + */ + getProfile(): ProfileInfo; + + /** + * An OpenID Connect ID Token which proves the identity of the signed in user. + * + * The ID token will be undefined if the signed in user does not have a verified + * identity, such as a demo user or mocked user for e2e tests. + */ + getIdToken(): Promise; + + /** + * Sign out the current user + */ + signOut(): Promise; +}; + +export const identityApiRef: ApiRef = createApiRef({ + id: 'core.identity', + description: 'Provides access to the identity of the signed in user', +}); diff --git a/packages/app-api/src/apis/definitions/OAuthRequestApi.ts b/packages/app-api/src/apis/definitions/OAuthRequestApi.ts new file mode 100644 index 0000000000..b9776ed037 --- /dev/null +++ b/packages/app-api/src/apis/definitions/OAuthRequestApi.ts @@ -0,0 +1,133 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IconComponent } from '../../icons'; +import { Observable } from '../../types'; +import { ApiRef, createApiRef } from '../system'; + +/** + * Information about the auth provider that we're requesting a login towards. + * + * This should be shown to the user so that they can be informed about what login is being requested + * before a popup is shown. + */ +export type AuthProvider = { + /** + * Title for the auth provider, for example "GitHub" + */ + title: string; + + /** + * Icon for the auth provider. + */ + icon: IconComponent; +}; + +/** + * Describes how to handle auth requests. Both how to show them to the user, and what to do when + * the user accesses the auth request. + */ +export type AuthRequesterOptions = { + /** + * Information about the auth provider, which will be forwarded to auth requests. + */ + provider: AuthProvider; + + /** + * Implementation of the auth flow, which will be called synchronously when + * trigger() is called on an auth requests. + */ + onAuthRequest(scopes: Set): Promise; +}; + +/** + * Function used to trigger new auth requests for a set of scopes. + * + * The returned promise will resolve to the same value returned by the onAuthRequest in the + * AuthRequesterOptions. Or rejected, if the request is rejected. + * + * This function can be called multiple times before the promise resolves. All calls + * will be merged into one request, and the scopes forwarded to the onAuthRequest will be the + * union of all requested scopes. + */ +export type AuthRequester = ( + scopes: Set, +) => Promise; + +/** + * An pending auth request for a single auth provider. The request will remain in this pending + * state until either reject() or trigger() is called. + * + * Any new requests for the same provider are merged into the existing pending request, meaning + * there will only ever be a single pending request for a given provider. + */ +export type PendingAuthRequest = { + /** + * Information about the auth provider, as given in the AuthRequesterOptions + */ + provider: AuthProvider; + + /** + * Rejects the request, causing all pending AuthRequester calls to fail with "RejectedError". + */ + reject: () => void; + + /** + * Trigger the auth request to continue the auth flow, by for example showing a popup. + * + * Synchronously calls onAuthRequest with all scope currently in the request. + */ + trigger(): Promise; +}; + +/** + * Provides helpers for implemented OAuth login flows within Backstage. + */ +export type OAuthRequestApi = { + /** + * A utility for showing login popups or similar things, and merging together multiple requests for + * different scopes into one request that includes all scopes. + * + * The passed in options provide information about the login provider, and how to handle auth requests. + * + * The returned AuthRequester function is used to request login with new scopes. These requests + * are merged together and forwarded to the auth handler, as soon as a consumer of auth requests + * triggers an auth flow. + * + * See AuthRequesterOptions, AuthRequester, and handleAuthRequests for more info. + */ + createAuthRequester( + options: AuthRequesterOptions, + ): AuthRequester; + + /** + * Observers pending auth requests. The returned observable will emit all + * current active auth request, at most one for each created auth requester. + * + * Each request has its own info about the login provider, forwarded from the auth requester options. + * + * Depending on user interaction, the request should either be rejected, or used to trigger the auth handler. + * If the request is rejected, all pending AuthRequester calls will fail with a "RejectedError". + * If a auth is triggered, and the auth handler resolves successfully, then all currently pending + * AuthRequester calls will resolve to the value returned by the onAuthRequest call. + */ + authRequest$(): Observable; +}; + +export const oauthRequestApiRef: ApiRef = createApiRef({ + id: 'core.oauthrequest', + description: 'An API for implementing unified OAuth flows in Backstage', +}); diff --git a/packages/app-api/src/apis/definitions/StorageApi.ts b/packages/app-api/src/apis/definitions/StorageApi.ts new file mode 100644 index 0000000000..2482af6d63 --- /dev/null +++ b/packages/app-api/src/apis/definitions/StorageApi.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiRef, createApiRef } from '../system'; +import { Observable } from '../../types'; +import { ErrorApi } from './ErrorApi'; + +export type StorageValueChange = { + key: string; + newValue?: T; +}; + +export type CreateStorageApiOptions = { + errorApi: ErrorApi; + namespace?: string; +}; + +export interface StorageApi { + /** + * Create a bucket to store data in. + * @param {String} name Namespace for the storage to be stored under, + * will inherit previous namespaces too + */ + forBucket(name: string): StorageApi; + + /** + * Get the current value for persistent data, use observe$ to be notified of updates. + * + * @param {String} key Unique key associated with the data. + * @return {Object} data The data that should is stored. + */ + get(key: string): T | undefined; + + /** + * Remove persistent data. + * + * @param {String} key Unique key associated with the data. + */ + remove(key: string): Promise; + + /** + * Save persistent data, and emit messages to anyone that is using observe$ for this key + * + * @param {String} key Unique key associated with the data. + */ + set(key: string, data: any): Promise; + + /** + * Observe changes on a particular key in the bucket + * @param {String} key Unique key associated with the data + */ + observe$(key: string): Observable>; +} + +export const storageApiRef: ApiRef = createApiRef({ + id: 'core.storage', + description: 'Provides the ability to store data which is unique to the user', +}); diff --git a/packages/app-api/src/apis/definitions/auth.ts b/packages/app-api/src/apis/definitions/auth.ts new file mode 100644 index 0000000000..30b07887ad --- /dev/null +++ b/packages/app-api/src/apis/definitions/auth.ts @@ -0,0 +1,347 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiRef, createApiRef } from '../system'; +import { Observable } from '../../types'; + +/** + * This file contains declarations for common interfaces of auth-related APIs. + * The declarations should be used to signal which type of authentication and + * authorization methods each separate auth provider supports. + * + * For example, a Google OAuth provider that supports OAuth 2 and OpenID Connect, + * would be declared as follows: + * + * const googleAuthApiRef = createApiRef({ ... }) + */ + +/** + * An array of scopes, or a scope string formatted according to the + * auth provider, which is typically a space separated list. + * + * See the documentation for each auth provider for the list of scopes + * supported by each provider. + */ +export type OAuthScope = string | string[]; + +export type AuthRequestOptions = { + /** + * If this is set to true, the user will not be prompted to log in, + * and an empty response will be returned if there is no existing session. + * + * This can be used to perform a check whether the user is logged in, or if you don't + * want to force a user to be logged in, but provide functionality if they already are. + * + * @default false + */ + optional?: boolean; + + /** + * If this is set to true, the request will bypass the regular oauth login modal + * and open the login popup directly. + * + * The method must be called synchronously from a user action for this to work in all browsers. + * + * @default false + */ + instantPopup?: boolean; +}; + +/** + * This API provides access to OAuth 2 credentials. It lets you request access tokens, + * which can be used to act on behalf of the user when talking to APIs. + */ +export type OAuthApi = { + /** + * Requests an OAuth 2 Access Token, optionally with a set of scopes. The access token allows + * you to make requests on behalf of the user, and the copes may grant you broader access, depending + * on the auth provider. + * + * Each auth provider has separate handling of scope, so you need to look at the documentation + * for each one to know what scope you need to request. + * + * This method is cheap and should be called each time an access token is used. Do not for example + * store the access token in React component state, as that could cause the token to expire. Instead + * fetch a new access token for each request. + * + * Be sure to include all required scopes when requesting an access token. When testing your implementation + * it is best to log out the Backstage session and then visit your plugin page directly, as + * you might already have some required scopes in your existing session. Not requesting the correct + * scopes can lead to 403 or other authorization errors, which can be tricky to debug. + * + * If the user has not yet granted access to the provider and the set of requested scopes, the user + * will be prompted to log in. The returned promise will not resolve until the user has + * successfully logged in. The returned promise can be rejected, but only if the user rejects the login request. + */ + getAccessToken( + scope?: OAuthScope, + options?: AuthRequestOptions, + ): Promise; +}; + +/** + * This API provides access to OpenID Connect credentials. It lets you request ID tokens, + * which can be passed to backend services to prove the user's identity. + */ +export type OpenIdConnectApi = { + /** + * Requests an OpenID Connect ID Token. + * + * This method is cheap and should be called each time an ID token is used. Do not for example + * store the id token in React component state, as that could cause the token to expire. Instead + * fetch a new id token for each request. + * + * If the user has not yet logged in to Google inside Backstage, the user will be prompted + * to log in. The returned promise will not resolve until the user has successfully logged in. + * The returned promise can be rejected, but only if the user rejects the login request. + */ + getIdToken(options?: AuthRequestOptions): Promise; +}; + +/** + * This API provides access to profile information of the user from an auth provider. + */ +export type ProfileInfoApi = { + /** + * Get profile information for the user as supplied by this auth provider. + * + * If the optional flag is not set, a session is guaranteed to be returned, while if + * the optional flag is set, the session may be undefined. See @AuthRequestOptions for more details. + */ + getProfile(options?: AuthRequestOptions): Promise; +}; + +/** + * This API provides access to the user's identity within Backstage. + * + * An auth provider that implements this interface can be used to sign-in to backstage. It is + * not intended to be used directly from a plugin, but instead serves as a connection between + * this authentication method and the app's @IdentityApi + */ +export type BackstageIdentityApi = { + /** + * Get the user's identity within Backstage. This should normally not be called directly, + * use the @IdentityApi instead. + * + * If the optional flag is not set, a session is guaranteed to be returned, while if + * the optional flag is set, the session may be undefined. See @AuthRequestOptions for more details. + */ + getBackstageIdentity( + options?: AuthRequestOptions, + ): Promise; +}; + +export type BackstageIdentity = { + /** + * The backstage user ID. + */ + id: string; + + /** + * An ID token that can be used to authenticate the user within Backstage. + */ + idToken: string; +}; + +/** + * Profile information of the user. + */ +export type ProfileInfo = { + /** + * Email ID. + */ + email?: string; + + /** + * Display name that can be presented to the user. + */ + displayName?: string; + + /** + * URL to an avatar image of the user. + */ + picture?: string; +}; + +/** + * Session state values passed to subscribers of the SessionApi. + */ +export enum SessionState { + SignedIn = 'SignedIn', + SignedOut = 'SignedOut', +} + +/** + * The SessionApi provides basic controls for any auth provider that is tied to a persistent session. + */ +export type SessionApi = { + /** + * Sign in with a minimum set of permissions. + */ + signIn(): Promise; + + /** + * Sign out from the current session. This will reload the page. + */ + signOut(): Promise; + + /** + * Observe the current state of the auth session. Emits the current state on subscription. + */ + sessionState$(): Observable; +}; + +/** + * Provides authentication towards Google APIs and identities. + * + * See https://developers.google.com/identity/protocols/googlescopes for a full list of supported scopes. + * + * Note that the ID token payload is only guaranteed to contain the user's numerical Google ID, + * email and expiration information. Do not rely on any other fields, as they might not be present. + */ +export const googleAuthApiRef: ApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +> = createApiRef({ + id: 'core.auth.google', + description: 'Provides authentication towards Google APIs and identities', +}); + +/** + * Provides authentication towards GitHub APIs. + * + * See https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/ + * for a full list of supported scopes. + */ +export const githubAuthApiRef: ApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +> = createApiRef({ + id: 'core.auth.github', + description: 'Provides authentication towards GitHub APIs', +}); + +/** + * Provides authentication towards Okta APIs. + * + * See https://developer.okta.com/docs/guides/implement-oauth-for-okta/scopes/ + * for a full list of supported scopes. + */ +export const oktaAuthApiRef: ApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +> = createApiRef({ + id: 'core.auth.okta', + description: 'Provides authentication towards Okta APIs', +}); + +/** + * Provides authentication towards GitLab APIs. + * + * See https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#limiting-scopes-of-a-personal-access-token + * for a full list of supported scopes. + */ +export const gitlabAuthApiRef: ApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +> = createApiRef({ + id: 'core.auth.gitlab', + description: 'Provides authentication towards GitLab APIs', +}); + +/** + * Provides authentication towards Auth0 APIs. + * + * See https://auth0.com/docs/scopes/current/oidc-scopes + * for a full list of supported scopes. + */ +export const auth0AuthApiRef: ApiRef< + OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +> = createApiRef({ + id: 'core.auth.auth0', + description: 'Provides authentication towards Auth0 APIs', +}); + +/** + * Provides authentication towards Microsoft APIs and identities. + * + * For more info and a full list of supported scopes, see: + * - https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent + * - https://docs.microsoft.com/en-us/graph/permissions-reference + */ +export const microsoftAuthApiRef: ApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +> = createApiRef({ + id: 'core.auth.microsoft', + description: 'Provides authentication towards Microsoft APIs and identities', +}); + +/** + * Provides authentication for custom identity providers. + */ +export const oauth2ApiRef: ApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +> = createApiRef({ + id: 'core.auth.oauth2', + description: 'Example of how to use oauth2 custom provider', +}); + +/** + * Provides authentication for custom OpenID Connect identity providers. + */ +export const oidcAuthApiRef: ApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +> = createApiRef({ + id: 'core.auth.oidc', + description: 'Example of how to use oidc custom provider', +}); + +/** + * Provides authentication for saml based identity providers + */ +export const samlAuthApiRef: ApiRef< + ProfileInfoApi & BackstageIdentityApi & SessionApi +> = createApiRef({ + id: 'core.auth.saml', + description: 'Example of how to use SAML custom provider', +}); + +export const oneloginAuthApiRef: ApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +> = createApiRef({ + id: 'core.auth.onelogin', + description: 'Provides authentication towards OneLogin APIs and identities', +}); diff --git a/packages/app-api/src/apis/definitions/index.ts b/packages/app-api/src/apis/definitions/index.ts new file mode 100644 index 0000000000..e29d1022c4 --- /dev/null +++ b/packages/app-api/src/apis/definitions/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This folder contains definitions for all core APIs. +// +// Plugins should rely on these APIs for functionality as much as possible. +// +// If you think some API definition is missing, please open an Issue or send a PR! + +export * from './auth'; + +export * from './AlertApi'; +export * from './AppThemeApi'; +export * from './ConfigApi'; +export * from './DiscoveryApi'; +export * from './ErrorApi'; +export * from './FeatureFlagsApi'; +export * from './IdentityApi'; +export * from './OAuthRequestApi'; +export * from './StorageApi'; diff --git a/packages/app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts b/packages/app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts new file mode 100644 index 0000000000..91a606059d --- /dev/null +++ b/packages/app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { AlertApi, AlertMessage } from '../..'; +import { PublishSubject } from '../../../lib'; +import { Observable } from '../../../types'; + +/** + * Base implementation for the AlertApi that simply forwards alerts to consumers. + */ +export class AlertApiForwarder implements AlertApi { + private readonly subject = new PublishSubject(); + + post(alert: AlertMessage) { + this.subject.next(alert); + } + + alert$(): Observable { + return this.subject; + } +} diff --git a/packages/app-api/src/apis/implementations/AlertApi/index.ts b/packages/app-api/src/apis/implementations/AlertApi/index.ts new file mode 100644 index 0000000000..12ab8bc60c --- /dev/null +++ b/packages/app-api/src/apis/implementations/AlertApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { AlertApiForwarder } from './AlertApiForwarder'; diff --git a/packages/app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.test.ts b/packages/app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.test.ts new file mode 100644 index 0000000000..0c23fe5219 --- /dev/null +++ b/packages/app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AppTheme } from '../../definitions'; +import { AppThemeSelector } from './AppThemeSelector'; + +describe('AppThemeSelector', () => { + it('should should select new themes', async () => { + const selector = new AppThemeSelector([]); + + expect(selector.getInstalledThemes()).toEqual([]); + + const subFn = jest.fn(); + selector.activeThemeId$().subscribe(subFn); + expect(selector.getActiveThemeId()).toBe(undefined); + await 'wait a tick'; + expect(subFn).toHaveBeenLastCalledWith(undefined); + + selector.setActiveThemeId('x'); + expect(subFn).toHaveBeenLastCalledWith('x'); + expect(selector.getActiveThemeId()).toBe('x'); + + selector.setActiveThemeId(undefined); + expect(subFn).toHaveBeenLastCalledWith(undefined); + expect(selector.getActiveThemeId()).toBe(undefined); + }); + + it('should return a new array of themes', () => { + const themes = new Array(); + const selector = new AppThemeSelector(themes); + + expect(selector.getInstalledThemes()).toEqual(themes); + expect(selector.getInstalledThemes()).not.toBe(themes); + }); + + it('should store theme in local storage', async () => { + expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe( + undefined, + ); + localStorage.setItem('theme', 'x'); + expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe('x'); + localStorage.removeItem('theme'); + expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe( + undefined, + ); + + const addListenerSpy = jest.spyOn(window, 'addEventListener'); + const selector = AppThemeSelector.createWithStorage([]); + + expect(addListenerSpy).toHaveBeenCalledTimes(1); + expect(addListenerSpy).toHaveBeenCalledWith( + 'storage', + expect.any(Function), + ); + + selector.setActiveThemeId('y'); + await 'wait a tick'; + expect(localStorage.getItem('theme')).toBe('y'); + + selector.setActiveThemeId(undefined); + await 'wait a tick'; + expect(localStorage.getItem('theme')).toBe(null); + + localStorage.setItem('theme', 'z'); + expect(selector.getActiveThemeId()).toBe(undefined); + + const listener = addListenerSpy.mock.calls[0][1] as EventListener; + listener({ key: 'theme' } as StorageEvent); + + expect(selector.getActiveThemeId()).toBe('z'); + }); +}); diff --git a/packages/app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts b/packages/app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts new file mode 100644 index 0000000000..43b52a254b --- /dev/null +++ b/packages/app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AppThemeApi, AppTheme } from '../../definitions'; +import { BehaviorSubject } from '../../../lib'; +import { Observable } from '../../../types'; + +const STORAGE_KEY = 'theme'; + +export class AppThemeSelector implements AppThemeApi { + static createWithStorage(themes: AppTheme[]) { + const selector = new AppThemeSelector(themes); + + if (!window.localStorage) { + return selector; + } + + const initialThemeId = + window.localStorage.getItem(STORAGE_KEY) ?? undefined; + + selector.setActiveThemeId(initialThemeId); + + selector.activeThemeId$().subscribe(themeId => { + if (themeId) { + window.localStorage.setItem(STORAGE_KEY, themeId); + } else { + window.localStorage.removeItem(STORAGE_KEY); + } + }); + + window.addEventListener('storage', event => { + if (event.key === STORAGE_KEY) { + const themeId = localStorage.getItem(STORAGE_KEY) ?? undefined; + selector.setActiveThemeId(themeId); + } + }); + + return selector; + } + + private activeThemeId: string | undefined; + private readonly subject = new BehaviorSubject(undefined); + + constructor(private readonly themes: AppTheme[]) {} + + getInstalledThemes(): AppTheme[] { + return this.themes.slice(); + } + + activeThemeId$(): Observable { + return this.subject; + } + + getActiveThemeId(): string | undefined { + return this.activeThemeId; + } + + setActiveThemeId(themeId?: string): void { + this.activeThemeId = themeId; + this.subject.next(themeId); + } +} diff --git a/packages/app-api/src/apis/implementations/AppThemeApi/index.ts b/packages/app-api/src/apis/implementations/AppThemeApi/index.ts new file mode 100644 index 0000000000..cb42c0f875 --- /dev/null +++ b/packages/app-api/src/apis/implementations/AppThemeApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './AppThemeSelector'; diff --git a/packages/app-api/src/apis/implementations/ConfigApi/index.ts b/packages/app-api/src/apis/implementations/ConfigApi/index.ts new file mode 100644 index 0000000000..7c7f88a3e5 --- /dev/null +++ b/packages/app-api/src/apis/implementations/ConfigApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ConfigReader } from '@backstage/config'; diff --git a/packages/app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts b/packages/app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts new file mode 100644 index 0000000000..9597443b98 --- /dev/null +++ b/packages/app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { UrlPatternDiscovery } from './UrlPatternDiscovery'; + +describe('UrlPatternDiscovery', () => { + it('should not require interpolation', async () => { + const discoveryApi = UrlPatternDiscovery.compile('http://example.com'); + await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe( + 'http://example.com', + ); + }); + + it('should use a plain pattern', async () => { + const discoveryApi = UrlPatternDiscovery.compile( + 'http://localhost:7000/{{ pluginId }}', + ); + await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe( + 'http://localhost:7000/my-plugin', + ); + }); + + it('should allow for multiple interpolation points', async () => { + const discoveryApi = UrlPatternDiscovery.compile( + 'https://{{pluginId }}.example.com/api/{{ pluginId}}', + ); + await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe( + 'https://my-plugin.example.com/api/my-plugin', + ); + }); + + it('should validate that the pattern is a valid URL', () => { + expect(() => { + UrlPatternDiscovery.compile('example.com'); + }).toThrow('Invalid discovery URL pattern, Invalid URL: example.com'); + + expect(() => { + UrlPatternDiscovery.compile('http://'); + }).toThrow('Invalid discovery URL pattern, Invalid URL: http://'); + + expect(() => { + UrlPatternDiscovery.compile('abc123'); + }).toThrow('Invalid discovery URL pattern, Invalid URL: abc123'); + + expect(() => { + UrlPatternDiscovery.compile('http://example.com:{{pluginId}}'); + }).toThrow( + 'Invalid discovery URL pattern, Invalid URL: http://example.com:pluginId', + ); + + expect(() => { + UrlPatternDiscovery.compile('/{{pluginId}}'); + }).toThrow('Invalid discovery URL pattern, Invalid URL: /pluginId'); + + expect(() => { + UrlPatternDiscovery.compile('http://localhost/{{pluginId}}?forbidden'); + }).toThrow('Invalid discovery URL pattern, URL must not have a query'); + + expect(() => { + UrlPatternDiscovery.compile('http://localhost/{{pluginId}}#forbidden'); + }).toThrow('Invalid discovery URL pattern, URL must not have a hash'); + + expect(() => { + UrlPatternDiscovery.compile('http://localhost/{{pluginId}}/'); + }).toThrow('Invalid discovery URL pattern, URL must not end with a slash'); + + expect(() => { + UrlPatternDiscovery.compile('http://localhost/'); + }).toThrow('Invalid discovery URL pattern, URL must not end with a slash'); + }); +}); diff --git a/packages/app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts b/packages/app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts new file mode 100644 index 0000000000..ca48784584 --- /dev/null +++ b/packages/app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DiscoveryApi } from '../../definitions/DiscoveryApi'; + +/** + * UrlPatternDiscovery is a lightweight DiscoveryApi implementation. + * It uses a single template string to construct URLs for each plugin. + */ +export class UrlPatternDiscovery implements DiscoveryApi { + /** + * Creates a new UrlPatternDiscovery given a template. The the only + * interpolation done for the template is to replace instances of `{{pluginId}}` + * with the ID of the plugin being requested. + * + * Example pattern: `http://localhost:7000/api/{{ pluginId }}` + */ + static compile(pattern: string): UrlPatternDiscovery { + const parts = pattern.split(/\{\{\s*pluginId\s*\}\}/); + + try { + const urlStr = parts.join('pluginId'); + const url = new URL(urlStr); + if (url.hash) { + throw new Error('URL must not have a hash'); + } + if (url.search) { + throw new Error('URL must not have a query'); + } + if (urlStr.endsWith('/')) { + throw new Error('URL must not end with a slash'); + } + } catch (error) { + throw new Error(`Invalid discovery URL pattern, ${error.message}`); + } + + return new UrlPatternDiscovery(parts); + } + + private constructor(private readonly parts: string[]) {} + + async getBaseUrl(pluginId: string): Promise { + return this.parts.join(pluginId); + } +} diff --git a/packages/app-api/src/apis/implementations/DiscoveryApi/index.ts b/packages/app-api/src/apis/implementations/DiscoveryApi/index.ts new file mode 100644 index 0000000000..60a5b815e7 --- /dev/null +++ b/packages/app-api/src/apis/implementations/DiscoveryApi/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This folder contains implementations for all core APIs. +// +// Plugins should rely on these APIs for functionality as much as possible. + +export { UrlPatternDiscovery } from './UrlPatternDiscovery'; diff --git a/packages/app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts b/packages/app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts new file mode 100644 index 0000000000..162b28f334 --- /dev/null +++ b/packages/app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ErrorApi, ErrorContext, AlertApi } from '../..'; + +/** + * Decorates an ErrorApi by also forwarding error messages + * to the alertApi with an 'error' severity. + */ +export class ErrorAlerter implements ErrorApi { + constructor( + private readonly alertApi: AlertApi, + private readonly errorApi: ErrorApi, + ) {} + + post(error: Error, context?: ErrorContext) { + if (!context?.hidden) { + this.alertApi.post({ message: error.message, severity: 'error' }); + } + + return this.errorApi.post(error, context); + } + + error$() { + return this.errorApi.error$(); + } +} diff --git a/packages/app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts b/packages/app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts new file mode 100644 index 0000000000..2ebf9db7a8 --- /dev/null +++ b/packages/app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ErrorApi, ErrorContext } from '../..'; +import { PublishSubject } from '../../../lib'; +import { Observable } from '../../../types'; + +/** + * Base implementation for the ErrorApi that simply forwards errors to consumers. + */ +export class ErrorApiForwarder implements ErrorApi { + private readonly subject = new PublishSubject<{ + error: Error; + context?: ErrorContext; + }>(); + + post(error: Error, context?: ErrorContext) { + this.subject.next({ error, context }); + } + + error$(): Observable<{ error: Error; context?: ErrorContext }> { + return this.subject; + } +} diff --git a/packages/app-api/src/apis/implementations/ErrorApi/index.ts b/packages/app-api/src/apis/implementations/ErrorApi/index.ts new file mode 100644 index 0000000000..757dfd0d8f --- /dev/null +++ b/packages/app-api/src/apis/implementations/ErrorApi/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ErrorAlerter } from './ErrorAlerter'; +export { ErrorApiForwarder } from './ErrorApiForwarder'; diff --git a/packages/app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx b/packages/app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx new file mode 100644 index 0000000000..2902e399c9 --- /dev/null +++ b/packages/app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx @@ -0,0 +1,222 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags'; +import { FeatureFlagState, FeatureFlagsApi } from '../../definitions'; + +describe('FeatureFlags', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + describe('getFlags', () => { + let featureFlags: FeatureFlagsApi; + + beforeEach(() => { + featureFlags = new LocalStorageFeatureFlags(); + }); + + it('returns no flags', () => { + expect(featureFlags.getRegisteredFlags()).toEqual([]); + }); + + it('loads flags from local storage', () => { + window.localStorage.setItem( + 'featureFlags', + JSON.stringify({ + 'feature-flag-one': 1, + 'feature-flag-two': 1, + 'feature-flag-three': 0, + 'feature-flag-four': 2, + 'feature-flag-five': 'not-valid', + }), + ); + + expect(featureFlags.isActive('feature-flag-one')).toBe(true); + expect(featureFlags.isActive('feature-flag-two')).toBe(true); + expect(featureFlags.isActive('feature-flag-three')).toBe(false); + expect(featureFlags.isActive('feature-flag-four')).toBe(false); + expect(featureFlags.isActive('feature-flag-five')).toBe(false); + }); + + it('sets the correct values', () => { + featureFlags.save({ + states: { + 'feature-flag-zero': FeatureFlagState.Active, + }, + }); + + expect(featureFlags.isActive('feature-flag-zero')).toBe(true); + expect(window.localStorage.getItem('featureFlags')).toEqual( + '{"feature-flag-zero":1}', + ); + }); + + it('deletes the correct values', () => { + window.localStorage.setItem( + 'featureFlags', + JSON.stringify({ + 'feature-flag-one': 1, + 'feature-flag-two': 0, + 'feature-flag-tree': 1, + 'feature-flag-four': 0, + }), + ); + + featureFlags.save({ + states: { + 'feature-flag-one': FeatureFlagState.None, + 'feature-flag-two': FeatureFlagState.Active, + }, + }); + + expect(window.localStorage.getItem('featureFlags')).toEqual( + '{"feature-flag-two":1}', + ); + }); + + it('clears all values', () => { + window.localStorage.setItem( + 'featureFlags', + JSON.stringify({ + 'feature-flag-one': 1, + 'feature-flag-two': 1, + 'feature-flag-three': 0, + }), + ); + + expect(featureFlags.isActive('feature-flag-one')).toBe(true); + expect(featureFlags.isActive('feature-flag-two')).toBe(true); + expect(featureFlags.isActive('feature-flag-three')).toBe(false); + + featureFlags.save({ states: {} }); + + expect(featureFlags.isActive('feature-flag-one')).toBe(false); + expect(featureFlags.isActive('feature-flag-two')).toBe(false); + expect(featureFlags.isActive('feature-flag-three')).toBe(false); + + expect(window.localStorage.getItem('featureFlags')).toEqual('{}'); + }); + }); + + describe('getRegisteredFlags', () => { + let featureFlags: FeatureFlagsApi; + + beforeEach(() => { + featureFlags = new LocalStorageFeatureFlags(); + featureFlags.registerFlag({ + name: 'registered-flag-1', + pluginId: 'plugin-one', + }); + featureFlags.registerFlag({ + name: 'registered-flag-2', + pluginId: 'plugin-one', + }); + featureFlags.registerFlag({ + name: 'registered-flag-3', + pluginId: 'plugin-two', + }); + }); + + it('should return an empty list', () => { + featureFlags = new LocalStorageFeatureFlags(); + expect(featureFlags.getRegisteredFlags()).toEqual([]); + }); + + it('should return an valid list', () => { + expect(featureFlags.getRegisteredFlags()).toEqual([ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + { name: 'registered-flag-3', pluginId: 'plugin-two' }, + ]); + }); + + it('should provide a copy of the list of flags', () => { + const flags = featureFlags.getRegisteredFlags(); + expect(flags).toEqual([ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + { name: 'registered-flag-3', pluginId: 'plugin-two' }, + ]); + flags.splice(2, 1); + expect(flags).toEqual([ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + ]); + expect(featureFlags.getRegisteredFlags()).toEqual([ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + { name: 'registered-flag-3', pluginId: 'plugin-two' }, + ]); + }); + + it('should get the correct values', () => { + const getByName = (name: string) => + featureFlags.getRegisteredFlags().find(flag => flag.name === name); + + expect(getByName('registered-flag-0')).toBeUndefined(); + expect(getByName('registered-flag-1')).toEqual({ + name: 'registered-flag-1', + pluginId: 'plugin-one', + }); + expect(getByName('registered-flag-2')).toEqual({ + name: 'registered-flag-2', + pluginId: 'plugin-one', + }); + expect(getByName('registered-flag-3')).toEqual({ + name: 'registered-flag-3', + pluginId: 'plugin-two', + }); + }); + + it('throws an error if length is less than three characters', () => { + expect(() => + featureFlags.registerFlag({ + name: 'ab', + pluginId: 'plugin-three', + }), + ).toThrow(/minimum length of three characters/i); + }); + + it('throws an error if length is greater than 150 characters', () => { + expect(() => + featureFlags.registerFlag({ + name: + 'loremipsumdolorsitametconsecteturadipiscingelitnuncvitaeportaexaullamcorperturpismaurisutmattisnequemorbisediaculisauguevivamuspulvinarcursuseratblandithendreritquisqueuttinciduntmagnavestibulumblanditaugueat', + pluginId: 'plugin-three', + }), + ).toThrow(/not exceed 150 characters/i); + }); + + it('throws an error if name does not start with a lowercase letter', () => { + expect(() => + featureFlags.registerFlag({ + name: '123456789', + pluginId: 'plugin-three', + }), + ).toThrow(/start with a lowercase letter/i); + }); + + it('throws an error if name contains characters other than lowercase letters, numbers and hyphens', () => { + expect(() => + featureFlags.registerFlag({ + name: 'Invalid_Feature_Flag', + pluginId: 'plugin-three', + }), + ).toThrow(/only contain lowercase letters, numbers and hyphens/i); + }); + }); +}); diff --git a/packages/app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx b/packages/app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx new file mode 100644 index 0000000000..00b3eff906 --- /dev/null +++ b/packages/app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx @@ -0,0 +1,109 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + FeatureFlagState, + FeatureFlagsApi, + FeatureFlag, + FeatureFlagsSaveOptions, +} from '../../definitions'; + +export function validateFlagName(name: string): void { + if (name.length < 3) { + throw new Error( + `The '${name}' feature flag must have a minimum length of three characters.`, + ); + } + + if (name.length > 150) { + throw new Error( + `The '${name}' feature flag must not exceed 150 characters.`, + ); + } + + if (!name.match(/^[a-z]+[a-z0-9-]+$/)) { + throw new Error( + `The '${name}' feature flag must start with a lowercase letter and only contain lowercase letters, numbers and hyphens. ` + + 'Examples: feature-flag-one, alpha, release-2020', + ); + } +} + +/** + * Create the FeatureFlags implementation based on the API. + */ +export class LocalStorageFeatureFlags implements FeatureFlagsApi { + private registeredFeatureFlags: FeatureFlag[] = []; + private flags?: Map; + + registerFlag(flag: FeatureFlag) { + validateFlagName(flag.name); + this.registeredFeatureFlags.push(flag); + } + + getRegisteredFlags(): FeatureFlag[] { + return this.registeredFeatureFlags.slice(); + } + + isActive(name: string): boolean { + if (!this.flags) { + this.flags = this.load(); + } + return this.flags.get(name) === FeatureFlagState.Active; + } + + save(options: FeatureFlagsSaveOptions): void { + if (!this.flags) { + this.flags = this.load(); + } + if (!options.merge) { + this.flags.clear(); + } + for (const [name, state] of Object.entries(options.states)) { + this.flags.set(name, state); + } + + const enabled = Array.from(this.flags.entries()).filter( + ([, state]) => state === FeatureFlagState.Active, + ); + window.localStorage.setItem( + 'featureFlags', + JSON.stringify(Object.fromEntries(enabled)), + ); + } + + private load(): Map { + try { + const jsonStr = window.localStorage.getItem('featureFlags'); + if (!jsonStr) { + return new Map(); + } + const json = JSON.parse(jsonStr) as unknown; + if (typeof json !== 'object' || json === null || Array.isArray(json)) { + return new Map(); + } + + const entries = Object.entries(json).filter(([name, value]) => { + validateFlagName(name); + return value === FeatureFlagState.Active; + }); + + return new Map(entries); + } catch { + return new Map(); + } + } +} diff --git a/packages/app-api/src/apis/implementations/FeatureFlagsApi/index.ts b/packages/app-api/src/apis/implementations/FeatureFlagsApi/index.ts new file mode 100644 index 0000000000..33990584f3 --- /dev/null +++ b/packages/app-api/src/apis/implementations/FeatureFlagsApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags'; diff --git a/packages/app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts b/packages/app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts new file mode 100644 index 0000000000..32170acc6f --- /dev/null +++ b/packages/app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MockOAuthApi from './MockOAuthApi'; +import PowerIcon from '@material-ui/icons/Power'; + +describe('MockOAuthApi', () => { + it('should trigger all requests', async () => { + const authResult = { is: 'done' }; + const mock = new MockOAuthApi(); + + const authHandler1 = jest.fn().mockImplementation(() => authResult); + const requester1 = mock.createAuthRequester({ + provider: { icon: PowerIcon, title: 'Test' }, + onAuthRequest: authHandler1, + }); + + const authHandler2 = jest.fn().mockResolvedValue('other'); + const requester2 = mock.createAuthRequester({ + provider: { icon: PowerIcon, title: 'Test' }, + onAuthRequest: authHandler2, + }); + + const promises = [ + requester1(new Set(['a'])), + requester1(new Set(['b'])), + requester2(new Set(['a', 'b'])), + requester2(new Set(['b', 'c'])), + requester2(new Set(['c', 'a'])), + ]; + + await expect( + Promise.race([Promise.all(promises), 'waiting']), + ).resolves.toBe('waiting'); + + await mock.triggerAll(); + + await expect(Promise.all(promises)).resolves.toEqual([ + authResult, + authResult, + 'other', + 'other', + 'other', + ]); + + expect(authHandler1).toHaveBeenCalledTimes(1); + expect(authHandler1).toHaveBeenCalledWith(new Set(['a', 'b'])); + expect(authHandler2).toHaveBeenCalledTimes(1); + expect(authHandler2).toHaveBeenCalledWith(new Set(['a', 'b', 'c'])); + }); + + it('should reject all requests', async () => { + const mock = new MockOAuthApi(); + + const authHandler1 = jest.fn(); + const requester1 = mock.createAuthRequester({ + provider: { icon: PowerIcon, title: 'Test' }, + onAuthRequest: authHandler1, + }); + + const authHandler2 = jest.fn(); + const requester2 = mock.createAuthRequester({ + provider: { icon: PowerIcon, title: 'Test' }, + onAuthRequest: authHandler2, + }); + + const promises = [ + requester1(new Set(['a'])), + requester1(new Set(['b'])), + requester2(new Set(['a', 'b'])), + requester2(new Set(['b', 'c'])), + requester2(new Set(['c', 'a'])), + ]; + + await expect( + Promise.race([Promise.all(promises), 'waiting']), + ).resolves.toBe('waiting'); + + await mock.rejectAll(); + + for (const promise of promises) { + await expect(promise).rejects.toMatchObject({ name: 'RejectedError' }); + } + + expect(authHandler1).not.toHaveBeenCalled(); + expect(authHandler2).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts b/packages/app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts new file mode 100644 index 0000000000..523f713f45 --- /dev/null +++ b/packages/app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { OAuthRequestApi, AuthRequesterOptions } from '../../definitions'; +import { OAuthRequestManager } from './OAuthRequestManager'; + +export default class MockOAuthApi implements OAuthRequestApi { + private readonly real = new OAuthRequestManager(); + + createAuthRequester(options: AuthRequesterOptions) { + return this.real.createAuthRequester(options); + } + + authRequest$() { + return this.real.authRequest$(); + } + + async triggerAll() { + await Promise.resolve(); // Wait a tick to allow new requests to get forwarded + + return new Promise(resolve => { + const subscription = this.authRequest$().subscribe(requests => { + subscription.unsubscribe(); + Promise.all(requests.map(request => request.trigger())).then(() => + resolve(), + ); + }); + }); + } + + async rejectAll() { + await Promise.resolve(); // Wait a tick to allow new requests to get forwarded + + return new Promise(resolve => { + const subscription = this.authRequest$().subscribe(requests => { + subscription.unsubscribe(); + requests.map(request => request.reject()); + resolve(); + }); + }); + } +} diff --git a/packages/app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts b/packages/app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts new file mode 100644 index 0000000000..280321daa0 --- /dev/null +++ b/packages/app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { waitFor } from '@testing-library/react'; +import { OAuthPendingRequests } from './OAuthPendingRequests'; + +describe('OAuthPendingRequests', () => { + it('notifies new observers about current state', async () => { + const target = new OAuthPendingRequests(); + const next = jest.fn(); + const error = jest.fn(); + + const input = new Set(['a', 'b']); + target.pending().subscribe({ next, error }); + target.request(input); + + await waitFor(() => expect(next).toBeCalledTimes(2)); + expect(next.mock.calls[0][0].scopes).toBeUndefined(); + expect(next.mock.calls[1][0].scopes.toString()).toBe(input.toString()); + expect(error.mock.calls.length).toBe(0); + }); + + it('resolves requests and notifies observers', async () => { + const target = new OAuthPendingRequests(); + const next = jest.fn(); + const error = jest.fn(); + + const request1 = target.request(new Set(['a'])); + const request2 = target.request(new Set(['a'])); + target.pending().subscribe({ next, error }); + target.resolve(new Set(['a']), 'session1'); + target.resolve(new Set(['a']), 'session2'); + + await expect(request1).resolves.toBe('session1'); + await expect(request2).resolves.toBe('session1'); + expect(next).toBeCalledTimes(3); // once on subscription, twice on resolve + expect(error).toBeCalledTimes(0); + }); + + it('can resolve through the observable', async () => { + const target = new OAuthPendingRequests(); + const next = jest.fn(pendingRequest => pendingRequest.resolve('done')); + const error = jest.fn(); + + const request1 = target.request(new Set(['a'])); + target.pending().subscribe({ next, error }); + + await expect(request1).resolves.toBe('done'); + expect(next).toBeCalledTimes(2); // once with data on subscription, once empty after resolution + expect(error).toBeCalledTimes(0); + }); + + it('rejects requests and notifies observers only once', async () => { + const target = new OAuthPendingRequests(); + const next = jest.fn(); + const error = jest.fn(); + const rejection = new Error('eek'); + + const request1 = target.request(new Set(['a'])); + const request2 = target.request(new Set(['a'])); + target.pending().subscribe({ next, error }); + target.reject(rejection); + target.resolve(new Set(['a']), 'session'); + + await expect(request1).rejects.toBe(rejection); + await expect(request2).rejects.toBe(rejection); + expect(next).toBeCalledTimes(3); // once on subscription, once or reject, once on resolve + expect(error).toBeCalledTimes(0); + }); + + it('can reject through the observable', async () => { + const target = new OAuthPendingRequests(); + const rejection = new Error('nope'); + const next = jest.fn(pendingRequest => pendingRequest.reject(rejection)); + const error = jest.fn(); + + const request1 = target.request(new Set(['a'])); + target.pending().subscribe({ next, error }); + + await expect(request1).rejects.toBe(rejection); + expect(next).toBeCalledTimes(2); + }); +}); diff --git a/packages/app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts b/packages/app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts new file mode 100644 index 0000000000..e616ece5ae --- /dev/null +++ b/packages/app-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts @@ -0,0 +1,126 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BehaviorSubject } from '../../../lib'; +import { Observable } from '../../../types'; + +type RequestQueueEntry = { + scopes: Set; + resolve: (value: ResultType | PromiseLike) => void; + reject: (reason: Error) => void; +}; + +export type PendingRequest = { + scopes: Set | undefined; + resolve: (value: ResultType) => void; + reject: (reason: Error) => void; +}; + +export function hasScopes( + searched: Set, + searchFor: Set, +): boolean { + for (const scope of searchFor) { + if (!searched.has(scope)) { + return false; + } + } + return true; +} + +export function joinScopes( + scopes: Set, + ...moreScopess: Set[] +): Set { + const result = new Set(scopes); + + for (const moreScopes of moreScopess) { + for (const scope of moreScopes) { + result.add(scope); + } + } + + return result; +} + +/** + * The OAuthPendingRequests class is a utility for managing and observing + * a stream of requests for oauth scopes for a single provider, and resolving + * them correctly once requests are fulfilled. + */ +export class OAuthPendingRequests { + private requests: RequestQueueEntry[] = []; + private subject = new BehaviorSubject>( + this.getCurrentPending(), + ); + + request(scopes: Set): Promise { + return new Promise((resolve, reject) => { + this.requests.push({ scopes, resolve, reject }); + + this.subject.next(this.getCurrentPending()); + }); + } + + resolve(scopes: Set, result: ResultType): void { + this.requests = this.requests.filter(request => { + if (hasScopes(scopes, request.scopes)) { + request.resolve(result); + return false; + } + return true; + }); + + this.subject.next(this.getCurrentPending()); + } + + reject(error: Error) { + this.requests.forEach(request => request.reject(error)); + this.requests = []; + + this.subject.next(this.getCurrentPending()); + } + + pending(): Observable> { + return this.subject; + } + + private getCurrentPending(): PendingRequest { + const currentScopes = + this.requests.length === 0 + ? undefined + : this.requests + .slice(1) + .reduce( + (acc, current) => joinScopes(acc, current.scopes), + this.requests[0].scopes, + ); + + return { + scopes: currentScopes, + resolve: (value: ResultType) => { + if (currentScopes) { + this.resolve(currentScopes, value); + } + }, + reject: (reason: Error) => { + if (currentScopes) { + this.reject(reason); + } + }, + }; + } +} diff --git a/packages/app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts b/packages/app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts new file mode 100644 index 0000000000..46a5362f35 --- /dev/null +++ b/packages/app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ProviderIcon from '@material-ui/icons/AcUnit'; +import { OAuthRequestManager } from './OAuthRequestManager'; + +describe('OAuthRequestManager', () => { + it('should forward a requests', async () => { + const manager = new OAuthRequestManager(); + + const reqSpy = jest.fn(); + manager.authRequest$().subscribe(reqSpy); + + const requester = manager.createAuthRequester({ + provider: { + title: 'My Provider', + icon: ProviderIcon, + }, + onAuthRequest: async () => 'hello', + }); + + expect(reqSpy).toHaveBeenCalledTimes(0); + await 'a tick'; + expect(reqSpy).toHaveBeenCalledTimes(2); + expect(reqSpy).toHaveBeenLastCalledWith([]); + + const req = requester(new Set(['my-scope'])); + + expect(reqSpy).toHaveBeenCalledTimes(3); + expect(reqSpy).toHaveBeenLastCalledWith([ + expect.objectContaining({ + reject: expect.any(Function), + trigger: expect.any(Function), + }), + ]); + + await expect(Promise.race([req, Promise.resolve('not yet')])).resolves.toBe( + 'not yet', + ); + + const [request] = reqSpy.mock.calls[2][0]; + request.trigger(); + + await expect(req).resolves.toBe('hello'); + }); +}); diff --git a/packages/app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts b/packages/app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts new file mode 100644 index 0000000000..23e9e1ddc4 --- /dev/null +++ b/packages/app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + OAuthRequestApi, + PendingAuthRequest, + AuthRequester, + AuthRequesterOptions, +} from '../../definitions'; +import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests'; +import { BehaviorSubject } from '../../../lib'; +import { Observable } from '../../../types'; + +/** + * The OAuthRequestManager is an implementation of the OAuthRequestApi. + * + * The purpose of this class and the API is to read a stream of incoming requests + * of OAuth access tokens from different providers with varying scope, and funnel + * them all together into a single request for each OAuth provider. + */ +export class OAuthRequestManager implements OAuthRequestApi { + private readonly subject = new BehaviorSubject([]); + private currentRequests: PendingAuthRequest[] = []; + private handlerCount = 0; + + createAuthRequester(options: AuthRequesterOptions): AuthRequester { + const handler = new OAuthPendingRequests(); + + const index = this.handlerCount; + this.handlerCount++; + + handler.pending().subscribe({ + next: scopeRequest => { + const newRequests = this.currentRequests.slice(); + const request = this.makeAuthRequest(scopeRequest, options); + if (!request) { + delete newRequests[index]; + } else { + newRequests[index] = request; + } + this.currentRequests = newRequests; + // Convert from sparse array to array of present items only + this.subject.next(newRequests.filter(Boolean)); + }, + }); + + return scopes => { + return handler.request(scopes); + }; + } + + // Converts the pending request and popup options into a popup request that we can forward to subscribers. + private makeAuthRequest( + request: PendingRequest, + options: AuthRequesterOptions, + ): PendingAuthRequest | undefined { + const { scopes } = request; + if (!scopes) { + return undefined; + } + + return { + provider: options.provider, + trigger: async () => { + const result = await options.onAuthRequest(scopes); + request.resolve(result); + }, + reject: () => { + const error = new Error('Login failed, rejected by user'); + error.name = 'RejectedError'; + request.reject(error); + }, + }; + } + + authRequest$(): Observable { + return this.subject; + } +} diff --git a/packages/app-api/src/apis/implementations/OAuthRequestApi/index.ts b/packages/app-api/src/apis/implementations/OAuthRequestApi/index.ts new file mode 100644 index 0000000000..0fe9bfae0f --- /dev/null +++ b/packages/app-api/src/apis/implementations/OAuthRequestApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { OAuthRequestManager } from './OAuthRequestManager'; diff --git a/packages/app-api/src/apis/implementations/StorageApi/WebStorage.test.ts b/packages/app-api/src/apis/implementations/StorageApi/WebStorage.test.ts new file mode 100644 index 0000000000..81140ffcc9 --- /dev/null +++ b/packages/app-api/src/apis/implementations/StorageApi/WebStorage.test.ts @@ -0,0 +1,173 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { WebStorage } from './WebStorage'; +import { CreateStorageApiOptions, StorageApi } from '../../definitions'; + +describe('WebStorage Storage API', () => { + const mockErrorApi = { post: jest.fn(), error$: jest.fn() }; + const createWebStorage = ( + args?: Partial, + ): StorageApi => { + return WebStorage.create({ + errorApi: mockErrorApi, + ...args, + }); + }; + it('should return undefined for values which are unset', async () => { + const storage = createWebStorage(); + + expect(storage.get('myfakekey')).toBeUndefined(); + }); + + it('should allow the setting and getting of the simple data structures', async () => { + const storage = createWebStorage(); + + await storage.set('myfakekey', 'helloimastring'); + await storage.set('mysecondfakekey', 1234); + await storage.set('mythirdfakekey', true); + expect(storage.get('myfakekey')).toBe('helloimastring'); + expect(storage.get('mysecondfakekey')).toBe(1234); + expect(storage.get('mythirdfakekey')).toBe(true); + }); + + it('should allow setting of complex datastructures', async () => { + const storage = createWebStorage(); + + const mockData = { + something: 'here', + is: [{ super: { complex: [{ but: 'something', why: true }] } }], + }; + + await storage.set('myfakekey', mockData); + + expect(storage.get('myfakekey')).toEqual(mockData); + }); + + it('should subscribe to key changes when setting a new value', async () => { + const storage = createWebStorage(); + + const wrongKeyNextHandler = jest.fn(); + const selectedKeyNextHandler = jest.fn(); + const mockData = { hello: 'im a great new value' }; + + await new Promise(resolve => { + storage.observe$('correctKey').subscribe({ + next: (...args) => { + selectedKeyNextHandler(...args); + resolve(); + }, + }); + + storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler }); + + storage.set('correctKey', mockData); + }); + + expect(wrongKeyNextHandler).not.toHaveBeenCalled(); + expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1); + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'correctKey', + newValue: mockData, + }); + }); + + it('should subscribe to key changes when deleting a value', async () => { + const storage = createWebStorage(); + + const wrongKeyNextHandler = jest.fn(); + const selectedKeyNextHandler = jest.fn(); + const mockData = { hello: 'im a great new value' }; + + storage.set('correctKey', mockData); + + await new Promise(resolve => { + storage.observe$('correctKey').subscribe({ + next: (...args) => { + selectedKeyNextHandler(...args); + resolve(); + }, + }); + + storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler }); + + storage.remove('correctKey'); + }); + + expect(wrongKeyNextHandler).not.toHaveBeenCalled(); + expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1); + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'correctKey', + newValue: undefined, + }); + }); + + it('should be able to create different buckets for different uses', async () => { + const rootStorage = createWebStorage(); + + const firstStorage = rootStorage.forBucket('userSettings'); + const secondStorage = rootStorage.forBucket('profileSettings'); + const keyName = 'blobby'; + + await firstStorage.set(keyName, 'boop'); + await secondStorage.set(keyName, 'deerp'); + + expect(firstStorage.get(keyName)).not.toBe(secondStorage.get(keyName)); + expect(firstStorage.get(keyName)).toBe('boop'); + expect(secondStorage.get(keyName)).toBe('deerp'); + }); + + it('should not clash with other namesapces when creating buckets', async () => { + const rootStorage = createWebStorage(); + + // when getting key test2 it will translate to /profile/something/deep/test2 + const firstStorage = rootStorage + .forBucket('profile') + .forBucket('something') + .forBucket('deep'); + // when getting key deep/test2 it will translate to /profile/something/deep/test2 + const secondStorage = rootStorage.forBucket('profile/something'); + + await firstStorage.set('test2', { error: true }); + + expect(secondStorage.get('deep/test2')).toBe(undefined); + }); + + it('should call the error api when the json can not be parsed in local storage', async () => { + const rootStorage = createWebStorage({ + namespace: '/Test/Mock/Thing', + }); + + localStorage.setItem('/Test/Mock/Thing/key', '{smd: asdouindA}'); + + const value = rootStorage.get('key'); + + expect(value).toBe(undefined); + expect(mockErrorApi.post).toHaveBeenCalledWith(expect.any(Error)); + expect(mockErrorApi.post).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error when parsing JSON config from storage for: key', + }), + ); + }); + + it('should return a singleton for the same namespace and same bucket', async () => { + const rootStorage = createWebStorage({ + namespace: '/Test/Mock/Thing/Thing ', + }); + + expect(rootStorage.forBucket('test')).toBe(rootStorage.forBucket('test')); + }); +}); diff --git a/packages/app-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/app-api/src/apis/implementations/StorageApi/WebStorage.ts new file mode 100644 index 0000000000..b9b01cd20b --- /dev/null +++ b/packages/app-api/src/apis/implementations/StorageApi/WebStorage.ts @@ -0,0 +1,94 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + StorageApi, + StorageValueChange, + ErrorApi, + CreateStorageApiOptions, +} from '../../definitions'; +import { Observable } from '../../../types'; +import ObservableImpl from 'zen-observable'; + +const buckets = new Map(); + +export class WebStorage implements StorageApi { + constructor( + private readonly namespace: string, + private readonly errorApi: ErrorApi, + ) {} + + static create(options: CreateStorageApiOptions): WebStorage { + return new WebStorage(options.namespace ?? '', options.errorApi); + } + + get(key: string): T | undefined { + try { + const storage = JSON.parse(localStorage.getItem(this.getKeyName(key))!); + return storage ?? undefined; + } catch (e) { + this.errorApi.post( + new Error(`Error when parsing JSON config from storage for: ${key}`), + ); + } + + return undefined; + } + + forBucket(name: string): WebStorage { + const bucketPath = `${this.namespace}/${name}`; + if (!buckets.has(bucketPath)) { + buckets.set(bucketPath, new WebStorage(bucketPath, this.errorApi)); + } + return buckets.get(bucketPath)!; + } + + async set(key: string, data: T): Promise { + localStorage.setItem(this.getKeyName(key), JSON.stringify(data, null, 2)); + this.notifyChanges({ key, newValue: data }); + } + + async remove(key: string): Promise { + localStorage.removeItem(this.getKeyName(key)); + this.notifyChanges({ key, newValue: undefined }); + } + + observe$(key: string): Observable> { + return this.observable.filter(({ key: messageKey }) => messageKey === key); + } + + private getKeyName(key: string) { + return `${this.namespace}/${encodeURIComponent(key)}`; + } + + private notifyChanges(message: StorageValueChange) { + for (const subscription of this.subscribers) { + subscription.next(message); + } + } + + private subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + private readonly observable = new ObservableImpl( + subscriber => { + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }, + ); +} diff --git a/packages/app-api/src/apis/implementations/StorageApi/index.ts b/packages/app-api/src/apis/implementations/StorageApi/index.ts new file mode 100644 index 0000000000..33b0094551 --- /dev/null +++ b/packages/app-api/src/apis/implementations/StorageApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { WebStorage } from './WebStorage'; diff --git a/packages/app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts b/packages/app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts new file mode 100644 index 0000000000..b41e705726 --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Auth0Icon from '@material-ui/icons/AcUnit'; +import { auth0AuthApiRef } from '../../../definitions/auth'; +import { OAuth2 } from '../oauth2'; +import { OAuthApiCreateOptions } from '../types'; + +const DEFAULT_PROVIDER = { + id: 'auth0', + title: 'Auth0', + icon: Auth0Icon, +}; + +class Auth0Auth { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = ['openid', `email`, `profile`], + }: OAuthApiCreateOptions): typeof auth0AuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes, + }); + } +} + +export default Auth0Auth; diff --git a/packages/app-api/src/apis/implementations/auth/auth0/index.ts b/packages/app-api/src/apis/implementations/auth/auth0/index.ts new file mode 100644 index 0000000000..dda27d0fa3 --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/auth0/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default as Auth0Auth } from './Auth0Auth'; diff --git a/packages/app-api/src/apis/implementations/auth/github/GithubAuth.test.ts b/packages/app-api/src/apis/implementations/auth/github/GithubAuth.test.ts new file mode 100644 index 0000000000..3ff29cc6ef --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/github/GithubAuth.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GithubAuth from './GithubAuth'; + +describe('GithubAuth', () => { + it('should get access token', async () => { + const getSession = jest + .fn() + .mockResolvedValue({ providerInfo: { accessToken: 'access-token' } }); + const githubAuth = new GithubAuth({ getSession } as any); + + expect(await githubAuth.getAccessToken()).toBe('access-token'); + expect(getSession).toBeCalledTimes(1); + }); +}); diff --git a/packages/app-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/app-api/src/apis/implementations/auth/github/GithubAuth.ts new file mode 100644 index 0000000000..a7c945a907 --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -0,0 +1,140 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GithubIcon from '@material-ui/icons/AcUnit'; +import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; +import { GithubSession } from './types'; +import { + OAuthApi, + SessionApi, + SessionState, + ProfileInfo, + BackstageIdentity, + AuthRequestOptions, +} from '../../../definitions/auth'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { + AuthSessionStore, + StaticAuthSessionManager, +} from '../../../../lib/AuthSessionManager'; +import { Observable } from '../../../../types'; +import { OAuthApiCreateOptions } from '../types'; + +export type GithubAuthResponse = { + providerInfo: { + accessToken: string; + scope: string; + expiresInSeconds: number; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +const DEFAULT_PROVIDER = { + id: 'github', + title: 'GitHub', + icon: GithubIcon, +}; + +class GithubAuth implements OAuthApi, SessionApi { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = ['read:user'], + }: OAuthApiCreateOptions) { + const connector = new DefaultAuthConnector({ + discoveryApi, + environment, + provider, + oauthRequestApi: oauthRequestApi, + sessionTransform(res: GithubAuthResponse): GithubSession { + return { + ...res, + providerInfo: { + accessToken: res.providerInfo.accessToken, + scopes: GithubAuth.normalizeScope(res.providerInfo.scope), + expiresAt: new Date( + Date.now() + res.providerInfo.expiresInSeconds * 1000, + ), + }, + }; + }, + }); + + const sessionManager = new StaticAuthSessionManager({ + connector, + defaultScopes: new Set(defaultScopes), + sessionScopes: (session: GithubSession) => session.providerInfo.scopes, + }); + + const authSessionStore = new AuthSessionStore({ + manager: sessionManager, + storageKey: `${provider.id}Session`, + sessionScopes: (session: GithubSession) => session.providerInfo.scopes, + }); + + return new GithubAuth(authSessionStore); + } + + constructor(private readonly sessionManager: SessionManager) {} + + async signIn() { + await this.getAccessToken(); + } + + async signOut() { + await this.sessionManager.removeSession(); + } + + sessionState$(): Observable { + return this.sessionManager.sessionState$(); + } + + async getAccessToken(scope?: string, options?: AuthRequestOptions) { + const session = await this.sessionManager.getSession({ + ...options, + scopes: GithubAuth.normalizeScope(scope), + }); + return session?.providerInfo.accessToken ?? ''; + } + + async getBackstageIdentity( + options: AuthRequestOptions = {}, + ): Promise { + const session = await this.sessionManager.getSession(options); + return session?.backstageIdentity; + } + + async getProfile(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.profile; + } + + static normalizeScope(scope?: string): Set { + if (!scope) { + return new Set(); + } + + const scopeList = Array.isArray(scope) + ? scope + : scope.split(/[\s|,]/).filter(Boolean); + + return new Set(scopeList); + } +} +export default GithubAuth; diff --git a/packages/app-api/src/apis/implementations/auth/github/index.ts b/packages/app-api/src/apis/implementations/auth/github/index.ts new file mode 100644 index 0000000000..9e1722f4a4 --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/github/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './types'; +export { default as GithubAuth } from './GithubAuth'; diff --git a/packages/app-api/src/apis/implementations/auth/github/types.ts b/packages/app-api/src/apis/implementations/auth/github/types.ts new file mode 100644 index 0000000000..54c427423c --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/github/types.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ProfileInfo } from '../../..'; +import { BackstageIdentity } from '../../../definitions'; + +export type GithubSession = { + providerInfo: { + accessToken: string; + scopes: Set; + expiresAt: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/app-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts b/packages/app-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts new file mode 100644 index 0000000000..6a592c7fbe --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; +import { UrlPatternDiscovery } from '../../DiscoveryApi'; +import GitlabAuth from './GitlabAuth'; + +const getSession = jest.fn(); + +jest.mock('../../../../lib/AuthSessionManager', () => ({ + ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), + RefreshingAuthSessionManager: class { + getSession = getSession; + }, +})); + +describe('GitlabAuth', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it.each([ + [ + 'read_user api write_repository', + ['read_user', 'api', 'write_repository'], + ], + ['read_repository sudo', ['read_repository', 'sudo']], + ])(`should normalize scopes correctly - %p`, (scope, scopes) => { + const gitlabAuth = GitlabAuth.create({ + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), + }); + + gitlabAuth.getAccessToken(scope); + expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) }); + }); +}); diff --git a/packages/app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts b/packages/app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts new file mode 100644 index 0000000000..3f4bc814e3 --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GitlabIcon from '@material-ui/icons/AcUnit'; +import { gitlabAuthApiRef } from '../../../definitions/auth'; +import { OAuth2 } from '../oauth2'; +import { OAuthApiCreateOptions } from '../types'; + +const DEFAULT_PROVIDER = { + id: 'gitlab', + title: 'GitLab', + icon: GitlabIcon, +}; + +class GitlabAuth { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = ['read_user'], + }: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes, + }); + } +} +export default GitlabAuth; diff --git a/packages/app-api/src/apis/implementations/auth/gitlab/index.ts b/packages/app-api/src/apis/implementations/auth/gitlab/index.ts new file mode 100644 index 0000000000..42d7210551 --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/gitlab/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default as GitlabAuth } from './GitlabAuth'; diff --git a/packages/app-api/src/apis/implementations/auth/google/GoogleAuth.test.ts b/packages/app-api/src/apis/implementations/auth/google/GoogleAuth.test.ts new file mode 100644 index 0000000000..9e8569c5cf --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/google/GoogleAuth.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GoogleAuth from './GoogleAuth'; +import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; +import { UrlPatternDiscovery } from '../../DiscoveryApi'; + +const PREFIX = 'https://www.googleapis.com/auth/'; + +const getSession = jest.fn(); + +jest.mock('../../../../lib/AuthSessionManager', () => ({ + ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), + RefreshingAuthSessionManager: class { + getSession = getSession; + }, +})); + +describe('GoogleAuth', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it.each([ + ['email', [`${PREFIX}userinfo.email`]], + ['profile', [`${PREFIX}userinfo.profile`]], + ['openid', ['openid']], + ['userinfo.email', [`${PREFIX}userinfo.email`]], + [ + 'userinfo.profile email', + [`${PREFIX}userinfo.profile`, `${PREFIX}userinfo.email`], + ], + [ + `profile ${PREFIX}userinfo.email`, + [`${PREFIX}userinfo.profile`, `${PREFIX}userinfo.email`], + ], + [`${PREFIX}userinfo.profile`, [`${PREFIX}userinfo.profile`]], + ['a', [`${PREFIX}a`]], + ['a b\tc', [`${PREFIX}a`, `${PREFIX}b`, `${PREFIX}c`]], + [`${PREFIX}a b`, [`${PREFIX}a`, `${PREFIX}b`]], + [`${PREFIX}a`, [`${PREFIX}a`]], + + // Some incorrect scopes that we don't try to fix + [`${PREFIX}email`, [`${PREFIX}email`]], + [`${PREFIX}profile`, [`${PREFIX}profile`]], + [`${PREFIX}openid`, [`${PREFIX}openid`]], + ])(`should normalize scopes correctly - %p`, (scope, scopes) => { + const googleAuth = GoogleAuth.create({ + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), + }); + + googleAuth.getAccessToken(scope); + expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) }); + }); +}); diff --git a/packages/app-api/src/apis/implementations/auth/google/GoogleAuth.ts b/packages/app-api/src/apis/implementations/auth/google/GoogleAuth.ts new file mode 100644 index 0000000000..fbc902cdd8 --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/google/GoogleAuth.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GoogleIcon from '@material-ui/icons/AcUnit'; +import { googleAuthApiRef } from '../../../definitions/auth'; +import { OAuth2 } from '../oauth2'; +import { OAuthApiCreateOptions } from '../types'; + +const DEFAULT_PROVIDER = { + id: 'google', + title: 'Google', + icon: GoogleIcon, +}; + +const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; + +class GoogleAuth { + static create({ + discoveryApi, + oauthRequestApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + defaultScopes = [ + 'openid', + `${SCOPE_PREFIX}userinfo.email`, + `${SCOPE_PREFIX}userinfo.profile`, + ], + }: OAuthApiCreateOptions): typeof googleAuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes, + scopeTransform(scopes: string[]) { + return scopes.map(scope => { + if (scope === 'openid') { + return scope; + } + + if (scope === 'profile' || scope === 'email') { + return `${SCOPE_PREFIX}userinfo.${scope}`; + } + + if (scope.startsWith(SCOPE_PREFIX)) { + return scope; + } + + return `${SCOPE_PREFIX}${scope}`; + }); + }, + }); + } +} +export default GoogleAuth; diff --git a/packages/app-api/src/apis/implementations/auth/google/index.ts b/packages/app-api/src/apis/implementations/auth/google/index.ts new file mode 100644 index 0000000000..2521d46046 --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/google/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default as GoogleAuth } from './GoogleAuth'; diff --git a/packages/app-api/src/apis/implementations/auth/index.ts b/packages/app-api/src/apis/implementations/auth/index.ts new file mode 100644 index 0000000000..7ef78d19cf --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './github'; +export * from './gitlab'; +export * from './google'; +export * from './oauth2'; +export * from './okta'; +export * from './saml'; +export * from './auth0'; +export * from './microsoft'; +export * from './onelogin'; diff --git a/packages/app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts b/packages/app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts new file mode 100644 index 0000000000..3e2711db3b --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MicrosoftIcon from '@material-ui/icons/AcUnit'; +import { microsoftAuthApiRef } from '../../../definitions/auth'; +import { OAuth2 } from '../oauth2'; +import { OAuthApiCreateOptions } from '../types'; + +const DEFAULT_PROVIDER = { + id: 'microsoft', + title: 'Microsoft', + icon: MicrosoftIcon, +}; + +class MicrosoftAuth { + static create({ + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + discoveryApi, + defaultScopes = [ + 'openid', + 'offline_access', + 'profile', + 'email', + 'User.Read', + ], + }: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes, + }); + } +} + +export default MicrosoftAuth; diff --git a/packages/app-api/src/apis/implementations/auth/microsoft/index.ts b/packages/app-api/src/apis/implementations/auth/microsoft/index.ts new file mode 100644 index 0000000000..77328d8557 --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/microsoft/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default as MicrosoftAuth } from './MicrosoftAuth'; diff --git a/packages/app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts b/packages/app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts new file mode 100644 index 0000000000..93db2c732d --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts @@ -0,0 +1,152 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import OAuth2 from './OAuth2'; + +const theFuture = new Date(Date.now() + 3600000); +const thePast = new Date(Date.now() - 10); + +const PREFIX = 'https://www.googleapis.com/auth/'; + +const scopeTransform = (x: string[]) => x; + +describe('OAuth2', () => { + it('should get refreshed access token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, + }); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + expect(await oauth2.getAccessToken('my-scope my-scope2')).toBe( + 'access-token', + ); + expect(getSession).toBeCalledTimes(1); + expect(getSession.mock.calls[0][0].scopes).toEqual( + new Set(['my-scope', 'my-scope2']), + ); + }); + + it('should transform scopes', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, + }); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`), + }); + + expect(await oauth2.getAccessToken('my-scope')).toBe('access-token'); + expect(getSession).toBeCalledTimes(1); + expect(getSession.mock.calls[0][0].scopes).toEqual( + new Set(['my-prefix/my-scope']), + ); + }); + + it('should get refreshed id token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { idToken: 'id-token', expiresAt: theFuture }, + }); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + expect(await oauth2.getIdToken()).toBe('id-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should get optional id token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { idToken: 'id-token', expiresAt: theFuture }, + }); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + expect(await oauth2.getIdToken({ optional: true })).toBe('id-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should share popup closed errors', async () => { + const error = new Error('NOPE'); + error.name = 'RejectedError'; + const getSession = jest + .fn() + .mockResolvedValueOnce({ + providerInfo: { + accessToken: 'access-token', + expiresAt: theFuture, + scopes: new Set([`${PREFIX}not-enough`]), + }, + }) + .mockRejectedValue(error); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + // Make sure we have a session before we do the double request, so that we get past the !this.currentSession check + await expect(oauth2.getAccessToken()).resolves.toBe('access-token'); + + const promise1 = oauth2.getAccessToken('more'); + const promise2 = oauth2.getAccessToken('more'); + await expect(promise1).rejects.toBe(error); + await expect(promise2).rejects.toBe(error); + expect(getSession).toBeCalledTimes(3); + }); + + it('should wait for all session refreshes', async () => { + const initialSession = { + providerInfo: { + idToken: 'token1', + expiresAt: theFuture, + scopes: new Set(), + }, + }; + const getSession = jest + .fn() + .mockResolvedValueOnce(initialSession) + .mockResolvedValue({ + providerInfo: { + idToken: 'token2', + expiresAt: theFuture, + scopes: new Set(), + }, + }); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + // Grab the expired session first + await expect(oauth2.getIdToken()).resolves.toBe('token1'); + expect(getSession).toBeCalledTimes(1); + + initialSession.providerInfo.expiresAt = thePast; + + const promise1 = oauth2.getIdToken(); + const promise2 = oauth2.getIdToken(); + const promise3 = oauth2.getIdToken(); + await expect(promise1).resolves.toBe('token2'); + await expect(promise2).resolves.toBe('token2'); + await expect(promise3).resolves.toBe('token2'); + expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client + }); +}); diff --git a/packages/app-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/app-api/src/apis/implementations/auth/oauth2/OAuth2.ts new file mode 100644 index 0000000000..73ad3dcf14 --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -0,0 +1,179 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import OAuth2Icon from '@material-ui/icons/AcUnit'; +import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; +import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { Observable } from '../../../../types'; +import { + AuthRequestOptions, + BackstageIdentity, + OAuthApi, + OpenIdConnectApi, + ProfileInfo, + ProfileInfoApi, + SessionState, + SessionApi, + BackstageIdentityApi, +} from '../../../definitions/auth'; +import { OAuth2Session } from './types'; +import { OAuthApiCreateOptions } from '../types'; + +type Options = { + sessionManager: SessionManager; + scopeTransform: (scopes: string[]) => string[]; +}; + +type CreateOptions = OAuthApiCreateOptions & { + scopeTransform?: (scopes: string[]) => string[]; +}; + +export type OAuth2Response = { + providerInfo: { + accessToken: string; + idToken: string; + scope: string; + expiresInSeconds: number; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +const DEFAULT_PROVIDER = { + id: 'oauth2', + title: 'Your Identity Provider', + icon: OAuth2Icon, +}; + +class OAuth2 + implements + OAuthApi, + OpenIdConnectApi, + ProfileInfoApi, + BackstageIdentityApi, + SessionApi { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = [], + scopeTransform = x => x, + }: CreateOptions) { + const connector = new DefaultAuthConnector({ + discoveryApi, + environment, + provider, + oauthRequestApi: oauthRequestApi, + sessionTransform(res: OAuth2Response): OAuth2Session { + return { + ...res, + providerInfo: { + idToken: res.providerInfo.idToken, + accessToken: res.providerInfo.accessToken, + scopes: OAuth2.normalizeScopes( + scopeTransform, + res.providerInfo.scope, + ), + expiresAt: new Date( + Date.now() + res.providerInfo.expiresInSeconds * 1000, + ), + }, + }; + }, + }); + + const sessionManager = new RefreshingAuthSessionManager({ + connector, + defaultScopes: new Set(defaultScopes), + sessionScopes: (session: OAuth2Session) => session.providerInfo.scopes, + sessionShouldRefresh: (session: OAuth2Session) => { + const expiresInSec = + (session.providerInfo.expiresAt.getTime() - Date.now()) / 1000; + return expiresInSec < 60 * 5; + }, + }); + + return new OAuth2({ sessionManager, scopeTransform }); + } + + private readonly sessionManager: SessionManager; + private readonly scopeTransform: (scopes: string[]) => string[]; + + constructor(options: Options) { + this.sessionManager = options.sessionManager; + this.scopeTransform = options.scopeTransform; + } + + async signIn() { + await this.getAccessToken(); + } + + async signOut() { + await this.sessionManager.removeSession(); + } + + sessionState$(): Observable { + return this.sessionManager.sessionState$(); + } + + async getAccessToken( + scope?: string | string[], + options?: AuthRequestOptions, + ) { + const normalizedScopes = OAuth2.normalizeScopes(this.scopeTransform, scope); + const session = await this.sessionManager.getSession({ + ...options, + scopes: normalizedScopes, + }); + return session?.providerInfo.accessToken ?? ''; + } + + async getIdToken(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.providerInfo.idToken ?? ''; + } + + async getBackstageIdentity( + options: AuthRequestOptions = {}, + ): Promise { + const session = await this.sessionManager.getSession(options); + return session?.backstageIdentity; + } + + async getProfile(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.profile; + } + + private static normalizeScopes( + scopeTransform: (scopes: string[]) => string[], + scopes?: string | string[], + ): Set { + if (!scopes) { + return new Set(); + } + + const scopeList = Array.isArray(scopes) + ? scopes + : scopes.split(/[\s|,]/).filter(Boolean); + + return new Set(scopeTransform(scopeList)); + } +} + +export default OAuth2; diff --git a/packages/app-api/src/apis/implementations/auth/oauth2/index.ts b/packages/app-api/src/apis/implementations/auth/oauth2/index.ts new file mode 100644 index 0000000000..52bcb1df2c --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/oauth2/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default as OAuth2 } from './OAuth2'; +export * from './types'; diff --git a/packages/app-api/src/apis/implementations/auth/oauth2/types.ts b/packages/app-api/src/apis/implementations/auth/oauth2/types.ts new file mode 100644 index 0000000000..0e9c6f124d --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/oauth2/types.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ProfileInfo, BackstageIdentity } from '../../../definitions'; + +export type OAuth2Session = { + providerInfo: { + idToken: string; + accessToken: string; + scopes: Set; + expiresAt: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/app-api/src/apis/implementations/auth/okta/OktaAuth.test.ts b/packages/app-api/src/apis/implementations/auth/okta/OktaAuth.test.ts new file mode 100644 index 0000000000..d6b1a07d9d --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/okta/OktaAuth.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import OktaAuth from './OktaAuth'; +import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; +import { UrlPatternDiscovery } from '../../DiscoveryApi'; + +const PREFIX = 'okta.'; + +const getSession = jest.fn(); + +jest.mock('../../../../lib/AuthSessionManager', () => ({ + ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), + RefreshingAuthSessionManager: class { + getSession = getSession; + }, +})); + +describe('OktaAuth', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it.each([ + ['openid', ['openid']], + ['profile email', ['profile', 'email']], + [`${PREFIX}groups.manage`, [`${PREFIX}groups.manage`]], + ['groups.read', [`${PREFIX}groups.read`]], + [ + `${PREFIX}groups.manage groups.read, openid`, + [`${PREFIX}groups.manage`, `${PREFIX}groups.read`, 'openid'], + ], + [`email\t ${PREFIX}groups.read`, ['email', `${PREFIX}groups.read`]], + + // Some incorrect scopes that we don't try to fix + [`${PREFIX}email`, [`${PREFIX}email`]], + [`${PREFIX}profile`, [`${PREFIX}profile`]], + [`${PREFIX}openid`, [`${PREFIX}openid`]], + ])(`should normalize scopes correctly - %p`, (scope, scopes) => { + const auth = OktaAuth.create({ + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), + }); + + auth.getAccessToken(scope); + expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) }); + }); +}); diff --git a/packages/app-api/src/apis/implementations/auth/okta/OktaAuth.ts b/packages/app-api/src/apis/implementations/auth/okta/OktaAuth.ts new file mode 100644 index 0000000000..2df908e9fb --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/okta/OktaAuth.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import OktaIcon from '@material-ui/icons/AcUnit'; +import { oktaAuthApiRef } from '../../../definitions/auth'; +import { OAuth2 } from '../oauth2'; +import { OAuthApiCreateOptions } from '../types'; + +const DEFAULT_PROVIDER = { + id: 'okta', + title: 'Okta', + icon: OktaIcon, +}; + +const OKTA_OIDC_SCOPES: Set = new Set([ + 'openid', + 'profile', + 'email', + 'phone', + 'address', + 'groups', + 'offline_access', +]); + +const OKTA_SCOPE_PREFIX: string = 'okta.'; + +class OktaAuth { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = ['openid', 'email', 'profile', 'offline_access'], + }: OAuthApiCreateOptions): typeof oktaAuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes, + scopeTransform(scopes) { + return scopes.map(scope => { + if (OKTA_OIDC_SCOPES.has(scope)) { + return scope; + } + + if (scope.startsWith(OKTA_SCOPE_PREFIX)) { + return scope; + } + + return `${OKTA_SCOPE_PREFIX}${scope}`; + }); + }, + }); + } +} + +export default OktaAuth; diff --git a/packages/app-api/src/apis/implementations/auth/okta/index.ts b/packages/app-api/src/apis/implementations/auth/okta/index.ts new file mode 100644 index 0000000000..4cc774b26b --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/okta/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default as OktaAuth } from './OktaAuth'; diff --git a/packages/app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts b/packages/app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts new file mode 100644 index 0000000000..ff5c7c1990 --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import OneLoginIcon from '@material-ui/icons/AcUnit'; +import { oneloginAuthApiRef } from '../../../definitions/auth'; +import { + OAuthRequestApi, + AuthProvider, + DiscoveryApi, +} from '../../../definitions'; +import { OAuth2 } from '../oauth2'; + +type CreateOptions = { + discoveryApi: DiscoveryApi; + oauthRequestApi: OAuthRequestApi; + + environment?: string; + provider?: AuthProvider & { id: string }; +}; + +const DEFAULT_PROVIDER = { + id: 'onelogin', + title: 'onelogin', + icon: OneLoginIcon, +}; + +const OIDC_SCOPES: Set = new Set([ + 'openid', + 'profile', + 'email', + 'phone', + 'address', + 'groups', + 'offline_access', +]); + +const SCOPE_PREFIX: string = 'onelogin.'; + +class OneLoginAuth { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + }: CreateOptions): typeof oneloginAuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes: ['openid', 'email', 'profile', 'offline_access'], + scopeTransform(scopes) { + return scopes.map(scope => { + if (OIDC_SCOPES.has(scope)) { + return scope; + } + + if (scope.startsWith(SCOPE_PREFIX)) { + return scope; + } + + return `${SCOPE_PREFIX}${scope}`; + }); + }, + }); + } +} + +export default OneLoginAuth; diff --git a/packages/app-api/src/apis/implementations/auth/onelogin/index.ts b/packages/app-api/src/apis/implementations/auth/onelogin/index.ts new file mode 100644 index 0000000000..1d163207db --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/onelogin/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default as OneLoginAuth } from './OneLoginAuth'; diff --git a/packages/app-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/app-api/src/apis/implementations/auth/saml/SamlAuth.ts new file mode 100644 index 0000000000..af97ad8f36 --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SamlIcon from '@material-ui/icons/AcUnit'; +import { DirectAuthConnector } from '../../../../lib/AuthConnector'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { Observable } from '../../../../types'; +import { + ProfileInfo, + BackstageIdentity, + SessionState, + AuthRequestOptions, + ProfileInfoApi, + BackstageIdentityApi, + SessionApi, +} from '../../../definitions/auth'; +import { SamlSession } from './types'; +import { + AuthSessionStore, + StaticAuthSessionManager, +} from '../../../../lib/AuthSessionManager'; +import { AuthApiCreateOptions } from '../types'; + +export type SamlAuthResponse = { + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +const DEFAULT_PROVIDER = { + id: 'saml', + title: 'SAML', + icon: SamlIcon, +}; + +class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + }: AuthApiCreateOptions) { + const connector = new DirectAuthConnector({ + discoveryApi, + environment, + provider, + }); + + const sessionManager = new StaticAuthSessionManager({ + connector, + }); + + const authSessionStore = new AuthSessionStore({ + manager: sessionManager, + storageKey: `${provider.id}Session`, + }); + + return new SamlAuth(authSessionStore); + } + + sessionState$(): Observable { + return this.sessionManager.sessionState$(); + } + + constructor(private readonly sessionManager: SessionManager) {} + + async signIn() { + await this.getBackstageIdentity({}); + } + async signOut() { + await this.sessionManager.removeSession(); + } + + async getBackstageIdentity( + options: AuthRequestOptions = {}, + ): Promise { + const session = await this.sessionManager.getSession(options); + return session?.backstageIdentity; + } + + async getProfile(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.profile; + } +} + +export default SamlAuth; diff --git a/packages/app-api/src/apis/implementations/auth/saml/index.ts b/packages/app-api/src/apis/implementations/auth/saml/index.ts new file mode 100644 index 0000000000..c2436ab435 --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/saml/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { default as SamlAuth } from './SamlAuth'; diff --git a/packages/app-api/src/apis/implementations/auth/saml/types.ts b/packages/app-api/src/apis/implementations/auth/saml/types.ts new file mode 100644 index 0000000000..296f70b0ea --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/saml/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ProfileInfo, BackstageIdentity } from '../../../definitions'; + +export type SamlSession = { + userId: string; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/app-api/src/apis/implementations/auth/types.ts b/packages/app-api/src/apis/implementations/auth/types.ts new file mode 100644 index 0000000000..db3b718d25 --- /dev/null +++ b/packages/app-api/src/apis/implementations/auth/types.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AuthProvider, DiscoveryApi, OAuthRequestApi } from '../../definitions'; + +export type OAuthApiCreateOptions = AuthApiCreateOptions & { + oauthRequestApi: OAuthRequestApi; + defaultScopes?: string[]; +}; + +export type AuthApiCreateOptions = { + discoveryApi: DiscoveryApi; + environment?: string; + provider?: AuthProvider & { id: string }; +}; diff --git a/packages/app-api/src/apis/implementations/index.ts b/packages/app-api/src/apis/implementations/index.ts new file mode 100644 index 0000000000..d0df2760ab --- /dev/null +++ b/packages/app-api/src/apis/implementations/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This folder contains implementations for all core APIs. +// +// Plugins should rely on these APIs for functionality as much as possible. + +export * from './auth'; + +export * from './AlertApi'; +export * from './AppThemeApi'; +export * from './ConfigApi'; +export * from './DiscoveryApi'; +export * from './ErrorApi'; +export * from './FeatureFlagsApi'; +export * from './OAuthRequestApi'; +export * from './StorageApi'; diff --git a/packages/app-api/src/apis/index.ts b/packages/app-api/src/apis/index.ts new file mode 100644 index 0000000000..03569f9570 --- /dev/null +++ b/packages/app-api/src/apis/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './system'; +export * from './definitions'; +export * from './implementations'; diff --git a/packages/app-api/src/apis/system/ApiAggregator.test.ts b/packages/app-api/src/apis/system/ApiAggregator.test.ts new file mode 100644 index 0000000000..2d14087fa2 --- /dev/null +++ b/packages/app-api/src/apis/system/ApiAggregator.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiAggregator } from './ApiAggregator'; +import { createApiRef } from './ApiRef'; +import { ApiRegistry } from './ApiRegistry'; + +describe('ApiAggregator', () => { + const apiARef = createApiRef({ id: 'a', description: '' }); + const apiBRef = createApiRef({ id: 'b', description: '' }); + + it('should forward implementations', () => { + const agg = new ApiAggregator( + ApiRegistry.from([ + [apiARef, 5], + [apiBRef, 10], + ]), + ); + expect(agg.get(apiARef)).toBe(5); + expect(agg.get(apiBRef)).toBe(10); + }); + + it('should return the first implementation', () => { + const agg = new ApiAggregator( + ApiRegistry.from([ + [apiARef, 1], + [apiARef, 2], + ]), + ); + expect(agg.get(apiARef)).toBe(2); + expect(agg.get(apiBRef)).toBe(undefined); + }); +}); diff --git a/packages/app-api/src/apis/system/ApiAggregator.ts b/packages/app-api/src/apis/system/ApiAggregator.ts new file mode 100644 index 0000000000..1587a1d10b --- /dev/null +++ b/packages/app-api/src/apis/system/ApiAggregator.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiRef, ApiHolder } from './types'; + +/** + * An ApiHolder that queries multiple other holders from for + * an Api implementation, returning the first one encountered.. + */ +export class ApiAggregator implements ApiHolder { + private readonly holders: ApiHolder[]; + + constructor(...holders: ApiHolder[]) { + this.holders = holders; + } + + get(apiRef: ApiRef): T | undefined { + for (const holder of this.holders) { + const api = holder.get(apiRef); + if (api) { + return api; + } + } + return undefined; + } +} diff --git a/packages/app-api/src/apis/system/ApiFactoryRegistry.test.ts b/packages/app-api/src/apis/system/ApiFactoryRegistry.test.ts new file mode 100644 index 0000000000..a51ed03fea --- /dev/null +++ b/packages/app-api/src/apis/system/ApiFactoryRegistry.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiFactoryRegistry } from './ApiFactoryRegistry'; +import { createApiRef } from './ApiRef'; + +const aRef = createApiRef({ id: 'a', description: '' }); +const aFactory1 = { api: aRef, deps: {}, factory: () => 1 }; +const aFactory2 = { api: aRef, deps: {}, factory: () => 2 }; +const bRef = createApiRef({ id: 'b', description: '' }); +const bFactory = { api: bRef, deps: {}, factory: () => 'x' }; +const cRef = createApiRef({ id: 'c', description: '' }); +const cFactory = { api: cRef, deps: {}, factory: () => 'y' }; + +describe('ApiFactoryRegistry', () => { + it('should be empty when created', () => { + const registry = new ApiFactoryRegistry(); + expect(registry.getAllApis()).toEqual(new Set()); + }); + + it('should register a factory', () => { + const registry = new ApiFactoryRegistry(); + expect(registry.register('default', aFactory1)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.getAllApis()).toEqual(new Set([aRef])); + }); + + it('should prioritize factories based on scope', () => { + const registry = new ApiFactoryRegistry(); + expect(registry.register('default', aFactory1)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.register('default', aFactory2)).toBe(false); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.register('app', aFactory2)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory2); + expect(registry.register('default', aFactory1)).toBe(false); + expect(registry.get(aRef)).toBe(aFactory2); + expect(registry.register('static', aFactory1)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.register('static', aFactory2)).toBe(false); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.register('app', aFactory2)).toBe(false); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.getAllApis()).toEqual(new Set([aRef])); + }); + + it('should register multiple factories without conflict', () => { + const registry = new ApiFactoryRegistry(); + expect(registry.register('static', aFactory1)).toBe(true); + expect(registry.register('default', bFactory)).toBe(true); + expect(registry.register('app', cFactory)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.get(bRef)).toBe(bFactory); + expect(registry.get(cRef)).toBe(cFactory); + expect(registry.getAllApis()).toEqual(new Set([aRef, bRef, cRef])); + }); + + it('should identify ApiRefs by id but still return the correct factory ref when listing all apis', () => { + const ref1 = createApiRef({ id: 'a', description: 'ref1' }); + const ref2 = createApiRef({ id: 'a', description: 'ref2' }); + + const factory1 = { api: ref1, deps: {}, factory: () => 3 }; + const factory2 = { api: ref2, deps: {}, factory: () => 3 }; + + const registry = new ApiFactoryRegistry(); + expect(registry.register('default', factory1)).toBe(true); + expect(registry.register('default', factory2)).toBe(false); + expect(registry.get(ref1)).toEqual(factory1); + expect(registry.get(ref2)).toEqual(factory1); + expect(registry.getAllApis()).toEqual(new Set([ref1])); + + expect(registry.register('app', factory2)).toBe(true); + expect(registry.get(ref1)).toEqual(factory2); + expect(registry.get(ref2)).toEqual(factory2); + expect(registry.getAllApis()).toEqual(new Set([ref2])); + expect(registry.getAllApis()).not.toEqual(new Set([ref1])); + }); +}); diff --git a/packages/app-api/src/apis/system/ApiFactoryRegistry.ts b/packages/app-api/src/apis/system/ApiFactoryRegistry.ts new file mode 100644 index 0000000000..c5a76ee1d4 --- /dev/null +++ b/packages/app-api/src/apis/system/ApiFactoryRegistry.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ApiRef, + ApiFactoryHolder, + ApiFactory, + AnyApiRef, + AnyApiFactory, +} from './types'; + +type ApiFactoryScope = + | 'default' // Default factories registered by core and plugins + | 'app' // Factories registered in the app, overriding default ones + | 'static'; // APIs that can't be overridden, e.g. config + +enum ScopePriority { + default = 10, + app = 50, + static = 100, +} + +type FactoryTuple = { + priority: number; + factory: AnyApiFactory; +}; + +/** + * ApiFactoryRegistry is an ApiFactoryHolder implementation that enables + * registration of API Factories with different scope. + * + * Each scope has an assigned priority, where factories registered with + * higher priority scopes override ones with lower priority. + */ +export class ApiFactoryRegistry implements ApiFactoryHolder { + private readonly factories = new Map(); + + /** + * Register a new API factory. Returns true if the factory was added + * to the registry. + * + * A factory will not be added to the registry if there is already + * an existing factory with the same or higher priority. + */ + register( + scope: ApiFactoryScope, + factory: ApiFactory, + ) { + const priority = ScopePriority[scope]; + const existing = this.factories.get(factory.api.id); + if (existing && existing.priority >= priority) { + return false; + } + + this.factories.set(factory.api.id, { priority, factory }); + return true; + } + + get( + api: ApiRef, + ): ApiFactory | undefined { + const tuple = this.factories.get(api.id); + if (!tuple) { + return undefined; + } + return tuple.factory as ApiFactory; + } + + getAllApis(): Set { + const refs = new Set(); + for (const { factory } of this.factories.values()) { + refs.add(factory.api); + } + return refs; + } +} diff --git a/packages/app-api/src/apis/system/ApiProvider.test.tsx b/packages/app-api/src/apis/system/ApiProvider.test.tsx new file mode 100644 index 0000000000..4c0b3f5683 --- /dev/null +++ b/packages/app-api/src/apis/system/ApiProvider.test.tsx @@ -0,0 +1,212 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { Context, useContext } from 'react'; +import { ApiProvider, useApi, withApis } from './ApiProvider'; +import { createApiRef } from './ApiRef'; +import { ApiRegistry } from './ApiRegistry'; +import { render } from '@testing-library/react'; +import { withLogCollector } from '@backstage/test-utils-core'; +import { getGlobalSingleton } from '../../lib/globalObject'; +import { ApiHolder, ApiRef } from './types'; +import { VersionedValue } from '../../lib/versionedValues'; + +describe('ApiProvider', () => { + type Api = () => string; + const apiRef = createApiRef({ id: 'x', description: '' }); + const registry = ApiRegistry.from([[apiRef, () => 'hello']]); + + const MyHookConsumer = () => { + const api = useApi(apiRef); + return

hook message: {api()}

; + }; + + const MyHocConsumer = withApis({ getMessage: apiRef })(({ getMessage }) => { + return

hoc message: {getMessage()}

; + }); + + it('should provide apis', () => { + const renderedHook = render( + + + , + ); + renderedHook.getByText('hook message: hello'); + + const renderedHoc = render( + + + , + ); + renderedHoc.getByText('hoc message: hello'); + }); + + it('should provide nested access to apis', () => { + const aRef = createApiRef({ id: 'a', description: '' }); + const bRef = createApiRef({ id: 'b', description: '' }); + + const MyComponent = () => { + const a = useApi(aRef); + const b = useApi(bRef); + return ( +
+ a={a} b={b} +
+ ); + }; + + const renderedHook = render( + + + + + , + ); + renderedHook.getByText('a=z b=y'); + }); + + it('should ignore deps in prototype', () => { + // 100% coverage + happy typescript = hasOwnProperty + this atrocity + const xRef = createApiRef({ id: 'x', description: '' }); + + const proto = { x: xRef }; + const props = { getMessage: { enumerable: true, value: apiRef } }; + const obj = Object.create(proto, props) as { + getMessage: typeof apiRef; + x: typeof xRef; + }; + + const MyWeirdHocConsumer = withApis(obj)(({ getMessage }) => { + return

hoc message: {getMessage()}

; + }); + + const renderedHoc = render( + + + , + ); + renderedHoc.getByText('hoc message: hello'); + }); + + it('should error if no provider is available', () => { + expect( + withLogCollector(['error'], () => { + expect(() => { + render(); + }).toThrow(/^No ApiProvider available in react context. /); + }).error, + ).toEqual([ + expect.stringMatching( + /^Error: Uncaught \[Error: No ApiProvider available in react context. /, + ), + expect.stringMatching( + /^The above error occurred in the component/, + ), + ]); + + expect( + withLogCollector(['error'], () => { + expect(() => { + render(); + }).toThrow(/^No ApiProvider available in react context. /); + }).error, + ).toEqual([ + expect.stringMatching( + /^Error: Uncaught \[Error: No ApiProvider available in react context. /, + ), + expect.stringMatching( + /^The above error occurred in the component/, + ), + ]); + }); + + it('should error if api is not available', () => { + expect( + withLogCollector(['error'], () => { + expect(() => { + render( + + + , + ); + }).toThrow('No implementation available for apiRef{x}'); + }).error, + ).toEqual([ + expect.stringMatching( + /^Error: Uncaught \[Error: No implementation available for apiRef{x}\]/, + ), + expect.stringMatching( + /^The above error occurred in the component/, + ), + ]); + + expect( + withLogCollector(['error'], () => { + expect(() => { + render( + + + , + ); + }).toThrow('No implementation available for apiRef{x}'); + }).error, + ).toEqual([ + expect.stringMatching( + /^Error: Uncaught \[Error: No implementation available for apiRef{x}\]/, + ), + expect.stringMatching( + /^The above error occurred in the component/, + ), + ]); + }); +}); + +describe('v1 consumer', () => { + const ApiContext = getGlobalSingleton< + Context> + >('api-context'); + + function useMockApiV1(apiRef: ApiRef): T { + const impl = useContext(ApiContext)?.atVersion(1)?.get(apiRef); + if (!impl) { + throw new Error('no impl'); + } + return impl; + } + + type Api = () => string; + const apiRef = createApiRef({ id: 'x', description: '' }); + const registry = ApiRegistry.from([[apiRef, () => 'hello']]); + + const MyHookConsumerV1 = () => { + const api = useMockApiV1(apiRef); + return

hook message: {api()}

; + }; + + it('should provide apis', () => { + const renderedHook = render( + + + , + ); + renderedHook.getByText('hook message: hello'); + }); +}); diff --git a/packages/app-api/src/apis/system/ApiProvider.tsx b/packages/app-api/src/apis/system/ApiProvider.tsx new file mode 100644 index 0000000000..6740746ce2 --- /dev/null +++ b/packages/app-api/src/apis/system/ApiProvider.tsx @@ -0,0 +1,126 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + createContext, + useContext, + ReactNode, + PropsWithChildren, + Context, +} from 'react'; +import PropTypes from 'prop-types'; +import { ApiRef, ApiHolder, TypesToApiRefs } from './types'; +import { ApiAggregator } from './ApiAggregator'; +import { getGlobalSingleton, setGlobalSingleton } from '../../lib/globalObject'; +import { + VersionedValue, + createVersionedValueMap, +} from '../../lib/versionedValues'; + +const missingHolderMessage = + 'No ApiProvider available in react context. ' + + 'A common cause of this error is that multiple versions of @backstage/core-api are installed. ' + + `You can check if that is the case using 'yarn backstage-cli versions:check', and can in many cases ` + + `fix the issue either with the --fix flag or using 'yarn backstage-cli versions:bump'`; + +type ApiProviderProps = { + apis: ApiHolder; + children: ReactNode; +}; + +type ApiContextType = VersionedValue<{ 1: ApiHolder }> | undefined; +const ApiContext = createContext(undefined); + +setGlobalSingleton('api-context', ApiContext); + +export const ApiProvider = ({ + apis, + children, +}: PropsWithChildren) => { + const parentHolder = useContext(ApiContext)?.atVersion(1); + const holder = parentHolder ? new ApiAggregator(apis, parentHolder) : apis; + + return ( + + ); +}; + +ApiProvider.propTypes = { + apis: PropTypes.shape({ get: PropTypes.func.isRequired }).isRequired, + children: PropTypes.node, +}; + +export function useApiHolder(): ApiHolder { + const versionedHolder = useContext( + getGlobalSingleton>('api-context'), + ); + + if (!versionedHolder) { + throw new Error(missingHolderMessage); + } + + const apiHolder = versionedHolder.atVersion(1); + if (!apiHolder) { + throw new Error('ApiContext v1 not available'); + } + + return apiHolder; +} + +export function useApi(apiRef: ApiRef): T { + const apiHolder = useApiHolder(); + + const api = apiHolder.get(apiRef); + if (!api) { + throw new Error(`No implementation available for ${apiRef}`); + } + return api; +} + +export function withApis(apis: TypesToApiRefs) { + return function withApisWrapper

( + WrappedComponent: React.ComponentType

, + ) { + const Hoc = (props: PropsWithChildren>) => { + const apiHolder = useApiHolder(); + + const impls = {} as T; + + for (const key in apis) { + if (apis.hasOwnProperty(key)) { + const ref = apis[key]; + + const api = apiHolder.get(ref); + if (!api) { + throw new Error(`No implementation available for ${ref}`); + } + impls[key] = api; + } + } + + return ; + }; + const displayName = + WrappedComponent.displayName || WrappedComponent.name || 'Component'; + + Hoc.displayName = `withApis(${displayName})`; + + return Hoc; + }; +} diff --git a/packages/app-api/src/apis/system/ApiRef.test.ts b/packages/app-api/src/apis/system/ApiRef.test.ts new file mode 100644 index 0000000000..b9ea1470cd --- /dev/null +++ b/packages/app-api/src/apis/system/ApiRef.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from './ApiRef'; + +describe('ApiRef', () => { + it('should be created', () => { + const ref = createApiRef({ id: 'abc', description: '123' }); + expect(ref.id).toBe('abc'); + expect(ref.description).toBe('123'); + expect(String(ref)).toBe('apiRef{abc}'); + expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}'); + }); + + it('should reject invalid ids', () => { + for (const id of ['a', 'abc', 'ab-c', 'a.b.c', 'a-b.c', 'abc.a-b-c.abc3']) { + expect(createApiRef({ id, description: '123' }).id).toBe(id); + } + + for (const id of [ + '123', + 'ab-3', + 'ab_c', + '.', + '2ac', + 'ab.3a', + '.abc', + 'abc.', + 'ab..s', + '', + '_', + ]) { + expect(() => createApiRef({ id, description: '123' }).id).toThrow( + `API id must only contain period separated lowercase alphanum tokens with dashes, got '${id}'`, + ); + } + }); +}); diff --git a/packages/app-api/src/apis/system/ApiRef.ts b/packages/app-api/src/apis/system/ApiRef.ts new file mode 100644 index 0000000000..e61036c61c --- /dev/null +++ b/packages/app-api/src/apis/system/ApiRef.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ApiRef } from './types'; + +export type ApiRefConfig = { + id: string; + description?: string; +}; + +class ApiRefImpl implements ApiRef { + constructor(private readonly config: ApiRefConfig) { + const valid = config.id + .split('.') + .flatMap(part => part.split('-')) + .every(part => part.match(/^[a-z][a-z0-9]*$/)); + if (!valid) { + throw new Error( + `API id must only contain period separated lowercase alphanum tokens with dashes, got '${config.id}'`, + ); + } + } + + get id(): string { + return this.config.id; + } + + get description() { + return this.config.description; + } + + // Utility for getting type of an api, using `typeof apiRef.T` + get T(): T { + throw new Error(`tried to read ApiRef.T of ${this}`); + } + + toString() { + return `apiRef{${this.config.id}}`; + } +} + +export function createApiRef(config: ApiRefConfig): ApiRef { + return new ApiRefImpl(config); +} diff --git a/packages/app-api/src/apis/system/ApiRegistry.test.ts b/packages/app-api/src/apis/system/ApiRegistry.test.ts new file mode 100644 index 0000000000..93dd6dc085 --- /dev/null +++ b/packages/app-api/src/apis/system/ApiRegistry.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiRegistry } from './ApiRegistry'; +import { createApiRef } from './ApiRef'; + +describe('ApiRegistry', () => { + const x1Ref = createApiRef({ id: 'x1', description: '' }); + const x1DuplicateRef = createApiRef({ id: 'x1', description: '' }); + const x2Ref = createApiRef({ id: 'x2', description: '' }); + + it('should be created', () => { + const registry = ApiRegistry.from([]); + expect(registry.get(x1Ref)).toBe(undefined); + }); + + it('should be created with APIs', () => { + const registry = ApiRegistry.from([ + [x1Ref, 3], + [x2Ref, 'y'], + ]); + expect(registry.get(x1Ref)).toBe(3); + expect(registry.get(x1DuplicateRef)).toBe(3); + expect(registry.get(x2Ref)).toBe('y'); + }); + + it('should be built', () => { + const registry = ApiRegistry.builder().build(); + expect(registry.get(x1Ref)).toBe(undefined); + expect(registry.get(x1DuplicateRef)).toBe(undefined); + }); + + it('should be built with APIs', () => { + const builder = ApiRegistry.builder(); + builder.add(x1Ref, 3); + builder.add(x2Ref, 'y'); + + const registry = builder.build(); + expect(registry.get(x1Ref)).toBe(3); + expect(registry.get(x1DuplicateRef)).toBe(3); + expect(registry.get(x2Ref)).toBe('y'); + }); + + it('should be created with API', () => { + const reg1 = ApiRegistry.with(x1Ref, 3); + const reg2 = reg1.with(x2Ref, 'y'); + const reg3 = reg2.with(x2Ref, 'z'); + const reg4 = reg3.with(x1Ref, 2); + const reg5 = reg3.with(x1DuplicateRef, 4); + + expect(reg1.get(x1Ref)).toBe(3); + expect(reg1.get(x2Ref)).toBe(undefined); + expect(reg2.get(x1Ref)).toBe(3); + expect(reg2.get(x2Ref)).toBe('y'); + expect(reg3.get(x1Ref)).toBe(3); + expect(reg3.get(x2Ref)).toBe('z'); + expect(reg4.get(x1Ref)).toBe(2); + expect(reg4.get(x2Ref)).toBe('z'); + expect(reg5.get(x1Ref)).toBe(4); + expect(reg5.get(x2Ref)).toBe('z'); + }); +}); diff --git a/packages/app-api/src/apis/system/ApiRegistry.ts b/packages/app-api/src/apis/system/ApiRegistry.ts new file mode 100644 index 0000000000..01101b2b62 --- /dev/null +++ b/packages/app-api/src/apis/system/ApiRegistry.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiRef, ApiHolder } from './types'; + +type ApiImpl = readonly [ApiRef, T]; + +class ApiRegistryBuilder { + private apis: [string, unknown][] = []; + + add(api: ApiRef, impl: I): I { + this.apis.push([api.id, impl]); + return impl; + } + + build(): ApiRegistry { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + return new ApiRegistry(new Map(this.apis)); + } +} + +export class ApiRegistry implements ApiHolder { + static builder() { + return new ApiRegistryBuilder(); + } + + static from(apis: ApiImpl[]) { + return new ApiRegistry(new Map(apis.map(([api, impl]) => [api.id, impl]))); + } + + /** + * Creates a new ApiRegistry with a single API implementation. + * + * @param api ApiRef for the API to add + * @param impl Implementation of the API to add + */ + static with(api: ApiRef, impl: T): ApiRegistry { + return new ApiRegistry(new Map([[api.id, impl]])); + } + + constructor(private readonly apis: Map) {} + + /** + * Returns a new ApiRegistry with the provided API added to the existing ones. + * + * @param api ApiRef for the API to add + * @param impl Implementation of the API to add + */ + with(api: ApiRef, impl: T): ApiRegistry { + return new ApiRegistry(new Map([...this.apis, [api.id, impl]])); + } + + get(api: ApiRef): T | undefined { + return this.apis.get(api.id) as T | undefined; + } +} diff --git a/packages/app-api/src/apis/system/ApiResolver.test.ts b/packages/app-api/src/apis/system/ApiResolver.test.ts new file mode 100644 index 0000000000..7a46d2db3b --- /dev/null +++ b/packages/app-api/src/apis/system/ApiResolver.test.ts @@ -0,0 +1,266 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiResolver } from './ApiResolver'; +import { createApiRef } from './ApiRef'; +import { ApiFactoryRegistry } from './ApiFactoryRegistry'; + +const aRef = createApiRef({ id: 'a', description: '' }); +const otherARef = createApiRef({ id: 'a', description: 'other' }); +const bRef = createApiRef({ id: 'b', description: '' }); +const otherBRef = createApiRef({ id: 'b', description: 'other' }); +const cRef = createApiRef<{ x: string }>({ id: 'c', description: '' }); +const otherCRef = createApiRef<{ x: string }>({ + id: 'c', + description: 'other', +}); + +function createRegistry() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: {}, + factory: () => 1, + }); + registry.register('default', { + api: bRef, + deps: {}, + factory: () => 'b', + }); + registry.register('default', { + api: cRef, + deps: { b: otherBRef }, + factory: ({ b }) => ({ x: 'x', b }), + }); + return registry; +} + +function createSelfCyclicRegistry() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: { a: aRef }, + factory: () => 1, + }); + return registry; +} + +function createShortCyclicRegistry() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: { b: bRef }, + factory: () => 1, + }); + registry.register('default', { + api: bRef, + deps: { a: aRef }, + factory: () => 'x', + }); + return registry; +} + +function createShortCyclicRegistryWithOther() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: { b: bRef }, + factory: () => 1, + }); + registry.register('default', { + api: otherBRef, + deps: { a: otherARef }, + factory: () => 'x', + }); + return registry; +} + +function createLongCyclicRegistry() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: { b: otherBRef }, + factory: () => 1, + }); + registry.register('default', { + api: bRef, + deps: { c: cRef }, + factory: () => 'b', + }); + registry.register('default', { + api: cRef, + deps: { a: aRef }, + factory: () => ({ x: 'x' }), + }); + return registry; +} + +describe('ApiResolver', () => { + it('should be created empty', () => { + const resolver = new ApiResolver(new ApiFactoryRegistry()); + expect(resolver.get(aRef)).toBe(undefined); + expect(resolver.get(bRef)).toBe(undefined); + expect(resolver.get(otherBRef)).toBe(undefined); + expect(resolver.get(cRef)).toBe(undefined); + }); + + it('should instantiate APIs', () => { + const resolver = new ApiResolver(createRegistry()); + expect(resolver.get(aRef)).toBe(1); + expect(resolver.get(otherARef)).toBe(1); + expect(resolver.get(bRef)).toBe('b'); + expect(resolver.get(otherBRef)).toBe('b'); + expect(resolver.get(cRef)).toEqual({ x: 'x', b: 'b' }); + expect(resolver.get(cRef)).toBe(resolver.get(otherCRef)); + }); + + it('should detect self dependency cycles', () => { + const resolver = new ApiResolver(createSelfCyclicRegistry()); + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + }); + + it('should detect short dependency cycles', () => { + const resolver = new ApiResolver(createShortCyclicRegistry()); + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => resolver.get(bRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + }); + + it('should detect short dependency cycles with other refs', () => { + const resolver = new ApiResolver(createShortCyclicRegistryWithOther()); + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => resolver.get(bRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => resolver.get(otherARef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => resolver.get(otherBRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + }); + + it('should detect long dependency cycles', () => { + const resolver = new ApiResolver(createLongCyclicRegistry()); + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + // Second call for same ref should still throw + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => resolver.get(bRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => resolver.get(otherBRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => resolver.get(cRef)).toThrow( + 'Circular dependency of api factory for apiRef{c}', + ); + }); + + it('should validate a factory holder', () => { + expect(() => { + ApiResolver.validateFactories(createRegistry(), [ + aRef, + bRef, + otherBRef, + cRef, + ]); + }).not.toThrow(); + }); + + it('should find self cycles with validation', () => { + const self = createSelfCyclicRegistry(); + expect(() => ApiResolver.validateFactories(self, [aRef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => ApiResolver.validateFactories(self, [otherARef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + }); + + it('should find dependency cycles with validation', () => { + const short = createShortCyclicRegistry(); + expect(() => ApiResolver.validateFactories(short, [aRef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => ApiResolver.validateFactories(short, [otherARef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => ApiResolver.validateFactories(short, [bRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => ApiResolver.validateFactories(short, [otherBRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + + const shortOther = createShortCyclicRegistryWithOther(); + expect(() => ApiResolver.validateFactories(shortOther, [aRef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => + ApiResolver.validateFactories(shortOther, [otherARef]), + ).toThrow('Circular dependency of api factory for apiRef{a}'); + expect(() => ApiResolver.validateFactories(shortOther, [bRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => + ApiResolver.validateFactories(shortOther, [otherBRef]), + ).toThrow('Circular dependency of api factory for apiRef{b}'); + + const long = createLongCyclicRegistry(); + expect(() => + ApiResolver.validateFactories(long, long.getAllApis()), + ).toThrow('Circular dependency of api factory for apiRef{a}'); + expect(() => ApiResolver.validateFactories(long, [bRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => ApiResolver.validateFactories(long, [otherBRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => ApiResolver.validateFactories(long, [cRef])).toThrow( + 'Circular dependency of api factory for apiRef{c}', + ); + }); + + it('should only call factory func once', () => { + const registry = new ApiFactoryRegistry(); + const factory = jest.fn().mockReturnValue(2); + registry.register('default', { + api: aRef, + deps: {}, + factory, + }); + + const resolver = new ApiResolver(registry); + expect(factory).toHaveBeenCalledTimes(0); + expect(resolver.get(aRef)).toBe(2); + expect(factory).toHaveBeenCalledTimes(1); + expect(resolver.get(aRef)).toBe(2); + expect(factory).toHaveBeenCalledTimes(1); + expect(resolver.get(otherARef)).toBe(2); + expect(factory).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app-api/src/apis/system/ApiResolver.ts b/packages/app-api/src/apis/system/ApiResolver.ts new file mode 100644 index 0000000000..9738e09622 --- /dev/null +++ b/packages/app-api/src/apis/system/ApiResolver.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ApiRef, + ApiHolder, + ApiFactoryHolder, + AnyApiRef, + TypesToApiRefs, +} from './types'; + +export class ApiResolver implements ApiHolder { + /** + * Validate factories by making sure that each of the apis can be created + * without hitting any circular dependencies. + */ + static validateFactories( + factories: ApiFactoryHolder, + apis: Iterable, + ) { + for (const api of apis) { + const heap = [api]; + const allDeps = new Set(); + + while (heap.length) { + const apiRef = heap.shift()!; + const factory = factories.get(apiRef); + if (!factory) { + continue; + } + + for (const dep of Object.values(factory.deps)) { + if (dep.id === api.id) { + throw new Error(`Circular dependency of api factory for ${api}`); + } + if (!allDeps.has(dep)) { + allDeps.add(dep); + heap.push(dep); + } + } + } + } + } + + private readonly apis = new Map(); + + constructor(private readonly factories: ApiFactoryHolder) {} + + get(ref: ApiRef): T | undefined { + return this.load(ref); + } + + private load(ref: ApiRef, loading: AnyApiRef[] = []): T | undefined { + const impl = this.apis.get(ref.id); + if (impl) { + return impl as T; + } + + const factory = this.factories.get(ref); + if (!factory) { + return undefined; + } + + if (loading.includes(factory.api)) { + throw new Error(`Circular dependency of api factory for ${factory.api}`); + } + + const deps = this.loadDeps(ref, factory.deps, [...loading, factory.api]); + const api = factory.factory(deps); + this.apis.set(ref.id, api); + return api as T; + } + + private loadDeps( + dependent: ApiRef, + apis: TypesToApiRefs, + loading: AnyApiRef[], + ): T { + const impls = {} as T; + + for (const key in apis) { + if (apis.hasOwnProperty(key)) { + const ref = apis[key]; + + const api = this.load(ref, loading); + if (!api) { + throw new Error( + `No API factory available for dependency ${ref} of dependent ${dependent}`, + ); + } + impls[key] = api; + } + } + + return impls; + } +} diff --git a/packages/app-api/src/apis/system/helpers.ts b/packages/app-api/src/apis/system/helpers.ts new file mode 100644 index 0000000000..cabff73060 --- /dev/null +++ b/packages/app-api/src/apis/system/helpers.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiRef, ApiFactory, TypesToApiRefs } from './types'; + +/** + * Used to infer types for a standalone ApiFactory that isn't immediately passed + * to another function. + * This function doesn't actually do anything, it's only used to infer types. + */ +export function createApiFactory< + Api, + Impl extends Api, + Deps extends { [name in string]: unknown } +>(factory: ApiFactory): ApiFactory; +export function createApiFactory( + api: ApiRef, + instance: Impl, +): ApiFactory; +export function createApiFactory< + Api, + Impl extends Api, + Deps extends { [name in string]: unknown } +>( + factory: ApiFactory | ApiRef, + instance?: Impl, +): ApiFactory { + if ('id' in factory) { + return { + api: factory, + deps: {} as TypesToApiRefs, + factory: () => instance!, + }; + } + return factory; +} diff --git a/packages/app-api/src/apis/system/index.ts b/packages/app-api/src/apis/system/index.ts new file mode 100644 index 0000000000..10b2e0f084 --- /dev/null +++ b/packages/app-api/src/apis/system/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ApiProvider, useApi, useApiHolder } from './ApiProvider'; +export { ApiRegistry } from './ApiRegistry'; +export { ApiResolver } from './ApiResolver'; +export { ApiFactoryRegistry } from './ApiFactoryRegistry'; +export { createApiRef } from './ApiRef'; +export * from './types'; +export * from './helpers'; diff --git a/packages/app-api/src/apis/system/types.ts b/packages/app-api/src/apis/system/types.ts new file mode 100644 index 0000000000..a4bc95a3c3 --- /dev/null +++ b/packages/app-api/src/apis/system/types.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type ApiRef = { + id: string; + description?: string; + T: T; +}; + +export type AnyApiRef = ApiRef; + +export type ApiRefType = T extends ApiRef ? U : never; + +export type TypesToApiRefs = { [key in keyof T]: ApiRef }; + +export type ApiRefsToTypes }> = { + [key in keyof T]: ApiRefType; +}; + +export type ApiHolder = { + get(api: ApiRef): T | undefined; +}; + +export type ApiFactory< + Api, + Impl extends Api, + Deps extends { [name in string]: unknown } +> = { + api: ApiRef; + deps: TypesToApiRefs; + factory(deps: Deps): Impl; +}; + +export type AnyApiFactory = ApiFactory< + unknown, + unknown, + { [key in string]: unknown } +>; + +export type ApiFactoryHolder = { + get( + api: ApiRef, + ): ApiFactory | undefined; +}; diff --git a/packages/app-api/src/app/App.test.tsx b/packages/app-api/src/app/App.test.tsx new file mode 100644 index 0000000000..a9f1e81a11 --- /dev/null +++ b/packages/app-api/src/app/App.test.tsx @@ -0,0 +1,299 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderWithEffects, withLogCollector } from '@backstage/test-utils'; +import { lightTheme } from '@backstage/theme'; +import { render, screen } from '@testing-library/react'; +import React, { PropsWithChildren } from 'react'; +import { BrowserRouter, Routes } from 'react-router-dom'; +import { createRoutableExtension } from '../extensions'; +import { defaultSystemIcons } from '../icons'; +import { createPlugin } from '../plugin'; +import { useRouteRef } from '../routing/hooks'; +import { + createExternalRouteRef, + createRouteRef, + createSubRouteRef, +} from '../routing'; +import { generateBoundRoutes, PrivateAppImpl } from './App'; + +describe('generateBoundRoutes', () => { + it('runs happy path', () => { + const external = { myRoute: createExternalRouteRef({ id: '1' }) }; + const ref = createRouteRef({ id: 'ref-1' }); + const result = generateBoundRoutes(({ bind }) => { + bind(external, { myRoute: ref }); + }); + + expect(result.get(external.myRoute)).toBe(ref); + }); + + it('throws on unknown keys', () => { + const external = { myRoute: createExternalRouteRef({ id: '2' }) }; + const ref = createRouteRef({ id: 'ref-2' }); + expect(() => + generateBoundRoutes(({ bind }) => { + bind(external, { someOtherRoute: ref } as any); + }), + ).toThrow('Key someOtherRoute is not an existing external route'); + }); +}); + +describe('Integration Test', () => { + const plugin1RouteRef = createRouteRef({ id: 'ref-1' }); + const plugin2RouteRef = createRouteRef({ id: 'ref-2', params: ['x'] }); + const subRouteRef1 = createSubRouteRef({ + id: 'sub1', + path: '/sub1', + parent: plugin1RouteRef, + }); + const subRouteRef2 = createSubRouteRef({ + id: 'sub2', + path: '/sub2/:x', + parent: plugin1RouteRef, + }); + const subRouteRef3 = createSubRouteRef({ + id: 'sub3', + path: '/sub3', + parent: plugin2RouteRef, + }); + const subRouteRef4 = createSubRouteRef({ + id: 'sub4', + path: '/sub4/:y', + parent: plugin2RouteRef, + }); + const extRouteRef1 = createExternalRouteRef({ id: 'extRouteRef1' }); + const extRouteRef2 = createExternalRouteRef({ + id: 'extRouteRef2', + params: ['x'], + }); + const extRouteRef3 = createExternalRouteRef({ + id: 'extRouteRef3', + optional: true, + }); + const extRouteRef4 = createExternalRouteRef({ + id: 'extRouteRef4', + optional: true, + params: ['x'], + }); + + const plugin1 = createPlugin({ + id: 'blob', + externalRoutes: { + extRouteRef1, + extRouteRef2, + extRouteRef3, + extRouteRef4, + }, + }); + + const plugin2 = createPlugin({ + id: 'plugin2', + }); + + const HiddenComponent = plugin2.provide( + createRoutableExtension({ + component: () => Promise.resolve((_: { path?: string }) =>

), + mountPoint: plugin2RouteRef, + }), + ); + + const ExposedComponent = plugin1.provide( + createRoutableExtension({ + component: () => + Promise.resolve((_: PropsWithChildren<{ path?: string }>) => { + const link1 = useRouteRef(plugin1RouteRef); + const link2 = useRouteRef(plugin2RouteRef); + const subLink1 = useRouteRef(subRouteRef1); + const subLink2 = useRouteRef(subRouteRef2); + const subLink3 = useRouteRef(subRouteRef3); + const subLink4 = useRouteRef(subRouteRef4); + const extLink1 = useRouteRef(extRouteRef1); + const extLink2 = useRouteRef(extRouteRef2); + const extLink3 = useRouteRef(extRouteRef3); + const extLink4 = useRouteRef(extRouteRef4); + return ( +
+ link1: {link1()} + link2: {link2({ x: 'a' })} + subLink1: {subLink1()} + subLink2: {subLink2({ x: 'a' })} + subLink3: {subLink3({ x: 'b' })} + subLink4: {subLink4({ x: 'c', y: 'd' })} + extLink1: {extLink1()} + extLink2: {extLink2({ x: 'a' })} + extLink3: {extLink3?.() ?? ''} + extLink4: {extLink4?.({ x: 'b' }) ?? ''} +
+ ); + }), + mountPoint: plugin1RouteRef, + }), + ); + + const components = { + NotFoundErrorPage: () => null, + BootErrorPage: () => null, + Progress: () => null, + Router: BrowserRouter, + }; + + it('runs happy paths', async () => { + const app = new PrivateAppImpl({ + apis: [], + defaultApis: [], + themes: [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: lightTheme, + }, + ], + icons: defaultSystemIcons, + plugins: [], + components, + bindRoutes: ({ bind }) => { + bind(plugin1.externalRoutes, { + extRouteRef1: plugin1RouteRef, + extRouteRef2: plugin2RouteRef, + extRouteRef3: subRouteRef1, + extRouteRef4: plugin2RouteRef, + }); + }, + }); + + const Provider = app.getProvider(); + const Router = app.getRouter(); + + await renderWithEffects( + + + + + + + + , + ); + + expect(screen.getByText('link1: /')).toBeInTheDocument(); + expect(screen.getByText('link2: /foo/a')).toBeInTheDocument(); + expect(screen.getByText('subLink1: /sub1')).toBeInTheDocument(); + expect(screen.getByText('subLink2: /sub2/a')).toBeInTheDocument(); + expect(screen.getByText('subLink3: /foo/b/sub3')).toBeInTheDocument(); + expect(screen.getByText('subLink4: /foo/c/sub4/d')).toBeInTheDocument(); + expect(screen.getByText('extLink1: /')).toBeInTheDocument(); + expect(screen.getByText('extLink2: /foo/a')).toBeInTheDocument(); + expect(screen.getByText('extLink3: /sub1')).toBeInTheDocument(); + expect(screen.getByText('extLink4: /foo/b')).toBeInTheDocument(); + }); + + it('runs happy paths without optional routes', async () => { + const app = new PrivateAppImpl({ + apis: [], + defaultApis: [], + themes: [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: lightTheme, + }, + ], + icons: defaultSystemIcons, + plugins: [], + components, + bindRoutes: ({ bind }) => { + bind(plugin1.externalRoutes, { + extRouteRef1: plugin1RouteRef, + extRouteRef2: plugin2RouteRef, + }); + }, + }); + + const Provider = app.getProvider(); + const Router = app.getRouter(); + + await renderWithEffects( + + + + + + + + , + ); + + expect(screen.getByText('extLink1: /')).toBeInTheDocument(); + expect(screen.getByText('extLink2: /foo')).toBeInTheDocument(); + expect(screen.getByText('extLink3: ')).toBeInTheDocument(); + expect(screen.getByText('extLink4: ')).toBeInTheDocument(); + }); + + it('should throw some error when the route has duplicate params', () => { + const app = new PrivateAppImpl({ + apis: [], + defaultApis: [], + themes: [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: lightTheme, + }, + ], + icons: defaultSystemIcons, + plugins: [], + components, + bindRoutes: ({ bind }) => { + bind(plugin1.externalRoutes, { + extRouteRef1: plugin1RouteRef, + extRouteRef2: plugin2RouteRef, + }); + }, + }); + + const Provider = app.getProvider(); + const Router = app.getRouter(); + const { error: errorLogs } = withLogCollector(() => { + expect(() => + render( + + + + + + + + + , + ), + ).toThrow( + 'Parameter :thing is duplicated in path /test/:thing/some/:thing', + ); + }); + expect(errorLogs).toEqual([ + expect.stringContaining( + 'Parameter :thing is duplicated in path /test/:thing/some/:thing', + ), + expect.stringContaining( + 'The above error occurred in the component', + ), + ]); + }); +}); diff --git a/packages/app-api/src/app/App.tsx b/packages/app-api/src/app/App.tsx new file mode 100644 index 0000000000..9e14c9a5fb --- /dev/null +++ b/packages/app-api/src/app/App.tsx @@ -0,0 +1,501 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + ComponentType, + PropsWithChildren, + ReactElement, + useMemo, + useState, +} from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { useAsync } from 'react-use'; +import { + AnyApiFactory, + ApiHolder, + ApiProvider, + ApiRegistry, + AppTheme, + appThemeApiRef, + AppThemeSelector, + configApiRef, + ConfigReader, + LocalStorageFeatureFlags, + useApi, +} from '../apis'; +import { + AppThemeApi, + ConfigApi, + featureFlagsApiRef, + identityApiRef, +} from '../apis/definitions'; +import { ApiFactoryRegistry, ApiResolver } from '../apis/system'; +import { + childDiscoverer, + routeElementDiscoverer, + traverseElementTree, +} from '../extensions/traversal'; +import { IconComponent, IconComponentMap, IconKey } from '../icons'; +import { BackstagePlugin } from '../plugin'; +import { AnyRoutes } from '../plugin/types'; +import { RouteRef, ExternalRouteRef } from '../routing'; +import { + routeObjectCollector, + routeParentCollector, + routePathCollector, +} from '../routing/collectors'; +import { RoutingProvider } from '../routing/hooks'; +import { validateRoutes } from '../routing/validation'; +import { AppContextProvider } from './AppContext'; +import { AppIdentity } from './AppIdentity'; +import { AppThemeProvider } from './AppThemeProvider'; +import { + AppComponents, + AppConfigLoader, + AppContext, + AppOptions, + AppRouteBinder, + BackstageApp, + SignInPageProps, + SignInResult, +} from './types'; + +export function generateBoundRoutes( + bindRoutes: AppOptions['bindRoutes'], +): Map { + const result = new Map(); + + if (bindRoutes) { + const bind: AppRouteBinder = (externalRoutes, targetRoutes: AnyRoutes) => { + for (const [key, value] of Object.entries(targetRoutes)) { + const externalRoute = externalRoutes[key]; + if (!externalRoute) { + throw new Error(`Key ${key} is not an existing external route`); + } + if (!value && !externalRoute.optional) { + throw new Error( + `External route ${key} is required but was undefined`, + ); + } + if (value) { + result.set(externalRoute, value); + } + } + }; + bindRoutes({ bind }); + } + + return result; +} + +type FullAppOptions = { + apis: Iterable; + icons: IconComponentMap; + plugins: BackstagePlugin[]; + components: AppComponents; + themes: AppTheme[]; + configLoader?: AppConfigLoader; + defaultApis: Iterable; + bindRoutes?: AppOptions['bindRoutes']; +}; + +function useConfigLoader( + configLoader: AppConfigLoader | undefined, + components: AppComponents, + appThemeApi: AppThemeApi, +): { api: ConfigApi } | { node: JSX.Element } { + // Keeping this synchronous when a config loader isn't set simplifies tests a lot + const hasConfig = Boolean(configLoader); + const config = useAsync(configLoader || (() => Promise.resolve([]))); + + let noConfigNode = undefined; + + if (hasConfig && config.loading) { + const { Progress } = components; + noConfigNode = ; + } else if (config.error) { + const { BootErrorPage } = components; + noConfigNode = ; + } + + // Before the config is loaded we can't use a router, so exit early + if (noConfigNode) { + return { + node: ( + + {noConfigNode} + + ), + }; + } + + const configReader = ConfigReader.fromConfigs(config.value ?? []); + + return { api: configReader }; +} + +class AppContextImpl implements AppContext { + constructor(private readonly app: PrivateAppImpl) {} + + getPlugins(): BackstagePlugin[] { + // eslint-disable-next-line no-console + console.warn('appContext.getPlugins() is deprecated and will be removed'); + return this.app.getPlugins(); + } + + getSystemIcon(key: IconKey): IconComponent | undefined { + return this.app.getSystemIcon(key); + } + + getComponents(): AppComponents { + return this.app.getComponents(); + } + + getProvider(): React.ComponentType<{}> { + // eslint-disable-next-line no-console + console.warn('appContext.getProvider() is deprecated and will be removed'); + return this.app.getProvider(); + } + + getRouter(): React.ComponentType<{}> { + // eslint-disable-next-line no-console + console.warn('appContext.getRouter() is deprecated and will be removed'); + return this.app.getRouter(); + } + + getRoutes(): JSX.Element[] { + // eslint-disable-next-line no-console + console.warn('appContext.getRoutes() is deprecated and will be removed'); + return this.app.getRoutes(); + } +} + +export class PrivateAppImpl implements BackstageApp { + private apiHolder?: ApiHolder; + private configApi?: ConfigApi; + + private readonly apis: Iterable; + private readonly icons: IconComponentMap; + private readonly plugins: BackstagePlugin[]; + private readonly components: AppComponents; + private readonly themes: AppTheme[]; + private readonly configLoader?: AppConfigLoader; + private readonly defaultApis: Iterable; + private readonly bindRoutes: AppOptions['bindRoutes']; + + private readonly identityApi = new AppIdentity(); + + constructor(options: FullAppOptions) { + this.apis = options.apis; + this.icons = options.icons; + this.plugins = options.plugins; + this.components = options.components; + this.themes = options.themes; + this.configLoader = options.configLoader; + this.defaultApis = options.defaultApis; + this.bindRoutes = options.bindRoutes; + } + + getPlugins(): BackstagePlugin[] { + return this.plugins; + } + + getSystemIcon(key: IconKey): IconComponent | undefined { + return this.icons[key]; + } + + getComponents(): AppComponents { + return this.components; + } + + getRoutes(): JSX.Element[] { + const routes = new Array(); + + const { NotFoundErrorPage } = this.components; + + for (const plugin of this.plugins.values()) { + for (const output of plugin.output()) { + switch (output.type) { + case 'legacy-route': { + const { path, component: Component } = output; + routes.push( + } />, + ); + break; + } + case 'route': { + const { target, component: Component } = output; + routes.push( + } + />, + ); + break; + } + case 'legacy-redirect-route': { + const { path, target } = output; + routes.push(); + break; + } + case 'redirect-route': { + const { from, to } = output; + routes.push(); + break; + } + default: + break; + } + } + } + + routes.push( + } + />, + ); + + return routes; + } + + getProvider(): ComponentType<{}> { + const appContext = new AppContextImpl(this); + const apiHolder = this.getApiHolder(); + + const featureFlagsApi = this.getApiHolder().get(featureFlagsApiRef)!; + + for (const plugin of this.plugins.values()) { + for (const output of plugin.output()) { + switch (output.type) { + case 'feature-flag': { + featureFlagsApi.registerFlag({ + name: output.name, + pluginId: plugin.getId(), + }); + break; + } + default: + break; + } + } + } + + const Provider = ({ children }: PropsWithChildren<{}>) => { + const appThemeApi = useMemo( + () => AppThemeSelector.createWithStorage(this.themes), + [], + ); + + const { routePaths, routeParents, routeObjects } = useMemo(() => { + const result = traverseElementTree({ + root: children, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routePaths: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + + validateRoutes(result.routePaths, result.routeParents); + + return result; + }, [children]); + + const loadedConfig = useConfigLoader( + this.configLoader, + this.components, + appThemeApi, + ); + + if ('node' in loadedConfig) { + // Loading or error + return loadedConfig.node; + } + + this.configApi = loadedConfig.api; + + return ( + + + + + {children} + + + + + ); + }; + return Provider; + } + + getRouter(): ComponentType<{}> { + const { + Router: RouterComponent, + SignInPage: SignInPageComponent, + } = this.components; + + // This wraps the sign-in page and waits for sign-in to be completed before rendering the app + const SignInPageWrapper = ({ + component: Component, + children, + }: { + component: ComponentType; + children: ReactElement; + }) => { + const [result, setResult] = useState(); + + if (result) { + this.identityApi.setSignInResult(result); + return children; + } + + return ; + }; + + const AppRouter = ({ children }: PropsWithChildren<{}>) => { + const configApi = useApi(configApiRef); + + let { pathname } = new URL( + configApi.getOptionalString('app.baseUrl') ?? '/', + 'http://dummy.dev', // baseUrl can be specified as just a path + ); + if (pathname.endsWith('/')) { + pathname = pathname.replace(/\/$/, ''); + } + + // If the app hasn't configured a sign-in page, we just continue as guest. + if (!SignInPageComponent) { + this.identityApi.setSignInResult({ + userId: 'guest', + profile: { + email: 'guest@example.com', + displayName: 'Guest', + }, + }); + + return ( + + + {children}} /> + + + ); + } + + return ( + + + + {children}} /> + + + + ); + }; + + return AppRouter; + } + + private getApiHolder(): ApiHolder { + if (this.apiHolder) { + return this.apiHolder; + } + + const registry = new ApiFactoryRegistry(); + + registry.register('static', { + api: appThemeApiRef, + deps: {}, + factory: () => AppThemeSelector.createWithStorage(this.themes), + }); + registry.register('static', { + api: configApiRef, + deps: {}, + factory: () => { + if (!this.configApi) { + throw new Error( + 'Tried to access config API before config was loaded', + ); + } + return this.configApi; + }, + }); + registry.register('static', { + api: identityApiRef, + deps: {}, + factory: () => this.identityApi, + }); + + // It's possible to replace the feature flag API, but since we must have at least + // one implementation we add it here directly instead of through the defaultApis. + registry.register('default', { + api: featureFlagsApiRef, + deps: {}, + factory: () => new LocalStorageFeatureFlags(), + }); + for (const factory of this.defaultApis) { + registry.register('default', factory); + } + + for (const plugin of this.plugins) { + for (const factory of plugin.getApis()) { + if (!registry.register('default', factory)) { + throw new Error( + `Plugin ${plugin.getId()} tried to register duplicate or forbidden API factory for ${ + factory.api + }`, + ); + } + } + } + + for (const factory of this.apis) { + if (!registry.register('app', factory)) { + throw new Error( + `Duplicate or forbidden API factory for ${factory.api} in app`, + ); + } + } + + ApiResolver.validateFactories(registry, registry.getAllApis()); + + this.apiHolder = new ApiResolver(registry); + + return this.apiHolder; + } + + verify() { + const pluginIds = new Set(); + + for (const plugin of this.plugins) { + const id = plugin.getId(); + if (pluginIds.has(id)) { + throw new Error(`Duplicate plugin found '${id}'`); + } + pluginIds.add(id); + } + } +} diff --git a/packages/app-api/src/app/AppContext.test.tsx b/packages/app-api/src/app/AppContext.test.tsx new file mode 100644 index 0000000000..55d98c18c0 --- /dev/null +++ b/packages/app-api/src/app/AppContext.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useContext, Context } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { VersionedValue } from '../lib/versionedValues'; +import { getGlobalSingleton } from '../lib/globalObject'; +import { AppContext as AppContextV1 } from './types'; +import { AppContextProvider } from './AppContext'; + +describe('v1 consumer', () => { + const AppContext = getGlobalSingleton< + Context> + >('app-context'); + + function useMockAppV1(): AppContextV1 { + const impl = useContext(AppContext)?.atVersion(1); + if (!impl) { + throw new Error('no impl'); + } + return impl; + } + + it('should provide an app context', () => { + const mockContext: AppContextV1 = { + getComponents: jest.fn(), + getSystemIcon: jest.fn(), + getPlugins: jest.fn(), + getProvider: jest.fn(), + getRouter: jest.fn(), + getRoutes: jest.fn(), + }; + + const renderedHook = renderHook(() => useMockAppV1(), { + wrapper: ({ children }) => ( + + ), + }); + const result = renderedHook.result.current; + + expect(mockContext.getComponents).toHaveBeenCalledTimes(0); + result.getComponents(); + expect(mockContext.getComponents).toHaveBeenCalledTimes(1); + + expect(mockContext.getSystemIcon).toHaveBeenCalledTimes(0); + result.getSystemIcon('icon'); + expect(mockContext.getSystemIcon).toHaveBeenCalledTimes(1); + expect(mockContext.getSystemIcon).toHaveBeenCalledWith('icon'); + + expect(mockContext.getPlugins).toHaveBeenCalledTimes(0); + result.getPlugins(); + expect(mockContext.getPlugins).toHaveBeenCalledTimes(1); + + expect(mockContext.getProvider).toHaveBeenCalledTimes(0); + result.getProvider(); + expect(mockContext.getProvider).toHaveBeenCalledTimes(1); + + expect(mockContext.getRouter).toHaveBeenCalledTimes(0); + result.getRouter(); + expect(mockContext.getRouter).toHaveBeenCalledTimes(1); + + expect(mockContext.getRoutes).toHaveBeenCalledTimes(0); + result.getRoutes(); + expect(mockContext.getRoutes).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app-api/src/app/AppContext.tsx b/packages/app-api/src/app/AppContext.tsx new file mode 100644 index 0000000000..7b73d71c6b --- /dev/null +++ b/packages/app-api/src/app/AppContext.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + createContext, + PropsWithChildren, + useContext, + Context, +} from 'react'; +import { + VersionedValue, + createVersionedValueMap, +} from '../lib/versionedValues'; +import { getGlobalSingleton, setGlobalSingleton } from '../lib/globalObject'; +import { AppContext as AppContextV1 } from './types'; + +type AppContextType = VersionedValue<{ 1: AppContextV1 }> | undefined; +const AppContext = createContext(undefined); + +setGlobalSingleton('app-context', AppContext); + +type Props = { + appContext: AppContextV1; +}; + +export const AppContextProvider = ({ + appContext, + children, +}: PropsWithChildren) => { + const versionedValue = createVersionedValueMap({ 1: appContext }); + + return ; +}; + +export const useApp = (): AppContextV1 => { + const versionedContext = useContext( + getGlobalSingleton>('app-context'), + ); + if (!versionedContext) { + throw new Error('No app context available'); + } + const appContext = versionedContext.atVersion(1); + if (!appContext) { + throw new Error('AppContext v1 not available'); + } + return appContext; +}; diff --git a/packages/app-api/src/app/AppIdentity.ts b/packages/app-api/src/app/AppIdentity.ts new file mode 100644 index 0000000000..d3e5fe567a --- /dev/null +++ b/packages/app-api/src/app/AppIdentity.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IdentityApi, ProfileInfo } from '../apis'; +import { SignInResult } from './types'; + +/** + * Implementation of the connection between the App-wide IdentityApi + * and sign-in page. + */ +export class AppIdentity implements IdentityApi { + private hasIdentity = false; + private userId?: string; + private profile?: ProfileInfo; + private idTokenFunc?: () => Promise; + private signOutFunc?: () => Promise; + + getUserId(): string { + if (!this.hasIdentity) { + throw new Error( + 'Tried to access IdentityApi userId before app was loaded', + ); + } + return this.userId!; + } + + getProfile(): ProfileInfo { + if (!this.hasIdentity) { + throw new Error( + 'Tried to access IdentityApi profile before app was loaded', + ); + } + return this.profile!; + } + + async getIdToken(): Promise { + if (!this.hasIdentity) { + throw new Error( + 'Tried to access IdentityApi idToken before app was loaded', + ); + } + return this.idTokenFunc?.(); + } + + async signOut(): Promise { + if (!this.hasIdentity) { + throw new Error( + 'Tried to access IdentityApi signOutFunc before app was loaded', + ); + } + await this.signOutFunc?.(); + location.reload(); + } + + // This is indirectly called by the sign-in page to continue into the app. + setSignInResult(result: SignInResult) { + if (this.hasIdentity) { + return; + } + if (!result.userId) { + throw new Error('Invalid sign-in result, userId not set'); + } + if (!result.profile) { + throw new Error('Invalid sign-in result, profile not set'); + } + this.hasIdentity = true; + this.userId = result.userId; + this.profile = result.profile; + this.idTokenFunc = result.getIdToken; + this.signOutFunc = result.signOut; + } +} diff --git a/packages/app-api/src/app/AppThemeProvider.tsx b/packages/app-api/src/app/AppThemeProvider.tsx new file mode 100644 index 0000000000..993de23a7a --- /dev/null +++ b/packages/app-api/src/app/AppThemeProvider.tsx @@ -0,0 +1,97 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useMemo, useEffect, useState, PropsWithChildren } from 'react'; +import { ThemeProvider, CssBaseline } from '@material-ui/core'; +import { useApi, appThemeApiRef, AppTheme } from '../apis'; +import { useObservable } from 'react-use'; + +// This tries to find the most accurate match, but also falls back to less +// accurate results in order to avoid errors. +function resolveTheme( + themeId: string | undefined, + shouldPreferDark: boolean, + themes: AppTheme[], +) { + if (themeId !== undefined) { + const selectedTheme = themes.find(theme => theme.id === themeId); + if (selectedTheme) { + return selectedTheme; + } + } + + if (shouldPreferDark) { + const darkTheme = themes.find(theme => theme.variant === 'dark'); + if (darkTheme) { + return darkTheme; + } + } + + const lightTheme = themes.find(theme => theme.variant === 'light'); + if (lightTheme) { + return lightTheme; + } + + return themes[0]; +} + +const useShouldPreferDarkTheme = () => { + const mediaQuery = useMemo( + () => window.matchMedia('(prefers-color-scheme: dark)'), + [], + ); + const [shouldPreferDark, setPrefersDark] = useState(mediaQuery.matches); + + useEffect(() => { + const listener = (event: MediaQueryListEvent) => { + setPrefersDark(event.matches); + }; + mediaQuery.addListener(listener); + return () => { + mediaQuery.removeListener(listener); + }; + }, [mediaQuery]); + + return shouldPreferDark; +}; + +export function AppThemeProvider({ children }: PropsWithChildren<{}>) { + const appThemeApi = useApi(appThemeApiRef); + const themeId = useObservable( + appThemeApi.activeThemeId$(), + appThemeApi.getActiveThemeId(), + ); + + // Browser feature detection won't change over time, so ignore lint rule + const shouldPreferDark = Boolean(window.matchMedia) + ? useShouldPreferDarkTheme() // eslint-disable-line react-hooks/rules-of-hooks + : false; + + const appTheme = resolveTheme( + themeId, + shouldPreferDark, + appThemeApi.getInstalledThemes(), + ); + if (!appTheme) { + throw new Error('App has no themes'); + } + + return ( + + {children} + + ); +} diff --git a/packages/app-api/src/app/index.ts b/packages/app-api/src/app/index.ts new file mode 100644 index 0000000000..17610ea3ee --- /dev/null +++ b/packages/app-api/src/app/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { useApp } from './AppContext'; +export * from './types'; diff --git a/packages/app-api/src/app/types.ts b/packages/app-api/src/app/types.ts new file mode 100644 index 0000000000..5bdf51837d --- /dev/null +++ b/packages/app-api/src/app/types.ts @@ -0,0 +1,257 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentType } from 'react'; +import { IconComponent, IconComponentMap, IconKey } from '../icons'; +import { AnyExternalRoutes, BackstagePlugin } from '../plugin/types'; +import { ExternalRouteRef, RouteRef } from '../routing'; +import { AnyApiFactory } from '../apis'; +import { AppTheme, ProfileInfo } from '../apis/definitions'; +import { AppConfig } from '@backstage/config'; +import { SubRouteRef } from '../routing/types'; + +export type BootErrorPageProps = { + step: 'load-config'; + error: Error; +}; + +export type SignInResult = { + /** + * User ID that will be returned by the IdentityApi + */ + userId: string; + + profile: ProfileInfo; + + /** + * Function used to retrieve an ID token for the signed in user. + */ + getIdToken?: () => Promise; + + /** + * Sign out handler that will be called if the user requests to sign out. + */ + signOut?: () => Promise; +}; + +export type SignInPageProps = { + /** + * Set the sign-in result for the app. This should only be called once. + */ + onResult(result: SignInResult): void; +}; + +export type AppComponents = { + NotFoundErrorPage: ComponentType<{}>; + BootErrorPage: ComponentType; + Progress: ComponentType<{}>; + Router: ComponentType<{}>; + + /** + * An optional sign-in page that will be rendered instead of the AppRouter at startup. + * + * If a sign-in page is set, it will always be shown before the app, and it is up + * to the sign-in page to handle e.g. saving of login methods for subsequent visits. + * + * The sign-in page will be displayed until it has passed up a result to the parent, + * and which point the AppRouter and all of its children will be rendered instead. + */ + SignInPage?: ComponentType; +}; + +/** + * A function that loads in the App config that will be accessible via the ConfigApi. + * + * If multiple config objects are returned in the array, values in the earlier configs + * will override later ones. + */ +export type AppConfigLoader = () => Promise; + +/** + * Extracts a union of the keys in a map whose value extends the given type + */ +type KeysWithType = { + [key in keyof Obj]: Obj[key] extends Type ? key : never; +}[keyof Obj]; + +/** + * Takes a map Map required values and makes all keys matching Keys optional + */ +type PartialKeys< + Map extends { [name in string]: any }, + Keys extends keyof Map +> = Partial> & Required>; + +/** + * Creates a map of target routes with matching parameters based on a map of external routes. + */ +type TargetRouteMap = { + [name in keyof ExternalRoutes]: ExternalRoutes[name] extends ExternalRouteRef< + infer Params, + any + > + ? RouteRef | SubRouteRef + : never; +}; + +export type AppRouteBinder = ( + externalRoutes: ExternalRoutes, + targetRoutes: PartialKeys< + TargetRouteMap, + KeysWithType> + >, +) => void; + +export type AppOptions = { + /** + * A collection of ApiFactories to register in the application to either + * add add new ones, or override factories provided by default or by plugins. + */ + apis?: Iterable; + + /** + * Supply icons to override the default ones. + */ + icons?: IconComponentMap; + + /** + * A list of all plugins to include in the app. + */ + plugins?: BackstagePlugin[]; + + /** + * Supply components to the app to override the default ones. + */ + components?: Partial; + + /** + * Themes provided as a part of the app. By default two themes are included, one + * light variant of the default backstage theme, and one dark. + * + * This is the default config: + * + * ``` + * [{ + * id: 'light', + * title: 'Light Theme', + * variant: 'light', + * theme: lightTheme, + * icon: , + * }, { + * id: 'dark', + * title: 'Dark Theme', + * variant: 'dark', + * theme: darkTheme, + * icon: , + * }] + * ``` + */ + themes?: AppTheme[]; + + /** + * A function that loads in App configuration that will be accessible via + * the ConfigApi. + * + * Defaults to an empty config. + * + * TODO(Rugvip): Omitting this should instead default to loading in configuration + * that was packaged by the backstage-cli and default docker container boot script. + */ + configLoader?: AppConfigLoader; + + /** + * A function that is used to register associations between cross-plugin route + * references, enabling plugins to navigate between each other. + * + * The `bind` function that is passed in should be used to bind all external + * routes of all used plugins. + * + * ```ts + * bindRoutes({ bind }) { + * bind(docsPlugin.externalRoutes, { + * homePage: managePlugin.routes.managePage, + * }) + * bind(homePagePlugin.externalRoutes, { + * settingsPage: settingsPlugin.routes.settingsPage, + * }) + * } + * ``` + */ + bindRoutes?(context: { bind: AppRouteBinder }): void; +}; + +export type BackstageApp = { + /** + * Returns all plugins registered for the app. + */ + getPlugins(): BackstagePlugin[]; + + /** + * Get a common or custom icon for this app. + */ + getSystemIcon(key: IconKey): IconComponent | undefined; + + /** + * Provider component that should wrap the Router created with getRouter() + * and any other components that need to be within the app context. + */ + getProvider(): ComponentType<{}>; + + /** + * Router component that should wrap the App Routes create with getRoutes() + * and any other components that should only be available while signed in. + */ + getRouter(): ComponentType<{}>; + + /** + * Routes component that contains all routes for plugin pages in the app. + * + * @deprecated Registering routes in plugins is deprecated and this method will be removed. + */ + getRoutes(): JSX.Element[]; +}; + +export type AppContext = { + /** + * @deprecated Will be removed + */ + getPlugins(): BackstagePlugin[]; + + /** + * Get a common or custom icon for this app. + */ + getSystemIcon(key: IconKey): IconComponent | undefined; + + /** + * Get the components registered for various purposes in the app. + */ + getComponents(): AppComponents; + + /** + * @deprecated Will be removed + */ + getProvider(): ComponentType<{}>; + + /** + * @deprecated Will be removed + */ + getRouter(): ComponentType<{}>; + + /** + * @deprecated Will be removed + */ + getRoutes(): JSX.Element[]; +}; diff --git a/packages/app-api/src/extensions/componentData.test.tsx b/packages/app-api/src/extensions/componentData.test.tsx new file mode 100644 index 0000000000..417fe07414 --- /dev/null +++ b/packages/app-api/src/extensions/componentData.test.tsx @@ -0,0 +1,118 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { attachComponentData, getComponentData } from './componentData'; + +describe('elementData', () => { + it('should attach a single piece of data', () => { + const data = { foo: 'bar' }; + const Component = () => null; + attachComponentData(Component, 'my-data', data); + + const element = ; + expect(getComponentData(element, 'my-data')).toBe(data); + }); + + it('should attach several distinct pieces of data', () => { + const data1 = { foo: 'bar' }; + const data2 = { test: 'value' }; + const Component = () => null; + attachComponentData(Component, 'my-data', data1); + attachComponentData(Component, 'second', data2); + + const element = ; + expect(getComponentData(element, 'my-data')).toBe(data1); + expect(getComponentData(element, 'second')).toBe(data2); + }); + + it('returns undefined for missing data', () => { + const data = { foo: 'bar' }; + const Component1 = () => null; + const Component2 = () => null; + attachComponentData(Component2, 'my-data', data); + + const element1 = ; + const element2 = ; + expect(getComponentData(element1, 'missing')).toBeUndefined(); + expect(getComponentData(element2, 'missing')).toBeUndefined(); + }); + + it('should throw when attempting to overwrite data', () => { + const data = { foo: 'bar' }; + const MyComponent = () => null; + attachComponentData(MyComponent, 'my-data', data); + expect(() => attachComponentData(MyComponent, 'my-data', data)).toThrow( + 'Attempted to attach duplicate data "my-data" to component "MyComponent"', + ); + }); + + describe('works across versions', () => { + function getDataSymbol() { + const Component = () => null; + attachComponentData(Component, 'my-data', {}); + const [symbol] = Object.getOwnPropertySymbols(Component); + return symbol; + } + + it('should should be able to get data from older versions', () => { + const symbol = getDataSymbol(); + + const data = { foo: 'bar' }; + const Component = () => null; + attachComponentData(Component, 'my-data', data); + + const element = ; + expect((element as any).type[symbol].map.get('my-data')).toBe(data); + }); + + it('should should be able to attach data for older versions', () => { + const symbol = getDataSymbol(); + + const data = { foo: 'bar' }; + const Component = () => null; + (Component as any)[symbol] = { + map: new Map([['my-data', data]]), + }; + + const element = ; + expect(getComponentData(element, 'my-data')).toBe(data); + }); + + it('should be able to get data from newer versions', () => { + const data = { foo: 'bar' }; + const Component = () => null; + attachComponentData(Component, 'my-data', data); + + const element = ; + const container = (global as any)[ + '__@backstage/component-data-store__' + ].get(element.type); + expect(container.map.get('my-data')).toBe(data); + }); + + it('should should be able to attach data for newer versions', () => { + const data = { foo: 'bar' }; + const Component = () => null; + (global as any)['__@backstage/component-data-store__'].set(Component, { + map: new Map([['my-data', data]]), + }); + + const element = ; + expect(getComponentData(element, 'my-data')).toBe(data); + }); + }); +}); diff --git a/packages/app-api/src/extensions/componentData.tsx b/packages/app-api/src/extensions/componentData.tsx new file mode 100644 index 0000000000..fc8594039c --- /dev/null +++ b/packages/app-api/src/extensions/componentData.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentType, ReactNode } from 'react'; +import { getOrCreateGlobalSingleton } from '../lib/globalObject'; + +// TODO(Rugvip): Access via symbol is deprecated, remove once on 0.3.x +const DATA_KEY = Symbol('backstage-component-data'); + +type ComponentWithData

= ComponentType

& { + [DATA_KEY]?: DataContainer; +}; + +type DataContainer = { + map: Map; +}; + +type MaybeComponentNode = ReactNode & { + type?: ComponentType & { [DATA_KEY]?: DataContainer }; +}; + +// The store is bridged across versions using the global object +const store = getOrCreateGlobalSingleton( + 'component-data-store', + () => new WeakMap, DataContainer>(), +); + +export function attachComponentData

( + component: ComponentType

, + type: string, + data: unknown, +) { + const dataComponent = component as ComponentWithData

; + + let container = store.get(component) || dataComponent[DATA_KEY]; + if (!container) { + container = { map: new Map() }; + store.set(component, container); + dataComponent[DATA_KEY] = container; + } + + if (container.map.has(type)) { + const name = component.displayName || component.name; + throw new Error( + `Attempted to attach duplicate data "${type}" to component "${name}"`, + ); + } + + container.map.set(type, data); +} + +export function getComponentData( + node: ReactNode, + type: string, +): T | undefined { + if (!node) { + return undefined; + } + + const component = (node as MaybeComponentNode).type; + if (!component) { + return undefined; + } + + const container = store.get(component) || component[DATA_KEY]; + if (!container) { + return undefined; + } + + return container.map.get(type) as T | undefined; +} diff --git a/packages/app-api/src/extensions/extensions.test.tsx b/packages/app-api/src/extensions/extensions.test.tsx new file mode 100644 index 0000000000..26755b3bcf --- /dev/null +++ b/packages/app-api/src/extensions/extensions.test.tsx @@ -0,0 +1,76 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { createPlugin } from '../plugin'; +import { createRouteRef } from '../routing'; +import { getComponentData } from './componentData'; +import { + createComponentExtension, + createReactExtension, + createRoutableExtension, +} from './extensions'; + +const plugin = createPlugin({ + id: 'my-plugin', +}); + +describe('extensions', () => { + it('should create a react extension with component data', () => { + const Component = () =>

; + + const extension = createReactExtension({ + component: { + sync: Component, + }, + data: { + myData: { foo: 'bar' }, + }, + }); + + const ExtensionComponent = plugin.provide(extension); + const element = ; + + expect(getComponentData(element, 'core.plugin')).toBe(plugin); + expect(getComponentData(element, 'myData')).toEqual({ foo: 'bar' }); + }); + + it('should create react extensions of different types', () => { + const Component = () =>
; + const routeRef = createRouteRef({ path: '/foo', title: 'Foo' }); + + const extension1 = createComponentExtension({ + component: { + sync: Component, + }, + }); + + const extension2 = createRoutableExtension({ + component: () => Promise.resolve(Component), + mountPoint: routeRef, + }); + + const ExtensionComponent1 = plugin.provide(extension1); + const ExtensionComponent2 = plugin.provide(extension2); + + const element1 = ; + const element2 = ; + + expect(getComponentData(element1, 'core.plugin')).toBe(plugin); + expect(getComponentData(element2, 'core.plugin')).toBe(plugin); + expect(getComponentData(element2, 'core.mountPoint')).toBe(routeRef); + }); +}); diff --git a/packages/app-api/src/extensions/extensions.tsx b/packages/app-api/src/extensions/extensions.tsx new file mode 100644 index 0000000000..e56b6901dd --- /dev/null +++ b/packages/app-api/src/extensions/extensions.tsx @@ -0,0 +1,109 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { lazy, Suspense } from 'react'; +import { RouteRef, useRouteRef } from '../routing'; +import { attachComponentData } from './componentData'; +import { Extension, BackstagePlugin } from '../plugin/types'; + +type ComponentLoader = + | { + lazy: () => Promise; + } + | { + sync: T; + }; + +export function createRoutableExtension< + T extends (props: any) => JSX.Element +>(options: { + component: () => Promise; + mountPoint: RouteRef; +}): Extension { + const { component, mountPoint } = options; + return createReactExtension({ + component: { + lazy: () => + component().then(InnerComponent => { + const RoutableExtensionWrapper = ((props: any) => { + // Validate that the routing is wired up correctly in the App.tsx + try { + useRouteRef(mountPoint); + } catch { + throw new Error( + 'Routable extension component was not discovered in the app element tree. ' + + 'Routable extension components may not be rendered by other components and must be ' + + 'directly available as an element within the App provider component.', + ); + } + return ; + }) as T; + return RoutableExtensionWrapper; + }), + }, + data: { + 'core.mountPoint': mountPoint, + }, + }); +} + +export function createComponentExtension< + T extends (props: any) => JSX.Element +>(options: { component: ComponentLoader }): Extension { + const { component } = options; + return createReactExtension({ component }); +} + +export function createReactExtension< + T extends (props: any) => JSX.Element +>(options: { + component: ComponentLoader; + data?: Record; +}): Extension { + const { data = {} } = options; + + let Component: T; + if ('lazy' in options.component) { + const lazyLoader = options.component.lazy; + Component = (lazy(() => + lazyLoader().then(component => ({ default: component })), + ) as unknown) as T; + } else { + Component = options.component.sync; + } + const componentName = + (Component as { displayName?: string }).displayName || + Component.name || + 'Component'; + + return { + expose(plugin: BackstagePlugin) { + const Result: any = (props: any) => ( + + + + ); + + attachComponentData(Result, 'core.plugin', plugin); + for (const [key, value] of Object.entries(data)) { + attachComponentData(Result, key, value); + } + + Result.displayName = `Extension(${componentName})`; + return Result; + }, + }; +} diff --git a/packages/app-api/src/extensions/index.ts b/packages/app-api/src/extensions/index.ts new file mode 100644 index 0000000000..26a0c597b1 --- /dev/null +++ b/packages/app-api/src/extensions/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { attachComponentData, getComponentData } from './componentData'; +export { + createReactExtension, + createRoutableExtension, + createComponentExtension, +} from './extensions'; diff --git a/packages/app-api/src/extensions/traversal.test.tsx b/packages/app-api/src/extensions/traversal.test.tsx new file mode 100644 index 0000000000..38571fdcc7 --- /dev/null +++ b/packages/app-api/src/extensions/traversal.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { Children, isValidElement } from 'react'; +import { + childDiscoverer, + createCollector, + traverseElementTree, +} from './traversal'; + +describe('discovery', () => { + it('should collect element names', () => { + const root = ( +
+
+

Title

+

Text

+
+
+
+

Title

+ Text +
+
+ ); + + const { names } = traverseElementTree({ + root, + discoverers: [childDiscoverer], + collectors: { + names: createCollector( + () => Array(), + (acc, el) => { + if (typeof el.type === 'string') { + acc.push(el.type); + } + }, + ), + }, + }); + + expect(names).toEqual([ + 'main', + 'div', + 'hr', + 'div', + 'h1', + 'p', + 'h2', + 'span', + ]); + }); + + it('should collect element names while skipping one level of children', () => { + const root = ( +
+
+

Title

+

Text

+
+
+
+

Title

+ Text +
+
+ ); + + const { names } = traverseElementTree({ + root, + discoverers: [ + el => + Children.toArray(el.props.children).flatMap(child => + isValidElement(child) ? child?.props?.children : [], + ), + ], + collectors: { + names: createCollector( + () => Array(), + (acc, el) => { + if (typeof el.type === 'string') { + acc.push(el.type); + } + }, + ), + }, + }); + + expect(names).toEqual(['main', 'h1', 'p', 'h2', 'span']); + }); +}); diff --git a/packages/app-api/src/extensions/traversal.ts b/packages/app-api/src/extensions/traversal.ts new file mode 100644 index 0000000000..bdf02d16c8 --- /dev/null +++ b/packages/app-api/src/extensions/traversal.ts @@ -0,0 +1,138 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { isValidElement, ReactNode, ReactElement, Children } from 'react'; + +export type Discoverer = (element: ReactElement) => ReactNode; + +export type Collector = () => { + accumulator: Result; + visit( + accumulator: Result, + element: ReactElement, + parent: ReactElement | undefined, + context: Context, + ): Context; +}; + +/** + * A function that allows you to traverse a tree of React elements using + * varying methods to discover child nodes and collect data along the way. + */ +export function traverseElementTree(options: { + root: ReactNode; + discoverers: Discoverer[]; + collectors: { [name in keyof Results]: Collector }; +}): Results { + const visited = new Set(); + const collectors: { + [name in string]: ReturnType>; + } = {}; + + // Bootstrap all collectors, initializing the accumulators and providing the visitor function + for (const name in options.collectors) { + if (options.collectors.hasOwnProperty(name)) { + collectors[name] = options.collectors[name](); + } + } + + // Internal representation of an element in the tree that we're iterating over + type QueueItem = { + node: ReactNode; + parent: ReactElement | undefined; + contexts: { [name in string]: unknown }; + }; + + const queue = [ + { + node: Children.toArray(options.root), + parent: undefined, + contexts: {}, + } as QueueItem, + ]; + + while (queue.length !== 0) { + const { node, parent, contexts } = queue.shift()!; + + // While the parent and the element we pass on to collectors and discoverers + // have been validated and are known to be React elements, the child nodes + // emitted by the discoverers are not. + Children.forEach(node, element => { + if (!isValidElement(element)) { + return; + } + if (visited.has(element)) { + const anyType = element?.type as + | { displayName?: string; name?: string } + | undefined; + const name = anyType?.displayName || anyType?.name || String(anyType); + throw new Error(`Visited element ${name} twice`); + } + visited.add(element); + + const nextContexts: QueueItem['contexts'] = {}; + + // Collectors populate their result data using the current node, and compute + // context for the next iteration + for (const name in collectors) { + if (collectors.hasOwnProperty(name)) { + const collector = collectors[name]; + + nextContexts[name] = collector.visit( + collector.accumulator, + element, + parent, + contexts[name], + ); + } + } + + // Discoverers provide ways to continue the traversal from the current element + for (const discoverer of options.discoverers) { + const children = discoverer(element); + if (children) { + queue.push({ + node: children, + parent: element, + contexts: nextContexts, + }); + } + } + }); + } + + return Object.fromEntries( + Object.entries(collectors).map(([name, c]) => [name, c.accumulator]), + ) as Results; +} + +export function createCollector( + accumulatorFactory: () => Result, + visit: ReturnType>['visit'], +): Collector { + return () => ({ accumulator: accumulatorFactory(), visit }); +} + +export function childDiscoverer(element: ReactElement): ReactNode { + return element.props?.children; +} + +export function routeElementDiscoverer(element: ReactElement): ReactNode { + if (element.props?.path && element.props?.element) { + return element.props?.element; + } + return undefined; +} diff --git a/packages/app-api/src/icons/icons.tsx b/packages/app-api/src/icons/icons.tsx new file mode 100644 index 0000000000..49db90efcc --- /dev/null +++ b/packages/app-api/src/icons/icons.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SvgIconProps } from '@material-ui/core'; +import MuiBrokenImageIcon from '@material-ui/icons/BrokenImage'; +import MuiChatIcon from '@material-ui/icons/Chat'; +import MuiDashboardIcon from '@material-ui/icons/Dashboard'; +import MuiEmailIcon from '@material-ui/icons/Email'; +import MuiGitHubIcon from '@material-ui/icons/GitHub'; +import MuiHelpIcon from '@material-ui/icons/Help'; +import MuiPeopleIcon from '@material-ui/icons/People'; +import MuiPersonIcon from '@material-ui/icons/Person'; +import MuiWarningIcon from '@material-ui/icons/Warning'; +import React from 'react'; +import { useApp } from '../app/AppContext'; +import { IconComponent, IconComponentMap, SystemIconKey } from './types'; + +export const defaultSystemIcons: IconComponentMap = { + brokenImage: MuiBrokenImageIcon, + chat: MuiChatIcon, + dashboard: MuiDashboardIcon, + email: MuiEmailIcon, + github: MuiGitHubIcon, + group: MuiPeopleIcon, + help: MuiHelpIcon, + user: MuiPersonIcon, + warning: MuiWarningIcon, +}; + +const overridableSystemIcon = (key: SystemIconKey): IconComponent => { + const Component = (props: SvgIconProps) => { + const app = useApp(); + const Icon = app.getSystemIcon(key); + return Icon ? : ; + }; + return Component; +}; + +export const BrokenImageIcon = overridableSystemIcon('brokenImage'); +export const ChatIcon = overridableSystemIcon('chat'); +export const DashboardIcon = overridableSystemIcon('dashboard'); +export const EmailIcon = overridableSystemIcon('email'); +export const GitHubIcon = overridableSystemIcon('github'); +export const GroupIcon = overridableSystemIcon('group'); +export const HelpIcon = overridableSystemIcon('help'); +export const UserIcon = overridableSystemIcon('user'); +export const WarningIcon = overridableSystemIcon('warning'); diff --git a/packages/app-api/src/icons/index.ts b/packages/app-api/src/icons/index.ts new file mode 100644 index 0000000000..4c97d27176 --- /dev/null +++ b/packages/app-api/src/icons/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './icons'; +export * from './types'; diff --git a/packages/app-api/src/icons/types.ts b/packages/app-api/src/icons/types.ts new file mode 100644 index 0000000000..c4634bd00a --- /dev/null +++ b/packages/app-api/src/icons/types.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentType } from 'react'; +import { SvgIconProps } from '@material-ui/core'; + +export type SystemIconKey = + | 'brokenImage' + | 'chat' + | 'dashboard' + | 'email' + | 'github' + | 'group' + | 'help' + | 'user' + | 'warning'; + +export type IconComponent = ComponentType; +export type IconKey = SystemIconKey | string; +export type IconComponentMap = { [key in IconKey]: IconComponent }; diff --git a/packages/app-api/src/index.ts b/packages/app-api/src/index.ts new file mode 100644 index 0000000000..90bde248f6 --- /dev/null +++ b/packages/app-api/src/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './public'; +import * as privateExports from './private'; + +export default privateExports; diff --git a/packages/app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts b/packages/app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts new file mode 100644 index 0000000000..391b86952d --- /dev/null +++ b/packages/app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts @@ -0,0 +1,182 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ProviderIcon from '@material-ui/icons/AcUnit'; +import { DefaultAuthConnector } from './DefaultAuthConnector'; +import MockOAuthApi from '../../apis/implementations/OAuthRequestApi/MockOAuthApi'; +import * as loginPopup from '../loginPopup'; +import { UrlPatternDiscovery } from '../../apis'; +import { msw } from '@backstage/test-utils'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; + +const defaultOptions = { + discoveryApi: UrlPatternDiscovery.compile('http://my-host/api/{{pluginId}}'), + environment: 'production', + provider: { + id: 'my-provider', + title: 'My Provider', + icon: ProviderIcon, + }, + oauthRequestApi: new MockOAuthApi(), + sessionTransform: ({ expiresInSeconds, ...res }: any) => ({ + ...res, + scopes: new Set(res.scopes.split(' ')), + expiresAt: new Date(Date.now() + expiresInSeconds * 1000), + }), +}; + +describe('DefaultAuthConnector', () => { + const server = setupServer(); + msw.setupDefaultHandlers(server); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should refresh a session', async () => { + server.use( + rest.get('*', (_req, res, ctx) => + res( + ctx.json({ + idToken: 'mock-id-token', + accessToken: 'mock-access-token', + scopes: 'a b c', + expiresInSeconds: '60', + }), + ), + ), + ); + + const helper = new DefaultAuthConnector(defaultOptions); + const session = await helper.refreshSession(); + expect(session.idToken).toBe('mock-id-token'); + expect(session.accessToken).toBe('mock-access-token'); + expect(session.scopes).toEqual(new Set(['a', 'b', 'c'])); + expect(session.expiresAt.getTime()).toBeLessThan(Date.now() + 70000); + expect(session.expiresAt.getTime()).toBeGreaterThan(Date.now() + 50000); + }); + + it('should handle failure to refresh session', async () => { + server.use( + rest.get('*', (_req, res, ctx) => + res(ctx.status(500, 'Error: Network NOPE')), + ), + ); + + const helper = new DefaultAuthConnector(defaultOptions); + await expect(helper.refreshSession()).rejects.toThrow( + 'Auth refresh request failed, Error: Network NOPE', + ); + }); + + it('should handle failure response when refreshing session', async () => { + server.use(rest.get('*', (_req, res, ctx) => res(ctx.status(401, 'NOPE')))); + + const helper = new DefaultAuthConnector(defaultOptions); + await expect(helper.refreshSession()).rejects.toThrow( + 'Auth refresh request failed, NOPE', + ); + }); + + it('should fail if popup was rejected', async () => { + const mockOauth = new MockOAuthApi(); + const helper = new DefaultAuthConnector({ + ...defaultOptions, + oauthRequestApi: mockOauth, + }); + const promise = helper.createSession({ scopes: new Set(['a', 'b']) }); + await mockOauth.rejectAll(); + await expect(promise).rejects.toMatchObject({ name: 'RejectedError' }); + }); + + it('should create a session', async () => { + const mockOauth = new MockOAuthApi(); + const popupSpy = jest + .spyOn(loginPopup, 'showLoginPopup') + .mockResolvedValue({ + idToken: 'my-id-token', + accessToken: 'my-access-token', + scopes: 'a b', + expiresInSeconds: 3600, + }); + const helper = new DefaultAuthConnector({ + ...defaultOptions, + oauthRequestApi: mockOauth, + }); + + const sessionPromise = helper.createSession({ + scopes: new Set(['a', 'b']), + }); + + await mockOauth.triggerAll(); + + expect(popupSpy).toBeCalledTimes(1); + expect(popupSpy.mock.calls[0][0]).toMatchObject({ + url: + 'http://my-host/api/auth/my-provider/start?scope=a%20b&env=production', + }); + + await expect(sessionPromise).resolves.toEqual({ + idToken: 'my-id-token', + accessToken: 'my-access-token', + scopes: expect.any(Set), + expiresAt: expect.any(Date), + }); + }); + + it('should instantly show popup if option is set', async () => { + const popupSpy = jest + .spyOn(loginPopup, 'showLoginPopup') + .mockResolvedValue('my-session'); + const helper = new DefaultAuthConnector({ + ...defaultOptions, + oauthRequestApi: new MockOAuthApi(), + sessionTransform: str => str, + }); + + const sessionPromise = helper.createSession({ + scopes: new Set(), + instantPopup: true, + }); + + await expect(sessionPromise).resolves.toBe('my-session'); + + expect(popupSpy).toBeCalledTimes(1); + }); + + it('should use join func to join scopes', async () => { + const mockOauth = new MockOAuthApi(); + const popupSpy = jest + .spyOn(loginPopup, 'showLoginPopup') + .mockResolvedValue({ scopes: '' }); + const helper = new DefaultAuthConnector({ + ...defaultOptions, + joinScopes: scopes => `-${[...scopes].join('')}-`, + oauthRequestApi: mockOauth, + }); + + helper.createSession({ scopes: new Set(['a', 'b']) }); + + await mockOauth.triggerAll(); + + expect(popupSpy).toBeCalledTimes(1); + expect(popupSpy.mock.calls[0][0]).toMatchObject({ + url: + 'http://my-host/api/auth/my-provider/start?scope=-ab-&env=production', + }); + }); +}); diff --git a/packages/app-api/src/lib/AuthConnector/DefaultAuthConnector.ts b/packages/app-api/src/lib/AuthConnector/DefaultAuthConnector.ts new file mode 100644 index 0000000000..1c7e92daa8 --- /dev/null +++ b/packages/app-api/src/lib/AuthConnector/DefaultAuthConnector.ts @@ -0,0 +1,204 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AuthRequester } from '../../apis'; +import { + OAuthRequestApi, + AuthProvider, + DiscoveryApi, +} from '../../apis/definitions'; +import { showLoginPopup } from '../loginPopup'; +import { AuthConnector, CreateSessionOptions } from './types'; + +type Options = { + /** + * DiscoveryApi instance used to locate the auth backend endpoint. + */ + discoveryApi: DiscoveryApi; + /** + * Environment hint passed on to auth backend, for example 'production' or 'development' + */ + environment: string; + /** + * Information about the auth provider to be shown to the user. + * The ID Must match the backend auth plugin configuration, for example 'google'. + */ + provider: AuthProvider & { id: string }; + /** + * API used to instantiate an auth requester. + */ + oauthRequestApi: OAuthRequestApi; + /** + * Function used to join together a set of scopes, defaults to joining with a space character. + */ + joinScopes?: (scopes: Set) => string; + /** + * Function used to transform an auth response into the session type. + */ + sessionTransform?(response: any): AuthSession | Promise; +}; + +function defaultJoinScopes(scopes: Set) { + return [...scopes].join(' '); +} + +/** + * DefaultAuthConnector is the default auth connector in Backstage. It talks to the + * backend auth plugin through the standardized API, and requests user permission + * via the OAuthRequestApi. + */ +export class DefaultAuthConnector + implements AuthConnector { + private readonly discoveryApi: DiscoveryApi; + private readonly environment: string; + private readonly provider: AuthProvider & { id: string }; + private readonly joinScopesFunc: (scopes: Set) => string; + private readonly authRequester: AuthRequester; + private readonly sessionTransform: (response: any) => Promise; + + constructor(options: Options) { + const { + discoveryApi, + environment, + provider, + joinScopes = defaultJoinScopes, + oauthRequestApi, + sessionTransform = id => id, + } = options; + + this.authRequester = oauthRequestApi.createAuthRequester({ + provider, + onAuthRequest: scopes => this.showPopup(scopes), + }); + + this.discoveryApi = discoveryApi; + this.environment = environment; + this.provider = provider; + this.joinScopesFunc = joinScopes; + this.sessionTransform = sessionTransform; + } + + async createSession(options: CreateSessionOptions): Promise { + if (options.instantPopup) { + return this.showPopup(options.scopes); + } + return this.authRequester(options.scopes); + } + + async refreshSession(): Promise { + const res = await fetch( + await this.buildUrl('/refresh', { optional: true }), + { + headers: { + 'x-requested-with': 'XMLHttpRequest', + }, + credentials: 'include', + }, + ).catch(error => { + throw new Error(`Auth refresh request failed, ${error}`); + }); + + if (!res.ok) { + const error: any = new Error( + `Auth refresh request failed, ${res.statusText}`, + ); + error.status = res.status; + throw error; + } + + const authInfo = await res.json(); + + if (authInfo.error) { + const error = new Error(authInfo.error.message); + if (authInfo.error.name) { + error.name = authInfo.error.name; + } + throw error; + } + return await this.sessionTransform(authInfo); + } + + async removeSession(): Promise { + const res = await fetch(await this.buildUrl('/logout'), { + method: 'POST', + headers: { + 'x-requested-with': 'XMLHttpRequest', + }, + credentials: 'include', + }).catch(error => { + throw new Error(`Logout request failed, ${error}`); + }); + + if (!res.ok) { + const error: any = new Error(`Logout request failed, ${res.statusText}`); + error.status = res.status; + throw error; + } + } + + private async showPopup(scopes: Set): Promise { + const scope = this.joinScopesFunc(scopes); + const popupUrl = await this.buildUrl('/start', { scope }); + + const payload = await showLoginPopup({ + url: popupUrl, + name: `${this.provider.title} Login`, + origin: new URL(popupUrl).origin, + width: 450, + height: 730, + }); + + return await this.sessionTransform(payload); + } + + private async buildUrl( + path: string, + query?: { [key: string]: string | boolean | undefined }, + ): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('auth'); + const queryString = this.buildQueryString({ + ...query, + env: this.environment, + }); + + return `${baseUrl}/${this.provider.id}${path}${queryString}`; + } + + private buildQueryString(query?: { + [key: string]: string | boolean | undefined; + }): string { + if (!query) { + return ''; + } + + const queryString = Object.entries(query) + .map(([key, value]) => { + if (typeof value === 'string') { + return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; + } else if (value) { + return encodeURIComponent(key); + } + return undefined; + }) + .filter(Boolean) + .join('&'); + + if (!queryString) { + return ''; + } + return `?${queryString}`; + } +} diff --git a/packages/app-api/src/lib/AuthConnector/DirectAuthConnector.ts b/packages/app-api/src/lib/AuthConnector/DirectAuthConnector.ts new file mode 100644 index 0000000000..e7764fcbaa --- /dev/null +++ b/packages/app-api/src/lib/AuthConnector/DirectAuthConnector.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { AuthProvider, DiscoveryApi } from '../../apis/definitions'; +import { showLoginPopup } from '../loginPopup'; + +type Options = { + discoveryApi: DiscoveryApi; + environment?: string; + provider: AuthProvider & { id: string }; +}; +export class DirectAuthConnector { + private readonly discoveryApi: DiscoveryApi; + private readonly environment: string | undefined; + private readonly provider: AuthProvider & { id: string }; + + constructor(options: Options) { + const { discoveryApi, environment, provider } = options; + + this.discoveryApi = discoveryApi; + this.environment = environment; + this.provider = provider; + } + + async createSession(): Promise { + const popupUrl = await this.buildUrl('/start'); + const payload = await showLoginPopup({ + url: popupUrl, + name: `${this.provider.title} Login`, + origin: new URL(popupUrl).origin, + width: 450, + height: 730, + }); + + return { + ...payload, + id: payload.profile.email, + }; + } + + async refreshSession(): Promise {} + + async removeSession(): Promise { + const res = await fetch(await this.buildUrl('/logout'), { + method: 'POST', + headers: { + 'x-requested-with': 'XMLHttpRequest', + }, + credentials: 'include', + }).catch(error => { + throw new Error(`Logout request failed, ${error}`); + }); + + if (!res.ok) { + const error: any = new Error(`Logout request failed, ${res.statusText}`); + error.status = res.status; + throw error; + } + } + + private async buildUrl(path: string): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('auth'); + return `${baseUrl}/${this.provider.id}${path}?env=${this.environment}`; + } +} diff --git a/packages/app-api/src/lib/AuthConnector/MockAuthConnector.test.ts b/packages/app-api/src/lib/AuthConnector/MockAuthConnector.test.ts new file mode 100644 index 0000000000..cd7986ffd0 --- /dev/null +++ b/packages/app-api/src/lib/AuthConnector/MockAuthConnector.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MockAuthConnector, mockAccessToken } from './MockAuthConnector'; + +describe('MockAuthConnector', () => { + it('should return mock tokens', async () => { + const helper = new MockAuthConnector(); + + await expect(helper.createSession()).resolves.toEqual({ + accessToken: mockAccessToken, + expiresAt: expect.any(Date), + scopes: expect.any(String), + }); + + await expect(helper.refreshSession()).resolves.toEqual({ + accessToken: mockAccessToken, + expiresAt: expect.any(Date), + scopes: expect.any(String), + }); + }); +}); diff --git a/packages/app-api/src/lib/AuthConnector/MockAuthConnector.ts b/packages/app-api/src/lib/AuthConnector/MockAuthConnector.ts new file mode 100644 index 0000000000..9134fd0773 --- /dev/null +++ b/packages/app-api/src/lib/AuthConnector/MockAuthConnector.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AuthConnector } from './types'; + +export const mockAccessToken = 'mock-access-token'; + +type MockSession = { + accessToken: string; + expiresAt: Date; + scopes: string; +}; + +const defaultMockSession: MockSession = { + accessToken: mockAccessToken, + expiresAt: new Date(), + scopes: 'profile email', +}; + +export class MockAuthConnector implements AuthConnector { + constructor(private readonly mockSession: MockSession = defaultMockSession) {} + + async createSession() { + return this.mockSession; + } + + async refreshSession() { + return this.mockSession; + } + + async removeSession() {} +} diff --git a/packages/app-api/src/lib/AuthConnector/index.ts b/packages/app-api/src/lib/AuthConnector/index.ts new file mode 100644 index 0000000000..388619e2c1 --- /dev/null +++ b/packages/app-api/src/lib/AuthConnector/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { DefaultAuthConnector } from './DefaultAuthConnector'; +export { DirectAuthConnector } from './DirectAuthConnector'; +export * from './types'; diff --git a/packages/app-api/src/lib/AuthConnector/types.ts b/packages/app-api/src/lib/AuthConnector/types.ts new file mode 100644 index 0000000000..46175a265f --- /dev/null +++ b/packages/app-api/src/lib/AuthConnector/types.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type CreateSessionOptions = { + scopes: Set; + instantPopup?: boolean; +}; + +/** + * An AuthConnector is responsible for realizing auth session actions + * by for example communicating with a backend or interacting with the user. + */ +export type AuthConnector = { + createSession(options: CreateSessionOptions): Promise; + refreshSession(): Promise; + removeSession(): Promise; +}; diff --git a/packages/app-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts b/packages/app-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts new file mode 100644 index 0000000000..5c960f7876 --- /dev/null +++ b/packages/app-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts @@ -0,0 +1,137 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { AuthSessionStore } from './AuthSessionStore'; +import { SessionManager } from './types'; + +const defaultOptions = { + storageKey: 'my-key', + sessionScopes: (session: string) => new Set(session.split(' ')), +}; + +class LocalStorage { + private store: Record = {}; + + getItem(key: string) { + return this.store[key] || null; + } + setItem(key: string, value: string) { + this.store[key] = value.toString(); + } + removeItem(key: string) { + delete this.store[key]; + } +} + +class MockManager implements SessionManager { + setSession = jest.fn(); + getSession = jest.fn(); + removeSession = jest.fn(); + sessionState$ = jest.fn(); +} + +describe('GheAuth AuthSessionStore', () => { + beforeEach(() => { + delete (window as any).localStorage; + (window as any).localStorage = new LocalStorage(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should load session', async () => { + localStorage.setItem('my-key', '"a b c"'); + + const manager = new MockManager(); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + + await expect(store.getSession({})).resolves.toBe('a b c'); + expect(manager.getSession).not.toHaveBeenCalled(); + expect(manager.setSession).toHaveBeenCalledWith('a b c'); + }); + + it('should not use session without enough scope', async () => { + localStorage.setItem('my-key', '"a b c"'); + + const manager = new MockManager(); + manager.getSession.mockResolvedValue('a b c d'); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + + await expect(store.getSession({ scopes: new Set(['d']) })).resolves.toBe( + 'a b c d', + ); + expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); + }); + + it('should not use expired session', async () => { + localStorage.setItem('my-key', '"a b c"'); + + const manager = new MockManager(); + manager.getSession.mockResolvedValue('123'); + const store = new AuthSessionStore({ + manager, + ...defaultOptions, + sessionShouldRefresh: () => true, + }); + + await expect(store.getSession({})).resolves.toBe('123'); + expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); + }); + + it('should not load missing session', async () => { + const manager = new MockManager(); + manager.getSession.mockResolvedValue('123'); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + + await expect(store.getSession({})).resolves.toBe('123'); + expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); + + expect(localStorage.getItem('my-key')).toBe('"123"'); + }); + + it('should ignore bad session values', async () => { + localStorage.setItem('my-key', 'derp'); + + const manager = new MockManager(); + manager.getSession.mockResolvedValue('123'); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + + await expect(store.getSession({})).resolves.toBe('123'); + expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); + }); + + it('should clear session', () => { + localStorage.setItem('my-key', '"a b c"'); + + const manager = new MockManager(); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + store.removeSession(); + + expect(localStorage.getItem('my-key')).toBe(null); + expect(manager.removeSession).toHaveBeenCalled(); + }); + + it('should forward sessionState calls', () => { + const manager = new MockManager(); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + store.sessionState$(); + expect(manager.sessionState$).toHaveBeenCalled(); + }); +}); diff --git a/packages/app-api/src/lib/AuthSessionManager/AuthSessionStore.ts b/packages/app-api/src/lib/AuthSessionManager/AuthSessionStore.ts new file mode 100644 index 0000000000..224036d283 --- /dev/null +++ b/packages/app-api/src/lib/AuthSessionManager/AuthSessionStore.ts @@ -0,0 +1,131 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + SessionManager, + MutableSessionManager, + SessionScopesFunc, + SessionShouldRefreshFunc, + GetSessionOptions, +} from './types'; +import { SessionScopeHelper } from './common'; + +type Options = { + /** The connector used for acting on the auth session */ + manager: MutableSessionManager; + /** Storage key to use to store sessions */ + storageKey: string; + /** Used to get the scope of the session */ + sessionScopes?: SessionScopesFunc; + /** Used to check if the session needs to be refreshed, defaults to never refresh */ + sessionShouldRefresh?: SessionShouldRefreshFunc; +}; + +/** + * AuthSessionStore decorates another SessionManager with a functionality + * to store the session in local storage. + * + * Session is serialized to JSON with special support for following types: Set. + */ +export class AuthSessionStore implements SessionManager { + private readonly manager: MutableSessionManager; + private readonly storageKey: string; + private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc; + private readonly helper: SessionScopeHelper; + + constructor(options: Options) { + const { + manager, + storageKey, + sessionScopes, + sessionShouldRefresh = () => false, + } = options; + + this.manager = manager; + this.storageKey = storageKey; + this.sessionShouldRefreshFunc = sessionShouldRefresh; + this.helper = new SessionScopeHelper({ + sessionScopes, + defaultScopes: new Set(), + }); + } + + async getSession(options: GetSessionOptions): Promise { + const { scopes } = options; + const session = this.loadSession(); + + if (this.helper.sessionExistsAndHasScope(session, scopes)) { + const shouldRefresh = this.sessionShouldRefreshFunc(session!); + + if (!shouldRefresh) { + this.manager.setSession(session!); + return session!; + } + } + + const newSession = await this.manager.getSession(options); + this.saveSession(newSession); + return newSession; + } + + async removeSession() { + localStorage.removeItem(this.storageKey); + await this.manager.removeSession(); + } + + sessionState$() { + return this.manager.sessionState$(); + } + + private loadSession(): T | undefined { + try { + const sessionJson = localStorage.getItem(this.storageKey); + if (sessionJson) { + const session = JSON.parse(sessionJson, (_key, value) => { + if (value?.__type === 'Set') { + return new Set(value.__value); + } + return value; + }); + return session; + } + + return undefined; + } catch (error) { + localStorage.removeItem(this.storageKey); + return undefined; + } + } + + private saveSession(session: T | undefined) { + if (session === undefined) { + localStorage.removeItem(this.storageKey); + } else { + localStorage.setItem( + this.storageKey, + JSON.stringify(session, (_key, value) => { + if (value instanceof Set) { + return { + __type: 'Set', + __value: Array.from(value), + }; + } + return value; + }), + ); + } + } +} diff --git a/packages/app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts b/packages/app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts new file mode 100644 index 0000000000..9e22e4cd69 --- /dev/null +++ b/packages/app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts @@ -0,0 +1,168 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager'; +import { SessionState } from '../../apis'; + +const defaultOptions = { + sessionScopes: (session: { scopes: Set }) => session.scopes, + sessionShouldRefresh: (session: { expired: boolean }) => session.expired, +}; + +describe('RefreshingAuthSessionManager', () => { + it('should save result from createSession', async () => { + const createSession = jest.fn().mockResolvedValue({ expired: false }); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const removeSession = jest.fn(); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession, removeSession }, + ...defaultOptions, + } as any); + const stateSubscriber = jest.fn(); + manager.sessionState$().subscribe(stateSubscriber); + + await Promise.resolve(); // Wait a tick for observer to post a value + + expect(stateSubscriber.mock.calls).toEqual([[SessionState.SignedOut]]); + await manager.getSession({}); + expect(createSession).toBeCalledTimes(1); + + expect(stateSubscriber.mock.calls).toEqual([ + [SessionState.SignedOut], + [SessionState.SignedIn], + ]); + await manager.getSession({}); + expect(createSession).toBeCalledTimes(1); + + expect(refreshSession).toBeCalledTimes(1); + expect(stateSubscriber.mock.calls).toEqual([ + [SessionState.SignedOut], + [SessionState.SignedIn], + ]); + + expect(removeSession).toHaveBeenCalledTimes(0); + await manager.removeSession(); + expect(removeSession).toHaveBeenCalledTimes(1); + expect(stateSubscriber.mock.calls).toEqual([ + [SessionState.SignedOut], + [SessionState.SignedIn], + [SessionState.SignedOut], + ]); + }); + + it('should ask consent only if scopes have changed', async () => { + const createSession = jest.fn(); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + createSession.mockResolvedValue({ + scopes: new Set(['a']), + expired: false, + }); + await manager.getSession({ scopes: new Set(['a']) }); + expect(createSession).toBeCalledTimes(1); + + await manager.getSession({ scopes: new Set(['a']) }); + expect(createSession).toBeCalledTimes(1); + + await manager.getSession({ scopes: new Set(['b']) }); + expect(createSession).toBeCalledTimes(2); + }); + + it('should check for session expiry', async () => { + const createSession = jest.fn(); + const refreshSession = jest + .fn() + .mockRejectedValueOnce(new Error('NOPE')) + .mockResolvedValue({ scopes: new Set(['a']) }); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + createSession.mockResolvedValue({ + scopes: new Set(['a']), + expired: true, + }); + + await manager.getSession({ scopes: new Set(['a']) }); + expect(createSession).toBeCalledTimes(1); + expect(refreshSession).toBeCalledTimes(1); + + await manager.getSession({ scopes: new Set(['a']) }); + expect(createSession).toBeCalledTimes(1); + expect(refreshSession).toBeCalledTimes(2); + }); + + it('should handle user closed popup', async () => { + const createSession = jest.fn(); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + createSession.mockRejectedValueOnce(new Error('some error')); + await expect( + manager.getSession({ scopes: new Set(['a']) }), + ).rejects.toThrow('some error'); + }); + + it('should not get optional session', async () => { + const createSession = jest.fn(); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + expect(await manager.getSession({ optional: true })).toBe(undefined); + expect(createSession).toBeCalledTimes(0); + expect(refreshSession).toBeCalledTimes(1); + }); + + it('should forward option to instantly show auth popup and not attempt refresh', async () => { + const createSession = jest.fn(); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + expect(await manager.getSession({ instantPopup: true })).toBe(undefined); + expect(createSession).toBeCalledTimes(1); + expect(createSession).toHaveBeenCalledWith({ + scopes: new Set(), + instantPopup: true, + }); + expect(refreshSession).toBeCalledTimes(0); + }); + + it('should remove session straight away', async () => { + const removeSession = jest.fn(); + const manager = new RefreshingAuthSessionManager({ + connector: { removeSession }, + ...defaultOptions, + } as any); + + await manager.removeSession(); + expect(removeSession).toHaveBeenCalled(); + expect(await manager.getSession({ optional: true })).toBe(undefined); + }); +}); diff --git a/packages/app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts b/packages/app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts new file mode 100644 index 0000000000..a098b384f9 --- /dev/null +++ b/packages/app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts @@ -0,0 +1,148 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + SessionManager, + SessionScopesFunc, + SessionShouldRefreshFunc, + GetSessionOptions, +} from './types'; +import { AuthConnector } from '../AuthConnector'; +import { SessionScopeHelper, hasScopes } from './common'; +import { SessionStateTracker } from './SessionStateTracker'; + +type Options = { + /** The connector used for acting on the auth session */ + connector: AuthConnector; + /** Used to get the scope of the session */ + sessionScopes: SessionScopesFunc; + /** Used to check if the session needs to be refreshed */ + sessionShouldRefresh: SessionShouldRefreshFunc; + /** The default scopes that should always be present in a session, defaults to none. */ + defaultScopes?: Set; +}; + +/** + * RefreshingAuthSessionManager manages an underlying session that has + * and expiration time and needs to be refreshed periodically. + */ +export class RefreshingAuthSessionManager implements SessionManager { + private readonly connector: AuthConnector; + private readonly helper: SessionScopeHelper; + private readonly sessionScopesFunc: SessionScopesFunc; + private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc; + private readonly stateTracker = new SessionStateTracker(); + + private refreshPromise?: Promise; + private currentSession: T | undefined; + + constructor(options: Options) { + const { + connector, + defaultScopes = new Set(), + sessionScopes, + sessionShouldRefresh, + } = options; + + this.connector = connector; + this.sessionScopesFunc = sessionScopes; + this.sessionShouldRefreshFunc = sessionShouldRefresh; + this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes }); + } + + async getSession(options: GetSessionOptions): Promise { + if ( + this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes) + ) { + const shouldRefresh = this.sessionShouldRefreshFunc(this.currentSession!); + if (!shouldRefresh) { + return this.currentSession!; + } + + try { + const refreshedSession = await this.collapsedSessionRefresh(); + const currentScopes = this.sessionScopesFunc(this.currentSession!); + const refreshedScopes = this.sessionScopesFunc(refreshedSession); + if (hasScopes(refreshedScopes, currentScopes)) { + this.currentSession = refreshedSession; + } + return refreshedSession; + } catch (error) { + if (options.optional) { + return undefined; + } + throw error; + } + } + + // The user may still have a valid refresh token in their cookies. Attempt to + // initiate a fresh session through the backend using that refresh token. + // + // We skip this check if an instant login popup is requested, as we need to + // stay in a synchronous call stack from the user interaction. The downside + // is that that the user will sometimes be requested to log in even if they + // already had an existing session. + if (!this.currentSession && !options.instantPopup) { + try { + const newSession = await this.collapsedSessionRefresh(); + this.currentSession = newSession; + // The session might not have the scopes requested so go back and check again + return this.getSession(options); + } catch { + // If the refresh attempt fails we assume we don't have a session, so continue to create one. + } + } + + // If we continue here we will show a popup, so exit if this is an optional session request. + if (options.optional) { + return undefined; + } + + // We can call authRequester multiple times, the returned session will contain all requested scopes. + this.currentSession = await this.connector.createSession({ + ...options, + scopes: this.helper.getExtendedScope(this.currentSession, options.scopes), + }); + this.stateTracker.setIsSignedIn(true); + return this.currentSession; + } + + async removeSession() { + this.currentSession = undefined; + await this.connector.removeSession(); + this.stateTracker.setIsSignedIn(false); + } + + sessionState$() { + return this.stateTracker.sessionState$(); + } + + private async collapsedSessionRefresh(): Promise { + if (this.refreshPromise) { + return this.refreshPromise; + } + + this.refreshPromise = this.connector.refreshSession(); + + try { + const session = await this.refreshPromise; + this.stateTracker.setIsSignedIn(true); + return session; + } finally { + delete this.refreshPromise; + } + } +} diff --git a/packages/app-api/src/lib/AuthSessionManager/SessionStateTracker.ts b/packages/app-api/src/lib/AuthSessionManager/SessionStateTracker.ts new file mode 100644 index 0000000000..c4260bb31d --- /dev/null +++ b/packages/app-api/src/lib/AuthSessionManager/SessionStateTracker.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BehaviorSubject } from '..'; +import { SessionState } from '../../apis'; +import { Observable } from '../../types'; + +export class SessionStateTracker { + private readonly subject = new BehaviorSubject( + SessionState.SignedOut, + ); + + private signedIn: boolean = false; + + setIsSignedIn(isSignedIn: boolean) { + if (this.signedIn !== isSignedIn) { + this.signedIn = isSignedIn; + this.subject.next( + this.signedIn ? SessionState.SignedIn : SessionState.SignedOut, + ); + } + } + + sessionState$(): Observable { + return this.subject; + } +} diff --git a/packages/app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts b/packages/app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts new file mode 100644 index 0000000000..6280750875 --- /dev/null +++ b/packages/app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts @@ -0,0 +1,97 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { StaticAuthSessionManager } from './StaticAuthSessionManager'; + +const defaultOptions = { + sessionScopes: (session: string) => new Set(session.split(' ')), +}; + +describe('StaticAuthSessionManager', () => { + const baseConnector = { + refreshSession() { + throw new Error('refreshSession should not be called'); + }, + removeSession() { + throw new Error('removeSession should not be called'); + }, + }; + + it('should get session by creating session once', async () => { + const createSession = jest.fn().mockResolvedValue('my-session'); + const manager = new StaticAuthSessionManager({ + connector: { createSession, ...baseConnector }, + ...defaultOptions, + }); + + expect(createSession).toHaveBeenCalledTimes(0); + await expect(manager.getSession({})).resolves.toBe('my-session'); + expect(createSession).toHaveBeenCalledTimes(1); + await expect(manager.getSession({})).resolves.toBe('my-session'); + expect(createSession).toHaveBeenCalledTimes(1); + }); + + it('should fail to get session if user rejects the request', async () => { + const createSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new StaticAuthSessionManager({ + connector: { createSession, ...baseConnector }, + ...defaultOptions, + }); + + expect(createSession).toHaveBeenCalledTimes(0); + await expect(manager.getSession({})).rejects.toThrow('NOPE'); + expect(createSession).toHaveBeenCalledTimes(1); + await expect(manager.getSession({ optional: true })).resolves.toBe( + undefined, + ); + }); + + it('should only request auth once for same scopes', async () => { + const createSession = jest + .fn() + .mockImplementation(({ scopes }) => [...scopes].join(' ')); + const manager = new StaticAuthSessionManager({ + connector: { createSession, ...baseConnector }, + ...defaultOptions, + }); + + expect(createSession).toHaveBeenCalledTimes(0); + await expect(manager.getSession({ scopes: new Set(['a']) })).resolves.toBe( + 'a', + ); + expect(createSession).toHaveBeenCalledTimes(1); + await expect(manager.getSession({ scopes: new Set(['a']) })).resolves.toBe( + 'a', + ); + expect(createSession).toHaveBeenCalledTimes(1); + await expect(manager.getSession({ scopes: new Set(['b']) })).resolves.toBe( + 'a b', + ); + expect(createSession).toHaveBeenCalledTimes(2); + }); + + it('should remove session and reload', async () => { + const removeSession = jest.fn(); + const manager = new StaticAuthSessionManager({ + connector: { removeSession }, + ...defaultOptions, + } as any); + + await manager.removeSession(); + expect(removeSession).toHaveBeenCalled(); + expect(await manager.getSession({ optional: true })).toBe(undefined); + }); +}); diff --git a/packages/app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts b/packages/app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts new file mode 100644 index 0000000000..e4f144b0a9 --- /dev/null +++ b/packages/app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MutableSessionManager, GetSessionOptions } from './types'; +import { AuthConnector } from '../AuthConnector'; +import { SessionScopeHelper } from './common'; +import { SessionStateTracker } from './SessionStateTracker'; + +type Options = { + /** The connector used for acting on the auth session */ + connector: AuthConnector; + /** Used to get the scope of the session */ + sessionScopes?: (session: T) => Set; + /** The default scopes that should always be present in a session, defaults to none. */ + defaultScopes?: Set; +}; + +/** + * StaticAuthSessionManager manages an underlying session that does not expire. + */ +export class StaticAuthSessionManager implements MutableSessionManager { + private readonly connector: AuthConnector; + private readonly helper: SessionScopeHelper; + private readonly stateTracker = new SessionStateTracker(); + + private currentSession: T | undefined; + + constructor(options: Options) { + const { connector, defaultScopes = new Set(), sessionScopes } = options; + + this.connector = connector; + this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes }); + } + + setSession(session: T | undefined): void { + this.currentSession = session; + this.stateTracker.setIsSignedIn(Boolean(session)); + } + + async getSession(options: GetSessionOptions): Promise { + if ( + this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes) + ) { + return this.currentSession; + } + + // If we continue here we will show a popup, so exit if this is an optional session request. + if (options.optional) { + return undefined; + } + + // We can call authRequester multiple times, the returned session will contain all requested scopes. + this.currentSession = await this.connector.createSession({ + ...options, + scopes: this.helper.getExtendedScope(this.currentSession, options.scopes), + }); + this.stateTracker.setIsSignedIn(true); + return this.currentSession; + } + + async removeSession() { + this.currentSession = undefined; + await this.connector.removeSession(); + this.stateTracker.setIsSignedIn(false); + } + + sessionState$() { + return this.stateTracker.sessionState$(); + } +} diff --git a/packages/app-api/src/lib/AuthSessionManager/common.ts b/packages/app-api/src/lib/AuthSessionManager/common.ts new file mode 100644 index 0000000000..ff2897535d --- /dev/null +++ b/packages/app-api/src/lib/AuthSessionManager/common.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SessionScopesFunc } from './types'; + +export function hasScopes( + searched: Set, + searchFor: Set, +): boolean { + for (const scope of searchFor) { + if (!searched.has(scope)) { + return false; + } + } + return true; +} + +type ScopeHelperOptions = { + sessionScopes: SessionScopesFunc | undefined; + defaultScopes?: Set; +}; + +export class SessionScopeHelper { + constructor(private readonly options: ScopeHelperOptions) {} + + sessionExistsAndHasScope( + session: T | undefined, + scopes?: Set, + ): boolean { + if (!session) { + return false; + } + if (!scopes) { + return true; + } + if (this.options.sessionScopes === undefined) { + return true; + } + const sessionScopes = this.options.sessionScopes(session); + return hasScopes(sessionScopes, scopes); + } + + getExtendedScope(session: T | undefined, scopes?: Set) { + const newScope = new Set(this.options.defaultScopes); + if (session && this.options.sessionScopes !== undefined) { + const sessionScopes = this.options.sessionScopes(session); + for (const scope of sessionScopes) { + newScope.add(scope); + } + } + if (scopes) { + for (const scope of scopes) { + newScope.add(scope); + } + } + return newScope; + } +} diff --git a/packages/app-api/src/lib/AuthSessionManager/index.ts b/packages/app-api/src/lib/AuthSessionManager/index.ts new file mode 100644 index 0000000000..5f4dde8662 --- /dev/null +++ b/packages/app-api/src/lib/AuthSessionManager/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager'; +export { StaticAuthSessionManager } from './StaticAuthSessionManager'; +export { AuthSessionStore } from './AuthSessionStore'; +export * from './types'; diff --git a/packages/app-api/src/lib/AuthSessionManager/types.ts b/packages/app-api/src/lib/AuthSessionManager/types.ts new file mode 100644 index 0000000000..f57afd6760 --- /dev/null +++ b/packages/app-api/src/lib/AuthSessionManager/types.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Observable } from '../../types'; +import { SessionState } from '../../apis'; + +export type GetSessionOptions = { + optional?: boolean; + instantPopup?: boolean; + scopes?: Set; +}; + +/** + * A sessions manager keeps track of the current session and makes sure that + * multiple simultaneous requests for sessions with different scope are handled + * in a correct way. + */ +export type SessionManager = { + getSession(options: GetSessionOptions): Promise; + + removeSession(): Promise; + + sessionState$(): Observable; +}; + +/** + * An extension of the session manager where the session can also be pushed from the manager. + */ +export interface MutableSessionManager extends SessionManager { + setSession(session: T | undefined): void; +} + +/** + * A function called to determine the scopes of a session. + */ +export type SessionScopesFunc = (session: T) => Set; + +/** + * A function called to determine whether it's time for a session to refresh. + * + * This should return true before the session expires, for example, if a session + * expires after 60 minutes, you could return true if the session is older than 45 minutes. + */ +export type SessionShouldRefreshFunc = (session: T) => boolean; diff --git a/packages/app-api/src/lib/globalObject.test.ts b/packages/app-api/src/lib/globalObject.test.ts new file mode 100644 index 0000000000..e72f027b46 --- /dev/null +++ b/packages/app-api/src/lib/globalObject.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + getGlobalSingleton, + getOrCreateGlobalSingleton, + setGlobalSingleton, +} from './globalObject'; + +const anyGlobal = global as any; + +describe('getGlobalSingleton', () => { + beforeEach(() => { + delete anyGlobal['__@backstage/my-thing__']; + }); + + it('should return an existing value', () => { + const myThing = {}; + const myOtherThing = {}; + + anyGlobal['__@backstage/my-thing__'] = myThing; + expect(getGlobalSingleton('my-thing')).toBe(myThing); + expect(getGlobalSingleton('my-thing')).toBe(myThing); + anyGlobal['__@backstage/my-thing__'] = myOtherThing; + expect(getGlobalSingleton('my-thing')).toBe(myOtherThing); + }); + + it('should throw if the value is not set', () => { + expect(() => getGlobalSingleton('my-thing')).toThrow( + 'Global my-thing is not set', + ); + }); +}); + +describe('getOrCreateGlobalSingleton', () => { + beforeEach(() => { + delete anyGlobal['__@backstage/my-thing__']; + }); + + it('should return an existing value', () => { + const myThing = {}; + anyGlobal['__@backstage/my-thing__'] = myThing; + + expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myThing); + expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myThing); + }); + + it('should should create a new value', () => { + const myNewThing = {}; + + expect(anyGlobal['__@backstage/my-thing__']).toBe(undefined); + expect(getOrCreateGlobalSingleton('my-thing', () => myNewThing)).toBe( + myNewThing, + ); + expect(anyGlobal['__@backstage/my-thing__']).toBe(myNewThing); + expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myNewThing); + }); +}); + +describe('setGlobalSingleton', () => { + beforeEach(() => { + delete anyGlobal['__@backstage/my-thing__']; + }); + + it('should set a global value', () => { + setGlobalSingleton('my-thing', 'global value'); + + expect(anyGlobal['__@backstage/my-thing__']).toBe('global value'); + }); + + it('should throw if global value is set', () => { + anyGlobal['__@backstage/my-thing__'] = 'already defined'; + expect(() => setGlobalSingleton('my-thing', () => 'global value')).toThrow( + 'Global my-thing is already se', + ); + }); +}); diff --git a/packages/app-api/src/lib/globalObject.ts b/packages/app-api/src/lib/globalObject.ts new file mode 100644 index 0000000000..87be58499d --- /dev/null +++ b/packages/app-api/src/lib/globalObject.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// https://github.com/zloirock/core-js/issues/86#issuecomment-115759028 +function getGlobalObject() { + if (typeof window !== 'undefined' && window.Math === Math) { + return window; + } + if (typeof self !== 'undefined' && self.Math === Math) { + return self; + } + // eslint-disable-next-line no-new-func + return Function('return this')(); +} + +const globalObject = getGlobalObject(); + +const makeKey = (id: string) => `__@backstage/${id}__`; + +/** + * Used to provide a global singleton value, failing if it is already set. + */ +export function setGlobalSingleton(id: string, value: unknown): void { + const key = makeKey(id); + if (key in globalObject) { + throw new Error(`Global ${id} is already set`); // TODO some sort of special build err + } + globalObject[key] = value; +} + +/** + * Used to access a global singleton value, failing if it is not already set. + */ +export function getGlobalSingleton(id: string): T { + const key = makeKey(id); + if (!(key in globalObject)) { + throw new Error(`Global ${id} is not set`); // TODO some sort of special build err + } + + return globalObject[key]; +} + +/** + * Serializes access to a global singleton value, with the first caller creating the value. + */ +export function getOrCreateGlobalSingleton( + id: string, + supplier: () => T, +): T { + const key = makeKey(id); + + let value = globalObject[key]; + if (value) { + return value; + } + + value = supplier(); + globalObject[key] = value; + return value; +} diff --git a/packages/app-api/src/lib/index.ts b/packages/app-api/src/lib/index.ts new file mode 100644 index 0000000000..10f213b50f --- /dev/null +++ b/packages/app-api/src/lib/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './subjects'; +export * from './loginPopup'; +export * from './AuthConnector'; +export * from './AuthSessionManager'; diff --git a/packages/app-api/src/lib/loginPopup.test.ts b/packages/app-api/src/lib/loginPopup.test.ts new file mode 100644 index 0000000000..98541c268e --- /dev/null +++ b/packages/app-api/src/lib/loginPopup.test.ts @@ -0,0 +1,218 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { showLoginPopup } from './loginPopup'; + +describe('showLoginPopup', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should show an auth popup', async () => { + const popupMock = { closed: false }; + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue(popupMock as Window); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + const payloadPromise = showLoginPopup({ + url: + 'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', + name: 'test-popup', + origin: 'my-origin', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(openSpy.mock.calls[0][0]).toBe( + 'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', + ); + expect(openSpy.mock.calls[0][1]).toBe('test-popup'); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(0); + + const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; + + await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( + 'waiting', + ); + + listener({} as MessageEvent); + + await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( + 'waiting', + ); + + // None of these should be accepted + listener({ source: popupMock } as MessageEvent); + listener({ origin: 'my-origin' } as MessageEvent); + listener({ data: { type: 'authorization_response' } } as MessageEvent); + listener({ + source: popupMock, + origin: 'my-origin', + data: {}, + } as MessageEvent); + listener({ + source: popupMock, + origin: 'my-origin', + data: { type: 'not-auth-result', response: {} }, + } as MessageEvent); + + await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( + 'waiting', + ); + + const myResponse = {}; + + // This should be accepted as a valid sessions response + listener({ + source: popupMock, + origin: 'my-origin', + data: { + type: 'authorization_response', + response: myResponse, + }, + } as MessageEvent); + + await expect(payloadPromise).resolves.toBe(myResponse); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(1); + }); + + it('should fail if popup returns error', async () => { + const popupMock = { closed: false }; + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue(popupMock as Window); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + const payloadPromise = showLoginPopup({ + url: 'url', + name: 'name', + origin: 'my-origin', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(0); + + const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; + + listener({ + source: popupMock, + origin: 'my-origin', + data: { + type: 'authorization_response', + error: { + message: 'NOPE', + name: 'NopeError', + }, + }, + } as MessageEvent); + + await expect(payloadPromise).rejects.toThrow({ + name: 'NopeError', + message: 'NOPE', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(1); + }); + + it('should fail if popup is closed', async () => { + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue({ closed: false } as Window); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + const popupMock = { closed: false }; + + openSpy.mockReturnValue(popupMock as Window); + + const payloadPromise = showLoginPopup({ + url: 'url', + name: 'name', + origin: 'origin', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(0); + + const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; + listener({ + source: popupMock, + origin: 'origin', + data: { + type: 'config_info', + targetOrigin: 'http://localhost', + }, + } as MessageEvent); + + setTimeout(() => { + popupMock.closed = true; + }, 150); + await expect(payloadPromise).rejects.toThrow( + 'Login failed, popup was closed', + ); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(1); + }); + + it('should indicate if origin does not match', async () => { + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue({ closed: false } as Window); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + const popupMock = { closed: false }; + + openSpy.mockReturnValue(popupMock as Window); + + const payloadPromise = showLoginPopup({ + url: 'url', + name: 'name', + origin: 'origin', + }); + + const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; + listener({ + source: popupMock, + origin: 'origin', + data: { + type: 'config_info', + targetOrigin: 'http://differenthost', + }, + } as MessageEvent); + + setTimeout(() => { + popupMock.closed = true; + }, 150); + await expect(payloadPromise).rejects.toThrow( + 'Login failed, Incorrect app origin, expected http://differenthost', + ); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(1); + }); +}); diff --git a/packages/app-api/src/lib/loginPopup.ts b/packages/app-api/src/lib/loginPopup.ts new file mode 100644 index 0000000000..716c4f9651 --- /dev/null +++ b/packages/app-api/src/lib/loginPopup.ts @@ -0,0 +1,143 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Options used to open a login popup. + */ +export type LoginPopupOptions = { + /** + * The URL that the auth popup should point to + */ + url: string; + + /** + * The name of the popup, as in second argument to window.open + */ + name: string; + + /** + * The origin of the final popup page that will post a message to this window. + */ + origin: string; + + /** + * The width of the popup in pixels, defaults to 500 + */ + width?: number; + + /** + * The height of the popup in pixels, defaults to 700 + */ + height?: number; +}; + +type AuthResult = + | { + type: 'authorization_response'; + response: unknown; + } + | { + type: 'authorization_response'; + error: { + name: string; + message: string; + }; + }; + +/** + * Show a popup pointing to a URL that starts an auth flow. Implementing the receiving + * end of the postMessage mechanism outlined in https://tools.ietf.org/html/draft-sakimura-oauth-wmrm-00 + * + * The redirect handler of the flow should use postMessage to communicate back + * to the app window. The message posted to the app must match the AuthResult type. + * + * The returned promise resolves to the response of the message that was posted from the auth popup. + */ +export function showLoginPopup(options: LoginPopupOptions): Promise { + return new Promise((resolve, reject) => { + const width = options.width || 500; + const height = options.height || 700; + const left = window.screen.width / 2 - width / 2; + const top = window.screen.height / 2 - height / 2; + + const popup = window.open( + options.url, + options.name, + `menubar=no,location=no,resizable=no,scrollbars=no,status=no,width=${width},height=${height},top=${top},left=${left}`, + ); + + let targetOrigin = ''; + + if (!popup || typeof popup.closed === 'undefined' || popup.closed) { + const error = new Error('Failed to open auth popup.'); + error.name = 'PopupRejectedError'; + reject(error); + return; + } + + const messageListener = (event: MessageEvent) => { + if (event.source !== popup) { + return; + } + if (event.origin !== options.origin) { + return; + } + const { data } = event; + + if (data.type === 'config_info') { + targetOrigin = data.targetOrigin; + return; + } + + if (data.type !== 'authorization_response') { + return; + } + const authResult = data as AuthResult; + + if ('error' in authResult) { + const error = new Error(authResult.error.message); + error.name = authResult.error.name; + // TODO: proper error type + // error.extra = authResult.error.extra; + reject(error); + } else { + resolve(authResult.response); + } + done(); + }; + + const intervalId = setInterval(() => { + if (popup.closed) { + const errMessage = `Login failed, ${ + targetOrigin && targetOrigin !== window.location.origin + ? `Incorrect app origin, expected ${targetOrigin}` + : 'popup was closed' + }`; + const error = new Error(errMessage); + error.name = 'PopupClosedError'; + reject(error); + done(); + } + }, 100); + + function done() { + window.removeEventListener('message', messageListener); + clearInterval(intervalId); + } + + window.addEventListener('message', messageListener); + }); +} diff --git a/packages/app-api/src/lib/subjects.test.ts b/packages/app-api/src/lib/subjects.test.ts new file mode 100644 index 0000000000..31ed26d97d --- /dev/null +++ b/packages/app-api/src/lib/subjects.test.ts @@ -0,0 +1,178 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PublishSubject, BehaviorSubject } from './subjects'; + +function observerSpy() { + return { + next: jest.fn(), + error: jest.fn(), + complete: jest.fn(), + }; +} + +describe('PublishSubject', () => { + it('should be completed', async () => { + const subj = new PublishSubject(); + subj.complete(); + + const spy = observerSpy(); + subj.subscribe(spy); + await 'a tick'; + + expect(spy.next).not.toHaveBeenCalled(); + expect(spy.error).not.toHaveBeenCalled(); + expect(spy.complete).toHaveBeenCalledTimes(1); + + expect(() => subj.next(1)).toThrow('PublishSubject is closed'); + expect(() => subj.error(new Error())).toThrow('PublishSubject is closed'); + expect(() => subj.complete()).toThrow('PublishSubject is closed'); + + expect(subj.closed).toBe(true); + }); + + it('should forward values to all subscribers', async () => { + const subj = new PublishSubject(); + + const spy1 = observerSpy(); + const spy2 = observerSpy(); + subj.subscribe(spy1); + const sub = subj.subscribe(spy2); + + subj.next(42); + + expect(spy1.next).toHaveBeenCalledTimes(1); + expect(spy1.next).toHaveBeenCalledWith(42); + expect(spy2.next).toHaveBeenCalledTimes(1); + expect(spy2.next).toHaveBeenCalledWith(42); + + sub.unsubscribe(); + + subj.next(1337); + + expect(spy1.next).toHaveBeenCalledTimes(2); + expect(spy1.next).toHaveBeenCalledWith(1337); + expect(spy2.next).toHaveBeenCalledTimes(1); + + expect(spy1.error).not.toHaveBeenCalled(); + expect(spy2.error).not.toHaveBeenCalled(); + expect(spy1.complete).not.toHaveBeenCalled(); + expect(spy2.complete).not.toHaveBeenCalled(); + + expect(subj.closed).toBe(false); + }); + + it('should forward errors', async () => { + const subj = new PublishSubject(); + + const spy1 = observerSpy(); + subj.subscribe(spy1); + + const error = new Error('NOPE'); + subj.error(error); + expect(spy1.error).toHaveBeenCalledWith(error); + expect(spy1.next).not.toHaveBeenCalled(); + expect(spy1.complete).not.toHaveBeenCalled(); + + const spy2 = observerSpy(); + subj.subscribe(spy2); + await 'a tick'; + expect(spy2.error).toHaveBeenCalledWith(error); + expect(spy2.next).not.toHaveBeenCalled(); + expect(spy2.complete).not.toHaveBeenCalled(); + + expect(subj.closed).toBe(true); + }); +}); + +describe('BehaviorSubject', () => { + it('should be completed', async () => { + const subj = new BehaviorSubject(0); + subj.complete(); + + const next = jest.fn(); + const complete = jest.fn(); + subj.subscribe({ next, complete }); + await 'a tick'; + + expect(complete).toHaveBeenCalledTimes(1); + + expect(() => subj.next(1)).toThrow('BehaviorSubject is closed'); + expect(() => subj.error(new Error())).toThrow('BehaviorSubject is closed'); + expect(() => subj.complete()).toThrow('BehaviorSubject is closed'); + + expect(subj.closed).toBe(true); + }); + + it('should forward values to all subscribers', async () => { + const subj = new BehaviorSubject(0); + + const obs1 = jest.fn(); + const obs2 = jest.fn(); + subj.subscribe(obs1); + const sub = subj.subscribe(obs2); + await 'a tick'; + + expect(obs1).toHaveBeenCalledTimes(1); + expect(obs1).toHaveBeenCalledWith(0); + expect(obs2).toHaveBeenCalledTimes(1); + expect(obs2).toHaveBeenCalledWith(0); + + subj.next(42); + + expect(obs1).toHaveBeenCalledTimes(2); + expect(obs1).toHaveBeenCalledWith(42); + expect(obs2).toHaveBeenCalledTimes(2); + expect(obs2).toHaveBeenCalledWith(42); + + sub.unsubscribe(); + + subj.next(1337); + + expect(obs1).toHaveBeenCalledTimes(3); + expect(obs1).toHaveBeenCalledWith(1337); + expect(obs2).toHaveBeenCalledTimes(2); + + expect(subj.closed).toBe(false); + }); + + it('should forward errors', async () => { + const subj = new BehaviorSubject(0); + + const spy1 = observerSpy(); + subj.subscribe(spy1); + await 'a tick'; + + expect(spy1.error).not.toHaveBeenCalled(); + expect(spy1.next).toHaveBeenCalledWith(0); + + const error = new Error('NOPE'); + subj.error(error); + expect(spy1.error).toHaveBeenCalledWith(error); + expect(spy1.next).toHaveBeenCalledWith(0); + expect(spy1.next).toHaveBeenCalledTimes(1); + expect(spy1.complete).not.toHaveBeenCalled(); + + const spy2 = observerSpy(); + subj.subscribe(spy2); + await 'a tick'; + expect(spy2.error).toHaveBeenCalledWith(error); + expect(spy2.next).not.toHaveBeenCalled(); + expect(spy2.complete).not.toHaveBeenCalled(); + + expect(subj.closed).toBe(true); + }); +}); diff --git a/packages/app-api/src/lib/subjects.ts b/packages/app-api/src/lib/subjects.ts new file mode 100644 index 0000000000..9ebde6ebbc --- /dev/null +++ b/packages/app-api/src/lib/subjects.ts @@ -0,0 +1,202 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Observable } from '../types'; +import ObservableImpl from 'zen-observable'; + +// TODO(Rugvip): These are stopgap and probably incomplete implementations of subjects. +// If we add a more complete Observables library they should be replaced. + +/** + * A basic implementation of ReactiveX publish subjects. + * + * A subject is a convenient way to create an observable when you want + * to fan out a single value to all subscribers. + * + * See http://reactivex.io/documentation/subject.html + */ +export class PublishSubject + implements Observable, ZenObservable.SubscriptionObserver { + private isClosed = false; + private terminatingError?: Error; + + private readonly observable = new ObservableImpl(subscriber => { + if (this.isClosed) { + if (this.terminatingError) { + subscriber.error(this.terminatingError); + } else { + subscriber.complete(); + } + return () => {}; + } + + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }); + + private readonly subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + get closed() { + return this.isClosed; + } + + next(value: T) { + if (this.isClosed) { + throw new Error('PublishSubject is closed'); + } + this.subscribers.forEach(subscriber => subscriber.next(value)); + } + + error(error: Error) { + if (this.isClosed) { + throw new Error('PublishSubject is closed'); + } + this.isClosed = true; + this.terminatingError = error; + this.subscribers.forEach(subscriber => subscriber.error(error)); + } + + complete() { + if (this.isClosed) { + throw new Error('PublishSubject is closed'); + } + this.isClosed = true; + this.subscribers.forEach(subscriber => subscriber.complete()); + } + + subscribe(observer: ZenObservable.Observer): ZenObservable.Subscription; + subscribe( + onNext: (value: T) => void, + onError?: (error: any) => void, + onComplete?: () => void, + ): ZenObservable.Subscription; + subscribe( + onNext: ZenObservable.Observer | ((value: T) => void), + onError?: (error: any) => void, + onComplete?: () => void, + ): ZenObservable.Subscription { + const observer = + typeof onNext === 'function' + ? { + next: onNext, + error: onError, + complete: onComplete, + } + : onNext; + + return this.observable.subscribe(observer); + } +} + +/** + * A basic implementation of ReactiveX behavior subjects. + * + * A subject is a convenient way to create an observable when you want + * to fan out a single value to all subscribers. + * + * The BehaviorSubject will emit the most recently emitted value or error + * whenever a new observer subscribes to the subject. + * + * See http://reactivex.io/documentation/subject.html + */ +export class BehaviorSubject + implements Observable, ZenObservable.SubscriptionObserver { + private isClosed = false; + private currentValue: T; + private terminatingError?: Error; + + constructor(value: T) { + this.currentValue = value; + } + + private readonly observable = new ObservableImpl(subscriber => { + if (this.isClosed) { + if (this.terminatingError) { + subscriber.error(this.terminatingError); + } else { + subscriber.complete(); + } + return () => {}; + } + + subscriber.next(this.currentValue); + + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }); + + private readonly subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + get closed() { + return this.isClosed; + } + + next(value: T) { + if (this.isClosed) { + throw new Error('BehaviorSubject is closed'); + } + this.currentValue = value; + this.subscribers.forEach(subscriber => subscriber.next(value)); + } + + error(error: Error) { + if (this.isClosed) { + throw new Error('BehaviorSubject is closed'); + } + this.isClosed = true; + this.terminatingError = error; + this.subscribers.forEach(subscriber => subscriber.error(error)); + } + + complete() { + if (this.isClosed) { + throw new Error('BehaviorSubject is closed'); + } + this.isClosed = true; + this.subscribers.forEach(subscriber => subscriber.complete()); + } + + subscribe(observer: ZenObservable.Observer): ZenObservable.Subscription; + subscribe( + onNext: (value: T) => void, + onError?: (error: any) => void, + onComplete?: () => void, + ): ZenObservable.Subscription; + subscribe( + onNext: ZenObservable.Observer | ((value: T) => void), + onError?: (error: any) => void, + onComplete?: () => void, + ): ZenObservable.Subscription { + const observer = + typeof onNext === 'function' + ? { + next: onNext, + error: onError, + complete: onComplete, + } + : onNext; + + return this.observable.subscribe(observer); + } +} diff --git a/packages/app-api/src/lib/versionedValues.test.ts b/packages/app-api/src/lib/versionedValues.test.ts new file mode 100644 index 0000000000..6ba7db4970 --- /dev/null +++ b/packages/app-api/src/lib/versionedValues.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createVersionedValueMap } from './versionedValues'; + +describe('createVersionedValueMap', () => { + it('should be empty', () => { + const map = createVersionedValueMap({}); + + // @ts-expect-error + expect(map.atVersion(1 as any)).toBe(undefined); + }); + + it('should access values by version', () => { + const map = createVersionedValueMap({ 1: 'v1', 2: 'v2' }); + + expect(map.atVersion(1)).toBe('v1'); + expect(map.atVersion(2)).toBe('v2'); + + // @ts-expect-error + expect(map.atVersion(0)).toBe(undefined); + // @ts-expect-error + expect(map.atVersion(NaN)).toBe(undefined); + // @ts-expect-error + expect(map.atVersion(Infinity)).toBe(undefined); + }); +}); diff --git a/packages/app-api/src/lib/versionedValues.ts b/packages/app-api/src/lib/versionedValues.ts new file mode 100644 index 0000000000..88e4e90084 --- /dev/null +++ b/packages/app-api/src/lib/versionedValues.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The versioned value interface is a container for a set of values that + * can be looked up by version. It is intended to be used as a container + * for values that can be versioned independently of package versions. + */ +export type VersionedValue = { + atVersion( + version: Version, + ): Versions[Version] | undefined; +}; + +/** + * Creates a container for a map of versioned values that implements VersionedValue. + */ +export function createVersionedValueMap< + Versions extends { [version: number]: any } +>(versions: Versions): VersionedValue { + Object.freeze(versions); + return { + atVersion(version) { + return versions[version]; + }, + }; +} diff --git a/packages/app-api/src/plugin/Plugin.tsx b/packages/app-api/src/plugin/Plugin.tsx new file mode 100644 index 0000000000..cc168707be --- /dev/null +++ b/packages/app-api/src/plugin/Plugin.tsx @@ -0,0 +1,99 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + PluginConfig, + PluginOutput, + BackstagePlugin, + Extension, + AnyRoutes, + AnyExternalRoutes, +} from './types'; +import { AnyApiFactory } from '../apis'; + +export class PluginImpl< + Routes extends AnyRoutes, + ExternalRoutes extends AnyExternalRoutes +> implements BackstagePlugin { + private storedOutput?: PluginOutput[]; + + constructor(private readonly config: PluginConfig) {} + + getId(): string { + return this.config.id; + } + + getApis(): Iterable { + return this.config.apis ?? []; + } + + get routes(): Routes { + return this.config.routes ?? ({} as Routes); + } + + get externalRoutes(): ExternalRoutes { + return this.config.externalRoutes ?? ({} as ExternalRoutes); + } + + output(): PluginOutput[] { + if (this.storedOutput) { + return this.storedOutput; + } + if (!this.config.register) { + return []; + } + + const outputs = new Array(); + + this.config.register({ + router: { + addRoute(target, component, options) { + outputs.push({ + type: 'route', + target, + component, + options, + }); + }, + }, + featureFlags: { + register(name) { + outputs.push({ type: 'feature-flag', name }); + }, + }, + }); + + this.storedOutput = outputs; + return this.storedOutput; + } + + provide(extension: Extension): T { + return extension.expose(this); + } + + toString() { + return `plugin{${this.config.id}}`; + } +} + +export function createPlugin< + Routes extends AnyRoutes = {}, + ExternalRoutes extends AnyExternalRoutes = {} +>( + config: PluginConfig, +): BackstagePlugin { + return new PluginImpl(config); +} diff --git a/packages/app-api/src/plugin/collectors.test.tsx b/packages/app-api/src/plugin/collectors.test.tsx new file mode 100644 index 0000000000..5baf2539ab --- /dev/null +++ b/packages/app-api/src/plugin/collectors.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { PropsWithChildren } from 'react'; +import { createRouteRef } from '../routing'; +import { createPlugin } from './Plugin'; +import { + createRoutableExtension, + createComponentExtension, +} from '../extensions'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { + traverseElementTree, + childDiscoverer, + routeElementDiscoverer, +} from '../extensions/traversal'; +import { pluginCollector } from './collectors'; + +const mockConfig = () => ({ path: '/foo', title: 'Foo' }); +const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( + <>{children} +); + +const pluginA = createPlugin({ id: 'my-plugin-a' }); +const pluginB = createPlugin({ id: 'my-plugin-b' }); +const pluginC = createPlugin({ id: 'my-plugin-c' }); + +const ref1 = createRouteRef(mockConfig()); +const ref2 = createRouteRef(mockConfig()); + +const Extension1 = pluginA.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref1, + }), +); +const Extension2 = pluginB.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref2, + }), +); +const Extension3 = pluginA.provide( + createComponentExtension({ component: { sync: MockComponent } }), +); +const Extension4 = pluginB.provide( + createComponentExtension({ component: { sync: MockComponent } }), +); +const Extension5 = pluginC.provide( + createComponentExtension({ component: { sync: MockComponent } }), +); + +describe('collection', () => { + it('should collect the plugins', () => { + const root = ( + + + +
+ +
+
+ {[]} + Some text here shouldn't be a problem +
+ {null} +
+ +
+ + {false} + {true} + {0} +
+ +
+ } /> +
+ + + ); + + const { plugins } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + plugins: pluginCollector, + }, + }); + + expect(plugins).toEqual(new Set([pluginA, pluginB, pluginC])); + }); +}); diff --git a/packages/app-api/src/plugin/collectors.ts b/packages/app-api/src/plugin/collectors.ts new file mode 100644 index 0000000000..b04c7b41c4 --- /dev/null +++ b/packages/app-api/src/plugin/collectors.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BackstagePlugin } from './types'; +import { getComponentData } from '../extensions'; +import { createCollector } from '../extensions/traversal'; + +export const pluginCollector = createCollector( + () => new Set>(), + (acc, node) => { + const plugin = getComponentData>( + node, + 'core.plugin', + ); + if (plugin) { + acc.add(plugin); + } + }, +); diff --git a/packages/app-api/src/plugin/index.ts b/packages/app-api/src/plugin/index.ts new file mode 100644 index 0000000000..bbeeca4824 --- /dev/null +++ b/packages/app-api/src/plugin/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { createPlugin } from './Plugin'; +export type { + BackstagePlugin, + Extension, + FeatureFlagOutput, + FeatureFlagsHooks, + LegacyRedirectRouteOutput, + LegacyRouteOutput, + PluginConfig, + PluginHooks, + PluginOutput, + RedirectRouteOutput, + RouteOptions, + RouteOutput, + RoutePath, + RouterHooks, +} from './types'; diff --git a/packages/app-api/src/plugin/types.ts b/packages/app-api/src/plugin/types.ts new file mode 100644 index 0000000000..c3b64d8cb6 --- /dev/null +++ b/packages/app-api/src/plugin/types.ts @@ -0,0 +1,121 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentType } from 'react'; +import { RouteRef, ExternalRouteRef } from '../routing'; +import { AnyApiFactory } from '../apis/system'; + +export type RouteOptions = { + // Whether the route path must match exactly, defaults to true. + exact?: boolean; +}; + +export type RoutePath = string; + +// Replace with using RouteRefs +export type LegacyRouteOutput = { + type: 'legacy-route'; + path: RoutePath; + component: ComponentType<{}>; + options?: RouteOptions; +}; + +export type RouteOutput = { + type: 'route'; + target: RouteRef; + component: ComponentType<{}>; + options?: RouteOptions; +}; + +export type RedirectRouteOutput = { + type: 'redirect-route'; + from: RouteRef; + to: RouteRef; + options?: RouteOptions; +}; + +export type LegacyRedirectRouteOutput = { + type: 'legacy-redirect-route'; + path: RoutePath; + target: RoutePath; + options?: RouteOptions; +}; + +export type FeatureFlagOutput = { + type: 'feature-flag'; + name: string; +}; + +export type PluginOutput = + | LegacyRouteOutput + | RouteOutput + | LegacyRedirectRouteOutput + | RedirectRouteOutput + | FeatureFlagOutput; + +export type Extension = { + expose(plugin: BackstagePlugin): T; +}; + +export type AnyRoutes = { [name: string]: RouteRef }; + +export type AnyExternalRoutes = { [name: string]: ExternalRouteRef }; + +export type BackstagePlugin< + Routes extends AnyRoutes = {}, + ExternalRoutes extends AnyExternalRoutes = {} +> = { + getId(): string; + output(): PluginOutput[]; + getApis(): Iterable; + provide(extension: Extension): T; + routes: Routes; + externalRoutes: ExternalRoutes; +}; + +export type PluginConfig< + Routes extends AnyRoutes, + ExternalRoutes extends AnyExternalRoutes +> = { + id: string; + apis?: Iterable; + register?(hooks: PluginHooks): void; + routes?: Routes; + externalRoutes?: ExternalRoutes; +}; + +export type PluginHooks = { + /** + * @deprecated All router hooks have been deprecated + */ + router: RouterHooks; + featureFlags: FeatureFlagsHooks; +}; + +export type RouterHooks = { + /** + * @deprecated Use a routable extension instead, see https://backstage.io/docs/plugins/composability#porting-existing-plugins + */ + addRoute( + target: RouteRef, + Component: ComponentType, + options?: RouteOptions, + ): void; +}; + +export type FeatureFlagsHooks = { + register(name: string): void; +}; diff --git a/packages/app-api/src/private.ts b/packages/app-api/src/private.ts new file mode 100644 index 0000000000..65462d8d51 --- /dev/null +++ b/packages/app-api/src/private.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { PrivateAppImpl } from './app/App'; diff --git a/packages/app-api/src/public.ts b/packages/app-api/src/public.ts new file mode 100644 index 0000000000..f91d97c31d --- /dev/null +++ b/packages/app-api/src/public.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './apis'; +export * from './app'; +export * from './extensions'; +export * from './icons'; +export * from './plugin'; +export * from './routing'; +export * from './types'; diff --git a/packages/app-api/src/routing/ExternalRouteRef.test.ts b/packages/app-api/src/routing/ExternalRouteRef.test.ts new file mode 100644 index 0000000000..785ad2732f --- /dev/null +++ b/packages/app-api/src/routing/ExternalRouteRef.test.ts @@ -0,0 +1,119 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AnyParams, ExternalRouteRef } from './types'; +import { createExternalRouteRef, isExternalRouteRef } from './ExternalRouteRef'; +import { isSubRouteRef } from './SubRouteRef'; +import { isRouteRef } from './RouteRef'; + +describe('ExternalRouteRef', () => { + it('should be created', () => { + const routeRef: ExternalRouteRef = createExternalRouteRef({ + id: 'my-route-ref', + }); + expect(routeRef.params).toEqual([]); + expect(routeRef.optional).toBe(false); + expect(String(routeRef)).toBe('routeRef{type=external,id=my-route-ref}'); + expect(isRouteRef(routeRef)).toBe(false); + expect(isSubRouteRef(routeRef)).toBe(false); + expect(isExternalRouteRef(routeRef)).toBe(true); + + expect(isRouteRef({} as ExternalRouteRef)).toBe(false); + }); + + it('should be created as optional', () => { + const routeRef: ExternalRouteRef<{ + x: string; + y: string; + }> = createExternalRouteRef({ + id: 'my-other-route-ref', + params: [], + optional: true, + }); + expect(routeRef.params).toEqual([]); + expect(routeRef.optional).toEqual(true); + }); + + it('should be created with params', () => { + const routeRef: ExternalRouteRef<{ + x: string; + y: string; + }> = createExternalRouteRef({ + id: 'my-other-route-ref', + params: ['x', 'y'], + }); + expect(routeRef.params).toEqual(['x', 'y']); + expect(routeRef.optional).toEqual(false); + }); + + it('should be created as optional with params', () => { + const routeRef: ExternalRouteRef<{ + x: string; + y: string; + }> = createExternalRouteRef({ + id: 'my-other-route-ref', + params: ['x', 'y'], + optional: true, + }); + expect(routeRef.params).toEqual(['x', 'y']); + expect(routeRef.optional).toEqual(true); + }); + + it('should properly infer and validate parameter types and assignments', () => { + function validateType( + _ref: ExternalRouteRef, + ) {} + + const _1 = createExternalRouteRef({ id: '1', params: ['notX'] }); + // @ts-expect-error + validateType<{ x: string }, any>(_1); + validateType<{ notX: string }, any>(_1); + + const _2 = createExternalRouteRef({ + id: '2', + params: ['x'], + optional: true, + }); + // @ts-expect-error + validateType(_2); + validateType<{ x: string }, true>(_2); + + const _3 = createExternalRouteRef({ id: '3', params: ['x', 'y'] }); + // @ts-expect-error + validateType<{ x: string }, any>(_3); + // TODO(Rugvip): Ideally this would fail as well, but settle for validating it at runtime instead + validateType<{ x: string; y: string; z: string }, any>(_3); + validateType<{ x: string; y: string }, false>(_3); + + const _4 = createExternalRouteRef({ id: '4', params: [] }); + // @ts-expect-error + validateType<{ x: string }, any>(_4); + validateType(_4); + + const _5 = createExternalRouteRef({ id: '5' }); + // @ts-expect-error + validateType<{ x: string }, any>(_5); + validateType(_5); + + const _6 = createExternalRouteRef({ id: '6', optional: true }); + // @ts-expect-error + validateType(_6); + validateType(_6); + + // To avoid complains about missing expectations and unused vars + expect([_1, _2, _3, _4, _5, _6].join('')).toEqual(expect.any(String)); + }); +}); diff --git a/packages/app-api/src/routing/ExternalRouteRef.ts b/packages/app-api/src/routing/ExternalRouteRef.ts new file mode 100644 index 0000000000..e6af9cf8a7 --- /dev/null +++ b/packages/app-api/src/routing/ExternalRouteRef.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + RouteRef, + SubRouteRef, + ExternalRouteRef, + routeRefType, + AnyParams, + ParamKeys, + OptionalParams, +} from './types'; + +export class ExternalRouteRefImpl< + Params extends AnyParams, + Optional extends boolean +> implements ExternalRouteRef { + readonly [routeRefType] = 'external'; + + constructor( + private readonly id: string, + readonly params: ParamKeys, + readonly optional: Optional, + ) {} + + toString() { + return `routeRef{type=external,id=${this.id}}`; + } +} + +export function createExternalRouteRef< + Params extends { [param in ParamKey]: string }, + Optional extends boolean = false, + ParamKey extends string = never +>(options: { + /** + * An identifier for this route, used to identify it in error messages + */ + id: string; + + /** + * The parameters that will be provided to the external route reference. + */ + params?: ParamKey[]; + + /** + * Whether or not this route is optional, defaults to false. + * + * Optional external routes are not required to be bound in the app, and + * if they aren't, `useRouteRef` will return `undefined`. + */ + optional?: Optional; +}): ExternalRouteRef, Optional> { + return new ExternalRouteRefImpl( + options.id, + (options.params ?? []) as ParamKeys>, + Boolean(options.optional) as Optional, + ); +} + +export function isExternalRouteRef< + Params extends AnyParams, + Optional extends boolean +>( + routeRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, +): routeRef is ExternalRouteRef { + return routeRef[routeRefType] === 'external'; +} diff --git a/packages/app-api/src/routing/FlatRoutes.test.tsx b/packages/app-api/src/routing/FlatRoutes.test.tsx new file mode 100644 index 0000000000..a9b83d1016 --- /dev/null +++ b/packages/app-api/src/routing/FlatRoutes.test.tsx @@ -0,0 +1,111 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, RenderResult } from '@testing-library/react'; +import React, { ReactNode } from 'react'; +import { MemoryRouter, Route, Routes, useOutlet } from 'react-router-dom'; +import { AppContext } from '../app'; +import { AppContextProvider } from '../app/AppContext'; +import { FlatRoutes } from './FlatRoutes'; + +function makeRouteRenderer(node: ReactNode) { + let rendered: RenderResult | undefined = undefined; + return (path: string) => { + const content = ( + ({ + NotFoundErrorPage: () => <>Not Found, + }), + } as unknown) as AppContext + } + > + + + ); + if (rendered) { + rendered.unmount(); + rendered.rerender(content); + } else { + rendered = render(content); + } + return rendered; + }; +} + +describe('FlatRoutes', () => { + it('renders some routes', () => { + const renderRoute = makeRouteRenderer( + + a} /> + b} /> + , + ); + expect(renderRoute('/a').getByText('a')).toBeInTheDocument(); + expect(renderRoute('/b').getByText('b')).toBeInTheDocument(); + expect(renderRoute('/c').getByText('Not Found')).toBeInTheDocument(); + expect(renderRoute('/b').queryByText('Not Found')).not.toBeInTheDocument(); + expect(renderRoute('/a').getByText('a')).toBeInTheDocument(); + }); + + it('is not sensitive to ordering and overlapping routes', () => { + // The '/*' suffixes here are intentional and will be ignored by FlatRoutes + const routes = ( + <> + a-1} /> + a} /> + a-2} /> + + ); + const renderRoute = makeRouteRenderer({routes}); + expect(renderRoute('/a').getByText('a')).toBeInTheDocument(); + expect(renderRoute('/a-1').getByText('a-1')).toBeInTheDocument(); + expect(renderRoute('/a-2').getByText('a-2')).toBeInTheDocument(); + renderRoute('').unmount(); + + // This uses regular Routes from react-router, not that a-2 renders a, which is the behavior we're working around + const renderBadRoute = makeRouteRenderer({routes}); + expect(renderBadRoute('/a').getByText('a')).toBeInTheDocument(); + expect(renderBadRoute('/a-1').getByText('a-1')).toBeInTheDocument(); + expect(renderBadRoute('/a-2').getByText('a')).toBeInTheDocument(); + }); + + it('renders children straight as outlets', () => { + const MyPage = () => { + return <>Outlet: {useOutlet()}; + }; + + // The '/*' suffixes here are intentional and will be ignored by FlatRoutes + const routes = ( + <> + }> + a + + }> + a-b + + }> + b + + + ); + const renderRoute = makeRouteRenderer({routes}); + expect(renderRoute('/a').getByText('Outlet: a')).toBeInTheDocument(); + expect(renderRoute('/a/b').getByText('Outlet: a-b')).toBeInTheDocument(); + expect(renderRoute('/b').getByText('Outlet: b')).toBeInTheDocument(); + }); +}); diff --git a/packages/app-api/src/routing/FlatRoutes.tsx b/packages/app-api/src/routing/FlatRoutes.tsx new file mode 100644 index 0000000000..5f2cfc150d --- /dev/null +++ b/packages/app-api/src/routing/FlatRoutes.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ReactNode, Children, isValidElement, Fragment } from 'react'; +import { useRoutes } from 'react-router-dom'; +import { useApp } from '../app'; + +type RouteObject = { + path: string; + element: JSX.Element; + children?: RouteObject[]; +}; + +// Similar to the same function from react-router, this collects routes from the +// children, but only the first level of routes +function createRoutesFromChildren(childrenNode: ReactNode): RouteObject[] { + return Children.toArray(childrenNode).flatMap(child => { + if (!isValidElement(child)) { + return []; + } + + const { children } = child.props; + + if (child.type === Fragment) { + return createRoutesFromChildren(children); + } + + let path = child.props.path as string | undefined; + + // TODO(Rugvip): Work around plugins registering empty paths, remove once deprecated routes are gone + if (path === '') { + return []; + } + path = path?.replace(/\/\*$/, '') ?? '/'; + + return [ + { + path, + element: child, + children: children && [ + { + path: '/*', + element: children, + }, + ], + }, + ]; + }); +} + +type FlatRoutesProps = { + children: ReactNode; +}; + +export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => { + const app = useApp(); + const { NotFoundErrorPage } = app.getComponents(); + const routes = createRoutesFromChildren(props.children) + // Routes are sorted to work around a bug where prefixes are unexpectedly matched + .sort((a, b) => b.path.localeCompare(a.path)) + // We make sure all routes have '/*' appended, except '/' + .map(obj => { + obj.path = obj.path === '/' ? '/' : `${obj.path}/*`; + return obj; + }); + + // TODO(Rugvip): Possibly add a way to skip this, like a noNotFoundPage prop + routes.push({ + element: , + path: '/*', + }); + + return useRoutes(routes); +}; diff --git a/packages/app-api/src/routing/RouteRef.test.ts b/packages/app-api/src/routing/RouteRef.test.ts new file mode 100644 index 0000000000..279589a6c7 --- /dev/null +++ b/packages/app-api/src/routing/RouteRef.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AnyParams, RouteRef } from './types'; +import { createRouteRef, isRouteRef } from './RouteRef'; +import { isSubRouteRef } from './SubRouteRef'; +import { isExternalRouteRef } from './ExternalRouteRef'; +import MyIcon from '@material-ui/icons/AcUnit'; + +describe('RouteRef', () => { + it('should be created', () => { + const routeRef: RouteRef = createRouteRef({ + id: 'my-route-ref', + }); + expect(routeRef.params).toEqual([]); + expect(String(routeRef)).toBe('routeRef{type=absolute,id=my-route-ref}'); + expect(isRouteRef(routeRef)).toBe(true); + expect(isSubRouteRef(routeRef)).toBe(false); + expect(isExternalRouteRef(routeRef)).toBe(false); + + expect(isRouteRef({} as RouteRef)).toBe(false); + }); + + it('should be created with params', () => { + const routeRef: RouteRef<{ + x: string; + y: string; + }> = createRouteRef({ + id: 'my-other-route-ref', + params: ['x', 'y'], + }); + expect(routeRef.params).toEqual(['x', 'y']); + }); + + it('should properly infer and validate parameter types and assignments', () => { + function validateType(_ref: RouteRef) {} + + const _1 = createRouteRef({ id: '1', params: ['x'] }); + // @ts-expect-error + validateType<{ y: string }>(_1); + // @ts-expect-error + validateType(_1); + validateType<{ x: string }>(_1); + + const _2 = createRouteRef({ id: '2', params: ['x', 'y'] }); + // @ts-expect-error + validateType<{ x: string }>(_2); + // @ts-expect-error + validateType(_2); + // @ts-expect-error + validateType<{ x: string; z: string }>(_2); + // TODO(Rugvip): Ideally this would fail as well, but settle for validating it at runtime instead + validateType<{ x: string; y: string; z: string }>(_2); + validateType<{ x: string; y: string }>(_2); + + const _3 = createRouteRef({ id: '3', params: [] }); + // @ts-expect-error + validateType<{ x: string }>(_3); + validateType(_3); + + const _4 = createRouteRef({ id: '4' }); + // @ts-expect-error + validateType<{ x: string }>(_4); + validateType(_4); + + // To avoid complains about missing expectations and unused vars + expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String)); + }); + + it('should support deprecated access', () => { + const routeRef = createRouteRef({ + title: 'My Ref', + path: '/my-path', + icon: MyIcon, + }); + expect(routeRef.title).toBe('My Ref'); + expect(routeRef.path).toBe('/my-path'); + expect(routeRef.icon).toBe(MyIcon); + expect(String(routeRef)).toBe('routeRef{type=absolute,id=My Ref}'); + }); +}); diff --git a/packages/app-api/src/routing/RouteRef.ts b/packages/app-api/src/routing/RouteRef.ts new file mode 100644 index 0000000000..7ce2b2afbd --- /dev/null +++ b/packages/app-api/src/routing/RouteRef.ts @@ -0,0 +1,106 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + RouteRef, + SubRouteRef, + ExternalRouteRef, + routeRefType, + AnyParams, + ParamKeys, + OptionalParams, +} from './types'; +import { IconComponent } from '../icons'; + +// TODO(Rugvip): Remove this in the next breaking release, it's exported but unused +export type RouteRefConfig = { + params?: ParamKeys; + path?: string; + icon?: IconComponent; + title: string; +}; + +export class RouteRefImpl + implements RouteRef { + readonly [routeRefType] = 'absolute'; + + constructor( + private readonly id: string, + readonly params: ParamKeys, + private readonly config: { + path?: string; + icon?: IconComponent; + title?: string; + }, + ) {} + + get icon() { + return this.config.icon; + } + + // TODO(Rugvip): Remove this, routes are looked up via the registry instead + get path() { + return this.config.path ?? ''; + } + + get title() { + return this.config.title ?? this.id; + } + + toString() { + return `routeRef{type=absolute,id=${this.id}}`; + } +} + +export function createRouteRef< + // Params is the type that we care about and the one to be embedded in the route ref. + // For example, given the params ['name', 'kind'], Params will be {name: string, kind: string} + Params extends { [param in ParamKey]: string }, + // ParamKey is here to make sure the Params type properly has its keys narrowed down + // to only the elements of params. Defaulting to never makes sure we end up with + // Param = {} if the params array is empty. + ParamKey extends string = never +>(config: { + /** The id of the route ref, used to identify it when printed */ + id?: string; + /** A list of parameter names that the path that this route ref is bound to must contain */ + params?: ParamKey[]; + /** @deprecated Route refs no longer decide their own path */ + path?: string; + /** @deprecated Route refs no longer decide their own icon */ + icon?: IconComponent; + /** @deprecated Route refs no longer decide their own title */ + title?: string; +}): RouteRef> { + const id = config.id || config.title; + if (!id) { + throw new Error('RouteRef must be provided a non-empty id'); + } + return new RouteRefImpl( + id, + (config.params ?? []) as ParamKeys>, + config, + ); +} + +export function isRouteRef( + routeRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, +): routeRef is RouteRef { + return routeRef[routeRefType] === 'absolute'; +} diff --git a/packages/app-api/src/routing/RouteResolver.test.ts b/packages/app-api/src/routing/RouteResolver.test.ts new file mode 100644 index 0000000000..e5394f6163 --- /dev/null +++ b/packages/app-api/src/routing/RouteResolver.test.ts @@ -0,0 +1,236 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createRouteRef } from './RouteRef'; +import { createSubRouteRef } from './SubRouteRef'; +import { createExternalRouteRef } from './ExternalRouteRef'; +import { RouteResolver } from './RouteResolver'; +import { ExternalRouteRef, RouteRef, SubRouteRef } from './types'; + +const element = () => null; +const rest = { element, caseSensitive: false }; + +const ref1 = createRouteRef({ id: 'rr1' }); +const ref2 = createRouteRef({ id: 'rr2', params: ['x'] }); +const ref3 = createRouteRef({ id: 'rr3', params: ['y'] }); +const subRef1 = createSubRouteRef({ + id: 'srr1', + parent: ref1, + path: '/foo', +}); +const subRef2 = createSubRouteRef({ + id: 'srr2', + parent: ref1, + path: '/foo/:a', +}); +const subRef3 = createSubRouteRef({ + id: 'srr3', + parent: ref2, + path: '/bar', +}); +const subRef4 = createSubRouteRef({ + id: 'srr4', + parent: ref2, + path: '/bar/:a', +}); +const externalRef1 = createExternalRouteRef({ id: 'err1' }); +const externalRef2 = createExternalRouteRef({ + id: 'err2', + optional: true, +}); +const externalRef3 = createExternalRouteRef({ id: 'err3', params: ['x'] }); +const externalRef4 = createExternalRouteRef({ + id: 'err4', + optional: true, + params: ['x'], +}); + +describe('RouteResolver', () => { + it('should not resolve anything with an empty resolver', () => { + const r = new RouteResolver(new Map(), new Map(), [], new Map()); + + expect(r.resolve(ref1, '/')?.()).toBe(undefined); + expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe(undefined); + expect(r.resolve(subRef1, '/')?.()).toBe(undefined); + expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe(undefined); + expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(undefined); + expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(undefined); + expect(r.resolve(externalRef1, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef2, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined); + expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined); + }); + + it('should resolve an absolute route', () => { + const r = new RouteResolver( + new Map([[ref1, '/my-route']]), + new Map(), + [{ routeRefs: new Set([ref1]), path: '/my-route', ...rest }], + new Map(), + ); + + expect(r.resolve(ref1, '/')?.()).toBe('/my-route'); + expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe(undefined); + expect(r.resolve(subRef1, '/')?.()).toBe('/my-route/foo'); + expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe('/my-route/foo/2a'); + expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(undefined); + expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(undefined); + expect(r.resolve(externalRef1, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef2, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined); + expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined); + }); + + it('should resolve an absolute route with a param and with a parent', () => { + const r = new RouteResolver( + new Map([ + [ref1, '/my-route'], + [ref2, '/my-parent/:x'], + ]), + new Map([[ref2, ref1]]), + [ + { + routeRefs: new Set([ref2]), + path: '/my-parent/:x', + ...rest, + children: [ + { routeRefs: new Set([ref1]), path: '/my-route', ...rest }, + ], + }, + ], + new Map([ + [externalRef1, ref1], + [externalRef3, ref2], + [externalRef4, subRef3], + ]), + ); + + expect(r.resolve(ref1, '/')?.()).toBe('/my-route'); + expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe('/my-route/my-parent/1x'); + expect(r.resolve(subRef1, '/')?.()).toBe('/my-route/foo'); + expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe('/my-route/foo/2a'); + expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe( + '/my-route/my-parent/3x/bar', + ); + expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe( + '/my-route/my-parent/4x/bar/4a', + ); + expect(r.resolve(externalRef1, '/')?.()).toBe('/my-route'); + expect(r.resolve(externalRef2, '/')?.()).toBe(undefined); + expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe( + '/my-route/my-parent/5x', + ); + expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe( + '/my-route/my-parent/6x/bar', + ); + }); + + it('should resolve an absolute route with multiple parents', () => { + const r = new RouteResolver( + new Map([ + [ref1, '/my-route'], + [ref2, '/my-parent/:x'], + [ref3, '/my-grandparent/:y'], + ]), + new Map([ + [ref1, ref2], + [ref2, ref3], + ]), + [ + { + routeRefs: new Set([ref3]), + path: '/my-grandparent/:y', + ...rest, + children: [ + { + routeRefs: new Set([ref2]), + path: '/my-parent/:x', + ...rest, + children: [ + { routeRefs: new Set([ref1]), path: '/my-route', ...rest }, + ], + }, + ], + }, + ], + new Map([ + [externalRef1, ref1], + [externalRef3, ref2], + [externalRef4, subRef3], + ]), + ); + + const l = '/my-grandparent/my-y/my-parent/my-x'; + expect(r.resolve(ref1, l)?.()).toBe( + '/my-grandparent/my-y/my-parent/my-x/my-route', + ); + expect(() => r.resolve(ref1, '/')?.()).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(ref2, l)?.({ x: '1x' })).toBe( + '/my-grandparent/my-y/my-parent/1x', + ); + expect(r.resolve(ref2, '/my-grandparent/my-y')?.({ x: '1x' })).toBe( + '/my-grandparent/my-y/my-parent/1x', + ); + expect(() => r.resolve(ref2, '/')?.({ x: '1x' })).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(subRef1, l)?.()).toBe( + '/my-grandparent/my-y/my-parent/my-x/my-route/foo', + ); + expect(() => r.resolve(subRef1, '/')?.()).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(subRef2, l)?.({ a: '2a' })).toBe( + '/my-grandparent/my-y/my-parent/my-x/my-route/foo/2a', + ); + expect(() => r.resolve(subRef2, '/')?.({ a: '2a' })).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(subRef3, l)?.({ x: '3x' })).toBe( + '/my-grandparent/my-y/my-parent/3x/bar', + ); + expect(r.resolve(subRef3, '/my-grandparent/my-y')?.({ x: '3x' })).toBe( + '/my-grandparent/my-y/my-parent/3x/bar', + ); + expect(r.resolve(subRef4, l)?.({ x: '4x', a: '4a' })).toBe( + '/my-grandparent/my-y/my-parent/4x/bar/4a', + ); + expect( + r.resolve(subRef4, '/my-grandparent/my-y')?.({ x: '4x', a: '4a' }), + ).toBe('/my-grandparent/my-y/my-parent/4x/bar/4a'); + expect(r.resolve(externalRef1, l)?.()).toBe( + '/my-grandparent/my-y/my-parent/my-x/my-route', + ); + expect(() => r.resolve(externalRef1, '/')?.()).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(externalRef2, l)?.()).toBe(undefined); + expect(r.resolve(externalRef3, l)?.({ x: '5x' })).toBe( + '/my-grandparent/my-y/my-parent/5x', + ); + expect(() => r.resolve(externalRef3, '/')?.({ x: '5x' })).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + expect(r.resolve(externalRef4, l)?.({ x: '6x' })).toBe( + '/my-grandparent/my-y/my-parent/6x/bar', + ); + expect(() => r.resolve(externalRef4, '/')?.({ x: '6x' })).toThrow( + /^Cannot route.*with parent.*as it has parameters$/, + ); + }); +}); diff --git a/packages/app-api/src/routing/RouteResolver.ts b/packages/app-api/src/routing/RouteResolver.ts new file mode 100644 index 0000000000..fb44b8c486 --- /dev/null +++ b/packages/app-api/src/routing/RouteResolver.ts @@ -0,0 +1,223 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { generatePath, matchRoutes } from 'react-router-dom'; +import { + AnyRouteRef, + BackstageRouteObject, + RouteRef, + ExternalRouteRef, + AnyParams, + SubRouteRef, + routeRefType, + RouteFunc, +} from './types'; +import { isRouteRef } from './RouteRef'; +import { isSubRouteRef } from './SubRouteRef'; +import { isExternalRouteRef } from './ExternalRouteRef'; + +// Joins a list of paths together, avoiding trailing and duplicate slashes +function joinPaths(...paths: string[]): string { + const normalized = paths.join('/').replace(/\/\/+/g, '/'); + if (normalized !== '/' && normalized.endsWith('/')) { + return normalized.slice(0, -1); + } + return normalized; +} + +/** + * Resolves the absolute route ref that our target route ref is pointing pointing to, as well + * as the relative target path. + * + * Returns an undefined target ref if one could not be fully resolved. + */ +function resolveTargetRef( + anyRouteRef: AnyRouteRef, + routePaths: Map, + routeBindings: Map, +): readonly [RouteRef | undefined, string] { + // First we figure out which absolute route ref we're dealing with, an if there was an sub route path to append. + // For sub routes it will be the parent path, while for external routes it will be the bound route. + let targetRef: RouteRef; + let subRoutePath = ''; + if (isRouteRef(anyRouteRef)) { + targetRef = anyRouteRef; + } else if (isSubRouteRef(anyRouteRef)) { + targetRef = anyRouteRef.parent; + subRoutePath = anyRouteRef.path; + } else if (isExternalRouteRef(anyRouteRef)) { + const resolvedRoute = routeBindings.get(anyRouteRef); + if (!resolvedRoute) { + return [undefined, '']; + } + if (isRouteRef(resolvedRoute)) { + targetRef = resolvedRoute; + } else if (isSubRouteRef(resolvedRoute)) { + targetRef = resolvedRoute.parent; + subRoutePath = resolvedRoute.path; + } else { + throw new Error( + `ExternalRouteRef was bound to invalid target, ${resolvedRoute}`, + ); + } + } else if (anyRouteRef[routeRefType]) { + throw new Error( + `Unknown or invalid route ref type, ${anyRouteRef[routeRefType]}`, + ); + } else { + throw new Error(`Unknown object passed to useRouteRef, got ${anyRouteRef}`); + } + + // Bail if no absolute path could be resolved + if (!targetRef) { + return [undefined, '']; + } + + // Find the path that our target route is bound to + const resolvedPath = routePaths.get(targetRef); + if (!resolvedPath) { + return [undefined, '']; + } + + // SubRouteRefs join the path from the parent route with its own path + const targetPath = joinPaths(resolvedPath, subRoutePath); + return [targetRef, targetPath]; +} + +/** + * Resolves the complete base path for navigating to the target RouteRef. + */ +function resolveBasePath( + targetRef: RouteRef, + sourceLocation: Parameters[1], + routePaths: Map, + routeParents: Map, + routeObjects: BackstageRouteObject[], +) { + // While traversing the app element tree we build up the routeObjects structure + // used here. It is the same kind of structure that react-router creates, with the + // addition that associated route refs are stored throughout the tree. This lets + // us look up all route refs that can be reached from our source location. + // Because of the similar route object structure, we can use `matchRoutes` from + // react-router to do the lookup of our current location. + const match = matchRoutes(routeObjects, sourceLocation) ?? []; + + // While we search for a common routing root between our current location and + // the target route, we build a list of all route refs we find that we need + // to traverse to reach the target. + const refDiffList = Array(); + + let matchIndex = -1; + for ( + let targetSearchRef: RouteRef | undefined = targetRef; + targetSearchRef; + targetSearchRef = routeParents.get(targetSearchRef) + ) { + // The match contains a list of all ancestral route refs present at our current location + // Starting at the desired target ref and traversing back through its parents, we search + // for a target ref that is present in the match for our current location. When a match + // is found it means we have found a common base to resolve the route from. + matchIndex = match.findIndex(m => + (m.route as BackstageRouteObject).routeRefs.has(targetSearchRef!), + ); + if (matchIndex !== -1) { + break; + } + + // Every time we move a step up in the ancestry of the target ref, we add the current ref + // to the diff list, which ends up being the list of route refs to traverse form the common base + // in order to reach our target. + refDiffList.unshift(targetSearchRef); + } + + // If our target route is present in the initial match we need to construct the final path + // from the parent of the matched route segment. That's to allow the caller of the route + // function to supply their own params. + if (refDiffList.length === 0) { + matchIndex -= 1; + } + + // This is the part of the route tree that the target and source locations have in common. + // We re-use the existing pathname directly along with all params. + const parentPath = matchIndex === -1 ? '' : match[matchIndex].pathname; + + // This constructs the mid section of the path using paths resolved from all route refs + // we need to traverse to reach our target except for the very last one. None of these + // paths are allowed to require any parameters, as the caller would have no way of knowing + // what parameters those are. + const diffPath = joinPaths( + ...refDiffList.slice(0, -1).map(ref => { + const path = routePaths.get(ref); + if (!path) { + throw new Error(`No path for ${ref}`); + } + if (path.includes(':')) { + throw new Error( + `Cannot route to ${targetRef} with parent ${ref} as it has parameters`, + ); + } + return path; + }), + ); + + return parentPath + diffPath; +} + +export class RouteResolver { + constructor( + private readonly routePaths: Map, + private readonly routeParents: Map, + private readonly routeObjects: BackstageRouteObject[], + private readonly routeBindings: Map< + ExternalRouteRef, + RouteRef | SubRouteRef + >, + ) {} + + resolve( + anyRouteRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, + sourceLocation: Parameters[1], + ): RouteFunc | undefined { + // First figure out what our target absolute ref is, as well as our target path. + const [targetRef, targetPath] = resolveTargetRef( + anyRouteRef, + this.routePaths, + this.routeBindings, + ); + if (!targetRef) { + return undefined; + } + + // Next we figure out the base path, which is the combination of the common parent path + // between our current location and our target location, as well as the additional path + // that is the difference between the parent path and the base of our target location. + const basePath = resolveBasePath( + targetRef, + sourceLocation, + this.routePaths, + this.routeParents, + this.routeObjects, + ); + + const routeFunc: RouteFunc = (...[params]) => { + return basePath + generatePath(targetPath, params); + }; + return routeFunc; + } +} diff --git a/packages/app-api/src/routing/SubRouteRef.test.ts b/packages/app-api/src/routing/SubRouteRef.test.ts new file mode 100644 index 0000000000..1c1a3c1b21 --- /dev/null +++ b/packages/app-api/src/routing/SubRouteRef.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AnyParams, SubRouteRef } from './types'; +import { createSubRouteRef, isSubRouteRef } from './SubRouteRef'; +import { createRouteRef, isRouteRef } from './RouteRef'; +import { isExternalRouteRef } from './ExternalRouteRef'; + +const parent = createRouteRef({ id: 'parent' }); +const parentX = createRouteRef({ id: 'parent-x', params: ['x'] }); + +describe('SubRouteRef', () => { + it('should be created', () => { + const routeRef: SubRouteRef = createSubRouteRef({ + parent, + id: 'my-route-ref', + path: '/foo', + }); + expect(routeRef.path).toBe('/foo'); + expect(routeRef.parent).toBe(parent); + expect(routeRef.params).toEqual([]); + expect(String(routeRef)).toBe('routeRef{type=sub,id=my-route-ref}'); + expect(isRouteRef(routeRef)).toBe(false); + expect(isSubRouteRef(routeRef)).toBe(true); + expect(isExternalRouteRef(routeRef)).toBe(false); + + expect(isRouteRef({} as SubRouteRef)).toBe(false); + }); + + it('should be created with params', () => { + const routeRef: SubRouteRef<{ bar: string }> = createSubRouteRef({ + parent, + id: 'my-other-route-ref', + path: '/foo/:bar', + }); + expect(routeRef.path).toBe('/foo/:bar'); + expect(routeRef.parent).toBe(parent); + expect(routeRef.params).toEqual(['bar']); + }); + + it('should be created with merged params', () => { + const routeRef: SubRouteRef<{ + x: string; + y: string; + z: string; + }> = createSubRouteRef({ + parent: parentX, + id: 'my-other-route-ref', + path: '/foo/:y/:z', + }); + expect(routeRef.path).toBe('/foo/:y/:z'); + expect(routeRef.parent).toBe(parentX); + expect(routeRef.params).toEqual(['x', 'y', 'z']); + }); + + it('should be created with params from parent', () => { + const routeRef: SubRouteRef<{ x: string }> = createSubRouteRef({ + parent: parentX, + id: 'my-other-route-ref', + path: '/foo/bar', + }); + expect(routeRef.path).toBe('/foo/bar'); + expect(routeRef.parent).toBe(parentX); + expect(routeRef.params).toEqual(['x']); + }); + + it.each([ + ['foo', "SubRouteRef path must start with '/', got 'foo'"], + [':foo', "SubRouteRef path must start with '/', got ':foo'"], + ['', "SubRouteRef path must start with '/', got ''"], + ['/', "SubRouteRef path must not end with '/', got '/'"], + ['/foo/', "SubRouteRef path must not end with '/', got '/foo/'"], + ['/foo/:x', 'SubRouteRef may not have params that overlap with its parent'], + ['/:/foo', "SubRouteRef path has invalid param, got ''"], + ['/:inva:lid/foo', "SubRouteRef path has invalid param, got 'inva:lid'"], + ['/:inva=lid/foo', "SubRouteRef path has invalid param, got 'inva=lid'"], + ])('should throw if path is invalid, %s', (path, message) => { + expect(() => + createSubRouteRef({ path, parent: parentX, id: path }), + ).toThrow(message); + }); + + it('should properly infer and parse path parameters', () => { + function validateType(_ref: SubRouteRef) {} + + const _1 = createSubRouteRef({ id: '1', parent, path: '/foo/bar' }); + // @ts-expect-error + validateType<{ x: string }>(_1); + validateType(_1); + + const _2 = createSubRouteRef({ id: '2', parent, path: '/foo/:x/:y' }); + // @ts-expect-error + validateType(_2); + // @ts-expect-error + validateType<{ x: string; z: string }>(_2); + // @ts-expect-error + validateType<{ y: string }>(_2); + // TODO(Rugvip): Ideally this would fail as well, but settle for validating it at runtime instead + validateType<{ x: string; y: string; z: string }>(_2); + validateType<{ x: string; y: string }>(_2); + + const _3 = createSubRouteRef({ id: '3', parent: parentX, path: '/foo' }); + // @ts-expect-error + validateType(_3); + // @ts-expect-error + validateType<{ y: string }>(_3); + validateType<{ x: string }>(_3); + + const _4 = createSubRouteRef({ id: '4', parent: parentX, path: '/foo/:y' }); + // @ts-expect-error + validateType(_4); + // @ts-expect-error + validateType<{ x: string; z: string }>(_4); + // @ts-expect-error + validateType<{ y: string }>(_4); + validateType<{ x: string; y: string }>(_4); + + // To avoid complains about missing expectations and unused vars + expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String)); + }); +}); diff --git a/packages/app-api/src/routing/SubRouteRef.ts b/packages/app-api/src/routing/SubRouteRef.ts new file mode 100644 index 0000000000..7ddfc89c80 --- /dev/null +++ b/packages/app-api/src/routing/SubRouteRef.ts @@ -0,0 +1,128 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + AnyParams, + ExternalRouteRef, + OptionalParams, + ParamKeys, + RouteRef, + routeRefType, + SubRouteRef, +} from './types'; + +// Should match the pattern in react-router +const PARAM_PATTERN = /^\w+$/; + +export class SubRouteRefImpl + implements SubRouteRef { + readonly [routeRefType] = 'sub'; + + constructor( + private readonly id: string, + readonly path: string, + readonly parent: RouteRef, + readonly params: ParamKeys, + ) {} + + toString() { + return `routeRef{type=sub,id=${this.id}}`; + } +} + +// These utility types help us infer a Param object type from a string path +// For example, `/foo/:bar/:baz` inferred to `{ bar: string, baz: string }` +type ParamPart = S extends `:${infer Param}` ? Param : never; +type ParamNames = S extends `${infer Part}/${infer Rest}` + ? ParamPart | ParamNames + : ParamPart; +type PathParams = { [name in ParamNames]: string }; + +/** + * Merges a param object type with with an optional params type into a params object + */ +type MergeParams< + P1 extends { [param in string]: string }, + P2 extends AnyParams +> = (P1[keyof P1] extends never ? {} : P1) & (P2 extends undefined ? {} : P2); + +/** + * Creates a SubRouteRef type given the desired parameters and parent route parameters. + * The parameters types are merged together while ensuring that there is no overlap between the two. + */ +type MakeSubRouteRef< + Params extends { [param in string]: string }, + ParentParams extends AnyParams +> = keyof Params & keyof ParentParams extends never + ? SubRouteRef>> + : never; + +export function createSubRouteRef< + Path extends string, + ParentParams extends AnyParams = never +>(config: { + id: string; + path: Path; + parent: RouteRef; +}): MakeSubRouteRef, ParentParams> { + const { id, path, parent } = config; + type Params = PathParams; + + // Collect runtime parameters from the path, e.g. ['bar', 'baz'] from '/foo/:bar/:baz' + const pathParams = path + .split('/') + .filter(p => p.startsWith(':')) + .map(p => p.substring(1)); + const params = [...parent.params, ...pathParams]; + + if (parent.params.some(p => pathParams.includes(p as string))) { + throw new Error( + 'SubRouteRef may not have params that overlap with its parent', + ); + } + if (!path.startsWith('/')) { + throw new Error(`SubRouteRef path must start with '/', got '${path}'`); + } + if (path.endsWith('/')) { + throw new Error(`SubRouteRef path must not end with '/', got '${path}'`); + } + for (const param of pathParams) { + if (!PARAM_PATTERN.test(param)) { + throw new Error(`SubRouteRef path has invalid param, got '${param}'`); + } + } + + // We ensure that the type of the return type is sane here + const subRouteRef = new SubRouteRefImpl( + id, + path, + parent, + params as ParamKeys>, + ) as SubRouteRef>>; + + // But skip type checking of the return value itself, because the conditional + // type checking of the parent parameter overlap is tricky to express. + return subRouteRef as any; +} + +export function isSubRouteRef( + routeRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, +): routeRef is SubRouteRef { + return routeRef[routeRefType] === 'sub'; +} diff --git a/packages/app-api/src/routing/collectors.test.tsx b/packages/app-api/src/routing/collectors.test.tsx new file mode 100644 index 0000000000..44d53856b4 --- /dev/null +++ b/packages/app-api/src/routing/collectors.test.tsx @@ -0,0 +1,376 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { PropsWithChildren } from 'react'; +import { + routePathCollector, + routeParentCollector, + routeObjectCollector, +} from './collectors'; + +import { + traverseElementTree, + childDiscoverer, + routeElementDiscoverer, +} from '../extensions/traversal'; +import { createRouteRef } from './RouteRef'; +import { createPlugin } from '../plugin'; +import { attachComponentData, createRoutableExtension } from '../extensions'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { RouteRef } from './types'; + +const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( + <>{children} +); + +const plugin = createPlugin({ id: 'my-plugin' }); + +const ref1 = createRouteRef({ path: '/foo1', title: 'Foo' }); +const ref2 = createRouteRef({ path: '/foo2', title: 'Foo' }); +const ref3 = createRouteRef({ path: '/foo3', title: 'Foo' }); +const ref4 = createRouteRef({ path: '/foo4', title: 'Foo' }); +const ref5 = createRouteRef({ path: '/foo5', title: 'Foo' }); +const refOrder = [ref1, ref2, ref3, ref4, ref5]; + +const Extension1 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref1, + }), +); +const Extension2 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref2, + }), +); +const Extension3 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref3, + }), +); +const Extension4 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref4, + }), +); +const Extension5 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref5, + }), +); + +const AggregationComponent = ({ + children, +}: PropsWithChildren<{ + path: string; +}>) => <>{children}; + +attachComponentData(AggregationComponent, 'core.gatherMountPoints', true); + +function sortedEntries(map: Map): [RouteRef, T][] { + return Array.from(map).sort( + ([a], [b]) => refOrder.indexOf(a) - refOrder.indexOf(b), + ); +} + +function routeObj(path: string, refs: RouteRef[], children: any[] = []) { + return { + path: path, + caseSensitive: false, + element: null, + routeRefs: new Set(refs), + children: children, + }; +} + +describe('discovery', () => { + it('should collect routes', () => { + const list = [ +
, +
, +
+ +
, + ]; + + const root = ( + + + +
+ +
+
+ Some text here shouldn't be a problem +
+ {null} +
+ +
+ + {false} + {list} + {true} + {0} +
+ +
+ } /> +
+ + + ); + + const { routes, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/bar/:id'], + [ref3, '/baz'], + [ref4, '/divsoup'], + [ref5, '/blop'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, ref2], + [ref4, undefined], + [ref5, ref1], + ]); + expect(routeObjects).toEqual([ + routeObj( + '/foo', + [ref1], + [ + routeObj('/bar/:id', [ref2], [routeObj('/baz', [ref3])]), + routeObj('/blop', [ref5]), + ], + ), + routeObj('/divsoup', [ref4]), + ]); + }); + + it('should handle all react router Route patterns', () => { + const root = ( + + + + + + + + } + /> + }> + } /> + + + + + ); + + const { routes, routeParents } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/bar/:id'], + [ref3, '/baz'], + [ref4, '/divsoup'], + [ref5, '/blop'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, undefined], + [ref4, ref3], + [ref5, ref3], + ]); + }); + + it('should use the route aggregator key to bind child routes to the same path', () => { + const root = ( + + + + +
+ +
+ HELLO +
+ + + + + + + +
+
+ ); + + const { routes, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/foo'], + [ref3, '/bar'], + [ref4, '/baz'], + [ref5, '/baz'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, undefined], + [ref3, undefined], + [ref4, ref3], + [ref5, ref3], + ]); + expect(routeObjects).toEqual([ + routeObj('/foo', [ref1, ref2]), + routeObj('/bar', [ref3], [routeObj('/baz', [ref4, ref5])]), + ]); + }); + + it('should use the route aggregator but stop when encountering explicit path', () => { + const root = ( + + + + + + + + + + + + + + + ); + + const { routes, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/bar'], + [ref3, '/baz'], + [ref4, '/blop'], + [ref5, '/bar'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, ref1], + [ref4, ref3], + [ref5, ref1], + ]); + expect(routeObjects).toEqual([ + routeObj( + '/foo', + [ref1], + [ + routeObj( + '/bar', + [ref2, ref5], + [routeObj('/baz', [ref3], [routeObj('/blop', [ref4])])], + ), + ], + ), + ]); + }); + + it('should stop gathering mount points after encountering explicit path', () => { + const root = ( + + + + + + + + + + + + ); + + expect(() => { + traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + }, + }); + }).toThrow('Mounted routable extension must have a path'); + }); + + it('should not visit the same element twice', () => { + const element = ; + + expect(() => + traverseElementTree({ + root: ( + + {element} + {element} + + ), + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + }, + }), + ).toThrow(`Visited element Extension(Component) twice`); + }); +}); diff --git a/packages/app-api/src/routing/collectors.tsx b/packages/app-api/src/routing/collectors.tsx new file mode 100644 index 0000000000..37362cf8d4 --- /dev/null +++ b/packages/app-api/src/routing/collectors.tsx @@ -0,0 +1,161 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { isValidElement, ReactElement, ReactNode } from 'react'; +import { BackstageRouteObject, RouteRef } from '../routing/types'; +import { getComponentData } from '../extensions'; +import { createCollector } from '../extensions/traversal'; + +function getMountPoint(node: ReactElement): RouteRef | undefined { + const element: ReactNode = node.props?.element; + + let routeRef = getComponentData(node, 'core.mountPoint'); + if (!routeRef && isValidElement(element)) { + routeRef = getComponentData(element, 'core.mountPoint'); + } + + return routeRef; +} + +export const routePathCollector = createCollector( + () => new Map(), + (acc, node, parent, ctxPath: string | undefined) => { + // The context path is used during mount point gathering to assign the same path + // to all discovered mount points + let currentCtxPath = ctxPath; + + if (parent?.props.element === node) { + return currentCtxPath; + } + + // Start gathering mount points when we encounter a mount point gathering flag + if (getComponentData(node, 'core.gatherMountPoints')) { + const path: string | undefined = node.props?.path; + if (!path) { + throw new Error('Mount point gatherer must have a path'); + } + currentCtxPath = path; + } + + const routeRef = getMountPoint(node); + if (routeRef) { + let path: string | undefined = node.props?.path; + // If we're gathering mount points we use the context path as out path, unless + // the element has its own path, in which case we use that instead and stop gathering + if (currentCtxPath) { + if (path) { + currentCtxPath = undefined; + } else { + path = currentCtxPath; + } + } + if (!path) { + throw new Error('Mounted routable extension must have a path'); + } + acc.set(routeRef, path); + } + return currentCtxPath; + }, +); + +export const routeParentCollector = createCollector( + () => new Map(), + (acc, node, parent, parentRouteRef?: RouteRef | { sticky: RouteRef }) => { + if (parent?.props.element === node) { + return parentRouteRef; + } + + let nextParent = parentRouteRef; + + const routeRef = getMountPoint(node); + if (routeRef) { + // "sticky" route ref is when we've encountered a mount point gatherer, and we want a + // mount points beneath it to have the same parent, regardless of internal structure + if (parentRouteRef && 'sticky' in parentRouteRef) { + acc.set(routeRef, parentRouteRef.sticky); + + // When we encounter a mount point with an explicit path, we stop gathering + // mount points withing the children and remove the sticky state + if (node.props?.path) { + nextParent = routeRef; + } else { + nextParent = parentRouteRef; + } + } else { + acc.set(routeRef, parentRouteRef); + nextParent = routeRef; + } + } + + // Mount point gatherers are marked as "sticky" + if (getComponentData(node, 'core.gatherMountPoints')) { + return { sticky: nextParent }; + } + + return nextParent; + }, +); + +export const routeObjectCollector = createCollector( + () => Array(), + (acc, node, parent, parentObj: BackstageRouteObject | undefined) => { + const parentChildren = parentObj?.children ?? acc; + if (parent?.props.element === node) { + return parentObj; + } + + const path: string | undefined = node.props?.path; + const caseSensitive: boolean = Boolean(node.props?.caseSensitive); + + const routeRef = getMountPoint(node); + if (routeRef) { + if (path) { + const newObject: BackstageRouteObject = { + caseSensitive, + path, + element: null, + routeRefs: new Set([routeRef]), + children: [], + }; + parentChildren.push(newObject); + return newObject; + } + + parentObj?.routeRefs.add(routeRef); + } + + const isGatherer = getComponentData( + node, + 'core.gatherMountPoints', + ); + if (isGatherer) { + if (!path) { + throw new Error('Mount point gatherer must have a path'); + } + const newObject: BackstageRouteObject = { + caseSensitive, + path, + element: null, + routeRefs: new Set(), + children: [], + }; + parentChildren.push(newObject); + return newObject; + } + + return parentObj; + }, +); diff --git a/packages/app-api/src/routing/hooks.test.tsx b/packages/app-api/src/routing/hooks.test.tsx new file mode 100644 index 0000000000..d16f9808ab --- /dev/null +++ b/packages/app-api/src/routing/hooks.test.tsx @@ -0,0 +1,372 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + PropsWithChildren, + ReactElement, + useContext, + Context, +} from 'react'; +import { MemoryRouter, Routes } from 'react-router-dom'; +import { render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import { VersionedValue } from '../lib/versionedValues'; +import { getGlobalSingleton } from '../lib/globalObject'; +import { createRoutableExtension } from '../extensions'; +import { + childDiscoverer, + routeElementDiscoverer, + traverseElementTree, +} from '../extensions/traversal'; +import { createPlugin } from '../plugin'; +import { + routePathCollector, + routeParentCollector, + routeObjectCollector, +} from './collectors'; +import { validateRoutes } from './validation'; +import { useRouteRef, RoutingProvider } from './hooks'; +import { createRouteRef, RouteRefConfig } from './RouteRef'; +import { RouteResolver } from './RouteResolver'; +import { createExternalRouteRef } from './ExternalRouteRef'; +import { AnyRouteRef, RouteFunc, RouteRef, ExternalRouteRef } from './types'; + +const mockConfig = (extra?: Partial>) => ({ + path: '/unused', + title: 'Unused', + ...extra, +}); +const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( + <>{children} +); + +const plugin = createPlugin({ id: 'my-plugin' }); + +const ref1 = createRouteRef(mockConfig({ path: '/wat1' })); +const ref2 = createRouteRef(mockConfig({ path: '/wat2' })); +const ref3 = createRouteRef(mockConfig({ path: '/wat3' })); +const ref4 = createRouteRef(mockConfig({ path: '/wat4' })); +const ref5 = createRouteRef({ + ...mockConfig({ path: '/wat5' }), + params: ['x'], +}); +const eRefA = createExternalRouteRef({ id: '1' }); +const eRefB = createExternalRouteRef({ id: '2' }); +const eRefC = createExternalRouteRef({ id: '3', params: ['y'] }); +const eRefD = createExternalRouteRef({ id: '4', optional: true }); +const eRefE = createExternalRouteRef({ + id: '5', + optional: true, + params: ['z'], +}); + +const MockRouteSource = (props: { + path?: string; + name: string; + routeRef: AnyRouteRef; + params?: T; +}) => { + try { + const routeFunc = useRouteRef(props.routeRef as any) as + | RouteFunc + | undefined; + return ( +
+ Path at {props.name}: {routeFunc?.(props.params) ?? ''} +
+ ); + } catch (ex) { + return ( +
+ Error at {props.name}: {ex.message} +
+ ); + } +}; + +const Extension1 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref1, + }), +); +const Extension2 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockRouteSource), + mountPoint: ref2, + }), +); +const Extension3 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref3, + }), +); +const Extension4 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockRouteSource), + mountPoint: ref4, + }), +); +const Extension5 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref5, + }), +); + +function withRoutingProvider( + root: ReactElement, + routeBindings: [ExternalRouteRef, RouteRef][] = [], +) { + const { routePaths, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routePaths: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + + return ( + + {root} + + ); +} + +describe('discovery', () => { + it('should handle simple routeRef path creation for routeRefs used in other parts of the app', async () => { + const root = ( + + + + + + + + + + + + + + + ); + + const rendered = render( + withRoutingProvider(root, [ + [eRefA, ref3], + [eRefB, ref1], + [eRefC, ref2], + [eRefD, ref1], + ]), + ); + + await expect( + rendered.findByText('Path at inside: /foo/bar'), + ).resolves.toBeInTheDocument(); + expect( + rendered.getByText('Path at insideExternal: /baz'), + ).toBeInTheDocument(); + expect(rendered.getByText('Path at outside: /foo/bar')).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal1: /foo'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal2: /foo/bar'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal3: /foo'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal4: '), + ).toBeInTheDocument(); + }); + + it('should handle routeRefs with parameters', async () => { + const root = ( + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + await expect( + rendered.findByText('Path at inside: /foo/bar/bleb'), + ).resolves.toBeInTheDocument(); + expect( + rendered.getByText('Path at outside: /foo/bar/blob'), + ).toBeInTheDocument(); + }); + + it('should handle relative routing within parameterized routePaths', async () => { + const root = ( + + + + + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + await expect( + rendered.findByText('Path at inside: /foo/blob/baz'), + ).resolves.toBeInTheDocument(); + }); + + it('should throw errors for routing to other routeRefs with unsupported parameters', () => { + const root = ( + + + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + expect( + rendered.getByText( + `Error at outsideWithParams: Cannot route to ${ref3} with parent ${ref5} as it has parameters`, + ), + ).toBeInTheDocument(); + expect( + rendered.getByText( + `Error at outsideNoParams: Cannot route to ${ref3} with parent ${ref5} as it has parameters`, + ), + ).toBeInTheDocument(); + }); + + it('should handle relative routing of parameterized routePaths with duplicate param names', () => { + const root = ( + + + + + + + + ); + + const { routePaths, routeParents } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routePaths: routePathCollector, + routeParents: routeParentCollector, + }, + }); + + expect(() => validateRoutes(routePaths, routeParents)).toThrow( + 'Parameter :id is duplicated in path /foo/:id/bar/:id', + ); + }); +}); + +describe('v1 consumer', () => { + const RoutingContext = getGlobalSingleton< + Context> + >('routing-context'); + + function useMockRouteRefV1( + routeRef: AnyRouteRef, + location: string, + ): RouteFunc | undefined { + const resolver = useContext(RoutingContext)?.atVersion(1); + if (!resolver) { + throw new Error('no impl'); + } + return resolver.resolve(routeRef, location); + } + + it('should resolve routes', () => { + const routeRef1 = createRouteRef({ id: 'ref1' }); + const routeRef2 = createRouteRef({ id: 'ref2' }); + const routeRef3 = createRouteRef({ id: 'ref3', params: ['x'] }); + + const renderedHook = renderHook( + ({ routeRef }) => useMockRouteRefV1(routeRef, '/'), + { + initialProps: { + routeRef: routeRef1 as AnyRouteRef, + }, + wrapper: ({ children }) => ( + , string>([ + [routeRef2, '/foo'], + [routeRef3, '/bar/:x'], + ]) + } + routeParents={new Map()} + routeObjects={[]} + routeBindings={new Map()} + children={children} + /> + ), + }, + ); + + expect(renderedHook.result.current).toBe(undefined); + renderedHook.rerender({ routeRef: routeRef2 }); + expect(renderedHook.result.current?.()).toBe('/foo'); + renderedHook.rerender({ routeRef: routeRef3 }); + expect(renderedHook.result.current?.({ x: 'my-x' })).toBe('/bar/my-x'); + }); +}); diff --git a/packages/app-api/src/routing/hooks.tsx b/packages/app-api/src/routing/hooks.tsx new file mode 100644 index 0000000000..b8690d867a --- /dev/null +++ b/packages/app-api/src/routing/hooks.tsx @@ -0,0 +1,110 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + createContext, + ReactNode, + useContext, + useMemo, + Context, +} from 'react'; +import { useLocation } from 'react-router-dom'; +import { + BackstageRouteObject, + RouteRef, + ExternalRouteRef, + AnyParams, + SubRouteRef, + RouteFunc, +} from './types'; +import { RouteResolver } from './RouteResolver'; +import { + VersionedValue, + createVersionedValueMap, +} from '../lib/versionedValues'; +import { setGlobalSingleton, getGlobalSingleton } from '../lib/globalObject'; + +type RoutingContextType = VersionedValue<{ 1: RouteResolver }> | undefined; +const RoutingContext = createContext(undefined); + +setGlobalSingleton('routing-context', RoutingContext); + +export function useRouteRef( + routeRef: ExternalRouteRef, +): Optional extends true ? RouteFunc | undefined : RouteFunc; +export function useRouteRef( + routeRef: RouteRef | SubRouteRef, +): RouteFunc; +export function useRouteRef( + routeRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, +): RouteFunc | undefined { + const sourceLocation = useLocation(); + const versionedContext = useContext( + getGlobalSingleton>('routing-context'), + ); + const resolver = versionedContext?.atVersion(1); + const routeFunc = useMemo( + () => resolver && resolver.resolve(routeRef, sourceLocation), + [resolver, routeRef, sourceLocation], + ); + + if (!versionedContext) { + throw new Error('useRouteRef used outside of routing context'); + } + if (!resolver) { + throw new Error('RoutingContext v1 not available'); + } + + const isOptional = 'optional' in routeRef && routeRef.optional; + if (!routeFunc && !isOptional) { + throw new Error(`No path for ${routeRef}`); + } + + return routeFunc; +} + +type ProviderProps = { + routePaths: Map; + routeParents: Map; + routeObjects: BackstageRouteObject[]; + routeBindings: Map; + children: ReactNode; +}; + +export const RoutingProvider = ({ + routePaths, + routeParents, + routeObjects, + routeBindings, + children, +}: ProviderProps) => { + const resolver = new RouteResolver( + routePaths, + routeParents, + routeObjects, + routeBindings, + ); + + const versionedValue = createVersionedValueMap({ 1: resolver }); + return ( + + {children} + + ); +}; diff --git a/packages/app-api/src/routing/index.ts b/packages/app-api/src/routing/index.ts new file mode 100644 index 0000000000..c6134cfe4f --- /dev/null +++ b/packages/app-api/src/routing/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type { + RouteRef, + AbsoluteRouteRef, + ConcreteRoute, + MutableRouteRef, + ExternalRouteRef, +} from './types'; +export { FlatRoutes } from './FlatRoutes'; +export { createRouteRef } from './RouteRef'; +export { createSubRouteRef } from './SubRouteRef'; +export { createExternalRouteRef } from './ExternalRouteRef'; +export type { RouteRefConfig } from './RouteRef'; +export { useRouteRef } from './hooks'; diff --git a/packages/app-api/src/routing/types.ts b/packages/app-api/src/routing/types.ts new file mode 100644 index 0000000000..1fabd39cc4 --- /dev/null +++ b/packages/app-api/src/routing/types.ts @@ -0,0 +1,97 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IconComponent } from '../icons'; +import { getOrCreateGlobalSingleton } from '../lib/globalObject'; + +export type AnyParams = { [param in string]: string } | undefined; +export type ParamKeys = keyof Params extends never + ? [] + : (keyof Params)[]; +export type OptionalParams< + Params extends { [param in string]: string } +> = Params[keyof Params] extends never ? undefined : Params; + +// The extra TS magic here is to require a single params argument if the RouteRef +// had at least one param defined, but require 0 arguments if there are no params defined. +// Without this we'd have to pass in empty object to all parameter-less RouteRefs +// just to make TypeScript happy, or we would have to make the argument optional in +// which case you might forget to pass it in when it is actually required. +export type RouteFunc = ( + ...[params]: Params extends undefined ? readonly [] : readonly [Params] +) => string; + +export const routeRefType: unique symbol = getOrCreateGlobalSingleton( + 'route-ref-type', + () => Symbol('route-ref-type'), +); + +export type RouteRef = { + readonly [routeRefType]: 'absolute'; + + params: ParamKeys; + + // TODO(Rugvip): Remove all of these once plugins don't rely on the path + /** @deprecated paths are no longer accessed directly from RouteRefs, use useRouteRef instead */ + path: string; + /** @deprecated icons are no longer accessed via RouteRefs */ + icon?: IconComponent; + /** @deprecated titles are no longer accessed via RouteRefs */ + title?: string; +}; + +export type SubRouteRef = { + readonly [routeRefType]: 'sub'; + + parent: RouteRef; + + path: string; + + params: ParamKeys; +}; + +export type ExternalRouteRef< + Params extends AnyParams = any, + Optional extends boolean = any +> = { + readonly [routeRefType]: 'external'; + + params: ParamKeys; + + optional?: Optional; +}; + +export type AnyRouteRef = + | RouteRef + | SubRouteRef + | ExternalRouteRef; + +// TODO(Rugvip): None of these should be found in the wild anymore, remove in next minor release +/** @deprecated */ +export type ConcreteRoute = {}; +/** @deprecated */ +export type AbsoluteRouteRef = RouteRef<{}>; +/** @deprecated */ +export type MutableRouteRef = RouteRef<{}>; + +// A duplicate of the react-router RouteObject, but with routeRef added +export interface BackstageRouteObject { + caseSensitive: boolean; + children?: BackstageRouteObject[]; + element: React.ReactNode; + path: string; + routeRefs: Set; +} diff --git a/packages/app-api/src/routing/validation.ts b/packages/app-api/src/routing/validation.ts new file mode 100644 index 0000000000..2d32471a14 --- /dev/null +++ b/packages/app-api/src/routing/validation.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AnyRouteRef } from './types'; + +export function validateRoutes( + routePaths: Map, + routeParents: Map, +) { + const notLeafRoutes = new Set(routeParents.values()); + notLeafRoutes.delete(undefined); + + for (const route of routeParents.keys()) { + if (notLeafRoutes.has(route)) { + continue; + } + + let currentRouteRef: AnyRouteRef | undefined = route; + + let fullPath = ''; + while (currentRouteRef) { + const path = routePaths.get(currentRouteRef); + if (!path) { + throw new Error(`No path for ${currentRouteRef}`); + } + fullPath = `${path}${fullPath}`; + currentRouteRef = routeParents.get(currentRouteRef); + } + + const params = fullPath.match(/:(\w+)/g); + if (params) { + for (let j = 0; j < params.length; j++) { + for (let i = j + 1; i < params.length; i++) { + if (params[i] === params[j]) { + throw new Error( + `Parameter ${params[i]} is duplicated in path ${fullPath}`, + ); + } + } + } + } + } +} diff --git a/packages/app-api/src/setupTests.ts b/packages/app-api/src/setupTests.ts new file mode 100644 index 0000000000..aea2220869 --- /dev/null +++ b/packages/app-api/src/setupTests.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/packages/app-api/src/types.ts b/packages/app-api/src/types.ts new file mode 100644 index 0000000000..13a8a72570 --- /dev/null +++ b/packages/app-api/src/types.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * This file contains non-react related core types used throughout Backstage. + */ + +/** + * Observer interface for consuming an Observer, see TC39. + */ +export type Observer = { + next?(value: T): void; + error?(error: Error): void; + complete?(): void; +}; + +/** + * Subscription returned when subscribing to an Observable, see TC39. + */ +export type Subscription = { + /** + * Cancels the subscription + */ + unsubscribe(): void; + + /** + * Value indicating whether the subscription is closed. + */ + readonly closed: Boolean; +}; + +/** + * Observable sequence of values and errors, see TC39. + * + * https://github.com/tc39/proposal-observable + * + * This is used as a common return type for observable values and can be created + * using many different observable implementations, such as zen-observable or RxJS 5. + */ +export type Observable = { + /** + * Subscribes to this observable to start receiving new values. + */ + subscribe(observer: Observer): Subscription; + subscribe( + onNext: (value: T) => void, + onError?: (error: Error) => void, + onComplete?: () => void, + ): Subscription; +};