UNPKG

@aws-amplify/datastore

Version:

AppSyncLocal support for aws-amplify

615 lines (529 loc) • 15.4 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, isTargetNameAssociation, isNonModelFieldType, ModelFields, ModelInstanceMetadata, OpType, PersistentModel, PersistentModelConstructor, PredicatesGroup, RelationshipType, SchemaModel, SchemaNamespace, SchemaNonModel, ModelOperation, InternalSchema, AuthModeStrategy, } from '../types'; import { exhaustiveCheck } 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: Omit<ModelInstanceMetadata, 'id'> = { _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)); } 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 (isSchemaModel(modelDefinition) && modelDefinition.attributes) { 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; } function getConnectionFields(modelDefinition: SchemaModel): string[] { 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 (isTargetNameAssociation(association)) { result.push(`${name} { id _deleted }`); } break; default: exhaustiveCheck(connectionType); } }); return result; } function getNonModelFields( namespace: SchemaNamespace, modelDefinition: SchemaModel | SchemaNonModel ): string[] { const result = []; 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 = []; 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 = [] .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 = [], } = rule; const isReadAuthorized = operations.includes('read'); const isOwnerAuth = authStrategy === 'owner'; if (!isReadAuthorized && !isOwnerAuth) { return; } const authRule: AuthorizationRule = { identityClaim, ownerField, provider, groupClaim, authStrategy, groups, areSubscriptionsPublic: false, }; if (isOwnerAuth) { // look for the subscription level override // only pay attention to the public level const modelConfig = (<typeof modelDefinition.attributes>[]) .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 ): [TransformerMutationType, string, string] { const selectionSet = generateSelectionSet(namespace, modelDefinition); const { name: typeName, pluralName: pluralTypeName } = modelDefinition; const opName = `on${transformerMutationType}${typeName}`; let docArgs = ''; let opArgs = ''; if (isOwnerAuthorization) { docArgs = `($${ownerField}: String!)`; opArgs = `(${ownerField}: $${ownerField})`; } return [ transformerMutationType, opName, `subscription operation${docArgs}{ ${opName}${opArgs}{ ${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: exhaustiveCheck(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: exhaustiveCheck(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 mutationEvent = modelInstanceCreator(MutationEventConstructor, { ...(id ? { id } : {}), data: JSON.stringify(element, replacer), modelId: element.id, model: model.name, operation, condition: JSON.stringify(condition), }); return mutationEvent; } export function predicateToGraphQLCondition( predicate: PredicatesGroup<any> ): GraphQLCondition { const result = {}; if (!predicate || !Array.isArray(predicate.predicates)) { return result; } predicate.predicates.forEach(p => { if (isPredicateObj(p)) { const { field, operator, operand } = p; if (field === 'id') { return; } result[field] = { [operator]: operand }; } else { result[p.type] = predicateToGraphQLCondition(p); } }); return result; } export function predicateToGraphQLFilter( predicatesGroup: PredicatesGroup<any> ): 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 appendToFilter = value => isList ? result[type].push(value) : (result[type] = value); predicates.forEach(predicate => { if (isPredicateObj(predicate)) { const { field, operator, operand } = predicate; const gqlField: GraphQLField = { [field]: { [operator]: operand }, }; appendToFilter(gqlField); return; } appendToFilter(predicateToGraphQLFilter(predicate)); }); return result; } 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}` ); } } }