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)

Get Documents (go to full list)

@@ -184,3 +185,23 @@ Get the list of registered users ``` +

Create User (go to full list)

+ +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("}") +}