tygor

API-First approach k6 extension development

Iván Szkiba

https://github.com/szkiba/tygor

Agenda

  • Use Case
  • Under The Hood
  • Demo

As a k6 extension developer

What I want:

  • define my k6 extension's API in an IDL
  • just write the implementation in golang
  • generate API doc from the definition

What I don't want:

  • write boilerplate code
  • understand JavaScript bindings
  • use any Node.js based tool

I want to define my k6 extension's API in an IDL!

export as namespace hitchhiker;

type int = number;

export declare class Guide {
  question: string;
  readonly answer: int;

  constructor(question: string);
  check(value: int): boolean;
}

declare const defaultGuide: Guide;

export default defaultGuide;

I don't want to write boilerplate code!

$ # generate only bindings
$ tygor hitchhiker.d.ts                  
$ ls
hitchhiker_bindings.go
hitchhiker.d.ts

 

$ # generate both bindings and skeletons
$ tygor --skeleton hitchhiker.d.ts       
$ ls
hitchhiker_bindings.go
hitchhiker_skeleton.go
hitchhiker.d.ts

I don't want to understand how the binding is done!

type goGuide interface {                              // export declare class Guide {
  checkMethod(valueArg int) (bool, error)             //   check(value: int): boolean
  questionGetter() (string, error)                    //   question: string
  questionSetter(v string) error                      //
  answerGetter() (int, error)                         //   readonly answer: int
}                                                     // }

type goModule interface { 
  newGuide(questionArg string) (goGuide, error)       // constructor(question: string)
  defaultGuideGetter() (goGuide, error)               // export default defaultGuide
}

type goModuleConstructor func(vu modules.VU) goModule // export as namespace ...

 
 

I just want to implement my k6 extension's API!

func init() { register(newModule) }

func newModule(_ modules.VU) goModule {
	return &goModuleImpl{defaultGuide: &goGuideImpl{question: "What's up?"}}
}

type goModuleImpl struct{ defaultGuide goGuide }

func (self *goModuleImpl) newGuide(questionArg string) (goGuide, error) {
	return &goGuideImpl{question: questionArg}, nil
}

func (self *goModuleImpl) checkMethod(valueArg int) (bool, error) {
	return self.defaultGuide.checkMethod(valueArg)
}

func (self *goModuleImpl) defaultGuideGetter() (goGuide, error) { return self.defaultGuide, nil }

type goGuideImpl struct{ question string }

func (self *goGuideImpl) checkMethod(valueArg int) (bool, error) {
	return valueArg == 42, nil
}

func (self *goGuideImpl) questionGetter() (string, error) { return self.question, nil }

func (self *goGuideImpl) questionSetter(questionArg string) error {
	self.question = questionArg
	return nil
}

func (self *goGuideImpl) answerGetter() (int, error) {
	return 42, nil
}

Let's see how it works!

import { describe, expect } from "https://jslib.k6.io/k6chaijs/4.3.4.3/index.js";
import guide, { Guide } from "k6/x/hitchhiker";

export default function () {
  describe("default", () => {
    expect(guide).to.have.property("answer", 42);
    expect(guide).to.have.property("question", "What's up?");
    expect(guide.check(42)).to.be.true;
    expect(guide.check(43)).to.be.false;
    expect(() => (guide.answer = 2)).to.throw(TypeError);
    guide.question = "Why are we here?";
    expect(guide).to.have.property("question", "Why are we here?");
  });

  describe("Guide", () => {
    const guide = new Guide("What is life all about?");
    expect(guide).to.have.property("answer", 42);
    expect(guide).to.have.property("question", "What is life all about?");
    expect(guide.check(42)).to.be.true;
    expect(guide.check(43)).to.be.false;
    expect(() => (guide.answer = 2)).to.throw(TypeError);
    guide.question = "Why are we here?";
    expect(guide).to.have.property("question", "Why are we here?");
  });
}

export const options = { thresholds: { checks: ["rate==1"] } };

I want to generate API doc without using Node.js!

$ # generate markdown documentation                 
$ tygor doc -o README.md faker.d.ts
$ ls
README.md
faker.d.ts
$ # generate HTML documentation                     
$ tygor doc -o index.html faker.d.ts
$ ls
index.html
faker.d.ts

see also examples/faker
or live faker example API docs

...but I want it as part of a larger document!

$ # inject as markdown into existing documentation   
$ tygor doc --inject README.md faker.d.ts
$ ls
README.md
faker.d.ts

 

$ # inject as HTML into existing documentation        
$ tygor doc --inject index.html faker.d.ts
$ ls
index.html
faker.d.ts

Oh, I want to do something special...

 
 

$ # convert TypeScript declarations to JSON        
$ tygor parse faker.d.ts | jq .

 
 
 
 

Under The Hood

Design Considerations

  • support an API-First approach
  • use JavaScript's de facto IDL, TypeScript
  • generate API documentation from IDL
  • re-generatable output in case of API change
  • single binary without dependencies

How It Works

  • embedding a real TypeScript compiler
  • embedded JavaScript engine (goja)
  • model building written in TypeScript
  • JSON interface between script part and go
  • Jennifer as a go source code generator
  • go interface and implementation are separated
  • go template engine for generating Markdown
  • HTML generation from Markdown

Demo

Sources are available in tygor repository:

examples/

That's All Folks!

Thank you for watching the presentation about API-First approach k6 extension development. Let's see what this presentation will be about.

The first part is a use case, from which it becomes clear what the API-First approach is in terms of k6 extension development. The second part is a brief technological overview of what's under the hood. How code generation works, what were the design considerations. The last part is a live demo, using code fragments from the first part.

This may be subjective, but let's see what I want as a k6 extension developer and what I don't: I would like to define the extension API in an IDL. Since the de facto IDL in the case of JavaScript is the TypeScript declaration file, I would like to describe the API using it. I would like go interfaces and methods to be automatically generated from the API description, and I would only have to implement them. I would like the API definition to be the primary source of documentation as well. --- I don't want to write boiler plate code. Nobody likes to write boiler plate code. I don't want to understand how the JavaScript engine and the go runtime fit together, I don't want to understand the bindings. As a go developer, I don't want to use Node.js based tools. Not because I don't like it, but because it would significantly increase the complexity of a go project.

As an example, let's look at a simple, completely useless extension. There is also a more usable extension in the examples directory, such as a simplified version of the faker extension. The example should fit on one slide, so I chose this example. What we see is a TypeScript declaration file. It defines a class called "Guide", with two properties, one method, and one constructor. The type of the "question" property is string, writable and readable. The type of the "answer" property is "int" and is read-only. Since there is no int type in TypeScript (and JavaScript), all numbers are of type "number". The "number" type maps to the "float64" go type by default. However, I want this property to be of type "int" in go, so I created a type alias from "int" to "number". As a result, the type "int" will appear in the generated go interfaces. In addition to the Guide class, the API definition also defines a Guide instance as a default export.

Instead of writing boring boiler plate code, it is a good practice to use a code generator. The following command generates the go interfaces to be implemented based on the API definition and optionally an empty skeleton implementation. The skeleton file contains a special "skeleton" go build tag, so it is not involved in the go compilation. The real implementation is placed in another file. In this way, code generation can be run at any time after the API changes. The skeleton file can be used to help implement API changes.

The go interfaces generated by the code generator can be matched almost one-to-one with those included in the API definition. In addition to the defined interfaces, an interface called "Module" is also created, which represents the extension as a JavaScript module. It contains the module-level variables, functions, the default export and the factory methods belonging to the constructors of each class. The binding is included in the generated code, so the developer just needs to focus on the implementation.

Huh, that seems like a lot to implement a simple extension. Note, however, that this is the complete implementation code, with extra features such as readonly property and type mapping. On the other hand, the goal is not to minimize the implementation code, but to simplify the development of the extension. This implementation is made from the generated skeleton with minimal modifications. The code is about 90% identical to the generated skeleton. ​Sorry if it's hard to read, this example can also be found in the examples directory of the GitHub repository. I put it on a slide so that we have an impression of what needs to be implemented. On the other hand, I will use this as a cheat sheet in the live demo.

Here is a simple integration test that demonstrates how to use the example extension in k6. The test uses k6chaijs and checks all elements of the API: default export, Guide class instantiation, property writing and reading, method call. In the case of a read-only property, we will get a "TypeError" exception as expected. The value of the read-only "answer" property is of course 42. If any "expect" fails, the k6 run will fail.

The API definition is the primary source for everything, including the documentation. The commands below generate Markdown and HTML API documentation from the API definition. The advantage of this solution is that there is no need to complicate the extension development and build process by using Node.js tools. Internally the HTML documentation is made from the Markdown format. At the moment, the generated documentation is minimalistic, and there is room for improvement in the future. Examples of generated documentation can be found in the examples/faker directory.

In many cases, the API documentation is part of a containing document. For example, the README.md file of the extension's repository contains a chapter on the API documentation. In such cases, the location of the API documentation can be marked with a so-called marker comment. The generation replaces the part between the marker comments with the current API documentation. Instead of the output, the containing document must be specified using the "inject" flag.

And of course there is the case when I want something completely different from the API definition. The API definition can be converted into a JSON data model and processed with other tools. So the API definition remains the single source of truth. External tools can use a JSON parser instead of the complicated TypeScript declaration file parsing. ​

A few technological details about the tygor follow.

What were the design considerations of tygor? One of the main considerations was to support an API-First approach. That is, first the developer designs (and documents) the JavaScript API of the k6 extension in an interface description language, then the go source code and documentation are generated from this. The de facto interface description language of JavaScript is the TypeScript declaration file. That's why it was chosen. The API changes over time, so it was important to be able to regenerate the output in case of changes. From a convenience point of view, it is good if the implementation is available in a single binary form.

A few bullet points about how tygor works. A more detailed description can be found in the readme file. An API model is built as a first step to generate the go source code and documentation. The API model build was implemented in TypeScript. The TypeScript declaration file is parsed using the TypeScript compiler API. For this purpose, a real TypeScript compiler is embedded in tygor. The embedded TypeScript compiler runs on a built-in JavaScript engine (goja), which may be familiar from k6. The API model is passed as a JSON string from the JavaScript engine to the go code. The go source code is generated from the API model using the Jennifer go source code generator library. The documentation is generated from the API model using the go template engine. The HTML version of the documentation will be made from the Markdown version using the blackfriday library.

Now the live demo follows. I'm going to cheat a little, I'm going to copy code from the previous slides of the presentation. That is, the presentation will be the cheat sheet for the demo. First I create a working directory. cd /tmp mkdir demo demo cd I'm initializing a new go module. go mod init example.com/demo I create the file containing the API definition (first cheat). vi hitchhiker.d.ts I generate the go source code from the API definition. tygor --skeleton hitchhiker.d.ts I will copy the skeleton file, this will be the implementation. cp hitchhiker_skeleton.go hitchhiker.go I will prepare the implementation (second cheat). I download the necessary go modules. go mod tidy I write the integration test (third cheat). vi test.js And finally I run the integration test. xk6 run test.js

That's all I wanted to share with you about the tygor in brief. Thank you for your attention.