Amplifying Clojurescript on AWS.
amplitude
provides a set of utilities to work with core amplify-js
modules, seemlessly.
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.
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
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
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
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
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.
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!)
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.
- [ ] 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)
- district0x for graphql-query library https://github.com/district0x/graphql-query