Skip to content

omnyway-labs/amplitude

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

56 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Amplifying Clojurescript on AWS. amplitude provides a set of utilities to work with core amplify-js modules, seemlessly.

Getting Started

Add following to deps.edn or your cljs deps file

{:deps {omnyway-labs/amplitude
        {:git/url "https://github.com/omnyway-labs/amplitude.git"
         :sha "016274a2086818b1a540adc13f4fdf22b7021b5b"}}}

Install amplify-cli and get started https://docs.amplify.aws/cli/start/install

This library expects the user to have basic familiarity in getting started with AWS Amplify. It also expects a local src/aws-exports.js file to load and invoke the APIs. The aws-exports.js file contains the resource identifiers, urls and keys needed to invoke Graphql queries, storage requests etc.

API

Config

amplitude.config provides an utility function to initialize the aws-exports.js config file. Initialize the config before invoking any other amplitude API

Typically, the aws-exports.js file looks something like this

const awsmobile = {
    "aws_project_region": "us-east-1",
    "aws_appsync_graphqlEndpoint": "http://localhost:20002/graphql",
    "aws_appsync_region": "us-east-1",
    "aws_appsync_authenticationType": "API_KEY",
    "aws_appsync_apiKey": "da2-fakeApiId123456"
};
export default awsmobile;
(:require
  [amplitude.config :as config]
  ["/aws-exports.js" :default awsmobile])
(config/init! awsmobile)

;; and elsewhere
(config/lookup)
;; => returns the aws-exports.js config as edn

AppSync GraphQL APIs

amplitude.graphql provides abstractions to query, mutate and search Appsync backends via graphql. The backends can be anything - HTTP server, DynamoDB, RDS, Lambda etc.

(require [amplitude.api.graphql :as gql])
(gql/init!)

Let us take the classic Shopper Cart example. The following is a Graphql Schema defining the Cart and Payment models. The @model directive implies that the defined type has a corresponding backend table - by default it is DynamoDB. AWS AppSync knows how to resolve these directives.

type LineItem {
  name: String
  quantity: Int
  price: Int
}

type Cart @model{
  id: ID!
  shopper_id: String!
  line_items: [LineItem]
  payment: Payment @connection
}

type Payment @model{
  id: ID!
  card: Card @connection
  amount: Int
  status: PaymentStatus!
}

type Card @model{
  id: ID!
  name: String
}

type subscription {
  subscribeCart(id:String): Cart
  @aws_subscribe(mutations: ["updateCart"])
}

The above schema can be deployed via amplify-cli. Deloying the schema is beyond the scope of this library. Please see https://docs.amplify.aws/cli/graphql-transformer/overview#create-a-graphql-api

The following set of examples demonstrate querying, mutating and subscribing to those mutations using the above Schema. While amplify-cli provides a mechanism to generate js/typescript code, the generated code is non-performant when there are deep graphql connections. Amplitude generates the graphql handler code dynamically during request-time based on the shape of data specified. shape has no limitation on the depth of the graph and can be shallow.

To create a cart record, we just do:

(gql/create! :cart
             {:input {:line-items items
                      :shopper-id shopper-id}
              :shape [:id]
              :on-create (fn [{:keys [id]}]
                          (gql/subscribe! :cart
                                          {:input {:id :id}
                                           :on-change #(rf/dispatch ::events/cart %)}))})

Notice that in the on-create callback function, we subscribe to the cart changes. The on-change function passed as arg to gql/subscribe! gets called whenever the cart is updated. The on-change callback-fn, for example, could use reframe’s dispatch and mutate the appdb.

Let us say we have a pay button, when clicked, needs to trigger some Payment processing business logic - perhaps in a secure cloud or VPC. Appsync resolves this mutation to any defined Resolver (HTTP API, Lambda, RDS or DynamoDB). The following defines the pay mutation to be resolved via a Lambda called “payment-processor”

type mutation {
  pay(
    cart_id: String!
    card_id: String!
    amount: Int
  ): String @function(name: "payment-processor")

In the above schema, the pay mutation has a @function directive which defines the backend resolver for this mutation. We assume here that “payment-processor” AWS Lambda is already provisioned and deployed. The function name can be suffixed with a “-{env}” template variable too, if needed. Okay, let us now trigger “pay”.

(gql/resolve! :pay
              {:input-schema {:cart-id :String
                              :card-id :String
                              :amount :Int}
               :input        {:cart-id "xxx"
                              :card-id "card-123"
                              :amount 100}
               :on-resolve   (fn [record] (rf/dispatch :cart %))})

resolve! invokes the graphql resolver via Appsync and executes the payment-processor Lambda. The Mutator could create a payment record and associate the payment-id with the cart. Assuming we are running cljs+amplitude in the lambda, we could do the following in the Lambda function

(gql/create! :payment
             {:input     {:cart-id "xxx"
                          :card-id* "card123"}
              :on-create (fn [{:keys [id]}]
                           (gql/update! :cart
                                        {:input     {:id          cart-id
                                                     :payment-id* id}
                                         :on-update log/info}))})

In the above code, the backend lambda process creates a payment record and in the on-create callback-fn it updates the cart with the payment-id. payment-id* is syntactic sugar to denote a connection to a payment type/record. Notice in the Cart type, we do not have an explicit payment-id field.

The payment-processor lambda gets an input event that looks something like this

{"arguments": {"card-id": "card-123", "cart-id" "xxx", "amount": 100},
 "fieldName": "pay"}

Having these mutations be resolved via tiny Lambda processes (in any language) makes it easier to write bite-sized business-logic code or mutations in an efficient way.

Meanwhile, we have the frontend cljs app subscribe to updates on the cart. When the payment-processor lambda mutates the cart, the subscription handler-fn gets invoked. Subscriptions are basically websocket connections for specific changes to the subscribed entity.

To list the current subscriptions:

(gql/list-subs)
=> [{:status :ready :sub-id :subscribe-cart}]

To unsubscribe from the subscription, say on a delete operation:

(gql/unsubscribe! :subscribe-cart)

amplitude also provides idiomatic APIs to search and filter. The simplest form is gql/list

(gql/list :payment
          {:filter {:cart-id {:eq "cart1"}}
           :shape [:id [:card [:name]]]
           :limit 100
           :on-list #(rf/dispatch ::to-some-fx records)})

Notice that filter takes a map that supports most graphql filters (eq, contains, between, starts-with, and, or etc). Filters are clojure maps with prefix operators.

amplitude also supports search using Global Secondary Indexes(GSI). For example, let us extend the Cart model to include a GSI on shopper-id

type Cart @model
@key(
  name: "shopperCarts",
  fields: ["shopper_id", "createdAt"],
  queryField: "cartsByShopper"
)
{
  id: ID!
  shopper_id: String!
  line_items: [LineItem]
  payment: Payment @connection
  tax: Int
  total: Int
  createdAt: String!
}

The @key directive defines GSI with a key and a sort-key. In this case, the sort-key is createdAt. createdAt and updatedAt are auto-filled by default via Appsync. There is no need to manage timestamps explicitly.

(gql/search :cart
            {:key :shopper-id
             :value "my-shopper-id"
             :query-field :carts-by-shopper
             :on-search #(rf/dispatch ::some-event %)
             :shape [:id :shopper-id [:payment [:card [:name]]]]})

gql/search also takes an optional :filter that applies the filter on the sorted resultset. shape specifies the keys or nodes in the Graph to return. In the above example, on-search returns a vector of maps

[{:id "xx" :shopper-id  "my-shopper-id" :payment {:card {:name "my-card"}}}]

gql/list and gql/search also support pagination. It returns a token that can be passed as a param in a loop/recur

Cognito-based Authentication

amplitude.auth provides a set of handy functions to build custom Auth flows using cognito

(:require [amplitude.auth :as auth])
(auth/sign-in {:username xxx :password xxx})
(auth/sign-out)

If the application needs to talk to REST API that is authenticated and authorized by Cognito, we can get the jwt-token for the Authenticated user as follows.

(auth/fetch-user-info)
=> {:username xxx
    :token jwt-token
    ...}

This token can be used subsequently as Authorization header in REST api requests. See amplitude.rest

Simplified REST Client

amplitude.rest provides functions to invoke http requests as authenticated users using jwt-tokens.

(:require
 [amplitude.rest :as rest])
(rest/init!)
(rest/get "/path" on-success on-error)
(rest/post "/path" body on-success on-error)

The callbacks on-success or on-error could be any arbitrary function

S3 Storage Abstraction

amplitude.storage provides idiomatic apis to put and get objects from S3 Storage.

(require [amplitude.storage :as storage])[
(storage/init!)
(storage/put key
             {:data        data
              :progress-fn progress-callback
              :on-success  on-success-fn
              :on-error    on-error-fn
              :options     {:level       "private"
                            :contentType "text/plain"}})

;; Example

(storage/put "foo/bar/baz.csv"
             {:data        data
              :progress-fn (fn [pct] (rf/dispatch ::events/progress pct))
              :on-success  on-success-fn
              :on-error    on-error
              :options     {:level       "private"
                            :contentType "text/plain"}})

and storage/get to retrive the key

(storage/get key callback-fn)

The callback-fn returns an url and not a stream.

Local Cache

amplitude.cache provides functions to query and mutate LocalStorage and SessionStorage. This is useful when caching resultsets

(require [amplitude.cache :as cache])
(cache/init! :storage :local) ;; storage can be :local or :session

(cache/put :foo "bar" :ttl 2400)
(cache/get :foo)
(cache/delete! :foo)
(cache/clear!)

License - Apache 2.0

Copyright 2020-21 Omnyway Inc.

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

Features in the Pipeline

  • [ ] Schema migrations and deploy Graphql schemas programatically to Appsync
  • [ ] Tests and examples
  • [ ] Better API documentation

Caveat: The goal of this library is not to provide a complete set of wrappers over amplifyjs. Instead, provide a robust set of abstractions over commonly used modules (Graphql, Storage, Cache)

Thanks