Setup Full-Stack Webapp

From Merrymake
Jump to navigation Jump to search

In this tutorial we're going to build a webapp that tells users a random joke from a database, and let's them submit their own jokes to it.

Prerequisites[edit | edit source]

First, open Git Bash. If you don't have Git Bash, get it from https://git-scm.com/

We also need to make sure we have NodeJS installed. We can test this by running this command in Git Bash:

npm --version

It should give something like 10.2.3 . If not get it from https://nodejs.org/ The final tool we need is the Merrymake CLI, which uses Git and NodeJS. Install the CLI with the command:

npm install --global @merrymake/cli

Start Using Merrymake[edit | edit source]

With the CLI installed, we are ready to start using Merrymake. The first command we should run on every device we want to use Merrymake on is:

mm start

The mm tool now guides us through the initial process. Select:

  1. setup new key specifically for Merrymake
  2. Input your email address
  3. create new organization
  4. Name your organization or press enter to accept the suggested name
  5. Press enter to accept the suggested service group name (service-group-1)
  6. Press enter to accept the suggested repository name (repo-1)
  7. initialize with a basic template
  8. typescript
  9. deploy the service immediately
  10. post an event to the rapids
  11. Press enter to accept the suggested event type (hello)
  12. attach plain text payload
  13. Input your name
  14. Input "admin key"
  15. Press enter to accept the suggested duration (14 days)

We should now see Hello, XXXX!, this means our service is running in the cloud. If you don't see this please reach out on our Discord to get assistance.

Connect a Database[edit | edit source]

We are ready to add a database to our backend. We navigate into the repository we just created with the command:

cd [organization name]/service-group-1/repo-1

Head over to mongodb and create a free MongoDB database. We need to allow all IPs to be able to access the cluster so head to Network Access under the Security tab. Add a new IP and allow access from everywhere. Head back to the database and copy the connection string. We put this URI in a secret environment variable with the command:

mm envvar
  1. Input DB
  2. the value is secret
  3. Insert the connection string
  4. accessible in both prod and init run

To work with the database we need to install some npm packages for it:

npm install mongodb

Setup collections in the init run[edit | edit source]

The init run is executed exactly once, on deployment. Therefore, it is perfect for setting up database collections. We need one documentjoke with one attributejoke. Open the file index.ts and replace its content with this:

service-group-1/repo-1/index.ts
Before After
import {
  merrymakeService,
  MIME_TYPES,
  replyToOrigin,
} from "@merrymake/service";


async function handleHello(payloadBuffer: Buffer) {
  let payload = payloadBuffer.toString();
  replyToOrigin(`Hello, ${payload}!`, MIME_TYPES.txt);
}

merrymakeService({
  handleHello,

    
    
    
    
    
    
    
    
    
    
    
    
});
import {
  merrymakeService,
  MIME_TYPES,
  replyToOrigin,
} from "@merrymake/service";
import { Client } from "pg";

async function handleHello(payloadBuffer: Buffer) {
  let payload = payloadBuffer.toString();
  replyToOrigin(`Hello, ${payload}!`, MIME_TYPES.txt);
}

merrymakeService(
  {
    handleHello,
  },
  async () => {
    if (process.env.DB) {
      const client = new MongoClient(process.env.DB);
      try {
        await client.connect();
        await client.db("Merrymake").createCollection("joke").then();
      } finally {
        await client.close().then();
      }
    } else {
      console.log("Environment variable DB is not defined");
    }
  }
);

Implement the functionality[edit | edit source]

Our backend will have three operations: retrieveOne, retrieveAll, and insert. First we need to setup hooks so we can eventually trigger the functions, so go into the merrymake.json file and replace its content with this:

service-group-1/repo-1/merrymake.json
Before After
{
  "hooks": {
    "main/hello": "handleHello"


  }
}
{
  "hooks": {
    "main/retrieveOne": "handleRetrieveOne",
    "main/retrieveAll": "handleRetrieveAll",
    "main/insert": "handleInsert"
  }
}

Then, we replace the handleHello function with three new functions index.ts

service-group-1/repo-1/index.ts
Before After
import {
  merrymakeService,
  MIME_TYPES,
  replyToOrigin,
} from "@merrymake/service";
import { Client } from "pg";

async function handleHello(payloadBuffer: Buffer) {
  let payload = payloadBuffer.toString();
  replyToOrigin(`Hello, ${payload}!`, MIME_TYPES.txt);
}






































merrymakeService(
  {
    handleHello,


  },
  async () => {
    const client = new Client({ connectionString: process.env.DB });
    try {
      await client.connect();
      await client.query(`CREATE TABLE IF NOT EXISTS joke ()`);
      await client.query(`ALTER TABLE joke
        ADD COLUMN IF NOT EXISTS joke VARCHAR`);
    } finally {
      client.end();
    }
  }
);
import {
  merrymakeService,
  MIME_TYPES,
  replyToOrigin,
} from "@merrymake/service";
import { MongoClient } from "mongodb";

async function handleRetrieveOne(payloadBuffer: Buffer) {
  if (process.env.DB) {
    const client = new MongoClient(process.env.DB);
    try {
      await client.connect().then();
      const row = await client
        .db("Merrymake")
        .collection("joke")
        .aggregate([{ $sample: { size: 1 } }])
        .toArray()
        .then();
      replyToOrigin(JSON.stringify(row), MIME_TYPES.json);
    } finally {
      await client.close().then();
    }
  } else {
    console.log("Environment variable DB is not defined");
  }
}

async function handleRetrieveAll(payloadBuffer: Buffer) {
  if (process.env.DB) {
    const client = new MongoClient(process.env.DB);
    try {
      await client.connect().then();
      const row = await client
        .db("Merrymake")
        .collection("joke")
        .find()
        .toArray()
        .then();
      replyToOrigin(JSON.stringify(row), MIME_TYPES.json);
    } finally {
      await client.close().then();
    }
  } else {
    console.log("Environment variable DB is not defined");
  }
}

async function handleInsert(payloadBuffer: Buffer) {
  const payload = payloadBuffer.toString().trim().replace("\\n", "<br>");
  const input = {
    joke: payload,
  };
  if (process.env.DB) {
    const client = new MongoClient(process.env.DB);
    try {
      await client.connect().then();
      await client.db("Merrymake").collection("joke").insertOne(input);
      replyToOrigin("OK", MIME_TYPES.txt);
    } finally {
      await client.close().then();
    }
  } else {
    console.log("Environment variable DB is not defined");
  }
}

merrymakeService(
  {
    handleRetrieveOne,
    handleRetrieveAll,
    handleInsert,
  },
  async () => {
    if (process.env.DB) {
      const client = new MongoClient(process.env.DB);
      try {
        await client.connect();
        await client.db("Merrymake").createCollection("joke").then();
      } finally {
        await client.close().then();
      }
    } else {
      console.log("Environment variable DB is not defined");
    }
  }
);

Now to deploy the service we simply run:

mm deploy

The Merrymake platform automatically builds, packages, and deploys the service, before executing the init-run, and then hooks it up for traffic.

Interacting with the Rapids[edit | edit source]

Every execution of our service is triggered by an event on a large queue called the Rapids. We can see the status of executions with the command:

mm rapids

From here we can see that we have an init event which was successful. We can also drill into specific events to get details, such as what they printed to stdout.

We can also post events to the rapids, to trigger services. Let's try adding a joke by triggering the insert event:

  1. post message to rapids using an api-key
  2. Input insert
  3. attach plain text payload
  4. Input Why did the chicken cross the road?\nTo get to the other side
  5. admin-key (XXXX)

We should see Ok. To verify that it worked we can trigger the retrieveOne like this:

mm rapids post retrieveOne empty _

Which should return our singular joke from above.

Add a Front-End[edit | edit source]

In Merrymake we get a free public front-end, which we can manage from the public folder, let's navigate to it:

cd ../../public

Replace the content of the index.html file

public/index.html
Before After
<html><body>Hello, World!</body></html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Dad jokes</title>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
  <link rel="stylesheet" href="styles.css">
</head>

<body>
  <p id="show-joke"><i class="fa fa-spinner fa-pulse"></i></p>

  <div class="spacer"></div>

  <form id="new-joke">
    <h1>Submit a joke</h1>
    <p><textarea id="joke" rows=3></textarea></p>
    <input type="submit" value="Submit!" />
  </form>

  <div id="thank-you">That's really funny 😂</div>

  <script>
    // More to come here
  </script>
</body>

</html>

Also create a new file style.css

body,
textarea {
  text-align: center;
  line-height: 1.8em;
}

h1 {
  font-size: 40px;
}

body,
.spacer {
  margin-top: 100px;
}

body,
textarea,
input {
  font-family: "Gill Sans", "Gill Sans MT", Calibri, "Trebuchet MS", sans-serif;
  font-size: 27px;
}

textarea,
input {
  border-radius: 5px;
}

textarea {
  width: 800px;
}

input[type="submit"] {
  padding: 10px 20px;
  background-color: #dfd;
}

#thank-you {
  display: none;
}

We can open up the index.html in a browser to see how our front-end looks and behaves. Currently it only shows an infinite loading spinner. So let's add some functionality.

Get a joke from the backend[edit | edit source]

We've see how we can trigger the backend from the CLI, during which we setup an "admin key". We can also trigger services via an HTTP request using an API key. It is best practice to use different keys for each source, so we create another key with:

mm key
  1. add a new apikey
  2. Input front-end
  3. Input 1 year

It then gives you a key, highlighted in yellow, keep this close at hand.

Using this key we can add code in the script block to retrieve one joke on startup and replace the spinner with it.

public/index.html
Before After
  <script>
    // More to come here






  </script>
  <script>
    (async () => {
      let resp = await fetch("https://rapids.merrymake.io/[YOUR KEY]/retrieveOne");
      let text = await resp.text();
      document.getElementById("show-joke").innerHTML = text;

      // Even more to come
    })();
  </script>

As we can see to trigger an event we use the URL https://rapids.merrymake.io/[APIKEY]/[EVENT]

Send a joke to the backend[edit | edit source]

The only thing missing from our app is the ability for users to add their own jokes. This works similarly but here we use a POST method, with a content type, and we pass the API key in the header.

public/index.html
Before After
  <script>
    (async () => {
      ...

      // Even more to come













    })();
  </script>
  <script>
    (async () => {
      ...
      
      let elem = document.getElementById("new-joke");
      elem.addEventListener("submit", async (e) => {
        e.preventDefault();
        let resp = await fetch("https://rapids.merrymake.io/insert", {
          method: "POST",
          headers: {
            "Content-type": "plain/text",
            "merrymake-key": "[YOUR KEY]"
          },
          body: document.getElementById("joke").value
        });
        elem.style.display = "none";
        document.getElementById("thank-you").style.display = "block";
      });
    })();
  </script>

We can test our app locally, and when we are happy with it we can deploy it with:

mm deploy

Our app is now available to everyone on https://[org].merrymake.app.

Secure the App[edit | edit source]

Currently both our keys are universal keys, that means they can trigger any event. However, the front-end only needs to trigger the insert and retrieveOne events, so let's limit front-end key to these events.

Declare events in the event-catalogue[edit | edit source]

To limit access we need to first declare the events in the event-catalogue. We do this by going into the event-catalogue folder

cd ../../event-catalogue

Here we can replace the content of api.json

event-catalogue/api.json
Before After
{
    
    
}
{
  "insert": {},
  "retrieveOne": { "waitFor": 2000 }
}

We also configure how long to wait for before timing out and sending back "Queued event". Save the file and deploy the event-catalogue:

mm deploy

Allowlist the events[edit | edit source]

The final step is to specify that only these events are allowed on the front-end key, we do this with

mm event
  1. front-end (XXXXX)
  2. Then check insert and retrieveOne

Notice we cannot allow the event retrieveAll because we didn't declare it in the event-catalogue.

We can verify that it worked by trying to trigger the retrieveAll on the front-end key with:

mm rapids post retrieveAll empty

You can also try the admin key which is still a universal key, thus it can trigger any event.

Conclusion[edit | edit source]

Using the CLI we have:

  • Registered an account and created an organization
  • Deployed a backend, the public front-end, and the event-catalogue
  • Inspected our Rapids
  • Posted events with and without payload to our Rapids
  • Set a secret environment variable
  • Created two API keys
  • Allow-listed events on the front-end key

We have seen how to implement and deploy a serverless backend with a database and three hooks.

We have deployed a public front-end, which we have also secured.

These are the basic steps to launch a full-stack webapp in Merrymake.

Thank you for your time.