diff --git a/private/apigen/endpoint.go b/private/apigen/endpoint.go
index a408335fd..8f4ca8dda 100644
--- a/private/apigen/endpoint.go
+++ b/private/apigen/endpoint.go
@@ -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
diff --git a/private/apigen/endpoint_test.go b/private/apigen/endpoint_test.go
index 88a95aeba..fed8a42de 100644
--- a/private/apigen/endpoint_test.go
+++ b/private/apigen/endpoint_test.go
@@ -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 {
diff --git a/private/apigen/example/api.gen.go b/private/apigen/example/api.gen.go
index 600ef685f..94967baf2 100644
--- a/private/apigen/example/api.gen.go
+++ b/private/apigen/example/api.gen.go
@@ -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)
+ }
+}
diff --git a/private/apigen/example/apidocs.gen.md b/private/apigen/example/apidocs.gen.md
index 4566b3805..625cbb81d 100644
--- a/private/apigen/example/apidocs.gen.md
+++ b/private/apigen/example/apidocs.gen.md
@@ -14,6 +14,7 @@
* [Update Content](#documents-update-content)
* Users
* [Get Users](#users-get-users)
+ * [Create User](#users-create-user)
@@ -184,3 +185,23 @@ Get the list of registered users
```
+
+
+Create a user
+
+`POST /api/v0/users/`
+
+**Request body:**
+
+```typescript
+[
+ {
+ name: string
+ surname: string
+ email: string
+ }
+
+]
+
+```
+
diff --git a/private/apigen/example/client-api-mock.gen.ts b/private/apigen/example/client-api-mock.gen.ts
new file mode 100644
index 000000000..fa10e5605
--- /dev/null
+++ b/private/apigen/example/client-api-mock.gen.ts
@@ -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
+
+export type DocsGetResponseItemLastRetrievals = Array
+
+export type UsersCreateRequest = Array
+
+export type UsersGetResponse = Array
+
+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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ if (this.respStatusCode != 0) {
+ throw new APIError('mock error message: ' + this.respStatusCode, this.respStatusCode);
+ }
+
+ return;
+ }
+}
diff --git a/private/apigen/example/client-api.gen.ts b/private/apigen/example/client-api.gen.ts
index 3c4c6ee5e..67c571162 100644
--- a/private/apigen/example/client-api.gen.ts
+++ b/private/apigen/example/client-api.gen.ts
@@ -56,6 +56,8 @@ export type DocsGetResponse = Array
export type DocsGetResponseItemLastRetrievals = Array
+export type UsersCreateRequest = Array
+
export type UsersGetResponse = Array
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 {
+ 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);
+ }
}
diff --git a/private/apigen/example/gen.go b/private/apigen/example/gen.go
index dbaf53efd..0ad68135d 100644
--- a/private/apigen/example/gen.go
+++ b/private/apigen/example/gen.go
@@ -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")
}
diff --git a/private/apigen/tsgenmock.go b/private/apigen/tsgenmock.go
new file mode 100644
index 000000000..e2fcba640
--- /dev/null
+++ b/private/apigen/tsgenmock.go
@@ -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("}")
+}