UNPKG

@aws-amplify/datastore

Version:

AppSyncLocal support for aws-amplify

701 lines (697 loc) • 28.3 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports.RTFError = exports.TransformerMutationType = void 0; exports.getMetadataFields = getMetadataFields; exports.generateSelectionSet = generateSelectionSet; exports.getAuthorizationRules = getAuthorizationRules; exports.buildSubscriptionGraphQLOperation = buildSubscriptionGraphQLOperation; exports.buildGraphQLOperation = buildGraphQLOperation; exports.createMutationInstanceFromModelOperation = createMutationInstanceFromModelOperation; exports.predicateToGraphQLCondition = predicateToGraphQLCondition; exports.predicateToGraphQLFilter = predicateToGraphQLFilter; exports.filterFields = filterFields; exports.dynamicAuthFields = dynamicAuthFields; exports.countFilterCombinations = countFilterCombinations; exports.repeatedFieldInGroup = repeatedFieldInGroup; exports.generateRTFRemediation = generateRTFRemediation; exports.getUserGroupsFromToken = getUserGroupsFromToken; exports.getModelAuthModes = getModelAuthModes; exports.getForbiddenError = getForbiddenError; exports.resolveServiceErrorStatusCode = resolveServiceErrorStatusCode; exports.getClientSideAuthError = getClientSideAuthError; exports.getTokenForCustomAuth = getTokenForCustomAuth; exports.getIdentifierValue = getIdentifierValue; // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 const api_1 = require("@aws-amplify/api"); const core_1 = require("@aws-amplify/core"); const types_1 = require("../types"); const util_1 = require("../util"); const logger = new core_1.ConsoleLogger('DataStore'); const GraphQLOperationType = { LIST: 'query', CREATE: 'mutation', UPDATE: 'mutation', DELETE: 'mutation', GET: 'query', }; var TransformerMutationType; (function (TransformerMutationType) { TransformerMutationType["CREATE"] = "Create"; TransformerMutationType["UPDATE"] = "Update"; TransformerMutationType["DELETE"] = "Delete"; TransformerMutationType["GET"] = "Get"; })(TransformerMutationType || (exports.TransformerMutationType = TransformerMutationType = {})); const dummyMetadata = { _version: undefined, _lastChangedAt: undefined, _deleted: undefined, }; const metadataFields = Object.keys(dummyMetadata); function getMetadataFields() { return metadataFields; } function generateSelectionSet(namespace, modelDefinition) { const scalarFields = getScalarFields(modelDefinition); const nonModelFields = getNonModelFields(namespace, modelDefinition); const implicitOwnerField = getImplicitOwnerField(modelDefinition, scalarFields); let scalarAndMetadataFields = Object.values(scalarFields) .map(({ name }) => name) .concat(implicitOwnerField) .concat(nonModelFields); if ((0, types_1.isSchemaModel)(modelDefinition)) { scalarAndMetadataFields = scalarAndMetadataFields .concat(getMetadataFields()) .concat(getConnectionFields(modelDefinition, namespace)); } const result = scalarAndMetadataFields.join('\n'); return result; } function getImplicitOwnerField(modelDefinition, scalarFields) { const ownerFields = getOwnerFields(modelDefinition); if (!scalarFields.owner && ownerFields.includes('owner')) { return ['owner']; } return []; } function getOwnerFields(modelDefinition) { const ownerFields = []; if ((0, types_1.isSchemaModelWithAttributes)(modelDefinition)) { modelDefinition.attributes.forEach(attr => { if (attr.properties && attr.properties.rules) { const rule = attr.properties.rules.find(currentRule => currentRule.allow === 'owner'); if (rule && rule.ownerField) { ownerFields.push(rule.ownerField); } } }); } return ownerFields; } function getScalarFields(modelDefinition) { const { fields } = modelDefinition; const result = Object.values(fields) .filter(field => { if ((0, types_1.isGraphQLScalarType)(field.type) || (0, types_1.isEnumFieldType)(field.type)) { return true; } return false; }) .reduce((acc, field) => { acc[field.name] = field; return acc; }, {}); return result; } // Used for generating the selection set for queries and mutations function getConnectionFields(modelDefinition, namespace) { const result = []; Object.values(modelDefinition.fields) .filter(({ association }) => association && Object.keys(association).length) .forEach(({ name, association }) => { const { connectionType } = association || {}; switch (connectionType) { case 'HAS_ONE': case 'HAS_MANY': // Intentionally blank break; case 'BELONGS_TO': if ((0, types_1.isTargetNameAssociation)(association)) { // New codegen (CPK) if (association.targetNames && association.targetNames.length > 0) { // Need to retrieve relations in order to get connected model keys const [relations] = (0, util_1.establishRelationAndKeys)(namespace); const connectedModelName = modelDefinition.fields[name].type.model; const byPkIndex = relations[connectedModelName].indexes.find(([currentName]) => currentName === 'byPk'); const keyFields = byPkIndex && byPkIndex[1]; const keyFieldSelectionSet = keyFields?.join(' '); // We rely on `_deleted` when we process the sync query (e.g. in batchSave in the adapters) result.push(`${name} { ${keyFieldSelectionSet} _deleted }`); } else { // backwards-compatability for schema generated prior to custom primary key support result.push(`${name} { id _deleted }`); } } break; default: throw new Error(`Invalid connection type ${connectionType}`); } }); return result; } function getNonModelFields(namespace, modelDefinition) { const result = []; Object.values(modelDefinition.fields).forEach(({ name, type }) => { if ((0, types_1.isNonModelFieldType)(type)) { const typeDefinition = namespace.nonModels[type.nonModel]; const scalarFields = Object.values(getScalarFields(typeDefinition)).map(({ name: currentName }) => currentName); const nested = []; Object.values(typeDefinition.fields).forEach(field => { const { type: fieldType, name: fieldName } = field; if ((0, types_1.isNonModelFieldType)(fieldType)) { const nonModelTypeDefinition = namespace.nonModels[fieldType.nonModel]; nested.push(`${fieldName} { ${generateSelectionSet(namespace, nonModelTypeDefinition)} }`); } }); result.push(`${name} { ${scalarFields.join(' ')} ${nested.join(' ')} }`); } }); return result; } function getAuthorizationRules(modelDefinition) { // Searching for owner authorization on attributes const authConfig = [] .concat(modelDefinition.attributes || []) .find(attr => attr && attr.type === 'auth'); const { properties: { rules = [] } = {} } = authConfig || {}; const resultRules = []; // Multiple rules can be declared for allow: owner rules.forEach(rule => { // setting defaults for backwards compatibility with old cli const { identityClaim = 'cognito:username', ownerField = 'owner', operations = ['create', 'update', 'delete', 'read'], provider = 'userPools', groupClaim = 'cognito:groups', allow: authStrategy = 'iam', groups = [], groupsField = '', } = rule; const isReadAuthorized = operations.includes('read'); const isOwnerAuth = authStrategy === 'owner'; if (!isReadAuthorized && !isOwnerAuth) { return; } const authRule = { identityClaim, ownerField, provider, groupClaim, authStrategy, groups, groupsField, areSubscriptionsPublic: false, }; if (isOwnerAuth) { // look for the subscription level override // only pay attention to the public level const modelConfig = [] .concat(modelDefinition.attributes || []) .find(attr => attr && attr.type === 'model'); // find the subscriptions level. ON is default const { properties: { subscriptions: { level = 'on' } = {} } = {} } = modelConfig || {}; // treat subscriptions as public for owner auth with unprotected reads // when `read` is omitted from `operations` authRule.areSubscriptionsPublic = !operations.includes('read') || level === 'public'; } if (isOwnerAuth) { // owner rules has least priority resultRules.push(authRule); return; } resultRules.unshift(authRule); }); return resultRules; } function buildSubscriptionGraphQLOperation(namespace, modelDefinition, transformerMutationType, isOwnerAuthorization, ownerField, filterArg = false) { const selectionSet = generateSelectionSet(namespace, modelDefinition); const { name: typeName } = modelDefinition; const opName = `on${transformerMutationType}${typeName}`; const docArgs = []; const opArgs = []; if (filterArg) { docArgs.push(`$filter: ModelSubscription${typeName}FilterInput`); opArgs.push('filter: $filter'); } if (isOwnerAuthorization) { docArgs.push(`$${ownerField}: String!`); opArgs.push(`${ownerField}: $${ownerField}`); } const docStr = docArgs.length ? `(${docArgs.join(',')})` : ''; const opStr = opArgs.length ? `(${opArgs.join(',')})` : ''; return [ transformerMutationType, opName, `subscription operation${docStr}{ ${opName}${opStr}{ ${selectionSet} } }`, ]; } function buildGraphQLOperation(namespace, modelDefinition, graphQLOpType) { let selectionSet = generateSelectionSet(namespace, modelDefinition); const { name: typeName, pluralName: pluralTypeName } = modelDefinition; let operation; let documentArgs; let operationArgs; let transformerMutationType; switch (graphQLOpType) { case 'LIST': operation = `sync${pluralTypeName}`; documentArgs = `($limit: Int, $nextToken: String, $lastSync: AWSTimestamp, $filter: Model${typeName}FilterInput)`; operationArgs = '(limit: $limit, nextToken: $nextToken, lastSync: $lastSync, filter: $filter)'; selectionSet = `items { ${selectionSet} } nextToken startedAt`; break; case 'CREATE': operation = `create${typeName}`; documentArgs = `($input: Create${typeName}Input!)`; operationArgs = '(input: $input)'; transformerMutationType = TransformerMutationType.CREATE; break; case 'UPDATE': operation = `update${typeName}`; documentArgs = `($input: Update${typeName}Input!, $condition: Model${typeName}ConditionInput)`; operationArgs = '(input: $input, condition: $condition)'; transformerMutationType = TransformerMutationType.UPDATE; break; case 'DELETE': operation = `delete${typeName}`; documentArgs = `($input: Delete${typeName}Input!, $condition: Model${typeName}ConditionInput)`; operationArgs = '(input: $input, condition: $condition)'; transformerMutationType = TransformerMutationType.DELETE; break; case 'GET': operation = `get${typeName}`; documentArgs = `($id: ID!)`; operationArgs = '(id: $id)'; transformerMutationType = TransformerMutationType.GET; break; default: throw new Error(`Invalid graphQlOpType ${graphQLOpType}`); } return [ [ transformerMutationType, operation, `${GraphQLOperationType[graphQLOpType]} operation${documentArgs}{ ${operation}${operationArgs}{ ${selectionSet} } }`, ], ]; } function createMutationInstanceFromModelOperation(relationships, modelDefinition, opType, model, element, condition, MutationEventConstructor, modelInstanceCreator, id) { let operation; switch (opType) { case types_1.OpType.INSERT: operation = TransformerMutationType.CREATE; break; case types_1.OpType.UPDATE: operation = TransformerMutationType.UPDATE; break; case types_1.OpType.DELETE: operation = TransformerMutationType.DELETE; break; default: throw new Error(`Invalid opType ${opType}`); } // stringify nested objects of type AWSJSON // this allows us to return parsed JSON to users (see `castInstanceType()` in datastore.ts), // but still send the object correctly over the wire const replacer = (k, v) => { const isAWSJSON = k && v !== null && typeof v === 'object' && modelDefinition.fields[k] && modelDefinition.fields[k].type === 'AWSJSON'; if (isAWSJSON) { return JSON.stringify(v); } return v; }; const modelId = getIdentifierValue(modelDefinition, element); const optionalId = types_1.OpType.INSERT && id ? { id } : {}; const mutationEvent = modelInstanceCreator(MutationEventConstructor, { ...optionalId, data: JSON.stringify(element, replacer), modelId, model: model.name, operation: operation, condition: JSON.stringify(condition), }); return mutationEvent; } function predicateToGraphQLCondition(predicate, modelDefinition) { const result = {}; if (!predicate || !Array.isArray(predicate.predicates)) { return result; } // This is compatible with how the GQL Transform currently generates the Condition Input, // i.e. any PK and SK fields are omitted and can't be used as conditions. // However, I think this limits usability. // What if we want to delete all records where SK > some value // Or all records where PK = some value but SKs are different values // TODO: if the Transform gets updated we'll need to modify this logic to only omit // key fields from the predicate/condition when ALL of the keyFields are present and using `eq` operators const keyFields = (0, util_1.extractPrimaryKeyFieldNames)(modelDefinition); return predicateToGraphQLFilter(predicate, keyFields); } /** * @param predicatesGroup - Predicate Group @returns GQL Filter Expression from Predicate Group @remarks Flattens redundant list predicates @example ```js { and:[{ and:[{ username: { eq: 'bob' }}] }] } ``` Becomes ```js { and:[{ username: { eq: 'bob' }}] } ``` */ function predicateToGraphQLFilter(predicatesGroup, fieldsToOmit = [], root = true) { const result = {}; if (!predicatesGroup || !Array.isArray(predicatesGroup.predicates)) { return result; } const { type, predicates } = predicatesGroup; const isList = type === 'and' || type === 'or'; result[type] = isList ? [] : {}; const children = []; predicates.forEach(predicate => { if ((0, types_1.isPredicateObj)(predicate)) { const { field, operator, operand } = predicate; if (fieldsToOmit.includes(field)) return; const gqlField = { [field]: { [operator]: operand }, }; children.push(gqlField); return; } const child = predicateToGraphQLFilter(predicate, fieldsToOmit, false); if (Object.keys(child).length > 0) { children.push(child); } }); // flatten redundant list predicates if (children.length === 1) { const [child] = children; if ( // any nested list node (isList && !root) || // root list node where the only child is also a list node (isList && root && ('and' in child || 'or' in child))) { delete result[type]; Object.assign(result, child); return result; } } children.forEach(child => { if (isList) { result[type].push(child); } else { result[type] = child; } }); if (isList) { if (result[type].length === 0) return {}; } else { if (Object.keys(result[type]).length === 0) return {}; } return result; } /** * * @param group - selective sync predicate group * @returns set of distinct field names in the filter group */ function filterFields(group) { const fields = new Set(); if (!group || !Array.isArray(group.predicates)) return fields; const { predicates } = group; const stack = [...predicates]; while (stack.length > 0) { const current = stack.pop(); if ((0, types_1.isPredicateObj)(current)) { fields.add(current.field); } else if ((0, types_1.isPredicateGroup)(current)) { stack.push(...current.predicates); } } return fields; } /** * * @param modelDefinition * @returns set of field names used with dynamic auth modes configured for the provided model definition */ function dynamicAuthFields(modelDefinition) { const rules = getAuthorizationRules(modelDefinition); const fields = new Set(); for (const rule of rules) { if (rule.groupsField && !rule.groups.length) { // dynamic group rule will have no values in `rule.groups` fields.add(rule.groupsField); } else if (rule.ownerField) { fields.add(rule.ownerField); } } return fields; } /** * * @param group - selective sync predicate group * @returns the total number of OR'd predicates in the filter group * * @example returns 2 * ```js * { type: "or", predicates: [ * { field: "username", operator: "beginsWith", operand: "a" }, * { field: "title", operator: "contains", operand: "abc" }, * ]} * ``` */ function countFilterCombinations(group) { if (!group || !Array.isArray(group.predicates)) return 0; let count = 0; const stack = [group]; while (stack.length > 0) { const current = stack.pop(); if ((0, types_1.isPredicateGroup)(current)) { const { predicates, type } = current; // ignore length = 1; groups with 1 predicate will get flattened when converted to gqlFilter if (type === 'or' && predicates.length > 1) { count += predicates.length; } stack.push(...predicates); } } // if we didn't encounter any OR groups, default to 1 return count || 1; } /** * * @param group - selective sync predicate group * @returns name of repeated field | null * * @example returns "username" * ```js * { type: "and", predicates: [ * { field: "username", operator: "beginsWith", operand: "a" }, * { field: "username", operator: "contains", operand: "abc" }, * ] } * ``` */ function repeatedFieldInGroup(group) { if (!group || !Array.isArray(group.predicates)) return null; // convert to filter in order to flatten redundant groups const gqlFilter = predicateToGraphQLFilter(group); const stack = [gqlFilter]; const hasGroupRepeatedFields = (fields) => { const seen = {}; for (const f of fields) { const [fieldName] = Object.keys(f); if (seen[fieldName]) { return fieldName; } seen[fieldName] = true; } return null; }; while (stack.length > 0) { const current = stack.pop(); const [key] = Object.keys(current); const values = current[key]; if (!Array.isArray(values)) { return null; } // field value will be single object const predicateObjects = values.filter(v => !Array.isArray(Object.values(v)[0])); // group value will be an array const predicateGroups = values.filter(v => Array.isArray(Object.values(v)[0])); if (key === 'and') { const repeatedField = hasGroupRepeatedFields(predicateObjects); if (repeatedField) { return repeatedField; } } stack.push(...predicateGroups); } return null; } var RTFError; (function (RTFError) { RTFError[RTFError["UnknownField"] = 0] = "UnknownField"; RTFError[RTFError["MaxAttributes"] = 1] = "MaxAttributes"; RTFError[RTFError["MaxCombinations"] = 2] = "MaxCombinations"; RTFError[RTFError["RepeatedFieldname"] = 3] = "RepeatedFieldname"; RTFError[RTFError["NotGroup"] = 4] = "NotGroup"; RTFError[RTFError["FieldNotInType"] = 5] = "FieldNotInType"; })(RTFError || (exports.RTFError = RTFError = {})); function generateRTFRemediation(errorType, modelDefinition, predicatesGroup) { const selSyncFields = filterFields(predicatesGroup); const selSyncFieldStr = [...selSyncFields].join(', '); const dynamicAuthModeFields = dynamicAuthFields(modelDefinition); const dynamicAuthFieldsStr = [...dynamicAuthModeFields].join(', '); const filterCombinations = countFilterCombinations(predicatesGroup); const repeatedField = repeatedFieldInGroup(predicatesGroup); switch (errorType) { case RTFError.UnknownField: return (`Your API was generated with an older version of the CLI that doesn't support backend subscription filtering.` + 'To enable backend subscription filtering, upgrade your Amplify CLI to the latest version and push your app by running `amplify upgrade` followed by `amplify push`'); case RTFError.MaxAttributes: { let message = `Your selective sync expression for ${modelDefinition.name} contains ${selSyncFields.size} different model fields: ${selSyncFieldStr}.\n\n`; if (dynamicAuthModeFields.size > 0) { message += `Note: the number of fields you can use with selective sync is affected by @auth rules configured on the model.\n\n` + `Dynamic auth modes, such as owner auth and dynamic group auth each utilize 1 field.\n` + `You currently have ${dynamicAuthModeFields.size} dynamic auth mode(s) configured on this model: ${dynamicAuthFieldsStr}.`; } return message; } case RTFError.MaxCombinations: { let message = `Your selective sync expression for ${modelDefinition.name} contains ${filterCombinations} field combinations (total number of predicates in an OR expression).\n\n`; if (dynamicAuthModeFields.size > 0) { message += `Note: the number of fields you can use with selective sync is affected by @auth rules configured on the model.\n\n` + `Dynamic auth modes, such as owner auth and dynamic group auth factor in to the number of combinations you're using.\n` + `You currently have ${dynamicAuthModeFields.size} dynamic auth mode(s) configured on this model: ${dynamicAuthFieldsStr}.`; } return message; } case RTFError.RepeatedFieldname: return `Your selective sync expression for ${modelDefinition.name} contains multiple entries for ${repeatedField} in the same AND group.`; case RTFError.NotGroup: return (`Your selective sync expression for ${modelDefinition.name} uses a \`not\` group. If you'd like to filter subscriptions in the backend, ` + `rewrite your expression using \`ne\` or \`notContains\` operators.`); case RTFError.FieldNotInType: // no remediation instructions. We'll surface the message directly return ''; } } function getUserGroupsFromToken(token, rule) { // validate token against groupClaim let userGroups = token[rule.groupClaim] || []; if (typeof userGroups === 'string') { let parsedGroups; try { parsedGroups = JSON.parse(userGroups); } catch (e) { parsedGroups = userGroups; } userGroups = [].concat(parsedGroups); } return userGroups; } async function getModelAuthModes({ authModeStrategy, defaultAuthMode, modelName, schema, }) { const operations = Object.values(types_1.ModelOperation); const modelAuthModes = { CREATE: [], READ: [], UPDATE: [], DELETE: [], }; try { await Promise.all(operations.map(async (operation) => { const authModes = await authModeStrategy({ schema, modelName, operation, }); if (typeof authModes === 'string') { modelAuthModes[operation] = [authModes]; } else if (Array.isArray(authModes) && authModes.length) { modelAuthModes[operation] = authModes; } else { // Use default auth mode if nothing is returned from authModeStrategy modelAuthModes[operation] = [defaultAuthMode]; } })); } catch (error) { logger.debug(`Error getting auth modes for model: ${modelName}`, error); } return modelAuthModes; } function getForbiddenError(error) { const forbiddenErrorCodes = [401, 403]; let forbiddenError; if (error && error.errors) { forbiddenError = error.errors.find(err => forbiddenErrorCodes.includes(resolveServiceErrorStatusCode(err))); } else if (error && error.message) { forbiddenError = error; } if (forbiddenError) { return (forbiddenError.message ?? `Request failed with status code ${resolveServiceErrorStatusCode(forbiddenError)}`); } return null; } function resolveServiceErrorStatusCode(error) { if (error?.$metadata?.httpStatusCode) { return Number(error?.$metadata?.httpStatusCode); } else if (error?.originalError) { return resolveServiceErrorStatusCode(error?.originalError); } else { return null; } } function getClientSideAuthError(error) { const clientSideAuthErrors = Object.values(api_1.GraphQLAuthError); const clientSideError = error && error.message && clientSideAuthErrors.find(clientError => error.message.includes(clientError)); return clientSideError || null; } async function getTokenForCustomAuth(authMode, amplifyConfig = {}) { if (authMode === 'lambda') { const { authProviders: { functionAuthProvider } = { functionAuthProvider: null }, } = amplifyConfig; if (functionAuthProvider && typeof functionAuthProvider === 'function') { try { const { token } = await functionAuthProvider(); return token; } catch (error) { throw new Error(`Error retrieving token from \`functionAuthProvider\`: ${error}`); } } else { // TODO: add docs link once available throw new Error('You must provide a `functionAuthProvider` function to `DataStore.configure` when using lambda'); } } } // Util that takes a modelDefinition and model and returns either the id value(s) or the custom primary key value(s) function getIdentifierValue(modelDefinition, model) { const pkFieldNames = (0, util_1.extractPrimaryKeyFieldNames)(modelDefinition); const idOrPk = pkFieldNames.map(f => model[f]).join(util_1.IDENTIFIER_KEY_SEPARATOR); return idOrPk; } //# sourceMappingURL=utils.js.map