UNPKG

@aws-amplify/datastore

Version:

AppSyncLocal support for aws-amplify

932 lines (799 loc) • 25.5 kB
import { GRAPHQL_AUTH_MODE } from '@aws-amplify/api-graphql'; import { GraphQLAuthError } from '@aws-amplify/api'; import { Logger } from '@aws-amplify/core'; import { ModelInstanceCreator } from '../datastore/datastore'; import { AuthorizationRule, GraphQLCondition, GraphQLFilter, GraphQLField, isEnumFieldType, isGraphQLScalarType, isPredicateObj, isSchemaModel, isSchemaModelWithAttributes, isTargetNameAssociation, isNonModelFieldType, ModelFields, ModelInstanceMetadata, OpType, PersistentModel, PersistentModelConstructor, PredicatesGroup, PredicateObject, RelationshipType, SchemaModel, SchemaNamespace, SchemaNonModel, ModelOperation, InternalSchema, AuthModeStrategy, ModelAttributes, isPredicateGroup, } from '../types'; import { extractPrimaryKeyFieldNames, establishRelationAndKeys, IDENTIFIER_KEY_SEPARATOR, } from '../util'; import { MutationEvent } from './'; const logger = new Logger('DataStore'); enum GraphQLOperationType { LIST = 'query', CREATE = 'mutation', UPDATE = 'mutation', DELETE = 'mutation', GET = 'query', } export enum TransformerMutationType { CREATE = 'Create', UPDATE = 'Update', DELETE = 'Delete', GET = 'Get', } const dummyMetadata: ModelInstanceMetadata = { _version: undefined!, _lastChangedAt: undefined!, _deleted: undefined!, }; const metadataFields = <(keyof ModelInstanceMetadata)[]>( Object.keys(dummyMetadata) ); export function getMetadataFields(): ReadonlyArray<string> { return metadataFields; } export function generateSelectionSet( namespace: SchemaNamespace, modelDefinition: SchemaModel | SchemaNonModel ): string { 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 (isSchemaModel(modelDefinition)) { scalarAndMetadataFields = scalarAndMetadataFields .concat(getMetadataFields()) .concat(getConnectionFields(modelDefinition, namespace)); } const result = scalarAndMetadataFields.join('\n'); return result; } function getImplicitOwnerField( modelDefinition: SchemaModel | SchemaNonModel, scalarFields: ModelFields ) { const ownerFields = getOwnerFields(modelDefinition); if (!scalarFields.owner && ownerFields.includes('owner')) { return ['owner']; } return []; } function getOwnerFields( modelDefinition: SchemaModel | SchemaNonModel ): string[] { const ownerFields: string[] = []; if (isSchemaModelWithAttributes(modelDefinition)) { modelDefinition.attributes!.forEach(attr => { if (attr.properties && attr.properties.rules) { const rule = attr.properties.rules.find(rule => rule.allow === 'owner'); if (rule && rule.ownerField) { ownerFields.push(rule.ownerField); } } }); } return ownerFields; } function getScalarFields( modelDefinition: SchemaModel | SchemaNonModel ): ModelFields { const { fields } = modelDefinition; const result = Object.values(fields) .filter(field => { if (isGraphQLScalarType(field.type) || isEnumFieldType(field.type)) { return true; } return false; }) .reduce((acc, field) => { acc[field.name] = field; return acc; }, {} as ModelFields); return result; } // Used for generating the selection set for queries and mutations function getConnectionFields( modelDefinition: SchemaModel, namespace: SchemaNamespace ): string[] { const result: string[] = []; 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 (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] = establishRelationAndKeys(namespace); const connectedModelName = modelDefinition.fields[name].type['model']; const byPkIndex = relations[connectedModelName].indexes.find( ([name]) => name === '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: SchemaNamespace, modelDefinition: SchemaModel | SchemaNonModel ): string[] { const result: string[] = []; Object.values(modelDefinition.fields).forEach(({ name, type }) => { if (isNonModelFieldType(type)) { const typeDefinition = namespace.nonModels![type.nonModel]; const scalarFields = Object.values(getScalarFields(typeDefinition)).map( ({ name }) => name ); const nested: string[] = []; Object.values(typeDefinition.fields).forEach(field => { const { type, name } = field; if (isNonModelFieldType(type)) { const typeDefinition = namespace.nonModels![type.nonModel]; nested.push( `${name} { ${generateSelectionSet(namespace, typeDefinition)} }` ); } }); result.push(`${name} { ${scalarFields.join(' ')} ${nested.join(' ')} }`); } }); return result; } export function getAuthorizationRules( modelDefinition: SchemaModel ): AuthorizationRule[] { // Searching for owner authorization on attributes const authConfig = ([] as ModelAttributes) .concat(modelDefinition.attributes || []) .find(attr => attr && attr.type === 'auth'); const { properties: { rules = [] } = {} } = authConfig || {}; const resultRules: AuthorizationRule[] = []; // 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: AuthorizationRule = { 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 = ([] as ModelAttributes) .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; } export function buildSubscriptionGraphQLOperation( namespace: SchemaNamespace, modelDefinition: SchemaModel, transformerMutationType: TransformerMutationType, isOwnerAuthorization: boolean, ownerField: string, filterArg: boolean = false ): [TransformerMutationType, string, string] { const selectionSet = generateSelectionSet(namespace, modelDefinition); const { name: typeName } = modelDefinition; const opName = `on${transformerMutationType}${typeName}`; const docArgs: string[] = []; const opArgs: string[] = []; 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} } }`, ]; } export function buildGraphQLOperation( namespace: SchemaNamespace, modelDefinition: SchemaModel, graphQLOpType: keyof typeof GraphQLOperationType ): [TransformerMutationType, string, string][] { let selectionSet = generateSelectionSet(namespace, modelDefinition); const { name: typeName, pluralName: pluralTypeName } = modelDefinition; let operation: string; let documentArgs: string; let operationArgs: string; let transformerMutationType: 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} } }`, ], ]; } export function createMutationInstanceFromModelOperation< T extends PersistentModel >( relationships: RelationshipType, modelDefinition: SchemaModel, opType: OpType, model: PersistentModelConstructor<T>, element: T, condition: GraphQLCondition, MutationEventConstructor: PersistentModelConstructor<MutationEvent>, modelInstanceCreator: ModelInstanceCreator, id?: string ): MutationEvent { let operation: TransformerMutationType; switch (opType) { case OpType.INSERT: operation = TransformerMutationType.CREATE; break; case OpType.UPDATE: operation = TransformerMutationType.UPDATE; break; case 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 = 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; } export function predicateToGraphQLCondition( predicate: PredicatesGroup<any>, modelDefinition: SchemaModel ): GraphQLCondition { 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 = extractPrimaryKeyFieldNames(modelDefinition); return predicateToGraphQLFilter(predicate, keyFields) as GraphQLCondition; } /** * @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' }}] } ``` */ export function predicateToGraphQLFilter( predicatesGroup: PredicatesGroup<any>, fieldsToOmit: string[] = [], root = true ): GraphQLFilter { const result: GraphQLFilter = {}; if (!predicatesGroup || !Array.isArray(predicatesGroup.predicates)) { return result; } const { type, predicates } = predicatesGroup; const isList = type === 'and' || type === 'or'; result[type] = isList ? [] : {}; const children: GraphQLFilter[] = []; predicates.forEach(predicate => { if (isPredicateObj(predicate)) { const { field, operator, operand } = predicate; if (fieldsToOmit.includes(field as string)) return; const gqlField: GraphQLField = { [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 */ export function filterFields(group?: PredicatesGroup<any>): Set<string> { const fields = new Set<string>(); if (!group || !Array.isArray(group.predicates)) return fields; const { predicates } = group; const stack = [...predicates]; while (stack.length > 0) { const current = stack.pop(); if (isPredicateObj(current)) { fields.add(current.field as string); } else if (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 */ export function dynamicAuthFields(modelDefinition: SchemaModel): Set<string> { const rules = getAuthorizationRules(modelDefinition); const fields = new Set<string>(); for (const rule of rules) { if (rule.groupsField && !rule.groups.length) { // dynamic group rule will have no values in `rule.groups` fields.add((rule as AuthorizationRule).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" }, * ]} * ``` */ export function countFilterCombinations(group?: PredicatesGroup<any>): number { if (!group || !Array.isArray(group.predicates)) return 0; let count = 0; const stack: (PredicatesGroup<any> | PredicateObject<any>)[] = [group]; while (stack.length > 0) { const current = stack.pop(); if (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" }, * ] } * ``` */ export function repeatedFieldInGroup( group?: PredicatesGroup<any> ): string | null { if (!group || !Array.isArray(group.predicates)) return null; // convert to filter in order to flatten redundant groups const gqlFilter = predicateToGraphQLFilter(group); const stack: GraphQLFilter[] = [gqlFilter]; const hasGroupRepeatedFields = (fields: GraphQLFilter[]): string | null => { 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; } export enum RTFError { UnknownField, MaxAttributes, MaxCombinations, RepeatedFieldname, NotGroup, FieldNotInType, } export function generateRTFRemediation( errorType: RTFError, modelDefinition: SchemaModel, predicatesGroup: PredicatesGroup<any> | undefined ): string { 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 ''; } } export function getUserGroupsFromToken( token: { [field: string]: any }, rule: AuthorizationRule ): string[] { // validate token against groupClaim let userGroups: string[] | string = token[rule.groupClaim] || []; if (typeof userGroups === 'string') { let parsedGroups; try { parsedGroups = JSON.parse(userGroups); } catch (e) { parsedGroups = userGroups; } userGroups = [].concat(parsedGroups); } return userGroups; } export async function getModelAuthModes({ authModeStrategy, defaultAuthMode, modelName, schema, }: { authModeStrategy: AuthModeStrategy; defaultAuthMode: GRAPHQL_AUTH_MODE; modelName: string; schema: InternalSchema; }): Promise<{ [key in ModelOperation]: GRAPHQL_AUTH_MODE[]; }> { const operations = Object.values(ModelOperation); const modelAuthModes: { [key in ModelOperation]: GRAPHQL_AUTH_MODE[]; } = { 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; } export function getForbiddenError(error) { const forbiddenErrorMessages = [ 'Request failed with status code 401', 'Request failed with status code 403', ]; let forbiddenError; if (error && error.errors) { forbiddenError = (error.errors as [any]).find(err => forbiddenErrorMessages.includes(err.message) ); } else if (error && error.message) { forbiddenError = error; } if (forbiddenError) { return forbiddenError.message; } return null; } export function getClientSideAuthError(error) { const clientSideAuthErrors = Object.values(GraphQLAuthError); const clientSideError = error && error.message && clientSideAuthErrors.find(clientError => error.message.includes(clientError) ); return clientSideError || null; } export async function getTokenForCustomAuth( authMode: GRAPHQL_AUTH_MODE, amplifyConfig: Record<string, any> = {} ): Promise<string | undefined> { if (authMode === GRAPHQL_AUTH_MODE.AWS_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 ${GRAPHQL_AUTH_MODE.AWS_LAMBDA}` ); } } } // Util that takes a modelDefinition and model and returns either the id value(s) or the custom primary key value(s) export function getIdentifierValue( modelDefinition: SchemaModel, model: ModelInstanceMetadata | PersistentModel ): string { const pkFieldNames = extractPrimaryKeyFieldNames(modelDefinition); const idOrPk = pkFieldNames.map(f => model[f]).join(IDENTIFIER_KEY_SEPARATOR); return idOrPk; }