Building a social photo app using Spin, KV & Nuxt.js
Rajat Jindal
slack
golang
nuxtjs
ssg
spin
bts
Fermyon is a fully remote company across 9 timezones, and this means that we primarily use Slack to communicate with each other. A LOT.
We also travel frequently for conferences and meetups. There’s a lot of work behind the scenes that goes into making this possible – from last-minute packing, and setting up the booths at events to taking naps between missed flights. We have a #behind-the-scenes
channel on our Slack to share these moments with each other. We thought we could share some of these photos with you, so we built a social photo app using Fermyon Spin, Key-Value Store, and Nuxt.js. Here’s how we did it.
High-level architecture
At a high level, we wanted to have the following user stories for this app:
- Users should be able to post pictures they want to share with the world easily
- The user should be able to sign off the pictures explicitly.
- User should be able to open a web app to view the posted pictures
Building Blocks
Let us now explore the different building blocks of this web app.
Slack Webhook
This component is responsible for receiving webhook from Slack whenever the bot @behind-the-scenes
receives a mention
or a reaction
is added to such message.
As part of the handler, we first create Slack’s HTTP Client (backed by Spin’s HTTP Client). A simplified version of the code is as follows:
package webhook
import (
spinhttp "github.com/fermyon/spin/sdk/go/http"
"github.com/fermyon/spin/sdk/go/v2/variables"
"github.com/slack-go/slack"
)
func NewClient() (*slack.Client, error) {
token, err := variables.Get("slack_token")
if err != nil {
return nil, err
}
signingSecret, err := variables.Get("slack_signing_secret")
if err != nil {
return nil, err
}
httpclient := spinhttp.NewClient() // <--- http client provided by Spin's SDK
return slack.New(token, slack.OptionHTTPClient(httpclient)), nil
}
Once we have the client, for this app, we need to handle two types of Slack webhook events:
- Verification of Webhook URL: For this, Slack sends a challenge to the configured URL and we need to reply with that challenge. You can read more about it in Slack’s documentation here.
- An
App mention
and Reaction added
event webhook: This event gets triggered when someone sends a message in the Slack channel and tags the bot account in that message or adds an emoji reaction to the message.
A simplified implementation of the handler:
func (s *Handler) Handle(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
err = s.slack.VerifySignature(r.Header, raw)
if err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
outerEvent, err := slack.ParseEvent(raw)
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
switch {
case outerEvent.Type == slackevents.URLVerification:
s.webhookVerificationHandler(w, raw)
return
case outerEvent.Type == slackevents.CallbackEvent:
err = s.handleCallbackEvent(ctx, outerEvent)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
return
}
logrus.Warnf("unknown event type %v", outerEvent.Type)
fmt.Fprintln(w, "OK")
}
Now, let us take a closer look at both these handlers.
The webhookVerificationHandler
is quite straightforward, and just needed us to send a challenge
(received in the request) in the response body:
func (s *Handler) urlVerificationHandler(w http.ResponseWriter, raw []byte) {
var r *slackevents.ChallengeResponse
err := json.Unmarshal([]byte(raw), &r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text")
w.Write([]byte(r.Challenge))
}
In the handleCallbackEvent
function, we check what kind of event this is, and based on that call specific event handler functions:
func (s *Handler) handleCallbackEvent(ctx context.Context, outerEvent slackevents.EventsAPIEvent) error {
logrus.Info("starting handleCallbackEvent")
appMentionEvent, ok := outerEvent.InnerEvent.Data.(*slack.MessageEvent)
if ok {
return s.handleAppMentionEvent(ctx, appMentionEvent)
}
reactionAddedEvent, ok := outerEvent.InnerEvent.Data.(*slackevents.ReactionAddedEvent)
if ok {
return s.handleReactionAddedEvent(ctx, reactionAddedEvent)
}
return fmt.Errorf("unsupported event")
}
Digging one level down further, in handleAppMentionEvent
, we first check if we have processed this event already. If not, we then check if it meets our validation criteria (message from an allowed channel, all tagged users’ approval received or not, etc.). If it does meet our validation criteria, we retrieve the images from the event msg and store the post
in our KV store as follows:
imageIdsMap := map[string]string{}
imageIds := []string{}
for _, file := range event.Files {
if file.Filetype == "mp4" {
continue
}
imageId := uuid.New().String()
imageIdsMap[imageId] = file.URLPrivateDownload
imageIds = append(imageIds, imageId)
}
post := &posts.Post{
Msg: event.Text,
ImageIds: imageIds,
ImageMap: imageIdsMap,
Timestamp: event.Timestamp,
Approved: verifySignoffFromEvent(event),
}
store, err := kv.OpenStore("default")
if err != nil {
return err
}
defer store.Close()
raw, err := json.Marshal(post)
if err != nil {
return err
}
err = store.Set(skey, raw)
if err != nil {
logrus.Infof("error when adding key %s into store %v", skey, err)
}
The handleReactionAddedEvent
looks quite similar to handleAppMentionEvent
. The only difference is that we use Slack’s SDK to retrieve the message details. As a reminder, when we initialized Slack’s client, we configured it with the HTTP Client provided by Spin’s runtime:
resp, err := s.slack.GetConversationHistory(&slack.GetConversationHistoryParameters{
ChannelID: event.Item.Channel,
Latest: event.Item.Timestamp,
Limit: 1,
Inclusive: true,
IncludeAllMetadata: true,
})
Frontend
This component is the front end of the BTS website. We use Nuxt for the app, static generation
mode to generate the static website, and the static file server WebAssembly component to serve it using spin.
To generate and serve the statically built frontend, we configure the component in spin.toml
as follows:
### configure the component for UI
[[trigger.http]]
route = "/..."
component = "fileserver-static"
[component.fileserver-static]
source = { url = "https://github.com/fermyon/spin-fileserver/releases/download/v0.2.0/spin_static_fs.wasm", digest = "sha256:1342e1b51f00ba3f9f5c96821f4ee8af75a8f49ca024a8bc892a3b74bbf53df2" }
files = [ { source = "ui/.output/public/", destination = "/" } ]
[component.fileserver-static.build]
command = "cd ui && yarn install && yarn generate && cd -"
Backend API
This component provides the API for the frontend and exposes the following endpoints:
GET /api/posts
- this returns the id
for all the posts available in our storage. Because we are using Spin’s KV store, we call the function GetKeys
to get all the keys and return them to the front end.
func GetAllPostsKeys() ([]string, error) {
store, err := kv.OpenStore("default")
if err != nil {
return nil, err
}
allKeys, err := store.GetKeys()
if err != nil {
return nil, err
}
keys := []string{}
for _, key := range allKeys {
if !strings.HasPrefix(key, "post:") {
continue
}
keys = append(keys, strings.TrimPrefix(key, "post:"))
}
return keys, nil
}
GET /api/posts/:postId
- using the id
returned above, we now fetch details of the specific post from the API. For this, we make use of the Get
function call to retrieve the value stored in the KV store.
func GetPost(id string) (*Post, error) {
store, err := kv.OpenStore("default")
if err != nil {
return nil, err
}
defer store.Close()
raw, err := store.Get(fmt.Sprintf("post:%s", id))
if err != nil {
return nil, err
}
var post Post
err = json.Unmarshal(raw, &post)
if err != nil {
return nil, err
}
return &post, nil
}
GET /api/posts/:postId/image/:imageId
- using the details of the post, we now have access to the list of imageId
. imageId
is the internal ID that we use to store the corresponding Slack download URL for the image.
This handler is slightly more interesting. Because these images are private and need a Slack auth token to retrieve, we cannot fetch them directly on the client side. Instead, we need to proxy the request. One of the previous implementations did that, and as one could imagine, that was not very efficient.
But thanks to recently added streaming support to Spin, we switched this api implementation to Rust + Streaming, which means this api was suddenly much more responsive and efficient:
use spin_sdk::{
key_value::Store,
variables,
http::{self, Headers, IncomingRequest, OutgoingResponse, ResponseOutparam, OutgoingRequest, Method, Scheme, IncomingResponse}
};
#[http_component]
async fn send_outbound(req: IncomingRequest, res: ResponseOutparam) {
get_and_stream_imagefile(req, res).await.unwrap();
}
async fn get_and_stream_imagefile(req: IncomingRequest, res: ResponseOutparam) -> Result<()> {
let token = variables::get("slack_token").unwrap();
let url = Url::parse("https://files.slack.com/path/to/image/file.png").unwrap();
let outgoing_request = OutgoingRequest::new(
&Method::Get,
Some(url.path()),
Some(&match url.scheme() {
"http" => Scheme::Http,
"https" => Scheme::Https,
scheme => Scheme::Other(scheme.into()),
}),
Some(url.authority()),
&Headers::new(&[(
"authorization".to_string(),
format!("Bearer {token}").as_bytes().to_vec(),
)]),
);
let response = http::send::<_, IncomingResponse>(outgoing_request).await?;
let status = response.status();
let mut stream = response.take_body_stream();
let out_response = OutgoingResponse::new(
status,
&Headers::new(&[(
"content-type".to_string(),
b"application/octet-stream".to_vec(),
)]),
);
let mut body = out_response.take_body();
res.set(out_response);
while let Some(chunk) = stream.next().await {
body.send(chunk?).await?;
}
Ok(())
}
CI/CD
An important step in the lifecycle of any app is how we securely and continually deploy that. Here, we are using Fermyon Cloud’s GitHub actions. This set of GitHub actions is provided by Fermyon and facilitates the following functionalities:
- set-up Spin
- package and push the Spin app to the OCI registry
- package and deploy the Spin app to Fermyon Cloud
Deploying your Spin app to Fermyon Cloud is as simple as the following:
- name: build and deploy
uses: fermyon/actions/spin/deploy@v1
id: deploy
with:
fermyon_token: ${{ secrets.FERMYON_CLOUD_TOKEN }}
manifest_file: spin.toml
variables: |-
allowed_channel=${{ secrets.ALLOWED_CHANNEL }}
trigger_on_emoji_code=slats
slack_token=${{ secrets.SLACK_TOKEN }}
slack_signing_secret=${{ secrets.SLACK_SIGNING_SECRET }}
bts_admin_api_key=${{ secrets.BTS_ADMIN_API_KEY }}
Refer to the complete GitHub action for this app or this tutorial for details.
Custom Domain
Last but not least, we wanted to host the app on a URL that tells a story in itself. We wanted it to convey that this is a “behind the scenes @ fermyon” but also that it is built using our open source project Spin. Therefore we now host this app at https://fermyon-bts.usingspin.com. The domain is configured using the Custom Domain feature in Fermyon Cloud. Using this, you can bring your domains (and thus branding) for providing access to your app to your users.
Conclusion
In this blog post, we covered how we implemented the Slack webhook and RESTful API using Spin, made use of Fermyon’s KV store as persistent storage, and used GitHub actions to implement a secure CI/CD pipeline for the app.
Building the “behind the scenes” app with Fermyon Spin was fun and exercising the abilities of Fermyon Cloud to deploy this made the journey easy.
We also have a #pets
channel on our Slack to share cute and adorable photos of our pets. We feature one of these pet photos in our weekly newsletter, which you can subscribe to in the footer below.