From 6a09f12f3c58c6adf2fcb488cf0f01399dbfc385 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 6 Apr 2023 20:00:55 +1000 Subject: [PATCH] Add cloud endpoints for frontend (#6002) Adds functions and types to access backend endpoints. This is in preparation for upcoming PRs that will flesh out the dashboard UI. # Important Notes Has not been tested since it is not currently used. It will be used (and tested) in future PRs. --- app/ide-desktop/eslint.config.js | 8 +- .../src/authentication/config.ts | 25 +- .../src/authentication/providers/auth.tsx | 15 +- .../src/authentication/service.tsx | 22 +- .../src/authentication/src/config.ts | 12 +- .../authentication/src/dashboard/service.ts | 852 ++++++++++++++++++ .../authentication/src/dashboard/service.tsx | 110 --- .../dashboard/src/authentication/src/http.tsx | 12 - .../src/authentication/src/newtype.ts | 39 + .../dashboard/src/authentication/src/utils.ts | 28 - 10 files changed, 938 insertions(+), 185 deletions(-) create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts delete mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/newtype.ts diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index 14971bbde4d4..a24bff2aed31 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -102,7 +102,7 @@ const RESTRICTED_SYNTAXES = [ }, { selector: - ':not(:matches(FunctionDeclaration, FunctionExpression, ArrowFunctionExpression, SwitchStatement, SwitchCase, IfStatement:has(.consequent > :matches(ReturnStatement, ThrowStatement)):has(.alternate :matches(ReturnStatement, ThrowStatement)), TryStatement:has(.block > :matches(ReturnStatement, ThrowStatement)):has(:matches([handler=null], .handler :matches(ReturnStatement, ThrowStatement))):has(:matches([finalizer=null], .finalizer :matches(ReturnStatement, ThrowStatement))))) > * > ReturnStatement', + ':not(:matches(FunctionDeclaration, FunctionExpression, ArrowFunctionExpression, SwitchStatement, SwitchCase, IfStatement:has(.consequent > :matches(ReturnStatement, ThrowStatement)):has(.alternate :matches(ReturnStatement, ThrowStatement)), TryStatement:has(.block > :matches(ReturnStatement, ThrowStatement)):has(:matches([handler=null], .handler :matches(ReturnStatement, ThrowStatement))):has(:matches([finalizer=null], .finalizer :matches(ReturnStatement, ThrowStatement))))) > * > :matches(ReturnStatement, ThrowStatement)', message: 'No early returns', }, { @@ -166,6 +166,10 @@ const RESTRICTED_SYNTAXES = [ 'ImportDeclaration[source.value=/^(?:assert|async_hooks|buffer|child_process|cluster|console|constants|crypto|dgram|diagnostics_channel|dns|domain|events|fs|fs\\u002Fpromises|http|http2|https|inspector|module|net|os|path|perf_hooks|process|punycode|querystring|readline|repl|stream|string_decoder|timers|tls|trace_events|tty|url|util|v8|vm|wasi|worker_threads|zlib)$/]', message: 'Use `node:` prefix to import builtin node modules', }, + { + selector: 'TSEnumDeclaration:not(:has(TSEnumMember))', + message: 'Enums must not be empty', + }, { selector: 'ImportDeclaration[source.value=/^(?!node:)/] ~ ImportDeclaration[source.value=/^node:/]', @@ -310,7 +314,7 @@ export default [ ], 'no-redeclare': 'off', // Important to warn on accidental duplicated `interface`s e.g. when writing API wrappers. - '@typescript-eslint/no-redeclare': 'error', + '@typescript-eslint/no-redeclare': ['error', { ignoreDeclarationMerge: false }], 'no-shadow': 'off', '@typescript-eslint/no-shadow': 'warn', 'no-unused-expressions': 'off', diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/config.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/config.ts index 67c52ecb08b0..4307e3ed6793 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/config.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/config.ts @@ -10,18 +10,21 @@ * pools, Amplify must be configured prior to use. This file defines all the information needed to * connect to and use these pools. */ -import * as utils from '../utils' +import * as newtype from '../newtype' // ================= // === Constants === // ================= /** AWS region in which our Cognito pool is located. */ -export const AWS_REGION = utils.brand('eu-west-1') +export const AWS_REGION = newtype.asNewtype('eu-west-1') /** Complete list of OAuth scopes used by the app. */ -export const OAUTH_SCOPES = [utils.brand('email'), utils.brand('openid')] +export const OAUTH_SCOPES = [ + newtype.asNewtype('email'), + newtype.asNewtype('openid'), +] /** OAuth response type used in the OAuth flows. */ -export const OAUTH_RESPONSE_TYPE = utils.brand('code') +export const OAUTH_RESPONSE_TYPE = newtype.asNewtype('code') // ============= // === Types === @@ -34,34 +37,34 @@ export const OAUTH_RESPONSE_TYPE = utils.brand('code') /** The AWS region in which our Cognito pool is located. This is always set to `eu-west-1` because * that is the only region in which our Cognito pools are currently available in. */ -type AwsRegion = utils.Brand<'AwsRegion'> & string +type AwsRegion = newtype.Newtype /** ID of the "Cognito user pool" that contains authentication & identity data of our users. * * This is created automatically by our Terraform scripts when the backend infrastructure is * created. Look in the `enso-org/cloud-v2` repo for details. */ -export type UserPoolId = utils.Brand<'UserPoolId'> & string +export type UserPoolId = newtype.Newtype /** ID of an OAuth client authorized to interact with the Cognito user pool specified by the * {@link UserPoolId}. * * This is created automatically by our Terraform scripts when the backend infrastructure is * created. Look in the `enso-org/cloud-v2` repo for details. */ -export type UserPoolWebClientId = utils.Brand<'UserPoolWebClientId'> & string +export type UserPoolWebClientId = newtype.Newtype /** Domain of the Cognito user pool used for authenticating/identifying the user. * * This must correspond to the public-facing domain name of the Cognito pool identified by the * {@link UserPoolId}, and must not contain an HTTP scheme, or a pathname. */ -export type OAuthDomain = utils.Brand<'OAuthDomain'> & string +export type OAuthDomain = newtype.Newtype /** Possible OAuth scopes to request from the federated identity provider during OAuth sign-in. */ -type OAuthScope = utils.Brand<'OAuthScope'> & string +type OAuthScope = newtype.Newtype /** The response type used to complete the OAuth flow. "code" means that the federated identity * provider will return an authorization code that can be exchanged for an access token. The * authorization code will be provided as a query parameter of the redirect URL. */ -type OAuthResponseType = utils.Brand<'OAuthResponseType'> & string +type OAuthResponseType = newtype.Newtype /** The URL used as a redirect (minus query parameters like `code` which get appended later), once * an OAuth flow (e.g., sign-in or sign-out) has completed. These must match the values set in the * Cognito pool and during the creation of the OAuth client. See the `enso-org/cloud-v2` repo for * details. */ -export type OAuthRedirect = utils.Brand<'OAuthRedirect'> & string +export type OAuthRedirect = newtype.Newtype /** Callback used to open URLs for the OAuth flow. This is only used in the desktop app (i.e., not in * the cloud). This is because in the cloud we just keep the user in their browser, but in the app * we want to open OAuth URLs in the system browser. This is because the user can't be expected to diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx index 4abed93fd4e6..bbf3d70dac2a 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx @@ -12,6 +12,7 @@ import * as authServiceModule from '../service' import * as backendService from '../../dashboard/service' import * as errorModule from '../../error' import * as loggerProvider from '../../providers/logger' +import * as newtype from '../../newtype' import * as sessionProvider from './session' // ================= @@ -47,7 +48,7 @@ export interface FullUserSession { /** User's email address. */ email: string /** User's organization information. */ - organization: backendService.Organization + organization: backendService.UserOrOrganization } /** Object containing the currently signed-in user's session data, if the user has not yet set their @@ -155,7 +156,7 @@ export function AuthProvider(props: AuthProviderProps) { const { accessToken, email } = session.val const backend = backendService.createBackend(accessToken, logger) - const organization = await backend.getUser() + const organization = await backend.usersMe() let newUserSession: UserSession if (!organization) { newUserSession = { @@ -239,17 +240,15 @@ export function AuthProvider(props: AuthProviderProps) { }) const setUsername = async (accessToken: string, username: string, email: string) => { - const body: backendService.SetUsernameRequestBody = { - userName: username, - userEmail: email, - } - /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/343 * The API client is reinitialised on every request. That is an inefficient way of usage. * Fix it by using React context and implementing it as a singleton. */ const backend = backendService.createBackend(accessToken, logger) - await backend.setUsername(body) + await backend.createUser({ + userName: username, + userEmail: newtype.asNewtype(email), + }) navigate(app.DASHBOARD_PATH) toast.success(MESSAGES.setUsernameSuccess) } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx index 1c0eb81bbee1..fc6d605daa0c 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx @@ -11,8 +11,8 @@ import * as cognito from './cognito' import * as config from '../config' import * as listen from './listen' import * as loggerProvider from '../providers/logger' +import * as newtype from '../newtype' import * as platformModule from '../platform' -import * as utils from '../utils' // ================= // === Constants === @@ -32,7 +32,7 @@ const CONFIRM_REGISTRATION_PATHNAME = '//auth/confirmation' const LOGIN_PATHNAME = '//auth/login' /** URL used as the OAuth redirect when running in the desktop app. */ -const DESKTOP_REDIRECT = utils.brand(`${common.DEEP_LINK_SCHEME}://auth`) +const DESKTOP_REDIRECT = newtype.asNewtype(`${common.DEEP_LINK_SCHEME}://auth`) /** Map from platform to the OAuth redirect URL that should be used for that platform. */ const PLATFORM_TO_CONFIG: Record< platformModule.Platform, @@ -58,16 +58,22 @@ const BASE_AMPLIFY_CONFIG = { const AMPLIFY_CONFIGS = { /** Configuration for @pbuchu's Cognito user pool. */ pbuchu: { - userPoolId: utils.brand('eu-west-1_jSF1RbgPK'), - userPoolWebClientId: utils.brand('1bnib0jfon3aqc5g3lkia2infr'), - domain: utils.brand('pb-enso-domain.auth.eu-west-1.amazoncognito.com'), + userPoolId: newtype.asNewtype('eu-west-1_jSF1RbgPK'), + userPoolWebClientId: newtype.asNewtype( + '1bnib0jfon3aqc5g3lkia2infr' + ), + domain: newtype.asNewtype( + 'pb-enso-domain.auth.eu-west-1.amazoncognito.com' + ), ...BASE_AMPLIFY_CONFIG, } satisfies Partial, /** Configuration for the production Cognito user pool. */ production: { - userPoolId: utils.brand('eu-west-1_9Kycu2SbD'), - userPoolWebClientId: utils.brand('4j9bfs8e7415erf82l129v0qhe'), - domain: utils.brand( + userPoolId: newtype.asNewtype('eu-west-1_9Kycu2SbD'), + userPoolWebClientId: newtype.asNewtype( + '4j9bfs8e7415erf82l129v0qhe' + ), + domain: newtype.asNewtype( 'production-enso-domain.auth.eu-west-1.amazoncognito.com' ), ...BASE_AMPLIFY_CONFIG, diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/config.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/config.ts index 487c03dbf98b..3088d550c654 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/config.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/config.ts @@ -1,7 +1,7 @@ /** @file Configuration definition for the Dashboard. */ import * as auth from './authentication/config' -import * as utils from './utils' +import * as newtype from './newtype' // ================= // === Constants === @@ -16,14 +16,14 @@ const CLOUD_REDIRECTS = { * The redirect URL must be known ahead of time because it is registered with the OAuth provider * when it is created. In the native app, the port is unpredictable, but this is not a problem * because the native app does not use port-based redirects, but deep links. */ - development: utils.brand('http://localhost:8081'), - production: utils.brand('https://cloud.enso.org'), + development: newtype.asNewtype('http://localhost:8081'), + production: newtype.asNewtype('https://cloud.enso.org'), } /** All possible API URLs, sorted by environment. */ const API_URLS = { - pbuchu: utils.brand('https://xw0g8j3tsb.execute-api.eu-west-1.amazonaws.com'), - production: utils.brand('https://7aqkn3tnbc.execute-api.eu-west-1.amazonaws.com'), + pbuchu: newtype.asNewtype('https://xw0g8j3tsb.execute-api.eu-west-1.amazonaws.com'), + production: newtype.asNewtype('https://7aqkn3tnbc.execute-api.eu-west-1.amazonaws.com'), } /** All possible configuration options, sorted by environment. */ @@ -65,4 +65,4 @@ export type Environment = 'pbuchu' | 'production' // =========== /** Base URL for requests to our Cloud API backend. */ -type ApiUrl = utils.Brand<'ApiUrl'> & string +type ApiUrl = newtype.Newtype diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts new file mode 100644 index 000000000000..d891e4d9bc8f --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts @@ -0,0 +1,852 @@ +/** @file Module containing the API client for the Cloud backend API. + * + * Each exported function in the {@link Backend} in this module corresponds to an API endpoint. The + * functions are asynchronous and return a `Promise` that resolves to the response from the API. */ +import * as config from '../config' +import * as http from '../http' +import * as loggerProvider from '../providers/logger' +import * as newtype from '../newtype' + +// ================= +// === Constants === +// ================= + +/** HTTP status indicating that the request was successful. */ +const STATUS_OK = 200 + +/** Default HTTP body for an "open project" request. */ +const DEFAULT_OPEN_PROJECT_BODY: OpenProjectRequestBody = { + forceCreate: false, +} + +/** Relative HTTP path to the "set username" endpoint of the Cloud backend API. */ +const CREATE_USER_PATH = 'users' +/** Relative HTTP path to the "get user" endpoint of the Cloud backend API. */ +const USERS_ME_PATH = 'users/me' +/** Relative HTTP path to the "list directory" endpoint of the Cloud backend API. */ +const LIST_DIRECTORY_PATH = 'directories' +/** Relative HTTP path to the "create directory" endpoint of the Cloud backend API. */ +const CREATE_DIRECTORY_PATH = 'directories' +/** Relative HTTP path to the "list projects" endpoint of the Cloud backend API. */ +const LIST_PROJECTS_PATH = 'projects' +/** Relative HTTP path to the "create project" endpoint of the Cloud backend API. */ +const CREATE_PROJECT_PATH = 'projects' +/** Relative HTTP path to the "list files" endpoint of the Cloud backend API. */ +const LIST_FILES_PATH = 'files' +/** Relative HTTP path to the "upload file" endpoint of the Cloud backend API. */ +const UPLOAD_FILE_PATH = 'files' +/** Relative HTTP path to the "create secret" endpoint of the Cloud backend API. */ +const CREATE_SECRET_PATH = 'secrets' +/** Relative HTTP path to the "list secrets" endpoint of the Cloud backend API. */ +const LIST_SECRETS_PATH = 'secrets' +/** Relative HTTP path to the "create tag" endpoint of the Cloud backend API. */ +const CREATE_TAG_PATH = 'tags' +/** Relative HTTP path to the "list tags" endpoint of the Cloud backend API. */ +const LIST_TAGS_PATH = 'tags' +/** Relative HTTP path to the "list versions" endpoint of the Cloud backend API. */ +const LIST_VERSIONS_PATH = 'versions' +/** Relative HTTP path to the "close project" endpoint of the Cloud backend API. */ +function closeProjectPath(projectId: ProjectId) { + return `projects/${projectId}/close` +} +/** Relative HTTP path to the "get project details" endpoint of the Cloud backend API. */ +function getProjectDetailsPath(projectId: ProjectId) { + return `projects/${projectId}` +} +/** Relative HTTP path to the "open project" endpoint of the Cloud backend API. */ +function openProjectPath(projectId: ProjectId) { + return `projects/${projectId}/open` +} +/** Relative HTTP path to the "project update" endpoint of the Cloud backend API. */ +function projectUpdatePath(projectId: ProjectId) { + return `projects/${projectId}` +} +/** Relative HTTP path to the "delete project" endpoint of the Cloud backend API. */ +function deleteProjectPath(projectId: ProjectId) { + return `projects/${projectId}` +} +/** Relative HTTP path to the "check resources" endpoint of the Cloud backend API. */ +function checkResourcesPath(projectId: ProjectId) { + return `projects/${projectId}/resources` +} +/** Relative HTTP path to the "delete file" endpoint of the Cloud backend API. */ +function deleteFilePath(fileId: FileId) { + return `files/${fileId}` +} +/** Relative HTTP path to the "get project" endpoint of the Cloud backend API. */ +function getSecretPath(secretId: SecretId) { + return `secrets/${secretId}` +} +/** Relative HTTP path to the "delete secret" endpoint of the Cloud backend API. */ +function deleteSecretPath(secretId: SecretId) { + return `secrets/${secretId}` +} +/** Relative HTTP path to the "delete tag" endpoint of the Cloud backend API. */ +function deleteTagPath(tagId: TagId) { + return `secrets/${tagId}` +} + +// ============= +// === Types === +// ============= + +/** Unique identifier for a user/organization. */ +export type UserOrOrganizationId = newtype.Newtype + +/** Unique identifier for a directory. */ +export type DirectoryId = newtype.Newtype + +/** Unique identifier for a user's project. */ +export type ProjectId = newtype.Newtype + +/** Unique identifier for an uploaded file. */ +export type FileId = newtype.Newtype + +/** Unique identifier for a secret environment variable. */ +export type SecretId = newtype.Newtype + +/** Unique identifier for a file tag or project tag. */ +export type TagId = newtype.Newtype + +/** A URL. */ +export type Address = newtype.Newtype + +/** An email address. */ +export type EmailAddress = newtype.Newtype + +/** An AWS S3 file path. */ +export type S3FilePath = newtype.Newtype + +export type Ami = newtype.Newtype + +export type Subject = newtype.Newtype + +/** An RFC 3339 DateTime string. */ +export type Rfc3339DateTime = newtype.Newtype + +/** A user/organization in the application. These are the primary owners of a project. */ +export interface UserOrOrganization { + id: UserOrOrganizationId + name: string + email: EmailAddress +} + +/** Possible states that a project can be in. */ +export enum ProjectState { + created = 'Created', + new = 'New', + openInProgress = 'OpenInProgress', + opened = 'Opened', + closed = 'Closed', +} + +/** Wrapper around a project state value. */ +export interface ProjectStateType { + type: ProjectState +} + +/** Common `Project` fields returned by all `Project`-related endpoints. */ +export interface BaseProject { + organizationId: string + projectId: ProjectId + name: string +} + +/** A `Project` returned by `createProject`. */ +export interface CreatedProject extends BaseProject { + state: ProjectStateType + packageName: string +} + +/** A `Project` returned by `listProjects`. */ +export interface ListedProject extends CreatedProject { + address: Address | null +} + +/** A `Project` returned by `updateProject`. */ +export interface UpdatedProject extends BaseProject { + ami: Ami | null + ideVersion: VersionNumber | null + engineVersion: VersionNumber | null +} + +/** A user/organization's project containing and/or currently executing code. */ +export interface Project extends ListedProject { + ideVersion: VersionNumber | null + engineVersion: VersionNumber | null +} + +/** Metadata describing an uploaded file. */ +export interface File { + // eslint-disable-next-line @typescript-eslint/naming-convention + file_id: FileId + // eslint-disable-next-line @typescript-eslint/naming-convention + file_name: string | null + path: S3FilePath +} + +/** Metadata uniquely identifying an uploaded file. */ +export interface FileInfo { + /* TODO: Should potentially be S3FilePath, + * but it's just string on the backend. */ + path: string + id: FileId +} + +/** A secret environment variable. */ +export interface Secret { + id: SecretId + value: string +} + +/** A secret environment variable and metadata uniquely identifying it. */ +export interface SecretAndInfo { + id: SecretId + name: string + value: string +} + +/** Metadata uniquely identifying a secret environment variable. */ +export interface SecretInfo { + name: string + id: SecretId +} + +export enum TagObjectType { + file = 'File', + project = 'Project', +} + +/** A file tag or project tag. */ +export interface Tag { + /* eslint-disable @typescript-eslint/naming-convention */ + organization_id: UserOrOrganizationId + id: TagId + name: string + value: string + object_type: TagObjectType + object_id: string + /* eslint-enable @typescript-eslint/naming-convention */ +} + +/** Metadata uniquely identifying a file tag or project tag. */ +export interface TagInfo { + id: TagId + name: string + value: string +} + +/** Type of application that a {@link Version} applies to. + * + * We keep track of both backend and IDE versions, so that we can update the two independently. + * However the format of the version numbers is the same for both, so we can use the same type for + * both. We just need this enum to disambiguate. */ +export enum VersionType { + backend = 'Backend', + ide = 'Ide', +} + +/** Stability of an IDE or backend version. */ +export enum VersionLifecycle { + stable = 'Stable', + releaseCandidate = 'ReleaseCandidate', + nightly = 'Nightly', + development = 'Development', +} + +/** Version number of an IDE or backend. */ +export interface VersionNumber { + value: string + lifecycle: VersionLifecycle +} + +/** A version describing a release of the backend or IDE. */ +export interface Version { + number: VersionNumber + ami: Ami | null + created: Rfc3339DateTime + // This does not follow our naming convention because it's defined this way in the backend, + // so we need to match it. + // eslint-disable-next-line @typescript-eslint/naming-convention + version_type: VersionType +} + +/** Resource usage of a VM. */ +export interface ResourceUsage { + /** Percentage of memory used. */ + memory: number + /** Percentage of CPU time used since boot. */ + cpu: number + /** Percentage of disk space used. */ + storage: number +} + +export interface User { + /* eslint-disable @typescript-eslint/naming-convention */ + pk: Subject + user_name: string + user_email: EmailAddress + organization_id: UserOrOrganizationId + /* eslint-enable @typescript-eslint/naming-convention */ +} + +export enum PermissionAction { + own = 'Own', + execute = 'Execute', + edit = 'Edit', + read = 'Read', +} + +export interface UserPermission { + user: User + permission: PermissionAction +} + +/** Metadata uniquely identifying a directory entry. + * Thes can be Projects, Files, Secrets, or other directories. */ +interface BaseAsset { + title: string + id: string + parentId: string + permissions: UserPermission[] | null +} + +export enum AssetType { + project = 'project', + file = 'file', + secret = 'secret', + directory = 'directory', +} + +export interface IdType { + [AssetType.project]: ProjectId + [AssetType.file]: FileId + [AssetType.secret]: SecretId + [AssetType.directory]: DirectoryId +} + +/** Metadata uniquely identifying a directory entry. + * Thes can be Projects, Files, Secrets, or other directories. */ +export interface Asset extends BaseAsset { + type: Type + id: IdType[Type] +} + +// This is an alias. +// It should be a separate type because it is the return value of one of the APIs. +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface Directory extends Asset {} + +// ================= +// === Endpoints === +// ================= + +/** HTTP request body for the "set username" endpoint. */ +export interface CreateUserRequestBody { + userName: string + userEmail: EmailAddress +} + +/** HTTP request body for the "create directory" endpoint. */ +export interface CreateDirectoryRequestBody { + title: string + parentId?: DirectoryId +} + +/** HTTP request body for the "create project" endpoint. */ +export interface CreateProjectRequestBody { + projectName: string + projectTemplateName?: string + parentDirectoryId?: DirectoryId +} + +/** + * HTTP request body for the "project update" endpoint. + * Only updates of the `projectName` or `ami` are allowed. + */ +export interface ProjectUpdateRequestBody { + projectName: string | null + ami: Ami | null + ideVersion: VersionNumber | null +} + +/** HTTP request body for the "open project" endpoint. */ +export interface OpenProjectRequestBody { + forceCreate: boolean +} + +/** HTTP request body for the "create secret" endpoint. */ +export interface CreateSecretRequestBody { + secretName: string + secretValue: string + parentDirectoryId?: DirectoryId +} + +/** HTTP request body for the "create tag" endpoint. */ +export interface CreateTagRequestBody { + name: string + value: string + objectType: TagObjectType + objectId: string +} + +export interface ListDirectoryRequestParams { + parentId?: string +} + +/** URL query string parameters for the "upload file" endpoint. */ +export interface UploadFileRequestParams { + fileId?: string + fileName?: string + parentDirectoryId?: DirectoryId +} + +/** URL query string parameters for the "list tags" endpoint. */ +export interface ListTagsRequestParams { + tagType: TagObjectType +} + +/** URL query string parameters for the "list versions" endpoint. */ +export interface ListVersionsRequestParams { + versionType: VersionType + default: boolean +} + +/** HTTP response body for the "list projects" endpoint. */ +interface ListDirectoryResponseBody { + assets: BaseAsset[] +} + +/** HTTP response body for the "list projects" endpoint. */ +interface ListProjectsResponseBody { + projects: ListedProject[] +} + +/** HTTP response body for the "list files" endpoint. */ +interface ListFilesResponseBody { + files: File[] +} + +/** HTTP response body for the "list secrets" endpoint. */ +interface ListSecretsResponseBody { + secrets: SecretInfo[] +} + +/** HTTP response body for the "list tag" endpoint. */ +interface ListTagsResponseBody { + tags: Tag[] +} + +/** HTTP response body for the "list versions" endpoint. */ +interface ListVersionsResponseBody { + versions: Version[] +} + +// =================== +// === Type guards === +// =================== + +export function assetIsType(type: Type) { + return (asset: Asset): asset is Asset => asset.type === type +} + +// =============== +// === Backend === +// =============== + +/** Class for sending requests to the Cloud backend API endpoints. */ +export class Backend { + /** Creates a new instance of the {@link Backend} API client. + * + * @throws An error if the `Authorization` header is not set on the given `client`. */ + constructor( + private readonly client: http.Client, + private readonly logger: loggerProvider.Logger + ) { + // All of our API endpoints are authenticated, so we expect the `Authorization` header to be + // set. + if (!this.client.defaultHeaders?.has('Authorization')) { + return this.throw('Authorization header not set.') + } else { + return + } + } + + throw(message: string): never { + this.logger.error(message) + throw new Error(message) + } + + /** Sets the username of the current user, on the Cloud backend API. */ + async createUser(body: CreateUserRequestBody): Promise { + const response = await this.post(CREATE_USER_PATH, body) + return await response.json() + } + + /** Returns organization info for the current user, from the Cloud backend API. + * + * @returns `null` if status code 401 or 404 was received. */ + async usersMe(): Promise { + const response = await this.get(USERS_ME_PATH) + if (response.status !== STATUS_OK) { + return null + } else { + return await response.json() + } + } + + /** Returns a list of assets in a directory, from the Cloud backend API. + * + * @throws An error if status code 401 or 404 was received. + */ + async listDirectory(query: ListDirectoryRequestParams): Promise { + const response = await this.get( + LIST_DIRECTORY_PATH + + '?' + + new URLSearchParams({ + // eslint-disable-next-line @typescript-eslint/naming-convention + ...(query.parentId ? { parent_id: query.parentId } : {}), + }).toString() + ) + if (response.status !== STATUS_OK) { + if (query.parentId) { + return this.throw(`Unable to list directory with ID '${query.parentId}'.`) + } else { + return this.throw('Unable to list root directory.') + } + } else { + return (await response.json()).assets.map( + // This type assertion is safe; it is only needed to convert `type` to a newtype. + // eslint-disable-next-line no-restricted-syntax + asset => ({ ...asset, type: asset.id.match(/^(.+?)-/)?.[1] } as Asset) + ) + } + } + + /** Creates a directory, on the Cloud backend API. + * + * @throws An error if a 401 or 404 status code was received. */ + async createDirectory(body: CreateDirectoryRequestBody): Promise { + const response = await this.post(CREATE_DIRECTORY_PATH, body) + if (response.status !== STATUS_OK) { + return this.throw(`Unable to create directory with name '${body.title}'.`) + } else { + return await response.json() + } + } + + /** Returns a list of projects belonging to the current user, from the Cloud backend API. + * + * @throws An error if status code 401 or 404 was received. + */ + async listProjects(): Promise { + const response = await this.get(LIST_PROJECTS_PATH) + if (response.status !== STATUS_OK) { + return this.throw('Unable to list projects.') + } else { + return (await response.json()).projects + } + } + + /** Creates a project for the current user, on the Cloud backend API. + * + * @throws An error if a 401 or 404 status code was received. */ + async createProject(body: CreateProjectRequestBody): Promise { + const response = await this.post(CREATE_PROJECT_PATH, body) + if (response.status !== STATUS_OK) { + return this.throw(`Unable to create project with name '${body.projectName}'.`) + } else { + return await response.json() + } + } + + /** Closes the project identified by the given project ID, on the Cloud backend API. + * + * @throws An error if a 401 or 404 status code was received. */ + async closeProject(projectId: ProjectId): Promise { + const response = await this.post(closeProjectPath(projectId), {}) + if (response.status !== STATUS_OK) { + return this.throw(`Unable to close project with ID '${projectId}'.`) + } else { + return + } + } + + /** Returns project details for the specified project ID, from the Cloud backend API. + * + * @throws An error if a 401 or 404 status code was received. */ + async getProjectDetails(projectId: ProjectId): Promise { + const response = await this.get(getProjectDetailsPath(projectId)) + if (response.status !== STATUS_OK) { + return this.throw(`Unable to get details of project with ID '${projectId}'.`) + } else { + return await response.json() + } + } + + /** Sets project to an open state, on the Cloud backend API. + * + * @throws An error if a 401 or 404 status code was received. */ + async openProject( + projectId: ProjectId, + body: OpenProjectRequestBody = DEFAULT_OPEN_PROJECT_BODY + ): Promise { + const response = await this.post(openProjectPath(projectId), body) + if (response.status !== STATUS_OK) { + return this.throw(`Unable to open project with ID '${projectId}'.`) + } else { + return + } + } + + async projectUpdate( + projectId: ProjectId, + body: ProjectUpdateRequestBody + ): Promise { + const response = await this.put(projectUpdatePath(projectId), body) + if (response.status !== STATUS_OK) { + return this.throw(`Unable to update project with ID '${projectId}'.`) + } else { + return await response.json() + } + } + + /** Deletes project, on the Cloud backend API. + * + * @throws An error if a 401 or 404 status code was received. */ + async deleteProject(projectId: ProjectId): Promise { + const response = await this.delete(deleteProjectPath(projectId)) + if (response.status !== STATUS_OK) { + return this.throw(`Unable to delete project with ID '${projectId}'.`) + } else { + return + } + } + + /** Returns project memory, processor and storage usage, from the Cloud backend API. + * + * @throws An error if a 401 or 404 status code was received. */ + async checkResources(projectId: ProjectId): Promise { + const response = await this.get(checkResourcesPath(projectId)) + if (response.status !== STATUS_OK) { + return this.throw(`Unable to get resource usage for project with ID '${projectId}'.`) + } else { + return await response.json() + } + } + + /** Returns a list of files accessible by the current user, from the Cloud backend API. + * + * @throws An error if a 401 or 404 status code was received. */ + async listFiles(): Promise { + const response = await this.get(LIST_FILES_PATH) + if (response.status !== STATUS_OK) { + return this.throw('Unable to list files.') + } else { + return (await response.json()).files + } + } + + /** Uploads a file, to the Cloud backend API. + * + * @throws An error if a 401 or 404 status code was received. */ + async uploadFile(params: UploadFileRequestParams, body: Blob): Promise { + const response = await this.postBase64( + UPLOAD_FILE_PATH + + '?' + + new URLSearchParams({ + /* eslint-disable @typescript-eslint/naming-convention */ + ...(params.fileName ? { file_name: params.fileName } : {}), + ...(params.fileId ? { file_id: params.fileId } : {}), + ...(params.parentDirectoryId + ? { parent_directory_id: params.parentDirectoryId } + : {}), + /* eslint-enable @typescript-eslint/naming-convention */ + }).toString(), + body + ) + if (response.status !== STATUS_OK) { + if (params.fileName) { + return this.throw(`Unable to upload file with name '${params.fileName}'.`) + } else if (params.fileId) { + return this.throw(`Unable to upload file with ID '${params.fileId}'.`) + } else { + return this.throw('Unable to upload file.') + } + } else { + return await response.json() + } + } + + /** Deletes a file, on the Cloud backend API. + * + * @throws An error if a 401 or 404 status code was received. */ + async deleteFile(fileId: FileId): Promise { + const response = await this.delete(deleteFilePath(fileId)) + if (response.status !== STATUS_OK) { + return this.throw(`Unable to delete file with ID '${fileId}'.`) + } else { + return + } + } + + /** Creates a secret environment variable, on the Cloud backend API. + * + * @throws An error if a 401 or 404 status code was received. */ + async createSecret(body: CreateSecretRequestBody): Promise { + const response = await this.post(CREATE_SECRET_PATH, body) + if (response.status !== STATUS_OK) { + return this.throw(`Unable to create secret with name '${body.secretName}'.`) + } else { + return await response.json() + } + } + + /** Returns a secret environment variable, from the Cloud backend API. + * + * @throws An error if a 401 or 404 status code was received. */ + async getSecret(secretId: SecretId): Promise { + const response = await this.get(getSecretPath(secretId)) + if (response.status !== STATUS_OK) { + return this.throw(`Unable to get secret with ID '${secretId}'.`) + } else { + return await response.json() + } + } + + /** Returns the secret environment variables accessible by the user, from the Cloud backend API. + * + * @throws An error if a 401 or 404 status code was received. */ + async listSecrets(): Promise { + const response = await this.get(LIST_SECRETS_PATH) + if (response.status !== STATUS_OK) { + return this.throw('Unable to list secrets.') + } else { + return (await response.json()).secrets + } + } + + /** Deletes a secret environment variable, on the Cloud backend API. + * + * @throws An error if a 401 or 404 status code was received. */ + async deleteSecret(secretId: SecretId): Promise { + const response = await this.delete(deleteSecretPath(secretId)) + if (response.status !== STATUS_OK) { + return this.throw(`Unable to delete secret with ID '${secretId}'.`) + } else { + return + } + } + + /** Creates a file tag or project tag, on the Cloud backend API. + * + * @throws An error if a 401 or 404 status code was received. */ + async createTag(body: CreateTagRequestBody): Promise { + const response = await this.post(CREATE_TAG_PATH, { + /* eslint-disable @typescript-eslint/naming-convention */ + tag_name: body.name, + tag_value: body.value, + object_type: body.objectType, + object_id: body.objectId, + /* eslint-enable @typescript-eslint/naming-convention */ + }) + if (response.status !== STATUS_OK) { + return this.throw(`Unable to create create tag with name '${body.name}'.`) + } else { + return await response.json() + } + } + + /** Returns file tags or project tags accessible by the user, from the Cloud backend API. + * + * @throws An error if a 401 or 404 status code was received. */ + async listTags(params: ListTagsRequestParams): Promise { + const response = await this.get( + LIST_TAGS_PATH + + '?' + + new URLSearchParams({ + // eslint-disable-next-line @typescript-eslint/naming-convention + tag_type: params.tagType, + }).toString() + ) + if (response.status !== STATUS_OK) { + return this.throw(`Unable to list tags of type '${params.tagType}'.`) + } else { + return (await response.json()).tags + } + } + + /** Deletes a secret environment variable, on the Cloud backend API. + * + * @throws An error if a 401 or 404 status code was received. */ + async deleteTag(tagId: TagId): Promise { + const response = await this.delete(deleteTagPath(tagId)) + if (response.status !== STATUS_OK) { + return this.throw(`Unable to delete tag with ID '${tagId}'.`) + } else { + return + } + } + + /** Returns list of backend or IDE versions, from the Cloud backend API. + * + * @throws An error if a 401 or 404 status code was received. */ + async listVersions(params: ListVersionsRequestParams): Promise { + const response = await this.get( + LIST_VERSIONS_PATH + + '?' + + new URLSearchParams({ + // eslint-disable-next-line @typescript-eslint/naming-convention + version_type: params.versionType, + default: String(params.default), + }).toString() + ) + if (response.status !== STATUS_OK) { + return this.throw(`Unable to list versions of type '${params.versionType}'.`) + } else { + return (await response.json()).versions + } + } + + /** Sends an HTTP GET request to the given path. */ + private get(path: string) { + return this.client.get(`${config.ACTIVE_CONFIG.apiUrl}/${path}`) + } + + /** Sends a JSON HTTP POST request to the given path. */ + private post(path: string, payload: object) { + return this.client.post(`${config.ACTIVE_CONFIG.apiUrl}/${path}`, payload) + } + + /** Sends a binary HTTP POST request to the given path. */ + private postBase64(path: string, payload: Blob) { + return this.client.postBase64(`${config.ACTIVE_CONFIG.apiUrl}/${path}`, payload) + } + + /** Sends a JSON HTTP PUT request to the given path. */ + private put(path: string, payload: object) { + return this.client.put(`${config.ACTIVE_CONFIG.apiUrl}/${path}`, payload) + } + + /** Sends an HTTP DELETE request to the given path. */ + private delete(path: string) { + return this.client.delete(`${config.ACTIVE_CONFIG.apiUrl}/${path}`) + } +} + +// ===================== +// === createBackend === +// ===================== + +/** Shorthand method for creating a new instance of the backend API, along with the necessary + * headers. */ +/* TODO [NP]: https://github.com/enso-org/cloud-v2/issues/343 + * This is a hack to quickly create the backend in the format we want, until we get the provider + * working. This should be removed entirely in favour of creating the backend once and using it from + * the context. */ +export function createBackend(accessToken: string, logger: loggerProvider.Logger): Backend { + const headers = new Headers() + headers.append('Authorization', `Bearer ${accessToken}`) + const client = new http.Client(headers) + return new Backend(client, logger) +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.tsx deleted file mode 100644 index 010184c96902..000000000000 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/** @file Module containing the API client for the Cloud backend API. - * - * Each exported function in the {@link Backend} in this module corresponds to an API endpoint. The - * functions are asynchronous and return a `Promise` that resolves to the response from the API. */ -import * as config from '../config' -import * as http from '../http' -import * as loggerProvider from '../providers/logger' - -// ================= -// === Constants === -// ================= - -/** Relative HTTP path to the "set username" endpoint of the Cloud backend API. */ -const SET_USER_NAME_PATH = 'users' -/** Relative HTTP path to the "get user" endpoint of the Cloud backend API. */ -const GET_USER_PATH = 'users/me' - -// ============= -// === Types === -// ============= - -/** A user/organization in the application. These are the primary owners of a project. */ -export interface Organization { - id: string - userEmail: string - name: string -} - -/** HTTP request body for the "set username" endpoint. */ -export interface SetUsernameRequestBody { - userName: string - userEmail: string -} - -// =============== -// === Backend === -// =============== - -/** Class for sending requests to the Cloud backend API endpoints. */ -export class Backend { - /** Creates a new instance of the {@link Backend} API client. - * - * @throws An error if the `Authorization` header is not set on the given `client`. */ - constructor( - private readonly client: http.Client, - private readonly logger: loggerProvider.Logger - ) { - /** All of our API endpoints are authenticated, so we expect the `Authorization` header to be - * set. */ - if (!this.client.defaultHeaders?.has('Authorization')) { - throw new Error('Authorization header not set.') - } - } - - /** Returns a {@link RequestBuilder} for an HTTP GET request to the given path. */ - get(path: string) { - return this.client.get(`${config.ACTIVE_CONFIG.apiUrl}/${path}`) - } - - /** Returns a {@link RequestBuilder} for an HTTP POST request to the given path. */ - post(path: string, payload: object) { - return this.client.post(`${config.ACTIVE_CONFIG.apiUrl}/${path}`, payload) - } - - /** Logs the error that occurred and throws a new one with a more user-friendly message. */ - errorHandler(message: string) { - return (error: Error) => { - this.logger.error(error.message) - throw new Error(message) - } - } - - /** Sets the username of the current user, on the Cloud backend API. */ - setUsername(body: SetUsernameRequestBody): Promise { - return this.post(SET_USER_NAME_PATH, body).then(response => response.json()) - } - - /** Returns organization info for the current user, from the Cloud backend API. - * - * @returns `null` if status code 401 or 404 was received. */ - getUser(): Promise { - return this.get(GET_USER_PATH).then(response => { - if ( - response.status === http.HttpStatus.unauthorized || - response.status === http.HttpStatus.notFound - ) { - return null - } else { - return response.json() - } - }) - } -} - -// ===================== -// === createBackend === -// ===================== - -/** Shorthand method for creating a new instance of the backend API, along with the necessary - * headers. */ -/* TODO [NP]: https://github.com/enso-org/cloud-v2/issues/343 - * This is a hack to quickly create the backend in the format we want, until we get the provider - * working. This should be removed entirely in favour of creating the backend once and using it from - * the context. */ -export function createBackend(accessToken: string, logger: loggerProvider.Logger): Backend { - const headers = new Headers() - headers.append('Authorization', `Bearer ${accessToken}`) - const client = new http.Client(headers) - return new Backend(client, logger) -} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/http.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/http.tsx index 930a28031be9..95dbeb1c3e8f 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/http.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/http.tsx @@ -2,18 +2,6 @@ * * Used to build authenticated clients for external APIs, like our Cloud backend API. */ -// ================== -// === HttpStatus === -// ================== - -/** HTTP status codes returned in a HTTP response. */ -export enum HttpStatus { - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - unauthorized = 401, - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - notFound = 404, -} - // ================== // === HttpMethod === // ================== diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/newtype.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/newtype.ts new file mode 100644 index 000000000000..b3a85ec61518 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/newtype.ts @@ -0,0 +1,39 @@ +/** @file TypeScript's closest equivalent of `newtype`s. */ + +interface NewtypeVariant { + // eslint-disable-next-line @typescript-eslint/naming-convention + _$type: TypeName +} + +/** Used to create a "branded type", + * which contains a property that only exists at compile time. + * + * `Newtype` and `Newtype` are not compatible with each other, + * however both are regular `string`s at runtime. + * + * This is useful in parameters that require values from a certain source, + * for example IDs for a specific object type. + * + * It is similar to a `newtype` in other languages. + * Note however because TypeScript is structurally typed, + * a branded type is assignable to its base type: + * `a: string = asNewtype>(b)` successfully typechecks. */ +export type Newtype = NewtypeVariant & T + +interface NotNewtype { + // eslint-disable-next-line @typescript-eslint/naming-convention + _$type?: never +} + +export function asNewtype>( + s: NotNewtype & Omit +): T { + // This cast is unsafe. + // `T` has an extra property `_$type` which is used purely for typechecking + // and does not exist at runtime. + // + // The property name is specifically chosen to trigger eslint's `naming-convention` lint, + // so it should not be possible to accidentally create a value with such a type. + // eslint-disable-next-line no-restricted-syntax + return s as unknown as T +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/utils.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/utils.ts index 31a97a322b8d..a0c293587c5a 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/utils.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/utils.ts @@ -7,31 +7,3 @@ export function handleEvent(callback: () => Promise) { await callback() } } - -/** Used to create a "branded type", which is a type intersected with a `Brand<'Name'>`. - * - * This is useful in parameters that require values from a certain source, - * for example IDs for a specific object type. - * - * It is similar to a `newtype` in other languages, - * however because TypeScript is structurally typed, a branded type is assignable to its base type: - * `a: string = b as (string & Brand<'Name'>)` is valid. */ -export interface Brand { - $brand: T -} - -interface NoBrand { - $brand?: never -} - -export function brand>(s: NoBrand & Omit): T { - // This cast is unsafe. It is possible to use this method to cast a value from a base type to a - // branded type, even if that value is not an instance of the branded type. For example, the - // string "foo" could be cast to the `UserPoolId` branded type, although this string is clearly - // not a valid `UserPoolId`. This is acceptable because the branded type is only used to prevent - // accidental misuse of values, and not to enforce correctness. That is, it is up to the - // programmer to declare the correct type of a value. After that point, it is up to the branded - // type to keep that guarantee by preventing accidental misuse of the value. - // eslint-disable-next-line no-restricted-syntax - return s as unknown as T -}