Skip to content

Commit

Permalink
feat(extensions): Add extensions namespace (#1960)
Browse files Browse the repository at this point in the history
* Scaffolding out extensions namespace (#1829)

* starting scaffolding

* Finish scaffolding extensions

* adding whitespace

* Implements Extensions namespace (#1838)

* starting scaffolding

* Finish scaffolding extensions

* adding whitespace

* Implements Extensions namespace

* Expose extensions module

* fixing api-extractor by adding @internal

* Improve error handling

* lint

* Add jsdocsand api-extract

* merging

* style fixes from 1829

* style fix

* Addressing PR comments

* Clean up getRuntimeData

* typo fix

* in the tests as well

* PR fixes

* round 2 of fixes

* PR fixes

* Update src/extensions/extensions.ts

Co-authored-by: Kevin Cheung <kevinthecheung@users.noreply.github.com>

* Update src/extensions/extensions.ts

Co-authored-by: Kevin Cheung <kevinthecheung@users.noreply.github.com>

* Update src/extensions/extensions.ts

Co-authored-by: Kevin Cheung <kevinthecheung@users.noreply.github.com>

* Update src/extensions/extensions.ts

Co-authored-by: Kevin Cheung <kevinthecheung@users.noreply.github.com>

* Docs pass

* lint

* Fix test

Co-authored-by: Kevin Cheung <kevinthecheung@users.noreply.github.com>

Co-authored-by: Kevin Cheung <kevinthecheung@users.noreply.github.com>
  • Loading branch information
joehan and kevinthecheung committed Nov 16, 2022
1 parent 9f4d23c commit 623c02d
Show file tree
Hide file tree
Showing 10 changed files with 690 additions and 0 deletions.
4 changes: 4 additions & 0 deletions entrypoints.json
Expand Up @@ -20,6 +20,10 @@
"typings": "./lib/database/index.d.ts",
"dist": "./lib/database/index.js"
},
"firebase-admin/extensions": {
"typings": "./lib/extensions/index.d.ts",
"dist": "./lib/extensions/index.js"
},
"firebase-admin/firestore": {
"typings": "./lib/firestore/index.d.ts",
"dist": "./lib/firestore/index.js"
Expand Down
24 changes: 24 additions & 0 deletions etc/firebase-admin.extensions.api.md
@@ -0,0 +1,24 @@
## API Report File for "firebase-admin.extensions"

> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts

/// <reference types="node" />

import { Agent } from 'http';

// @public
export class Extensions {
// Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts
//
// (undocumented)
readonly app: App;
// Warning: (ae-forgotten-export) The symbol "Runtime" needs to be exported by the entry point index.d.ts
runtime(): Runtime;
}

// @public
export function getExtensions(app?: App): Extensions;

```
8 changes: 8 additions & 0 deletions package.json
Expand Up @@ -74,6 +74,9 @@
"eventarc": [
"lib/eventarc"
],
"extensions": [
"lib/extensions"
],
"database": [
"lib/database"
],
Expand Down Expand Up @@ -136,6 +139,11 @@
"require": "./lib/eventarc/index.js",
"import": "./lib/esm/eventarc/index.js"
},
"./extensions": {
"types": "./lib/extensions/index.d.ts",
"require": "./lib/extensions/index.js",
"import": "./lib/esm/extensions/index.js"
},
"./firestore": {
"types": "./lib/firestore/index.d.ts",
"require": "./lib/firestore/index.js",
Expand Down
150 changes: 150 additions & 0 deletions src/extensions/extensions-api-client-internal.ts
@@ -0,0 +1,150 @@
/*!
* @license
* Copyright 2022 Google Inc.
*
* 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 { App } from '../app';
import { FirebaseApp } from '../app/firebase-app';
import { AuthorizedHttpClient, HttpClient, HttpError, HttpRequestConfig } from '../utils/api-request';
import { FirebaseAppError, PrefixedFirebaseError } from '../utils/error';
import * as validator from '../utils/validator';
import * as utils from '../utils';

const FIREBASE_FUNCTIONS_CONFIG_HEADERS = {
'X-Firebase-Client': `fire-admin-node/${utils.getSdkVersion()}`
};
const EXTENSIONS_API_VERSION = 'v1beta';
// Note - use getExtensionsApiUri() instead so that changing environments is consistent.
const EXTENSIONS_URL = 'https://firebaseextensions.googleapis.com';

/**
* Class that facilitates sending requests to the Firebase Extensions backend API.
*
* @internal
*/
export class ExtensionsApiClient {
private readonly httpClient: HttpClient;

constructor(private readonly app: App) {
if (!validator.isNonNullObject(app) || !('options' in app)) {
throw new FirebaseAppError(
'invalid-argument',
'First argument passed to getExtensions() must be a valid Firebase app instance.');
}
this.httpClient = new AuthorizedHttpClient(this.app as FirebaseApp);
}

async updateRuntimeData(
projectId: string,
instanceId: string,
runtimeData: RuntimeData
): Promise<RuntimeDataResponse> {
const url = this.getRuntimeDataUri(projectId, instanceId);
const request: HttpRequestConfig = {
method: 'PATCH',
url,
headers: FIREBASE_FUNCTIONS_CONFIG_HEADERS,
data: runtimeData,
};
try {
const res = await this.httpClient.send(request);
return res.data
} catch (err: any) {
throw this.toFirebaseError(err);
}
}

private getExtensionsApiUri(): string {
return process.env['FIREBASE_EXT_URL'] ?? EXTENSIONS_URL;
}

private getRuntimeDataUri(projectId: string, instanceId: string): string {
return `${
this.getExtensionsApiUri()
}/${EXTENSIONS_API_VERSION}/projects/${projectId}/instances/${instanceId}/runtimeData`;
}

private toFirebaseError(err: HttpError): PrefixedFirebaseError {
if (err instanceof PrefixedFirebaseError) {
return err;
}

const response = err.response;
if (!response?.isJson()) {
return new FirebaseExtensionsError(
'unknown-error',
`Unexpected response with status: ${response.status} and body: ${response.text}`);
}
const error = response.data?.error;
const message = error?.message || `Unknown server error: ${response.text}`;
switch (error.code) {
case 403:
return new FirebaseExtensionsError('forbidden', message);
case 404:
return new FirebaseExtensionsError('not-found', message);
case 500:
return new FirebaseExtensionsError('internal-error', message);
}
return new FirebaseExtensionsError('unknown-error', message);
}
}

interface RuntimeData {

//oneof
processingState?: ProcessingState;
fatalError?: FatalError;
}

interface RuntimeDataResponse extends RuntimeData{
name: string,
updateTime: string,
}

interface FatalError {
errorMessage: string;
}

interface ProcessingState {
detailMessage: string;
state: State;
}

type State = 'STATE_UNSPECIFIED' |
'NONE' |
'PROCESSING' |
'PROCESSING_COMPLETE' |
'PROCESSING_WARNING' |
'PROCESSING_FAILED';

type ExtensionsErrorCode = 'invalid-argument' | 'not-found' | 'forbidden' | 'internal-error' | 'unknown-error';
/**
* Firebase Extensions error code structure. This extends PrefixedFirebaseError.
*
* @param code - The error code.
* @param message - The error message.
* @constructor
*/
export class FirebaseExtensionsError extends PrefixedFirebaseError {
constructor(code: ExtensionsErrorCode, message: string) {
super('Extensions', code, message);

/* tslint:disable:max-line-length */
// Set the prototype explicitly. See the following link for more details:
// https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work
/* tslint:enable:max-line-length */
(this as any).__proto__ = FirebaseExtensionsError.prototype;
}
}
29 changes: 29 additions & 0 deletions src/extensions/extensions-api.ts
@@ -0,0 +1,29 @@
/*!
* @license
* Copyright 2022 Google Inc.
*
* 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.
*/

/**
* SettableProcessingState represents all the Processing states that can be set on an ExtensionInstance's runtimeData.
*
* - NONE: No relevant lifecycle event work has been done. Set this to clear out old statuses.
* - PROCESSING_COMPLETE: Lifecycle event work completed with no errors.
* - PROCESSING_WARNING: Lifecycle event work succeeded partially,
* or something happened that the user should be warned about.
* - PROCESSING_FAILED: Lifecycle event work failed completely,
* but the instance will still work correctly going forward.
* - If the extension instance is in a broken state due to the errors, instead set FatalError.
*/
export type SettableProcessingState = 'NONE' | 'PROCESSING_COMPLETE' | 'PROCESSING_WARNING' | 'PROCESSING_FAILED';
138 changes: 138 additions & 0 deletions src/extensions/extensions.ts
@@ -0,0 +1,138 @@
/*!
* @license
* Copyright 2022 Google Inc.
*
* 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 { App } from '../app';
import { SettableProcessingState } from './extensions-api';
import { ExtensionsApiClient, FirebaseExtensionsError } from './extensions-api-client-internal';
import * as validator from '../utils/validator';

/**
* The Firebase `Extensions` service interface.
*/
export class Extensions {
private readonly client: ExtensionsApiClient;
/**
* @param app - The app for this `Extensions` service.
* @constructor
* @internal
*/
constructor(readonly app: App) {
this.client = new ExtensionsApiClient(app);
}

/**
* The runtime() method returns a new Runtime, which provides methods to modify an extension instance's runtime data.
*
* @returns A new Runtime object.
*/
public runtime(): Runtime {
return new Runtime(this.client);
}
}

/**
* Runtime provides methods to modify an extension instance's runtime data.
*/
class Runtime {
private projectId: string;
private extensionInstanceId: string;
private readonly client: ExtensionsApiClient;
/**
* @param client - The API client for this `Runtime` service.
* @constructor
* @internal
*/
constructor(client: ExtensionsApiClient) {
this.projectId = this.getProjectId();
if (!validator.isNonEmptyString(process.env['EXT_INSTANCE_ID'])) {
throw new FirebaseExtensionsError(
'invalid-argument',
'Runtime is only available from within a running Extension instance.'
);
}
this.extensionInstanceId = process.env['EXT_INSTANCE_ID'];
if (!validator.isNonNullObject(client) || !('updateRuntimeData' in client)) {
throw new FirebaseExtensionsError(
'invalid-argument',
'Must provide a valid ExtensionsApiClient instance to create a new Runtime.');
}
this.client = client;
}

/**
* Sets the processing state of an extension instance.
*
* Use this method to report the results of a lifecycle event handler. If the
* lifecycle event failed & the extension instance will no longer work
* correctly, use `setFatalError` instead.
*
* @param state - The state to set the instance to.
* @param detailMessage - A message explaining the results of the lifecycle function.
*/
public async setProcessingState(state: SettableProcessingState, detailMessage: string): Promise<void> {
await this.client.updateRuntimeData(
this.projectId,
this.extensionInstanceId,
{
processingState: {
state,
detailMessage,
},
},
);
}

/**
* Reports a fatal error while running a lifecycle event handler.
*
* Call this method when a lifecycle event handler fails in a way that makes
* the Instance inoperable.
* If the lifecycle event failed but the instance will still work as expected,
* call `setProcessingState` with the "PROCESSING_WARNING" or
* "PROCESSING_FAILED" state instead.
*
* @param errorMessage - A message explaining what went wrong and how to fix it.
*/
public async setFatalError(errorMessage: string): Promise<void> {
if (!validator.isNonEmptyString(errorMessage)) {
throw new FirebaseExtensionsError(
'invalid-argument',
'errorMessage must not be empty'
);
}
await this.client.updateRuntimeData(
this.projectId,
this.extensionInstanceId,
{
fatalError: {
errorMessage,
},
},
);
}

private getProjectId(): string {
const projectId = process.env['PROJECT_ID'];
if (!validator.isNonEmptyString(projectId)) {
throw new FirebaseExtensionsError(
'invalid-argument',
'PROJECT_ID must not be undefined in Extensions runtime environment'
);
}
return projectId;
}
}

0 comments on commit 623c02d

Please sign in to comment.