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:
parent
882c9d64e4
commit
9338f3f088
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
|
164
private/apigen/example/client-api-mock.gen.ts
Normal file
164
private/apigen/example/client-api-mock.gen.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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
129
private/apigen/tsgenmock.go
Normal 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("}")
|
||||
}
|
Loading…
Reference in New Issue
Block a user