Building a smart home sensor application with AWS AppSync and AWS Amplify components

Collecting sensor data such as temperature values is an example of smart home use cases. To display sensor data in real time, the use of AWS AppSync / GraphQL with its real-time message transmission capabilities is a good choice.

This blog article describes how to implement a GraphQL backend using AWS AppSync. An Angular-based web application is used as frontend. To integrate backend and frontend, the frontend library of AWS Amplify is added to the Angular application to easily implement authentication and GraphQL API calls.

Architecture

This diagram shows the high-level architecture of the application:

Architecture details:

  • ZigBee home automation sensors send temperature values to a local Raspberry PI. A Node-RED flow processes the sensor values. For this, the Node-RED plugin node-red-contrib-zigbee2mqtt listens for new sensor values. Then they will be sent to AWS IoT core using MQTT protocol.
  • In AWS, IoT core sends the sensor data to an SQS queue which will be processed by a Lambda function. The Lambda function transfers the data to AWS AppSync to enable real-time updates.
  • The frontend (Angular application) is published on S3 / CloudFront.
  • The client accesses the frontend, authenticates using Cognito and reads/saves data from the AppSync backend.

Project setup

Instead of using AWS Amplify for setting up the frontend and backend project, this application is build from scratch. This demonstrates that applications can be built easily when AWS Amplify is not the first choice because more flexibility in developing custom frontend or backend is requested.

This project uses AWS CDK as infrastructure as code solution. To maintain project configuration files efficiently, the project structure is generated using projen:

npx projen new awscdk-app-ts

The frontend project does not use projen because Angular is not a supported project type yet.

Part 1: Sensor configuration

First, CRUD operations to maintain sensors are added to file schema.graphql. The directive @aws_cognito_user_pools specifies that only users authenticated with AWS Cognito can access these queries and mutations.

type Query {
  getSensors: [ Sensor! ]
  @aws_cognito_user_pools(cognito_groups: ["Admin"])
}
type Mutation {
  addSensor(input: SensorInput!): Sensor
  @aws_cognito_user_pools(cognito_groups: ["Admin"])
  updateSensor(id: String!, input: SensorInput!): Sensor
  @aws_cognito_user_pools(cognito_groups: ["Admin"])
  deleteSensor(id: String!): Sensor
  @aws_cognito_user_pools(cognito_groups: ["Admin"])
}

File schema.graphql is referenced to define the GraphQL API in AWS CDK. In addition, the possible authentication options are defined here. In the first step, only AWS Cognito User Pools are used. Later, AWS IAM will also be used to access the GraphQL API using a Lambda function.

const api = new appsync.GraphqlApi(this, "Api", {
  name: "Smart Home API",
  schema: appsync.SchemaFile.fromAsset(
    path.join(__dirname, "../schema.graphql")
  ),
  authorizationConfig: {
    defaultAuthorization: {
      authorizationType: AuthorizationType.USER_POOL,
      userPoolConfig: {
        userPool,
      },
    },
    additionalAuthorizationModes: [
      {
        authorizationType: AuthorizationType.IAM,
      },
    ],
  },
  xrayEnabled: true,
  logConfig: {
    retention: RetentionDays.ONE_WEEK,
    fieldLogLevel: appsync.FieldLogLevel.ALL,
  },
});

Sensor configurations are store in a DynamoDB table. A DynamoDB data source is created for this table. For each query / mutation, a resolver is defined using the MappingTemplates provided by AWS CDK. These mapping templates can be used to easily define AppSync resolvers without writing custom resolvers. Operations like scan, query, getItem, putItem are supported. The configuration options for these resolvers are limited. However they are sufficient to develop the simple CRUD API.

const sensorDS = api.addDynamoDbDataSource(
  "sensorDataSource", sensorTable);
sensorDS.createResolver("QueryGetSensorsResolver", {
  typeName: "Query",
  fieldName: "getSensors",
  requestMappingTemplate:
    appsync.MappingTemplate.dynamoDbScanTable(),
  responseMappingTemplate:
    appsync.MappingTemplate.dynamoDbResultList(),
});
// ...

Setup AWS Amplify library

AWS Amplify provides capabilities to build full-stack applications. However, it is also possible to use only the Amplify libraries to integrate your own frontend with a backend hosted in AWS. For this, the dependency aws-amplify must be added to the Angular frontend. This project uses Cognito Hosted UI for authentication. After configuration Amplify in the frontend, methods like Auth.federatedSignIn() or Auth.signOut() can be executed to handle authentication. No special knowledge of login flows etc. is necessary.

The Amplify documentation explain how to use an existing Cognito user pool for authentication and an existing AppSync API. Parameters like Cognito user pool id or AppSync URL are required to configure the Amplify library.

These parameters are different for each environment (e.g. dev, prod). To avoid maintaining parameters for each environment in the frontend, a configuration file called frontend-config.json is written during the CDK deployment. This file is hosted in the same S3 bucket as the frontend. The Angular frontend can read the config file dynamically and uses the current values. No manual configuration for each environment is necessary.

new s3deploy.BucketDeployment(this, "DeployWithInvalidation", {
  sources: [
    s3deploy.Source.asset(
      "./frontend/dist/smart-home-frontend"),
    s3deploy.Source.jsonData("frontend-config.json", {
      frontendUrl:
        `https://${distribution.distributionDomainName}`,
      region: Stack.of(this).region,
      userPoolId: userPool.userPoolId,
      userPoolWebClientId: userPoolClient.userPoolClientId,
      cognitoDomain: `${userPoolDomain.domainName}.auth.${
        Stack.of(this).region
      }.amazoncognito.com`,
      appsyncEndpoint: api.graphqlUrl,
    }),
  ],
  destinationBucket: bucket,
  distribution,
});

In the Angular frontend, Amplify is configured in file app.module.ts. The parameter values are read from the config file and passed to function Amplify.configure().

const appInitializerFn =  (configService: FrontendConfigService) => {
  return () => {
    const loadedAppConfig = configService.loadAppConfig()
    loadedAppConfig.then(() => {
      const config = configService.getConfig();
      if (config) {
        Amplify.configure({
          Auth: {
            region: config.region,
            userPoolId: config.userPoolId,
            userPoolWebClientId: config.userPoolWebClientId,
            cookieStorage: {
              path: '/',
              expires: 30,
              domain: window.location.hostname,
              secure: true,
            },
            oauth: {
              domain: config.cognitoDomain,
              scope: [
                'phone',
                'email',
                'profile',
                'openid',
                'aws.cognito.signin.user.admin',
              ],
              redirectSignIn: config.frontendUrl,
              redirectSignOut: config.frontendUrl,
              responseType: 'code',
            },
          },
          aws_appsync_graphqlEndpoint:
            config.appsyncEndpoint,
          aws_appsync_region: config.region,
          aws_appsync_authenticationType:
            'AMAZON_COGNITO_USER_POOLS',
        });
      }
    });
    return loadedAppConfig;
  };
};

The code above is executed as part of an Angular app initializer.

providers: [
  FrontendConfigService,
  {
    provide: APP_INITIALIZER,
    useFactory: appInitializerFn,
    multi: true,
    deps: [FrontendConfigService]
  }
],

Reading the config file is implemented in FrontendConfigService.

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';

interface FrontendConfig {
  region: string;
  userPoolId: string;
  userPoolWebClientId: string;
  cognitoDomain: string;
  frontendUrl: string;
  appsyncEndpoint: string;
}

@Injectable({
  providedIn: 'root',
})
export class FrontendConfigService {
  private frontendConfig: FrontendConfig | undefined;

  constructor(private http: HttpClient) {}

  loadAppConfig() {
    return firstValueFrom(
      this.http.get<FrontendConfig>('/frontend-config.json')
    ).then((data) => {
      this.frontendConfig = data;
    });
  }

  getConfig() {
    return this.frontendConfig;
  }
}

For local frontend testing, the config file will be maintained with hard coded values in file src/frontend-config.json.

{
  "frontendUrl": "http://localhost:4200",
  "region": "eu-central-1",
  "userPoolId": "eu-central-1_BPw...",
  "userPoolWebClientId": "28red...",
  "cognitoDomain":"smart-home-dev.auth.eu-central-1.amazoncognito.com",
  "appsyncEndpoint": "https://qpl...5dy.appsync-api.eu-central-1.amazonaws.com/graphql"
}

To publish file src/frontend-config.json for local testing, it has to be added as asset in angular.json.

"architect": {
  "build": {
    "builder": "@angular-devkit/build-angular:browser",
    "options": {
      "assets": [
        "src/favicon.ico",
        "src/frontend-config.json",
        "src/assets"
      ],

Interacting with the GraphQL API

Amplify provides a useful code generation feature that generates an API service to call AppSync GraphQL APIs. Cognito authentication data is automatically added to the request which is a very nice feature. E.g. a new sensor can be added using the following code.

const api = new APIService();
await api.AddSensor({
  ieeeAddr: this.sensor.ieeeAddr,
  name: this.sensor.name,
});

To enable code generation, execute amplify codegen add.

? Choose the type of app that you're building javascript
? What javascript framework are you using angular
? Choose the code generation language target angular
? Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.graphql
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested] 2
? Enter the file name for the generated code src/app/API.service.ts
? Do you want to generate code for your newly created GraphQL API Yes
✔ Generated GraphQL operations successfully and saved at src/graphql
✔ Code generated successfully and saved in file src/app/API.service.ts

If the GraphQL schema is changed, execute amplify codegen to update the generated files.

Part 2: Process sensor values

New queries, mutation and subscriptions are added to the GraphQL schema to save and fetch sensor values. Mutation addValue is used by a Lambda function. The function does not use Cognito for authentication. Instead, directive @aws_iam sets the authentication to IAM for this mutation. Subscription addedValue is necessary to enable real-time capabilities in AppSync. Whenever mutation addValue is called, the new sensor values will be pushed to the frontend without fetching new values periodically.

type Query {
  getValues(id: String!): [ Value! ]
  @aws_cognito_user_pools(cognito_groups: ["Admin"])
}
type Mutation {
  addValue(id: String!, timestamp: String!,
    input: ValueInput!): Value
  @aws_iam
}
type Subscription {
  addedValue(id: String!): Value
  @aws_subscribe(mutations: ["addValue"]) @aws_cognito_user_pools(cognito_groups: ["Admin"])
}

Instead of using CDK mapping templates for creating resolvers, custom resolvers are possible. Query getValues uses a JavaScript resolver to pass options like scanIndexForward and limit.

import { util } from '@aws-appsync/utils';

export function request(ctx) {
    const { id } = ctx.args;
    return { 
        operation: 'Query',
        query: {
            expression: 'id = :id',
            expressionValues: util.dynamodb.toMapValues({ ':id': id }),
        },
        scanIndexForward: false,
        limit: 10,
    };
}

export function response(ctx) {
    return ctx.result.items;
}

In CDK this JavaScript resolver is referenced:

const getValuesFunc = valuesDS.createFunction("GetValuesFunction", {
  name: "getValues",
  runtime: appsync.FunctionRuntime.JS_1_0_0,
  code: appsync.Code.fromAsset("src/resolvers/getValues.js"),
});

const pipelineReqResCode = appsync.Code.fromInline(`
  export function request(ctx) {
    return {}
  }

  export function response(ctx) {
    return ctx.prev.result
  }
`);

new appsync.Resolver(this, "PipelineResolver", {
  api,
  typeName: "Query",
  fieldName: "getValues",
  code: pipelineReqResCode,
  runtime: appsync.FunctionRuntime.JS_1_0_0,
  pipelineConfig: [getValuesFunc],
});

To enable real-time data with GraphQL subscriptions, no additional configurations are necessary in the backend. It is sufficient to define the subscription in the GraphQL schema.

AWS AppSync and IAM authentication

When using IAM authentication to call an AppSync API, the API requests have to be signed. The Amplify documentation contains an example that explains the required steps. Even if the code does not use Amplify libraries, it is part of the Amplify documentation. So it’s worth taking a look at the Amplify documentation, even if you’re only concerned with AppSync.

Required AppSync permissions for the Lambda function can be set in AWS CDK.

api.grantMutation(sendSensorValuesFunction);

Result

A proof of concept of the application is implemented and available on GitHub. Sensor configurations can be displayed, created, updated and deleted.

An overview with the last sensor values can be displayed for each sensor. New sensor values are automatically displayed via the real-time capabilities of AWS AppSync without the need to reload the page.

AWS AppSync is a great tool for implementations of requirement like this. In combination with the AWS Amplify libraries, it is easy to integrate a frontend with the AWS AppSync backend. Especially the integration of authentication (Cognito user pools) with GraphQL API calls and the code generation feature is useful. Authentication information is automatically added to backend calls after a user has been authenticated in the frontend. Based on the GraphQL schema file it generates an API service class to interact with the GraphQL API methods.

The serverless GraphQL backend in AWS AppSync offers general cloud benefits such as high availability, scalability, etc. Furthermore, the different resolver types are very useful for the implementation of the backend. Resolver with basic functionalities can be defined using CDK mapping templates. More complex resolvers can be implemented by JavaScript resolvers or even Lambda resolvers. The different resolver types can be mixed within a GraphQL API. For example, simple resolvers can easily be replaced by complex lambda resolvers without having to adapt other API methods.

Summary

The application is successfully implemented and works as expected. AWS AppSync and the AWS Amplify libraries simplify building GraphQL applications on AWS. Building the backend and frontend from scratch allows maximum flexibility in using AWS backend services and implementing features in backend and frontend.


Posted

in

by

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *