private/apigen: Add a TypeScript client mock generator

Implement a TypeScript client mock generator to generate mocks with
static data specified in the API definition.

Change-Id: I11419f4bbf72576fcd829f9d4acd8471034ca571
This commit is contained in:
Ivan Fraixedes 2023-10-18 19:54:03 +02:00
parent 882c9d64e4
commit 9338f3f088
No known key found for this signature in database
GPG Key ID: FB6101AFB5CB5AD5
8 changed files with 460 additions and 0 deletions

View File

@ -54,6 +54,10 @@ type Endpoint struct {
// PathParams is the list of path parameters that appear in the path associated with this
// endpoint.
PathParams []Param
// ResponseMock is the data to use as a response for the generated mocks.
// It must be of the same type than Response.
// If a mock generator is called it must not be nil unless Response is nil.
ResponseMock interface{}
}
// CookieAuth returns endpoint's cookie auth status.
@ -112,6 +116,14 @@ func (e *Endpoint) Validate() error {
reflect.UnsafePointer:
return errsEndpoint.New("Response cannot be of a type %q", k)
}
if e.ResponseMock != nil {
if m, r := reflect.TypeOf(e.ResponseMock), reflect.TypeOf(e.Response); m != r {
return errsEndpoint.New(
"ResponseMock isn't of the same type than Response. Have=%q Want=%q", m, r,
)
}
}
}
return nil

View File

@ -127,6 +127,20 @@ func TestEndpoint_Validate(t *testing.T) {
},
errMsg: fmt.Sprintf("Response cannot be of a type %q", reflect.Map),
},
{
testName: "different ResponseMock type",
endpointFn: func() *Endpoint {
e := validEndpoint
e.Response = int(0)
e.ResponseMock = int8(0)
return &e
},
errMsg: fmt.Sprintf(
"ResponseMock isn't of the same type than Response. Have=%q Want=%q",
reflect.TypeOf(int8(0)),
reflect.TypeOf(int(0)),
),
},
}
for _, tc := range tcases {

View File

@ -54,6 +54,11 @@ type UsersService interface {
Surname string "json:\"surname\""
Email string "json:\"email\""
}, api.HTTPError)
Create(ctx context.Context, request []struct {
Name string "json:\"name\""
Surname string "json:\"surname\""
Email string "json:\"email\""
}) api.HTTPError
}
// DocumentsHandler is an api handler that implements all Documents API endpoints functionality.
@ -100,6 +105,7 @@ func NewUsers(log *zap.Logger, mon *monkit.Scope, service UsersService, router *
usersRouter := router.PathPrefix("/api/v0/users").Subrouter()
usersRouter.HandleFunc("/", handler.handleGet).Methods("GET")
usersRouter.HandleFunc("/", handler.handleCreate).Methods("POST")
return handler
}
@ -321,3 +327,33 @@ func (h *UsersHandler) handleGet(w http.ResponseWriter, r *http.Request) {
h.log.Debug("failed to write json Get response", zap.Error(ErrUsersAPI.Wrap(err)))
}
}
func (h *UsersHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer h.mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
payload := []struct {
Name string "json:\"name\""
Surname string "json:\"surname\""
Email string "json:\"email\""
}{}
if err = json.NewDecoder(r.Body).Decode(&payload); err != nil {
api.ServeError(h.log, w, http.StatusBadRequest, err)
return
}
ctx, err = h.auth.IsAuthenticated(ctx, r, true, true)
if err != nil {
h.auth.RemoveAuthCookie(w)
api.ServeError(h.log, w, http.StatusUnauthorized, err)
return
}
httpErr := h.service.Create(ctx, payload)
if httpErr.Err != nil {
api.ServeError(h.log, w, httpErr.Status, httpErr.Err)
}
}

View File

@ -14,6 +14,7 @@
* [Update Content](#documents-update-content)
* Users
* [Get Users](#users-get-users)
* [Create User](#users-create-user)
<h3 id='documents-get-documents'>Get Documents (<a href='#list-of-endpoints'>go to full list</a>)</h3>
@ -184,3 +185,23 @@ Get the list of registered users
```
<h3 id='users-create-user'>Create User (<a href='#list-of-endpoints'>go to full list</a>)</h3>
Create a user
`POST /api/v0/users/`
**Request body:**
```typescript
[
{
name: string
surname: string
email: string
}
]
```

View File

@ -0,0 +1,164 @@
// AUTOGENERATED BY private/apigen
// DO NOT EDIT.
import { Time, UUID } from '@/types/common';
export class DocsGetResponseItem {
id: UUID;
path: string;
date: Time;
metadata: Metadata;
last_retrievals?: DocsGetResponseItemLastRetrievals;
}
export class DocsGetResponseItemLastRetrievalsItem {
user: string;
when: Time;
}
export class DocsUpdateContentRequest {
content: string;
}
export class DocsUpdateContentResponse {
id: UUID;
date: Time;
pathParam: string;
body: string;
}
export class Document {
id: UUID;
date: Time;
pathParam: string;
body: string;
version: Version;
}
export class Metadata {
owner: string;
tags?: string[][];
}
export class UsersCreateRequestItem {
name: string;
surname: string;
email: string;
}
export class Version {
date: Time;
number: number;
}
export type DocsGetResponse = Array<DocsGetResponseItem>
export type DocsGetResponseItemLastRetrievals = Array<DocsGetResponseItemLastRetrievalsItem>
export type UsersCreateRequest = Array<UsersCreateRequestItem>
export type UsersGetResponse = Array<UsersCreateRequestItem>
class APIError extends Error {
constructor(
public readonly msg: string,
public readonly responseStatusCode?: number,
) {
super(msg);
}
}
export class DocumentsHttpApiV0 {
public readonly respStatusCode: number;
// When respStatuscode is passed, the client throws an APIError on each method call
// with respStatusCode as HTTP status code.
// respStatuscode must be equal or greater than 400
constructor(respStatusCode?: number) {
if (typeof respStatusCode === 'undefined') {
this.respStatusCode = 0;
return;
}
if (respStatusCode < 400) {
throw new Error('invalid response status code for API Error, it must be greater or equal than 400');
}
this.respStatusCode = respStatusCode;
}
public async get(): Promise<DocsGetResponse> {
if (this.respStatusCode != 0) {
throw new APIError('mock error message: ' + this.respStatusCode, this.respStatusCode);
}
return JSON.parse("[{\"id\":\"00000000-0000-0000-0000-000000000000\",\"path\":\"/workspace/notes.md\",\"date\":\"0001-01-01T00:00:00Z\",\"metadata\":{\"owner\":\"Storj\",\"tags\":[[\"category\",\"general\"]]},\"last_retrievals\":[{\"user\":\"Storj\",\"when\":\"2023-10-19T12:54:40.418932461+02:00\"}]}]") as DocsGetResponse;
}
public async getOne(path: string): Promise<Document> {
if (this.respStatusCode != 0) {
throw new APIError('mock error message: ' + this.respStatusCode, this.respStatusCode);
}
return JSON.parse("{\"id\":\"00000000-0000-0000-0000-000000000000\",\"date\":\"2023-10-18T13:54:40.418935224+02:00\",\"pathParam\":\"ID\",\"body\":\"## Notes\",\"version\":{\"date\":\"2023-10-19T13:24:40.418935292+02:00\",\"number\":1}}") as Document;
}
public async getTag(path: string, tagName: string): Promise<string[]> {
if (this.respStatusCode != 0) {
throw new APIError('mock error message: ' + this.respStatusCode, this.respStatusCode);
}
return JSON.parse("[\"category\",\"notes\"]") as string[];
}
public async getVersions(path: string): Promise<Version[]> {
if (this.respStatusCode != 0) {
throw new APIError('mock error message: ' + this.respStatusCode, this.respStatusCode);
}
return JSON.parse("[{\"date\":\"2023-10-04T13:54:40.418937913+02:00\",\"number\":1},{\"date\":\"2023-10-19T08:54:40.418937979+02:00\",\"number\":2}]") as Version[];
}
public async updateContent(request: DocsUpdateContentRequest, path: string, id: UUID, date: Time): Promise<DocsUpdateContentResponse> {
if (this.respStatusCode != 0) {
throw new APIError('mock error message: ' + this.respStatusCode, this.respStatusCode);
}
return JSON.parse("{\"id\":\"00000000-0000-0000-0000-000000000000\",\"date\":\"2023-10-19T13:54:40.418939503+02:00\",\"pathParam\":\"ID\",\"body\":\"## Notes\\n### General\"}") as DocsUpdateContentResponse;
}
}
export class UsersHttpApiV0 {
public readonly respStatusCode: number;
// When respStatuscode is passed, the client throws an APIError on each method call
// with respStatusCode as HTTP status code.
// respStatuscode must be equal or greater than 400
constructor(respStatusCode?: number) {
if (typeof respStatusCode === 'undefined') {
this.respStatusCode = 0;
return;
}
if (respStatusCode < 400) {
throw new Error('invalid response status code for API Error, it must be greater or equal than 400');
}
this.respStatusCode = respStatusCode;
}
public async get(): Promise<UsersGetResponse> {
if (this.respStatusCode != 0) {
throw new APIError('mock error message: ' + this.respStatusCode, this.respStatusCode);
}
return JSON.parse("[{\"name\":\"Storj\",\"surname\":\"Labs\",\"email\":\"storj@storj.test\"},{\"name\":\"Test1\",\"surname\":\"Testing\",\"email\":\"test1@example.test\"},{\"name\":\"Test2\",\"surname\":\"Testing\",\"email\":\"test2@example.test\"}]") as UsersGetResponse;
}
public async create(request: UsersCreateRequest): Promise<void> {
if (this.respStatusCode != 0) {
throw new APIError('mock error message: ' + this.respStatusCode, this.respStatusCode);
}
return;
}
}

View File

@ -56,6 +56,8 @@ export type DocsGetResponse = Array<DocsGetResponseItem>
export type DocsGetResponseItemLastRetrievals = Array<DocsGetResponseItemLastRetrievalsItem>
export type UsersCreateRequest = Array<UsersGetResponseItem>
export type UsersGetResponse = Array<UsersGetResponseItem>
class APIError extends Error {
@ -138,4 +140,14 @@ export class UsersHttpApiV0 {
const err = await response.json();
throw new APIError(response.status, err.error);
}
public async create(request: UsersCreateRequest): Promise<void> {
const fullPath = `${this.ROOT_PATH}/`;
const response = await this.http.post(fullPath, JSON.stringify(request));
if (response.ok) {
return;
}
const err = await response.json();
throw new Error(err.error);
}
}

View File

@ -35,6 +35,30 @@ func main() {
When time.Time `json:"when"`
} `json:"last_retrievals"`
}{},
ResponseMock: []struct {
ID uuid.UUID `json:"id"`
Path string `json:"path"`
Date time.Time `json:"date"`
Metadata myapi.Metadata `json:"metadata"`
LastRetrievals []struct {
User string `json:"user"`
When time.Time `json:"when"`
} `json:"last_retrievals"`
}{{
ID: uuid.UUID{},
Path: "/workspace/notes.md",
Metadata: myapi.Metadata{
Owner: "Storj",
Tags: [][2]string{{"category", "general"}},
},
LastRetrievals: []struct {
User string `json:"user"`
When time.Time `json:"when"`
}{{
User: "Storj",
When: time.Now().Add(-time.Hour),
}},
}},
})
g.Get("/{path}", &apigen.Endpoint{
@ -46,6 +70,16 @@ func main() {
PathParams: []apigen.Param{
apigen.NewParam("path", ""),
},
ResponseMock: myapi.Document{
ID: uuid.UUID{},
Date: time.Now().Add(-24 * time.Hour),
PathParam: "ID",
Body: "## Notes",
Version: myapi.Version{
Date: time.Now().Add(-30 * time.Minute),
Number: 1,
},
},
})
g.Get("/{path}/tag/{tagName}", &apigen.Endpoint{
@ -58,6 +92,7 @@ func main() {
apigen.NewParam("path", ""),
apigen.NewParam("tagName", ""),
},
ResponseMock: [2]string{"category", "notes"},
})
g.Get("/{path}/versions", &apigen.Endpoint{
@ -69,6 +104,10 @@ func main() {
PathParams: []apigen.Param{
apigen.NewParam("path", ""),
},
ResponseMock: []myapi.Version{
{Date: time.Now().Add(-360 * time.Hour), Number: 1},
{Date: time.Now().Add(-5 * time.Hour), Number: 2},
},
})
g.Post("/{path}", &apigen.Endpoint{
@ -92,6 +131,17 @@ func main() {
PathParams: []apigen.Param{
apigen.NewParam("path", ""),
},
ResponseMock: struct {
ID uuid.UUID `json:"id"`
Date time.Time `json:"date"`
PathParam string `json:"pathParam"`
Body string `json:"body"`
}{
ID: uuid.UUID{},
Date: time.Now(),
PathParam: "ID",
Body: "## Notes\n### General",
},
})
g = a.Group("Users", "users")
@ -106,9 +156,31 @@ func main() {
Surname string `json:"surname"`
Email string `json:"email"`
}{},
ResponseMock: []struct {
Name string `json:"name"`
Surname string `json:"surname"`
Email string `json:"email"`
}{
{Name: "Storj", Surname: "Labs", Email: "storj@storj.test"},
{Name: "Test1", Surname: "Testing", Email: "test1@example.test"},
{Name: "Test2", Surname: "Testing", Email: "test2@example.test"},
},
})
g.Post("/", &apigen.Endpoint{
Name: "Create User",
Description: "Create a user",
GoName: "Create",
TypeScriptName: "create",
Request: []struct {
Name string `json:"name"`
Surname string `json:"surname"`
Email string `json:"email"`
}{},
})
a.MustWriteGo("api.gen.go")
a.MustWriteTS("client-api.gen.ts")
a.MustWriteTSMock("client-api-mock.gen.ts")
a.MustWriteDocs("apidocs.gen.md")
}

129
private/apigen/tsgenmock.go Normal file
View File

@ -0,0 +1,129 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
package apigen
import (
"encoding/json"
"fmt"
"strings"
"github.com/zeebo/errs"
)
// MustWriteTSMock writes generated TypeScript code into a file indicated by path.
// The generated code is an API client mock to run in the browser.
//
// If an error occurs, it panics.
func (a *API) MustWriteTSMock(path string) {
f := newTSGenMockFile(path, a)
f.generateTS()
err := f.write()
if err != nil {
panic(errs.Wrap(err))
}
}
type tsGenMockFile struct {
*tsGenFile
}
func newTSGenMockFile(filepath string, api *API) *tsGenMockFile {
return &tsGenMockFile{
tsGenFile: newTSGenFile(filepath, api),
}
}
func (f *tsGenMockFile) generateTS() {
f.pf("// AUTOGENERATED BY private/apigen")
f.pf("// DO NOT EDIT.")
f.registerTypes()
f.result += f.types.GenerateTypescriptDefinitions()
f.result += `
class APIError extends Error {
constructor(
public readonly msg: string,
public readonly responseStatusCode?: number,
) {
super(msg);
}
}
`
for _, group := range f.api.EndpointGroups {
f.createAPIClient(group)
}
}
func (f *tsGenMockFile) createAPIClient(group *EndpointGroup) {
f.pf("\nexport class %sHttpApi%s {", capitalize(group.Name), strings.ToUpper(f.api.Version))
// Properties.
f.pf("\tpublic readonly respStatusCode: number;")
f.pf("")
// Constructor
f.pf("\t// When respStatuscode is passed, the client throws an APIError on each method call")
f.pf("\t// with respStatusCode as HTTP status code.")
f.pf("\t// respStatuscode must be equal or greater than 400")
f.pf("\tconstructor(respStatusCode?: number) {")
f.pf("\t\tif (typeof respStatusCode === 'undefined') {")
f.pf("\t\t\tthis.respStatusCode = 0;")
f.pf("\t\t\treturn;")
f.pf("\t\t}")
f.pf("")
f.pf("\t\tif (respStatusCode < 400) {")
f.pf("\t\t\tthrow new Error('invalid response status code for API Error, it must be greater or equal than 400');")
f.pf("\t\t}")
f.pf("")
f.pf("\t\tthis.respStatusCode = respStatusCode;")
f.pf("\t}")
// Methods to call API endpoints.
for _, method := range group.endpoints {
f.pf("")
funcArgs, _ := f.getArgsAndPath(method, group)
returnType := "void"
if method.Response != nil {
if method.ResponseMock == nil {
panic(
fmt.Sprintf(
"ResponseMock is nil and Response isn't nil. Endpoint.Method=%q, Endpoint.Path=%q",
method.Method, method.Path,
))
}
respType := method.responseType(group)
returnType = TypescriptTypeName(respType)
}
f.pf("\tpublic async %s(%s): Promise<%s> {", method.TypeScriptName, funcArgs, returnType)
f.pf("\t\tif (this.respStatusCode != 0) {")
f.pf("\t\t\tthrow new APIError('mock error message: ' + this.respStatusCode, this.respStatusCode);")
f.pf("\t\t}")
f.pf("")
if method.ResponseMock != nil {
res, err := json.Marshal(method.ResponseMock)
if err != nil {
panic(
fmt.Sprintf(
"error when marshaling ResponseMock: %+v. Endpoint.Method=%q, Endpoint.Path=%q",
err, method.Method, method.Path,
))
}
f.pf("\t\treturn JSON.parse(%q) as %s;", string(res), returnType)
} else {
f.pf("\t\treturn;")
}
f.pf("\t}")
}
f.pf("}")
}