diff --git a/private/apigen/common.go b/private/apigen/common.go index cb15a9614..53d27d9ae 100644 --- a/private/apigen/common.go +++ b/private/apigen/common.go @@ -62,6 +62,15 @@ func (a *API) Group(name, prefix string) *EndpointGroup { ) } + for _, g := range a.EndpointGroups { + if strings.EqualFold(g.Name, name) { + panic(fmt.Sprintf("name has to be case-insensitive unique across all the groups. name=%q", name)) + } + if strings.EqualFold(g.Prefix, prefix) { + panic(fmt.Sprintf("prefix has to be case-insensitive unique across all the groups. prefix=%q", prefix)) + } + } + group := &EndpointGroup{ Name: name, Prefix: prefix, diff --git a/private/apigen/common_test.go b/private/apigen/common_test.go index 14b48c2d9..3d0778c53 100644 --- a/private/apigen/common_test.go +++ b/private/apigen/common_test.go @@ -83,4 +83,36 @@ func TestAPI_Group(t *testing.T) { api.Group("testname", "t_name") }) }) + + t.Run("group with repeated name", func(t *testing.T) { + api := API{} + + require.NotPanics(t, func() { + api.Group("testName", "tName") + }) + + require.Panics(t, func() { + api.Group("TESTNAME", "tName2") + }) + + require.Panics(t, func() { + api.Group("testname", "tName3") + }) + }) + + t.Run("group with repeated prefix", func(t *testing.T) { + api := API{} + + require.NotPanics(t, func() { + api.Group("testName", "tName") + }) + + require.Panics(t, func() { + api.Group("testName2", "tname") + }) + + require.Panics(t, func() { + api.Group("testname3", "tnamE") + }) + }) } diff --git a/private/apigen/endpoint.go b/private/apigen/endpoint.go index 860aa7220..29df2db81 100644 --- a/private/apigen/endpoint.go +++ b/private/apigen/endpoint.go @@ -122,7 +122,9 @@ type fullEndpoint struct { } // requestType guarantees to return a named Go type associated to the Endpoint.Request field. -func (fe fullEndpoint) requestType() reflect.Type { +// g is used to avoid clashes with types defined in different groups that are different, but with +// the same name. It cannot be nil. +func (fe fullEndpoint) requestType(g *EndpointGroup) reflect.Type { t := reflect.TypeOf(fe.Request) if t.Name() != "" { return t @@ -131,10 +133,10 @@ func (fe fullEndpoint) requestType() reflect.Type { switch k := t.Kind(); k { case reflect.Array, reflect.Slice: if t.Elem().Name() == "" { - t = typeCustomName{Type: t, name: compoundTypeName(fe.TypeScriptName, "Request")} + t = typeCustomName{Type: t, name: compoundTypeName(capitalize(g.Prefix), fe.TypeScriptName, "Request")} } case reflect.Struct: - t = typeCustomName{Type: t, name: compoundTypeName(fe.TypeScriptName, "Request")} + t = typeCustomName{Type: t, name: compoundTypeName(capitalize(g.Prefix), fe.TypeScriptName, "Request")} default: panic( fmt.Sprintf( @@ -148,7 +150,9 @@ func (fe fullEndpoint) requestType() reflect.Type { } // responseType guarantees to return a named Go type associated to the Endpoint.Response field. -func (fe fullEndpoint) responseType() reflect.Type { +// g is used to avoid clashes with types defined in different groups that are different, but with +// the same name. It cannot be nil. +func (fe fullEndpoint) responseType(g *EndpointGroup) reflect.Type { t := reflect.TypeOf(fe.Response) if t.Name() != "" { return t @@ -157,10 +161,10 @@ func (fe fullEndpoint) responseType() reflect.Type { switch k := t.Kind(); k { case reflect.Array, reflect.Slice: if t.Elem().Name() == "" { - t = typeCustomName{Type: t, name: compoundTypeName(fe.TypeScriptName, "Response")} + t = typeCustomName{Type: t, name: compoundTypeName(capitalize(g.Prefix), fe.TypeScriptName, "Response")} } case reflect.Struct: - t = typeCustomName{Type: t, name: compoundTypeName(fe.TypeScriptName, "Response")} + t = typeCustomName{Type: t, name: compoundTypeName(capitalize(g.Prefix), fe.TypeScriptName, "Response")} default: panic( fmt.Sprintf( diff --git a/private/apigen/example/api.gen.go b/private/apigen/example/api.gen.go index 770d1ddd9..600ef685f 100644 --- a/private/apigen/example/api.gen.go +++ b/private/apigen/example/api.gen.go @@ -22,6 +22,7 @@ import ( const dateLayout = "2006-01-02T15:04:05.999Z" var ErrDocsAPI = errs.Class("example docs api") +var ErrUsersAPI = errs.Class("example users api") type DocumentsService interface { Get(ctx context.Context) ([]struct { @@ -47,6 +48,14 @@ type DocumentsService interface { }, api.HTTPError) } +type UsersService interface { + Get(ctx context.Context) ([]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. type DocumentsHandler struct { log *zap.Logger @@ -55,6 +64,14 @@ type DocumentsHandler struct { auth api.Auth } +// UsersHandler is an api handler that implements all Users API endpoints functionality. +type UsersHandler struct { + log *zap.Logger + mon *monkit.Scope + service UsersService + auth api.Auth +} + func NewDocuments(log *zap.Logger, mon *monkit.Scope, service DocumentsService, router *mux.Router, auth api.Auth) *DocumentsHandler { handler := &DocumentsHandler{ log: log, @@ -73,6 +90,20 @@ func NewDocuments(log *zap.Logger, mon *monkit.Scope, service DocumentsService, return handler } +func NewUsers(log *zap.Logger, mon *monkit.Scope, service UsersService, router *mux.Router, auth api.Auth) *UsersHandler { + handler := &UsersHandler{ + log: log, + mon: mon, + service: service, + auth: auth, + } + + usersRouter := router.PathPrefix("/api/v0/users").Subrouter() + usersRouter.HandleFunc("/", handler.handleGet).Methods("GET") + + return handler +} + func (h *DocumentsHandler) handleGet(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var err error @@ -264,3 +295,29 @@ func (h *DocumentsHandler) handleUpdateContent(w http.ResponseWriter, r *http.Re h.log.Debug("failed to write json UpdateContent response", zap.Error(ErrDocsAPI.Wrap(err))) } } + +func (h *UsersHandler) handleGet(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") + + 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 + } + + retVal, httpErr := h.service.Get(ctx) + if httpErr.Err != nil { + api.ServeError(h.log, w, httpErr.Status, httpErr.Err) + return + } + + err = json.NewEncoder(w).Encode(retVal) + if err != nil { + h.log.Debug("failed to write json Get response", zap.Error(ErrUsersAPI.Wrap(err))) + } +} diff --git a/private/apigen/example/apidocs.gen.md b/private/apigen/example/apidocs.gen.md index 22d9ac873..4566b3805 100644 --- a/private/apigen/example/apidocs.gen.md +++ b/private/apigen/example/apidocs.gen.md @@ -12,6 +12,8 @@ * [Get a tag](#documents-get-a-tag) * [Get Version](#documents-get-version) * [Update Content](#documents-update-content) +* Users + * [Get Users](#users-get-users)

Get Documents (go to full list)

@@ -162,3 +164,23 @@ Update the content of the document with the specified path and ID if the last up ``` +

Get Users (go to full list)

+ +Get the list of registered users + +`GET /api/v0/users/` + +**Response body:** + +```typescript +[ + { + name: string + surname: string + email: string + } + +] + +``` + diff --git a/private/apigen/example/client-api.gen.ts b/private/apigen/example/client-api.gen.ts index fcbe1aac4..067f81abc 100644 --- a/private/apigen/example/client-api.gen.ts +++ b/private/apigen/example/client-api.gen.ts @@ -4,6 +4,30 @@ import { HttpClient } from '@/utils/httpClient'; 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; @@ -17,48 +41,32 @@ export class Metadata { tags?: string[][]; } +export class UsersGetResponseItem { + name: string; + surname: string; + email: string; +} + export class Version { date: Time; number: number; } -export class GetResponseItem { - id: UUID; - path: string; - date: Time; - metadata: Metadata; - last_retrievals?: GetResponseItemLastRetrievals; -} +export type DocsGetResponse = Array -export class GetResponseItemLastRetrievalsItem { - user: string; - when: Time; -} +export type DocsGetResponseItemLastRetrievals = Array -export class UpdateContentRequest { - content: string; -} - -export class UpdateContentResponse { - id: UUID; - date: Time; - pathParam: string; - body: string; -} - -export type GetResponse = Array - -export type GetResponseItemLastRetrievals = Array +export type UsersGetResponse = Array export class DocumentsHttpApiV0 { private readonly http: HttpClient = new HttpClient(); private readonly ROOT_PATH: string = '/api/v0/docs'; - public async get(): Promise { + public async get(): Promise { const fullPath = `${this.ROOT_PATH}/`; const response = await this.http.get(fullPath); if (response.ok) { - return response.json().then((body) => body as GetResponse); + return response.json().then((body) => body as DocsGetResponse); } const err = await response.json(); throw new Error(err.error); @@ -94,14 +102,29 @@ export class DocumentsHttpApiV0 { throw new Error(err.error); } - public async updateContent(request: UpdateContentRequest, path: string, id: UUID, date: Time): Promise { + public async updateContent(request: DocsUpdateContentRequest, path: string, id: UUID, date: Time): Promise { const u = new URL(`${this.ROOT_PATH}/${path}`); u.searchParams.set('id', id); u.searchParams.set('date', date); const fullPath = u.toString(); const response = await this.http.post(fullPath, JSON.stringify(request)); if (response.ok) { - return response.json().then((body) => body as UpdateContentResponse); + return response.json().then((body) => body as DocsUpdateContentResponse); + } + const err = await response.json(); + throw new Error(err.error); + } +} + +export class UsersHttpApiV0 { + private readonly http: HttpClient = new HttpClient(); + private readonly ROOT_PATH: string = '/api/v0/users'; + + public async get(): Promise { + const fullPath = `${this.ROOT_PATH}/`; + const response = await this.http.get(fullPath); + if (response.ok) { + return response.json().then((body) => body as UsersGetResponse); } 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 5af2ab871..dbaf53efd 100644 --- a/private/apigen/example/gen.go +++ b/private/apigen/example/gen.go @@ -94,6 +94,20 @@ func main() { }, }) + g = a.Group("Users", "users") + + g.Get("/", &apigen.Endpoint{ + Name: "Get Users", + Description: "Get the list of registered users", + GoName: "Get", + TypeScriptName: "get", + Response: []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.MustWriteDocs("apidocs.gen.md") diff --git a/private/apigen/tsgen.go b/private/apigen/tsgen.go index 0ae58fc0d..fdb000ce2 100644 --- a/private/apigen/tsgen.go +++ b/private/apigen/tsgen.go @@ -68,10 +68,10 @@ func (f *tsGenFile) registerTypes() { for _, group := range f.api.EndpointGroups { for _, method := range group.endpoints { if method.Request != nil { - f.types.Register(method.requestType()) + f.types.Register(method.requestType(group)) } if method.Response != nil { - f.types.Register(method.responseType()) + f.types.Register(method.responseType(group)) } if len(method.QueryParams) > 0 { for _, p := range method.QueryParams { @@ -91,12 +91,12 @@ func (f *tsGenFile) createAPIClient(group *EndpointGroup) { for _, method := range group.endpoints { f.pf("") - funcArgs, path := f.getArgsAndPath(method) + funcArgs, path := f.getArgsAndPath(method, group) returnStmt := "return" returnType := "void" if method.Response != nil { - respType := method.responseType() + respType := method.responseType(group) returnType = TypescriptTypeName(respType) returnStmt += fmt.Sprintf(" response.json().then((body) => body as %s)", returnType) } @@ -129,7 +129,7 @@ func (f *tsGenFile) createAPIClient(group *EndpointGroup) { f.pf("}") } -func (f *tsGenFile) getArgsAndPath(method *fullEndpoint) (funcArgs, path string) { +func (f *tsGenFile) getArgsAndPath(method *fullEndpoint, group *EndpointGroup) (funcArgs, path string) { // remove path parameter placeholders path = method.Path i := strings.Index(path, "{") @@ -139,7 +139,7 @@ func (f *tsGenFile) getArgsAndPath(method *fullEndpoint) (funcArgs, path string) path = "${this.ROOT_PATH}" + path if method.Request != nil { - funcArgs += fmt.Sprintf("request: %s, ", TypescriptTypeName(method.requestType())) + funcArgs += fmt.Sprintf("request: %s, ", TypescriptTypeName(method.requestType(group))) } for _, p := range method.PathParams {