Winglang

Creating a simple API with Winglang

Learn about the power of cloud-agnostic IaC with Winglang

Andre Lopes
Level Up Coding
Published in
10 min readApr 22, 2024

--

Source: https://www.winglang.io/

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 synthesized
  • postSynth — Code that will run after the hook will have access to the raw Terraform JSON configuration
  • validate — This is called after the postSynth 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 use bring cloud for example, which imports the official cloud 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 example let db = new cloud.Table({...}) which defines a new NoSQL table (DynamoDB in AWS)
  • inflight — This keyword defines an inflight 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! 💻

--

--