@aws-amplify/datastore
Version:
AppSyncLocal support for aws-amplify
380 lines (377 loc) • 23 kB
JavaScript
import { InternalAPI } from '@aws-amplify/api/internals';
import { ConsoleLogger, fetchAuthSession, Hub } from '@aws-amplify/core';
import { BackgroundProcessManager, DataStoreAction, Category } from '@aws-amplify/core/internals/utils';
import { Observable } from 'rxjs';
import { CONTROL_MSG as CONTROL_MSG$1 } from '@aws-amplify/api-graphql';
import { ProcessName } from '../../types.mjs';
import { buildSubscriptionGraphQLOperation, getAuthorizationRules, getUserGroupsFromToken, getModelAuthModes, TransformerMutationType, RTFError, generateRTFRemediation, getTokenForCustomAuth, predicateToGraphQLFilter } from '../utils.mjs';
import { ModelPredicateCreator } from '../../predicates/index.mjs';
import { validatePredicate } from '../../util.mjs';
import { getSubscriptionErrorType } from './errorMaps.mjs';
const logger = new ConsoleLogger('DataStore');
var CONTROL_MSG;
(function (CONTROL_MSG) {
CONTROL_MSG["CONNECTED"] = "CONNECTED";
})(CONTROL_MSG || (CONTROL_MSG = {}));
var USER_CREDENTIALS;
(function (USER_CREDENTIALS) {
USER_CREDENTIALS[USER_CREDENTIALS["none"] = 0] = "none";
USER_CREDENTIALS[USER_CREDENTIALS["unauth"] = 1] = "unauth";
USER_CREDENTIALS[USER_CREDENTIALS["auth"] = 2] = "auth";
})(USER_CREDENTIALS || (USER_CREDENTIALS = {}));
class SubscriptionProcessor {
constructor(schema, syncPredicates, amplifyConfig = {}, authModeStrategy, errorHandler, amplifyContext = {
InternalAPI,
}) {
this.schema = schema;
this.syncPredicates = syncPredicates;
this.amplifyConfig = amplifyConfig;
this.authModeStrategy = authModeStrategy;
this.errorHandler = errorHandler;
this.amplifyContext = amplifyContext;
this.typeQuery = new WeakMap();
this.buffer = [];
this.runningProcesses = new BackgroundProcessManager();
}
buildSubscription(namespace, model, transformerMutationType, userCredentials, oidcTokenPayload, authMode, filterArg = false) {
const { aws_appsync_authenticationType } = this.amplifyConfig;
const { isOwner, ownerField, ownerValue } = this.getAuthorizationInfo(model, userCredentials, aws_appsync_authenticationType, oidcTokenPayload, authMode) || {};
const [opType, opName, query] = buildSubscriptionGraphQLOperation(namespace, model, transformerMutationType, isOwner, ownerField, filterArg);
return { authMode, opType, opName, query, isOwner, ownerField, ownerValue };
}
getAuthorizationInfo(model, userCredentials, defaultAuthType, oidcTokenPayload, authMode) {
const rules = getAuthorizationRules(model);
// Return null if user doesn't have proper credentials for private API with IAM auth
const iamPrivateAuth = authMode === 'iam' &&
rules.find(rule => rule.authStrategy === 'private' && rule.provider === 'iam');
if (iamPrivateAuth && userCredentials === USER_CREDENTIALS.unauth) {
return null;
}
// Group auth should take precedence over owner auth, so we are checking
// if rule(s) have group authorization as well as if either the Cognito or
// OIDC token has a groupClaim. If so, we are returning auth info before
// any further owner-based auth checks.
const groupAuthRules = rules.filter(rule => rule.authStrategy === 'groups' &&
['userPools', 'oidc'].includes(rule.provider));
const validGroup = (authMode === 'oidc' || authMode === 'userPool') &&
// eslint-disable-next-line array-callback-return
groupAuthRules.find(groupAuthRule => {
// validate token against groupClaim
if (oidcTokenPayload) {
const oidcUserGroups = getUserGroupsFromToken(oidcTokenPayload, groupAuthRule);
return [...oidcUserGroups].find(userGroup => {
return groupAuthRule.groups.find(group => group === userGroup);
});
}
});
if (validGroup) {
return {
authMode,
isOwner: false,
};
}
let ownerAuthInfo;
if (ownerAuthInfo) {
return ownerAuthInfo;
}
// Owner auth needs additional values to be returned in order to create the subscription with
// the correct parameters so we are getting the owner value from the OIDC token via the
// identityClaim from the auth rule.
const oidcOwnerAuthRules = authMode === 'oidc' || authMode === 'userPool'
? rules.filter(rule => rule.authStrategy === 'owner' &&
(rule.provider === 'oidc' || rule.provider === 'userPools'))
: [];
oidcOwnerAuthRules.forEach(ownerAuthRule => {
const ownerValue = oidcTokenPayload?.[ownerAuthRule.identityClaim];
const singleOwner = model.fields[ownerAuthRule.ownerField]?.isArray !== true;
const isOwnerArgRequired = singleOwner && !ownerAuthRule.areSubscriptionsPublic;
if (ownerValue) {
ownerAuthInfo = {
authMode,
isOwner: isOwnerArgRequired,
ownerField: ownerAuthRule.ownerField,
ownerValue: String(ownerValue),
};
}
});
if (ownerAuthInfo) {
return ownerAuthInfo;
}
// Fallback: return authMode or default auth type
return {
authMode: authMode || defaultAuthType,
isOwner: false,
};
}
hubQueryCompletionListener(completed, capsule) {
const { payload: { event }, } = capsule;
if (event === CONTROL_MSG$1.SUBSCRIPTION_ACK) {
completed();
}
}
start() {
this.runningProcesses =
this.runningProcesses || new BackgroundProcessManager();
const ctlObservable = new Observable(observer => {
const promises = [];
// Creating subs for each model/operation combo so they can be unsubscribed
// independently, since the auth retry behavior is asynchronous.
let subscriptions = {};
let oidcTokenPayload;
let userCredentials = USER_CREDENTIALS.none;
this.runningProcesses.add(async () => {
try {
// retrieving current AWS Credentials
const credentials = (await fetchAuthSession()).tokens?.accessToken;
userCredentials = credentials
? USER_CREDENTIALS.auth
: USER_CREDENTIALS.unauth;
}
catch (err) {
// best effort to get AWS credentials
}
try {
// retrieving current token info from Cognito UserPools
const session = await fetchAuthSession();
oidcTokenPayload = session.tokens?.idToken?.payload;
}
catch (err) {
// best effort to get jwt from Cognito
}
Object.values(this.schema.namespaces).forEach(namespace => {
Object.values(namespace.models)
.filter(({ syncable }) => syncable)
.forEach(modelDefinition => this.runningProcesses.isOpen &&
this.runningProcesses.add(async () => {
const modelAuthModes = await getModelAuthModes({
authModeStrategy: this.authModeStrategy,
defaultAuthMode: this.amplifyConfig.aws_appsync_authenticationType,
modelName: modelDefinition.name,
schema: this.schema,
});
// subscriptions are created only based on the READ auth mode(s)
const readAuthModes = modelAuthModes.READ;
subscriptions = {
...subscriptions,
[modelDefinition.name]: {
[TransformerMutationType.CREATE]: [],
[TransformerMutationType.UPDATE]: [],
[TransformerMutationType.DELETE]: [],
},
};
const operations = [
TransformerMutationType.CREATE,
TransformerMutationType.UPDATE,
TransformerMutationType.DELETE,
];
const operationAuthModeAttempts = {
[TransformerMutationType.CREATE]: 0,
[TransformerMutationType.UPDATE]: 0,
[TransformerMutationType.DELETE]: 0,
};
const predicatesGroup = ModelPredicateCreator.getPredicates(this.syncPredicates.get(modelDefinition), false);
const addFilterArg = predicatesGroup !== undefined;
// Retry subscriptions that failed for one of the following reasons:
// 1. unauthorized - retry with next auth mode (if available)
// 2. RTF error - retry without sending filter arg. (filtering will fall back to clientside)
const subscriptionRetry = async (operation, addFilter = addFilterArg) => {
const { opType: transformerMutationType, opName, query, isOwner, ownerField, ownerValue, authMode, } = this.buildSubscription(namespace, modelDefinition, operation, userCredentials, oidcTokenPayload, readAuthModes[operationAuthModeAttempts[operation]], addFilter);
const authToken = await getTokenForCustomAuth(authMode, this.amplifyConfig);
const variables = {};
const customUserAgentDetails = {
category: Category.DataStore,
action: DataStoreAction.Subscribe,
};
if (addFilter && predicatesGroup) {
variables.filter =
predicateToGraphQLFilter(predicatesGroup);
}
if (isOwner) {
if (!ownerValue) {
observer.error('Owner field required, sign in is needed in order to perform this operation');
return;
}
variables[ownerField] = ownerValue;
}
logger.debug(`Attempting ${operation} subscription with authMode: ${readAuthModes[operationAuthModeAttempts[operation]]}`);
const queryObservable = this.amplifyContext.InternalAPI.graphql({
query,
variables,
...{ authMode },
authToken,
}, undefined, customUserAgentDetails);
let subscriptionReadyCallback;
// TODO: consider onTerminate.then(() => API.cancel(...))
subscriptions[modelDefinition.name][transformerMutationType].push(queryObservable.subscribe({
next: result => {
const { data, errors } = result;
if (Array.isArray(errors) && errors.length > 0) {
const messages = errors.map(({ message }) => message);
logger.warn(`Skipping incoming subscription. Messages: ${messages.join('\n')}`);
this.drainBuffer();
return;
}
const resolvedPredicatesGroup = ModelPredicateCreator.getPredicates(this.syncPredicates.get(modelDefinition), false);
const { [opName]: record } = data;
// checking incoming subscription against syncPredicate.
// once AppSync implements filters on subscriptions, we'll be
// able to set these when establishing the subscription instead.
// Until then, we'll need to filter inbound
if (this.passesPredicateValidation(record, resolvedPredicatesGroup)) {
this.pushToBuffer(transformerMutationType, modelDefinition, record);
}
this.drainBuffer();
},
error: async (subscriptionError) => {
const { errors: [{ message = '' } = {}], } = (subscriptionError);
const isRTFError =
// only attempt catch if a filter variable was added to the subscription query
addFilter &&
this.catchRTFError(message, modelDefinition, predicatesGroup);
// Catch RTF errors
if (isRTFError) {
// Unsubscribe and clear subscription array for model/operation
subscriptions[modelDefinition.name][transformerMutationType].forEach(subscription => subscription.unsubscribe());
subscriptions[modelDefinition.name][transformerMutationType] = [];
// retry subscription connection without filter
subscriptionRetry(operation, false);
return;
}
if (message.includes(CONTROL_MSG$1.REALTIME_SUBSCRIPTION_INIT_ERROR) ||
message.includes(CONTROL_MSG$1.CONNECTION_FAILED)) {
// Unsubscribe and clear subscription array for model/operation
subscriptions[modelDefinition.name][transformerMutationType].forEach(subscription => subscription.unsubscribe());
subscriptions[modelDefinition.name][transformerMutationType] = [];
operationAuthModeAttempts[operation]++;
if (operationAuthModeAttempts[operation] >=
readAuthModes.length) {
// last auth mode retry. Continue with error
logger.debug(`${operation} subscription failed with authMode: ${readAuthModes[operationAuthModeAttempts[operation] - 1]}`);
}
else {
// retry with different auth mode. Do not trigger
// observer error or error handler
logger.debug(`${operation} subscription failed with authMode: ${readAuthModes[operationAuthModeAttempts[operation] - 1]}. Retrying with authMode: ${readAuthModes[operationAuthModeAttempts[operation]]}`);
subscriptionRetry(operation);
return;
}
}
logger.warn('subscriptionError', message);
try {
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
await this.errorHandler({
recoverySuggestion: 'Ensure app code is up to date, auth directives exist and are correct on each model, and that server-side data has not been invalidated by a schema change. If the problem persists, search for or create an issue: https://github.com/aws-amplify/amplify-js/issues',
localModel: null,
message,
model: modelDefinition.name,
operation,
errorType: getSubscriptionErrorType(subscriptionError),
process: ProcessName.subscribe,
remoteModel: null,
cause: subscriptionError,
});
}
catch (e) {
logger.error('Subscription error handler failed with:', e);
}
if (typeof subscriptionReadyCallback === 'function') {
subscriptionReadyCallback();
}
if (message.includes('"errorType":"Unauthorized"') ||
message.includes('"errorType":"OperationDisabled"')) {
return;
}
observer.error(message);
},
}));
promises.push((async () => {
let boundFunction;
let removeBoundFunctionListener;
await new Promise(resolve => {
subscriptionReadyCallback = resolve;
boundFunction = this.hubQueryCompletionListener.bind(this, resolve);
removeBoundFunctionListener = Hub.listen('api', boundFunction);
});
removeBoundFunctionListener();
})());
};
operations.forEach(op => subscriptionRetry(op));
}));
});
this.runningProcesses.isOpen &&
this.runningProcesses.add(() => Promise.all(promises).then(() => {
observer.next(CONTROL_MSG.CONNECTED);
}));
}, 'subscription processor new subscriber');
return this.runningProcesses.addCleaner(async () => {
Object.keys(subscriptions).forEach(modelName => {
subscriptions[modelName][TransformerMutationType.CREATE].forEach(subscription => {
subscription.unsubscribe();
});
subscriptions[modelName][TransformerMutationType.UPDATE].forEach(subscription => {
subscription.unsubscribe();
});
subscriptions[modelName][TransformerMutationType.DELETE].forEach(subscription => {
subscription.unsubscribe();
});
});
});
});
const dataObservable = new Observable(observer => {
this.dataObserver = observer;
this.drainBuffer();
return this.runningProcesses.addCleaner(async () => {
this.dataObserver = null;
});
});
return [ctlObservable, dataObservable];
}
async stop() {
await this.runningProcesses.close();
await this.runningProcesses.open();
}
passesPredicateValidation(record, predicatesGroup) {
if (!predicatesGroup) {
return true;
}
const { predicates, type } = predicatesGroup;
return validatePredicate(record, type, predicates);
}
pushToBuffer(transformerMutationType, modelDefinition, data) {
this.buffer.push([transformerMutationType, modelDefinition, data]);
}
drainBuffer() {
if (this.dataObserver) {
this.buffer.forEach(data => {
this.dataObserver.next(data);
});
this.buffer = [];
}
}
/**
* @returns true if the service returned an RTF subscription error
* @remarks logs a warning with remediation instructions
*
*/
catchRTFError(message, modelDefinition, predicatesGroup) {
const header = 'Backend subscriptions filtering error.\n' +
'Subscriptions filtering will be applied clientside.\n';
const messageErrorTypeMap = {
'UnknownArgument: Unknown field argument filter': RTFError.UnknownField,
'Filters exceed maximum attributes limit': RTFError.MaxAttributes,
'Filters combination exceed maximum limit': RTFError.MaxCombinations,
'filter uses same fieldName multiple time': RTFError.RepeatedFieldname,
"The variables input contains a field name 'not'": RTFError.NotGroup,
'The variables input contains a field that is not defined for input object type': RTFError.FieldNotInType,
};
const [_errorMsg, errorType] = Object.entries(messageErrorTypeMap).find(([errorMsg]) => message.includes(errorMsg)) || [];
if (errorType !== undefined) {
const remediationMessage = generateRTFRemediation(errorType, modelDefinition, predicatesGroup);
logger.warn(`${header}\n${message}\n${remediationMessage}`);
return true;
}
return false;
}
}
export { CONTROL_MSG, SubscriptionProcessor, USER_CREDENTIALS };
//# sourceMappingURL=subscription.mjs.map