Skip to content

Commit 623c02d

Browse files
feat(extensions): Add extensions namespace (#1960)
* 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 <[email protected]> * Update src/extensions/extensions.ts Co-authored-by: Kevin Cheung <[email protected]> * Update src/extensions/extensions.ts Co-authored-by: Kevin Cheung <[email protected]> * Update src/extensions/extensions.ts Co-authored-by: Kevin Cheung <[email protected]> * Docs pass * lint * Fix test Co-authored-by: Kevin Cheung <[email protected]> Co-authored-by: Kevin Cheung <[email protected]>
1 parent 9f4d23c commit 623c02d

10 files changed

+690
-0
lines changed

entrypoints.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
"typings": "./lib/database/index.d.ts",
2121
"dist": "./lib/database/index.js"
2222
},
23+
"firebase-admin/extensions": {
24+
"typings": "./lib/extensions/index.d.ts",
25+
"dist": "./lib/extensions/index.js"
26+
},
2327
"firebase-admin/firestore": {
2428
"typings": "./lib/firestore/index.d.ts",
2529
"dist": "./lib/firestore/index.js"

etc/firebase-admin.extensions.api.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
## API Report File for "firebase-admin.extensions"
2+
3+
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
4+
5+
```ts
6+
7+
/// <reference types="node" />
8+
9+
import { Agent } from 'http';
10+
11+
// @public
12+
export class Extensions {
13+
// Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts
14+
//
15+
// (undocumented)
16+
readonly app: App;
17+
// Warning: (ae-forgotten-export) The symbol "Runtime" needs to be exported by the entry point index.d.ts
18+
runtime(): Runtime;
19+
}
20+
21+
// @public
22+
export function getExtensions(app?: App): Extensions;
23+
24+
```

package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@
7474
"eventarc": [
7575
"lib/eventarc"
7676
],
77+
"extensions": [
78+
"lib/extensions"
79+
],
7780
"database": [
7881
"lib/database"
7982
],
@@ -136,6 +139,11 @@
136139
"require": "./lib/eventarc/index.js",
137140
"import": "./lib/esm/eventarc/index.js"
138141
},
142+
"./extensions": {
143+
"types": "./lib/extensions/index.d.ts",
144+
"require": "./lib/extensions/index.js",
145+
"import": "./lib/esm/extensions/index.js"
146+
},
139147
"./firestore": {
140148
"types": "./lib/firestore/index.d.ts",
141149
"require": "./lib/firestore/index.js",
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*!
2+
* @license
3+
* Copyright 2022 Google Inc.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { App } from '../app';
19+
import { FirebaseApp } from '../app/firebase-app';
20+
import { AuthorizedHttpClient, HttpClient, HttpError, HttpRequestConfig } from '../utils/api-request';
21+
import { FirebaseAppError, PrefixedFirebaseError } from '../utils/error';
22+
import * as validator from '../utils/validator';
23+
import * as utils from '../utils';
24+
25+
const FIREBASE_FUNCTIONS_CONFIG_HEADERS = {
26+
'X-Firebase-Client': `fire-admin-node/${utils.getSdkVersion()}`
27+
};
28+
const EXTENSIONS_API_VERSION = 'v1beta';
29+
// Note - use getExtensionsApiUri() instead so that changing environments is consistent.
30+
const EXTENSIONS_URL = 'https://firebaseextensions.googleapis.com';
31+
32+
/**
33+
* Class that facilitates sending requests to the Firebase Extensions backend API.
34+
*
35+
* @internal
36+
*/
37+
export class ExtensionsApiClient {
38+
private readonly httpClient: HttpClient;
39+
40+
constructor(private readonly app: App) {
41+
if (!validator.isNonNullObject(app) || !('options' in app)) {
42+
throw new FirebaseAppError(
43+
'invalid-argument',
44+
'First argument passed to getExtensions() must be a valid Firebase app instance.');
45+
}
46+
this.httpClient = new AuthorizedHttpClient(this.app as FirebaseApp);
47+
}
48+
49+
async updateRuntimeData(
50+
projectId: string,
51+
instanceId: string,
52+
runtimeData: RuntimeData
53+
): Promise<RuntimeDataResponse> {
54+
const url = this.getRuntimeDataUri(projectId, instanceId);
55+
const request: HttpRequestConfig = {
56+
method: 'PATCH',
57+
url,
58+
headers: FIREBASE_FUNCTIONS_CONFIG_HEADERS,
59+
data: runtimeData,
60+
};
61+
try {
62+
const res = await this.httpClient.send(request);
63+
return res.data
64+
} catch (err: any) {
65+
throw this.toFirebaseError(err);
66+
}
67+
}
68+
69+
private getExtensionsApiUri(): string {
70+
return process.env['FIREBASE_EXT_URL'] ?? EXTENSIONS_URL;
71+
}
72+
73+
private getRuntimeDataUri(projectId: string, instanceId: string): string {
74+
return `${
75+
this.getExtensionsApiUri()
76+
}/${EXTENSIONS_API_VERSION}/projects/${projectId}/instances/${instanceId}/runtimeData`;
77+
}
78+
79+
private toFirebaseError(err: HttpError): PrefixedFirebaseError {
80+
if (err instanceof PrefixedFirebaseError) {
81+
return err;
82+
}
83+
84+
const response = err.response;
85+
if (!response?.isJson()) {
86+
return new FirebaseExtensionsError(
87+
'unknown-error',
88+
`Unexpected response with status: ${response.status} and body: ${response.text}`);
89+
}
90+
const error = response.data?.error;
91+
const message = error?.message || `Unknown server error: ${response.text}`;
92+
switch (error.code) {
93+
case 403:
94+
return new FirebaseExtensionsError('forbidden', message);
95+
case 404:
96+
return new FirebaseExtensionsError('not-found', message);
97+
case 500:
98+
return new FirebaseExtensionsError('internal-error', message);
99+
}
100+
return new FirebaseExtensionsError('unknown-error', message);
101+
}
102+
}
103+
104+
interface RuntimeData {
105+
106+
//oneof
107+
processingState?: ProcessingState;
108+
fatalError?: FatalError;
109+
}
110+
111+
interface RuntimeDataResponse extends RuntimeData{
112+
name: string,
113+
updateTime: string,
114+
}
115+
116+
interface FatalError {
117+
errorMessage: string;
118+
}
119+
120+
interface ProcessingState {
121+
detailMessage: string;
122+
state: State;
123+
}
124+
125+
type State = 'STATE_UNSPECIFIED' |
126+
'NONE' |
127+
'PROCESSING' |
128+
'PROCESSING_COMPLETE' |
129+
'PROCESSING_WARNING' |
130+
'PROCESSING_FAILED';
131+
132+
type ExtensionsErrorCode = 'invalid-argument' | 'not-found' | 'forbidden' | 'internal-error' | 'unknown-error';
133+
/**
134+
* Firebase Extensions error code structure. This extends PrefixedFirebaseError.
135+
*
136+
* @param code - The error code.
137+
* @param message - The error message.
138+
* @constructor
139+
*/
140+
export class FirebaseExtensionsError extends PrefixedFirebaseError {
141+
constructor(code: ExtensionsErrorCode, message: string) {
142+
super('Extensions', code, message);
143+
144+
/* tslint:disable:max-line-length */
145+
// Set the prototype explicitly. See the following link for more details:
146+
// https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work
147+
/* tslint:enable:max-line-length */
148+
(this as any).__proto__ = FirebaseExtensionsError.prototype;
149+
}
150+
}

src/extensions/extensions-api.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*!
2+
* @license
3+
* Copyright 2022 Google Inc.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
/**
19+
* SettableProcessingState represents all the Processing states that can be set on an ExtensionInstance's runtimeData.
20+
*
21+
* - NONE: No relevant lifecycle event work has been done. Set this to clear out old statuses.
22+
* - PROCESSING_COMPLETE: Lifecycle event work completed with no errors.
23+
* - PROCESSING_WARNING: Lifecycle event work succeeded partially,
24+
* or something happened that the user should be warned about.
25+
* - PROCESSING_FAILED: Lifecycle event work failed completely,
26+
* but the instance will still work correctly going forward.
27+
* - If the extension instance is in a broken state due to the errors, instead set FatalError.
28+
*/
29+
export type SettableProcessingState = 'NONE' | 'PROCESSING_COMPLETE' | 'PROCESSING_WARNING' | 'PROCESSING_FAILED';

src/extensions/extensions.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/*!
2+
* @license
3+
* Copyright 2022 Google Inc.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { App } from '../app';
19+
import { SettableProcessingState } from './extensions-api';
20+
import { ExtensionsApiClient, FirebaseExtensionsError } from './extensions-api-client-internal';
21+
import * as validator from '../utils/validator';
22+
23+
/**
24+
* The Firebase `Extensions` service interface.
25+
*/
26+
export class Extensions {
27+
private readonly client: ExtensionsApiClient;
28+
/**
29+
* @param app - The app for this `Extensions` service.
30+
* @constructor
31+
* @internal
32+
*/
33+
constructor(readonly app: App) {
34+
this.client = new ExtensionsApiClient(app);
35+
}
36+
37+
/**
38+
* The runtime() method returns a new Runtime, which provides methods to modify an extension instance's runtime data.
39+
*
40+
* @returns A new Runtime object.
41+
*/
42+
public runtime(): Runtime {
43+
return new Runtime(this.client);
44+
}
45+
}
46+
47+
/**
48+
* Runtime provides methods to modify an extension instance's runtime data.
49+
*/
50+
class Runtime {
51+
private projectId: string;
52+
private extensionInstanceId: string;
53+
private readonly client: ExtensionsApiClient;
54+
/**
55+
* @param client - The API client for this `Runtime` service.
56+
* @constructor
57+
* @internal
58+
*/
59+
constructor(client: ExtensionsApiClient) {
60+
this.projectId = this.getProjectId();
61+
if (!validator.isNonEmptyString(process.env['EXT_INSTANCE_ID'])) {
62+
throw new FirebaseExtensionsError(
63+
'invalid-argument',
64+
'Runtime is only available from within a running Extension instance.'
65+
);
66+
}
67+
this.extensionInstanceId = process.env['EXT_INSTANCE_ID'];
68+
if (!validator.isNonNullObject(client) || !('updateRuntimeData' in client)) {
69+
throw new FirebaseExtensionsError(
70+
'invalid-argument',
71+
'Must provide a valid ExtensionsApiClient instance to create a new Runtime.');
72+
}
73+
this.client = client;
74+
}
75+
76+
/**
77+
* Sets the processing state of an extension instance.
78+
*
79+
* Use this method to report the results of a lifecycle event handler. If the
80+
* lifecycle event failed & the extension instance will no longer work
81+
* correctly, use `setFatalError` instead.
82+
*
83+
* @param state - The state to set the instance to.
84+
* @param detailMessage - A message explaining the results of the lifecycle function.
85+
*/
86+
public async setProcessingState(state: SettableProcessingState, detailMessage: string): Promise<void> {
87+
await this.client.updateRuntimeData(
88+
this.projectId,
89+
this.extensionInstanceId,
90+
{
91+
processingState: {
92+
state,
93+
detailMessage,
94+
},
95+
},
96+
);
97+
}
98+
99+
/**
100+
* Reports a fatal error while running a lifecycle event handler.
101+
*
102+
* Call this method when a lifecycle event handler fails in a way that makes
103+
* the Instance inoperable.
104+
* If the lifecycle event failed but the instance will still work as expected,
105+
* call `setProcessingState` with the "PROCESSING_WARNING" or
106+
* "PROCESSING_FAILED" state instead.
107+
*
108+
* @param errorMessage - A message explaining what went wrong and how to fix it.
109+
*/
110+
public async setFatalError(errorMessage: string): Promise<void> {
111+
if (!validator.isNonEmptyString(errorMessage)) {
112+
throw new FirebaseExtensionsError(
113+
'invalid-argument',
114+
'errorMessage must not be empty'
115+
);
116+
}
117+
await this.client.updateRuntimeData(
118+
this.projectId,
119+
this.extensionInstanceId,
120+
{
121+
fatalError: {
122+
errorMessage,
123+
},
124+
},
125+
);
126+
}
127+
128+
private getProjectId(): string {
129+
const projectId = process.env['PROJECT_ID'];
130+
if (!validator.isNonEmptyString(projectId)) {
131+
throw new FirebaseExtensionsError(
132+
'invalid-argument',
133+
'PROJECT_ID must not be undefined in Extensions runtime environment'
134+
);
135+
}
136+
return projectId;
137+
}
138+
}

0 commit comments

Comments
 (0)