Winglang
Creating a simple API with Winglang
Learn about the power of cloud-agnostic IaC with Winglang
Hi people!
Today I want to talk about Winglang, a powerful tool that combines infrastructure as code and runtime in one.
It allows developers to build infrastructure without having to worry about the cloud it will be deployed. You can compile it to many platforms, like Terraform for AWS, Azure, and others.
Winglang also offers a simulated environment, where you can see and interact with your infrastructure in your localhost.
One amazing thing that it also supports is to test your infrastructure locally in the simulated environment, and also live in the cloud, where it builds the infra and runs the tests for you. During the compilation time, Winglang will assess all the minimal permissions required and will handle all the necessary steps to make sure the connection will be successful.
To learn more about it, visit their website here: https://www.winglang.io/.
Requirements
- NPM
- Docker — It is necessary for local testing, as some simulations depend on containers
- Your favorite code editor (I’ll be using Visual Studio Code)
The Project
The project will be a simple application to create and fetch books. It will have:
- GET endpoint for getting book information
- POST endpoint for creating a book
- DynamoDB for storing books
Let’s take off
To get started, let’s first install Winglang by running the following npm command:
npm install -g winglang
This will enable you to use the wing CLI tool to run wing commands in your terminal.
After the installation is finished, you can check it by running:
wing -V
Now, let’s initialize our wing project with the following command:
wing new empty
This will initialize an empty wing project with the following structure:
.
├── main.w
├── package.json
└── package-lock.json
Now, if you open the main.w
file you’ll see a simple function with one test already defined:
bring cloud;
bring expect;
let fn = new cloud.Function(inflight () => {
return "hello, world";
});
test "fn returns hello" {
expect.equal(fn.invoke(""), "hello, world");
}
Here you can see the cool feature that you can define and write tests for your infrastructure.
Now, if you run:
wing it
It will spin up the infrastructure locally for you at the URL http://localhost:3000/
and you’ll be able to see all the components and even interact with them, like invoking a function:
Now, run the following command to add DynamoDB wing library:
npm i --save @winglibs/dynamodb
If we add a DynamoDB NoSQL database with the code:
bring cloud;
bring dynamodb;
bring expect;
let db = new dynamodb.Table(
name: "MyTable",
attributes: [
{
name: "ID",
type: "S"
}
],
hashKey: "ID"
);
let fn = new cloud.Function(inflight () => {
let record = db.get(
Key: {
ID: "id"
}
);
return "hello, world";
});
test "fn returns hello" {
expect.equal(fn.invoke(""), "hello, world");
}
You can see that it automatically updates the graph with the database table:
Amazingly enough, you can also test your infrastructure code by running the following command:
wing test
This will spin up locally the infrastructure and run and test "" {...}
statements that you have defined in the main.w
file. In this case, it will run the test case fn returns hello
:
If your tests are in a different file, you can target the file by adding to the command:
wing test main.test.w
You also have the possibility of running your tests against a real cloud infrastructure by adding the -t
command. For example, the command below will run against AWS
:
wing test -t tf-aws
Wing will spin up the infrastructure in the cloud, run your defined test cases, produce the output, and then destroy the test infrastructure.
Compilation
If you want to generate the IaC code for your wing project, you can run the following command:
wing compile -t TARGET
Where the target is the platform you are compiling to. For example, the following code compiles to Terraform with AWS:
wing compile -t tf-aws
This will generate a target
folder with your compiled code.
.
└── target/
└── main.tfaws/
├── .wing/
├── assets/
├── connections.json
├── main.tf.json
└── tree.json
- The
main.tf.json
file is the Terraform code in JSON format. assets
folder is where any code generated to be run asynchronously will go. In this case, it has the code for our defined function.
If you want to use a backend, like an S3 bucket, you can create a platform.js
file with a similar code:
exports.Platform = class Platform {
postSynth(config) {
config.terraform.backend = {
s3: {
bucket: 'YOUR_BUCKET',
key: 'state.tfstate',
},
};
return config;
}
};
This is called a custom platform, which supports some hooks:
preSynth
— Code that will run before your resources are synthesizedpostSynth
— Code that will run after the hook will have access to the raw Terraform JSON configurationvalidate
— This is called after thepostSynth
hook and has the same context object. But it doesn’t allow you to make any changes, so it is only useful for validating your resources.
And then you can run
wing compile -t tf-aws -t ./platform.js
Let’s break down a few concepts
Looking at the code we just wrote:
bring cloud;
bring ex;
bring expect;
let db = new ex.Table({
name: "MyTable",
primaryKey: "ID",
columns: {
ID: ex.ColumnType.STRING,
Text: ex.ColumnType.STRING
}
});
let fn = new cloud.Function(inflight () => {
let record = db.tryGet("id");
return "hello, world";
});
test "fn returns hello" {
expect.equal(fn.invoke(""), "hello, world");
}
We can discuss a few concepts before proceeding.
bring
— it is a keyword that you’ll be using to import wing modules.
Here we usebring cloud
for example, which imports the officialcloud
module.
For more official modules, check the documentation here.preflight
— This is code that will define your infrastructure and will run at compile time.
For examplelet db = new cloud.Table({...})
which defines a new NoSQL table (DynamoDB in AWS)inflight
— This keyword defines aninflight
code, which is code that will run asynchronously, like a function code that runs when a function is executed.
Note that we combine preflight
and inflight
inside our function code. Once compiled, Winglang
will automatically identify the components and connect them with the least permissions necessary.
Adding an API
Now let’s start by adding our books API.
Let’s clean up our file and start by adding:
bring cloud;
let api = new cloud.Api() as "book_manager";
See that I added as "book-manager"
after declaring a new API? This will allow Winglang to add an API with this name to our infrastructure. The default name of a resource is the name of the class, but we override it with the as
syntax.
Note that the name needs to be unique in the scope it is defined.
Now let’s add a first endpoint to get some dummy data at /books/:bookID
.
Add the following code to the main.w
file:
struct BookResponse extends BookRequest {
id: str;
title: str;
author: str;
createdAt: str;
}
api.get("/books/:bookID", inflight (req) => {
let bookID = req.vars.get("bookID");
log(bookID);
let response = BookResponse{
id: bookID,
title: "",
author: bookRequest?.author!,
createdAt: createdAt.toIso()
};
return {
status: 200,
body: Json.stringify(response)
};
});
You should get the endpoint defined in the simulator. And you can also test it by sending a GET
request to /books/{bookID}
.
Let’s add one small test to it. First, let’s add the http
and expect
packages to the main.w
by adding the following to the top of the file:
bring http;
bring expect;
Now let’s add our test to the end of the file:
/*
***********
Tests
***********
*/
test "GET /books/:bookID should return 200 when record exists" {
let result = http.get("{api.url}/books/1");
let body = BookResponse.parseJson(result.body);
expect.equal(result.status, 200);
expect.equal(body.id, id);
expect.equal(body.author, author);
expect.equal(body.title, title);
expect.equal(body.createdAt, createdAt);
}
If you run:
wing test
You should get the following output:
First, let’s add the util
packaged to the top of the file:
bring util;
Now let’s add a POST
endpoint to add a book by first adding a structs for the request and modifying the response:
struct BookRequest {
title: str;
author: str;
}
struct BookResponse extends BookRequest {
id: str;
createdAt: str;
}
And then, let’s implement the POST
endpoint:
api.post("/books", inflight (req) => {
let bookRequest = BookRequest.tryParseJson(req.body);
if (bookRequest == nil) {
return {
status: 400,
body: "Request invalid"
};
}
if (bookRequest?.title == nil || bookRequest?.author == nil) {
return {
status: 400,
body: "Title and author are required"
};
}
let bookID = util.uuidv4();
let createdAt = std.Datetime.utcNow();
let response = BookResponse {
id: bookID,
title: bookRequest?.title!,
author: bookRequest?.author!,
createdAt: createdAt.toIso()
};
return {
status: 200,
body: Json.stringify(response)
};
});
Now, you can use the simulator to test it:
Let’s add some tests to the end of our file:
test "POST /books should return 200 with correct body " {
let body = Json.stringify({title: "A Title", author: "An Author"});
let result = http.post("{api.url}/books", { body: body });
let responseBody = BookResponse.parseJson(result.body);
expect.equal(result.status, 200);
expect.equal(responseBody.author, request.author);
expect.equal(responseBody.title, request.title);
expect.notNil(responseBody.id);
expect.notNil(responseBody.createdAt);
}
test "POST /books should return 400 without body " {
let result = http.post("{api.url}/books");
expect.equal(result.status, 400);
}
test "POST /books should return 400 with empty body " {
let body = Json.stringify({});
let result = http.post("{api.url}/books", { body: body });
expect.equal(result.status, 400);
}
test "POST /books should return 400 without title " {
let body = Json.stringify({ author: "An Author"});
let result = http.post("{api.url}/books", { body: body });
expect.equal(result.status, 400);
}
test "POST /books should return 400 without author " {
let body = Json.stringify({title: "A Title" });
let result = http.post("{api.url}/books", { body: body });
expect.equal(result.status, 400);
}
Adding a database
Now, let’s add data persistence to our implementation.
First for our GET
endpoint. Let’s start by adding the ex
package by adding the following statement to the top of our main.w
file:
bring dynamodb;
Now, let’s add our database NoSQL table initialization right after we initialize our API:
let api = new cloud.Api() as "books_manager";
let table = new dynamodb.Table(
name: "books",
attributes: [
{
name: "ID",
type: "S"
}
],
hashKey: "ID"
);
And then let’s add it to our GET
endpoint:
api.get("/books/:bookID", inflight (req) => {
let bookID = req.vars.get("bookID");
log("Getting record with ID {bookID}");
let result = table.get(
Key: {
ID: bookID
}
);
if (book == nil) {
return {
status: 404,
body: "Book not found"
};
}
let response = BookResponse {
id: book?.get("ID"),
title: book?.get("Title"),
author: book?.get("Author"),
createdAt: book?.get("CreatedAt")
};
return {
status: 200,
body: Json.stringify(response)
};
});
After making these changes, you’ll be able to see it in the simulator:
Now we need to adapt our GET /books/:bookID should return 200 when record exists
test.
test "GET /books/:bookID should return 200 when record exists" {
let id = "1";
let author = "An Author";
let title = "A Book";
let createdAt = "2024-04-14";
table.put(
Item: {
ID: id,
Title: title,
Author: author,
CreatedAt: createdAt
}
);
let result = http.get("{api.url}/books/{id}");
let body = Json.parse(result.body);
expect.equal(result.status, 200);
expect.equal(body.get("id"), id);
expect.equal(body.get("author"), author);
expect.equal(body.get("title"), title);
expect.equal(body.get("createdAt"), createdAt);
}
And let’s add a test for a failed result
test "GET /books/:bookID should return 404 when record is not found" {
let id = "1";
let result = http.get("{api.url}/books/{id}");
expect.equal(result.status, 404);
}
Adding records to the database
Now, let’s implement the database for our POST /books
endpoint.
api.post("/books", inflight (req) => {
let bookRequest = BookRequest.tryParseJson(req.body);
if (bookRequest == nil) {
return {
status: 400,
body: "Request invalid"
};
}
if (bookRequest?.title == nil || bookRequest?.author == nil) {
return {
status: 400,
body: "Title and author are required"
};
}
let bookID = util.uuidv4();
let createdAt = std.Datetime.utcNow();
table.put(
Item: {
ID: bookID,
Title: bookRequest?.title,
Author: bookRequest?.author,
CreatedAt: createdAt.toIso()
}
);
let response = BookResponse {
id: bookID,
title: bookRequest?.title!,
author: bookRequest?.author!,
createdAt: createdAt.toIso()
};
return {
status: 200,
body: Json.stringify(response)
};
});
Now, you can try it in the simulator:
Great! We have our API finished with the database connection.
To compile it to Terraform code for AWS, you can run the following code
wing compile -t tf-aws main.w
To deploy it, you can follow the guide steps here in the documentation.
Conclusion
In this article, you can see how powerful Winglang is. The project is still on version 0.70, at the time of writing this article, but it looks promising.
You could see how you can easily write code and test your infrastructure and runtime code.
Not only that, you can easily see how your components are connected and how to invoke them locally. Also, Winglang makes it much easier to connect components because it automatically handles all policies and permissions necessary.
The code for this article can be found here.
Happy coding! 💻