I remember the first time I saw a demonstration of Ruby on Rails. With very little effort, demonstrators created a full-stack web application that could be used for real business purposes. I was impressed – especially when I thought about how much time it took me to deliver similar solutions using the Seam and Struts frameworks.
Ruby was created in 1993 to be an easy-to-use scripting language that also included object-oriented features. Ruby on Rails took things to the next level in the mid 2000s – arriving at the right time to become the tech-of-choice for the initial startup efforts of Twitter, Shopify, GitHub, and Airbnb.
I began to ask the question, “Is it possible to have a product, like Ruby on Rails, without needing to worry about the infrastructure or underlying data tier?”
That’s when I discovered the Zipper platform.
About Zipper
Zipper is a platform for building web services using simple TypeScript functions. You use Zipper to create applets (not related to Java, though they share the same name), which are then built and deployed on Zipper’s platform.
The coolest thing about Zipper is that it lets you focus on coding your solution using TypeScript, and you don’t need to worry about anything else. Zipper takes care of:
User interface
Infrastructure to host your solution
Persistence layer
APIs to interact with your applet
Authentication
Although the platform is currently in beta, it’s open for consumers to use. At the time I wrote this article, there were four templates in place to help new adopters get started:
Hello World – a basic applet to get you started
CRUD Template – offers a ToDo list where items can be created, viewed, updated, and deleted
Slack App Template – provides an example on how to interact with the Slack service
AI Generated Code – expresses your solution in human language and lets AI create an applet for you
There is also a gallery on the Zipper platform which provides applets that can be forked in the same manner as Git-based repositories.
I thought I would put the Zipper platform to the test and create a ballot applet.
HOA Ballot Use Case
The homeowner’s association (HOA) concept started to gain momentum in the United States back in the 20th century. Subdivisions formed HOAs to handle things like the care of common areas and for establishing rules and guidelines for residents. Their goal is to maintain the subdivision’s quality of living as a whole, long after the home builder has finished development.
HOAs often hold elections to allow homeowners to vote on the candidate they feel best matches their views and perspectives. In fact, last year I published an article on how an HOA ballot could be created using Web3 technologies.
For this article, I wanted to take the same approach using Zipper.
Ballot Requirements
The requirements for the ballot applet are:
As a ballot owner, I need the ability to create a list of candidates for the ballot.
As a ballot owner, I need the ability to create a list of registered voters.
As a voter, I need the ability to view the list of candidates.
As a voter, I need the ability to cast one vote for a single candidate.
As a voter, I need the ability to see a current tally of votes that have been cast for each candidate.
Additionally, I thought some stretch goals would be nice too:
As a ballot owner, I need the ability to clear all candidates.
As a ballot owner, I need the ability to clear all voters.
As a ballot owner, I need the ability to set a title for the ballot.
As a ballot owner, I need the ability to set a subtitle for the ballot.
Designing the Ballot Applet
To start working on the Zipper platform, I navigated to zipper.dev and clicked the Sign In button. Next, I selected an authentication source:
Once logged in, I used the Create Applet button from the dashboard to create a new applet:
A unique name is generated, but that can be changed to better identify your use case. For now, I left all the defaults the same and pushed the Next button – which allowed me to select from four different templates for applet creation.
I started with the CRUD template because it provides a solid example of how the common create, view, update, and delete flows work on the Zipper platform. Once the code was created, the screen appears as shown below:
With a fully functional applet in place, we can now update the code to meet the HOA ballot use requirements.
Establish Core Elements
For the ballot applet, the first thing I wanted to do was update the types.ts
file as shown below:
export type Candidate = {
id: string;
name: string;
votes: number;
};
export type Voter = {
email: string;
name: string;
voted: boolean;
};
I wanted to establish constant values for the ballot title and subtitle within a new file called constants.ts
:
export class Constants {
static readonly BALLOT_TITLE = "Sample Ballot";
static readonly BALLOT_SUBTITLE = "Sample Ballot Subtitle";
};
To allow only the ballot owner to make changes to the ballot, I used the Secrets tab for the applet to create an owner secret with a value of my email address.
Then I introduced a common.ts file which contained the validateRequest()
function:
export function validateRequest(context: Zipper.HandlerContext) {
if (context.userInfo?.email !== Deno.env.get('owner')) {
return (
<>
<Markdown>
{`### Error:
You are not authorized to perform this action`}
</Markdown>
</>
);
}
};
This way I could pass in the context to this function to make sure only the value in the owner
secret would be allowed to make changes to the ballot and voters.
Establishing Candidates
After understanding how the ToDo item was created in the original CRUD applet, I was able to introduce the create-candidate.ts
file as shown below:
import { Candidate } from "./types.ts";
import { validateRequest } from "./common.ts";
type Input = {
name: string;
};
export async function handler({ name }: Input, context: Zipper.HandlerContext) {
validateRequest(context);
const candidates =
(await Zipper.storage.get<Candidate[]>("candidates")) || [];
const newCandidate: Candidate = {
id: crypto.randomUUID(),
name: name,
votes: 0,
};
candidates.push(newCandidate);
await Zipper.storage.set("candidates", candidates);
return newCandidate;
}
For this use case, we just need to provide a candidate name, but the Candidate object contains a unique ID and the number of votes received.
While here, I went ahead and wrote the delete-all-candidates.ts
file, which removes all candidates from the key/value data store:
import { validateRequest } from "./common.ts";
type Input = {
force: boolean;
};
export async function handler(
{ force }: Input,
context: Zipper.HandlerContext
) {
validateRequest(context);
if (force) {
await Zipper.storage.set("candidates", []);
}
}
At this point, I used the Preview functionality to create Candidate A, Candidate B, and Candidate C:
Registering Voters
With the ballot ready, I needed the ability to register voters for the ballot. So I added a create-voter.ts
file with the following content:
import { Voter } from "./types.ts";
import { validateRequest } from "./common.ts";
type Input = {
email: string;
name: string;
};
export async function handler(
{ email, name }: Input,
context: Zipper.HandlerContext
) {
validateRequest(context);
const voters = (await Zipper.storage.get<Voter[]>("voters")) || [];
const newVoter: Voter = {
email: email,
name: name,
voted: false,
};
voters.push(newVoter);
await Zipper.storage.set("voters", voters);
return newVoter;
}
To register a voter, I decided to provide inputs for email address and name. There is also a boolean property called voted
which will be used to enforce the vote-only-once rule.
Like before, I went ahead and created the delete-all-voters.ts
file:
import { validateRequest } from "./common.ts";
type Input = {
force: boolean;
};
export async function handler(
{ force }: Input,
context: Zipper.HandlerContext
) {
validateRequest(context);
if (force) {
await Zipper.storage.set("voters", []);
}
}
Now that we were ready to register some voters, I registered myself as a voter for the ballot:
Creating the Ballot
The last thing I needed to do was establish the ballot. This involved updating the main.ts
as shown below:
import { Constants } from "./constants.ts";
import { Candidate, Voter } from "./types.ts";
type Input = {
email: string;
};
export async function handler({ email }: Input) {
const voters = (await Zipper.storage.get<Voter[]>("voters")) || [];
const voter = voters.find((v) => v.email == email);
const candidates =
(await Zipper.storage.get<Candidate[]>("candidates")) || [];
if (email && voter && candidates.length > 0) {
return {
candidates: candidates.map((candidate) => {
return {
Candidate: candidate.name,
Votes: candidate.votes,
actions: [
Zipper.Action.create({
actionType: "button",
showAs: "refresh",
path: "vote",
text: `Vote for ${candidate.name}`,
isDisabled: voter.voted,
inputs: {
candidateId: candidate.id,
voterId: voter.email,
},
}),
],
};
}),
};
} else if (!email) {
<>
<h4>Error:</h4>
<p>
You must provide a valid email address in order to vote for this ballot.
</p>
</>;
} else if (!voter) {
return (
<>
<h4>Invalid Email Address:</h4>
<p>
The email address provided ({email}) is not authorized to vote for
this ballot.
</p>
</>
);
} else {
return (
<>
<h4>Ballot Not Ready:</h4>
<p>No candidates have been configured for this ballot.</p>
<p>Please try again later.</p>
</>
);
}
}
export const config: Zipper.HandlerConfig = {
description: {
title: Constants.BALLOT_TITLE,
subtitle: Constants.BALLOT_SUBTITLE,
},
};
I added the following validations as part of the processing logic:
The email property must be included or else a “You must provide a valid email address in order to vote for this ballot” message will be displayed.
The email value provided must match a registered voter or else a “The email address provided is not authorized to vote for this ballot” message will be displayed.
There must be at least one candidate to vote on or else a “No candidates have been configured for this ballot” message will be displayed.
If the registered voter has already voted, the voting buttons will be disabled for all candidates on the ballot.
The main.ts
file contains a button for each candidate, all of which call the vote.ts
file, displayed below:
import { Candidate, Voter } from "./types.ts";
type Input = {
candidateId: string;
voterId: string;
};
export async function handler({ candidateId, voterId }: Input) {
const candidates = (await Zipper.storage.get<Candidate[]>("candidates")) || [];
const candidate = candidates.find((c) => c.id == candidateId);
const candidateIndex = candidates.findIndex(c => c.id == candidateId);
const voters = (await Zipper.storage.get<Voter[]>("voters")) || [];
const voter = voters.find((v) => v.email == voterId);
const voterIndex = voters.findIndex(v => v.email == voterId);
if (candidate && voter) {
candidate.votes++;
candidates[candidateIndex] = candidate;
voter.voted = true;
voters[voterIndex] = voter;
await Zipper.storage.set("candidates", candidates);
await Zipper.storage.set("voters", voters);
return `${voter.name} successfully voted for ${candidate.name}`;
}
return `Could not vote. candidate=${ candidate }, voter=${ voter }`;
}
At this point, the ballot applet was ready for use.
HOA Ballot In Action
For each registered voter, I would send them an email with a link similar to what is listed below:
https://squeeking-echoing-cricket.zipper.run/run/main.ts?email=some.email@example.com
The link would be customized to provide the appropriate email address for the email
query parameter. Clicking the link runs the main.ts
file and passes in the email parameter, avoiding the need for the registered voter to have to type in their email address.
The ballot appears as shown below:
I decided to cast my vote for Candidate B. Once I pushed the button, the ballot was updated as shown:
The number of votes for Candidate B increased by one, and all of the voting buttons were disabled. Success!
Conclusion
Looking back on the requirements for the ballot applet, I realized I was able to meet all of the criteria, including the stretch goals in about two hours—and this included having a UI, infrastructure, and a deployment. The best part of this experience was that 100% of my time was focused on building my solution, and I didn’t need to spend any time dealing with infrastructure or even the persistence store.
My readers may recall that I have been focused on the following mission statement, which I feel can apply to any IT professional:
“Focus your time on delivering features/functionality that extends the value of your intellectual property. Leverage frameworks, products, and services for everything else.”
- J. Vester
The Zipper platform adheres to my personal mission statement 100%. In fact, they have been able to take things a step further than Ruby on Rails did, because I don’t have to worry about where my service will run or what data store I will need to configure. Using the applet approach, my ballot is already deployed and ready for use.
If you are interested in giving applets a try, simply log in to zipper.dev and start building. Currently, using the Zipper platform is free. Give the AI Generated Code template a try, as it is really cool to provide a paragraph of what you want to build and see how close the resulting applet matches what you have in mind.
If you want to give my ballot applet a try, it is also available to fork in the Zipper gallery at the following location:
https://zipper.dev/johnjvester/ballot
Have a really great day!
Top comments (0)