diff --git a/private/apigen/common.go b/private/apigen/common.go index 7cdffe356..222482802 100644 --- a/private/apigen/common.go +++ b/private/apigen/common.go @@ -121,6 +121,20 @@ func isNillableType(t reflect.Type) bool { return false } +// isJSONOmittableType returns whether the "omitempty" JSON tag option works with struct fields of this type. +func isJSONOmittableType(t reflect.Type) bool { + switch t.Kind() { + case reflect.Array, reflect.Map, reflect.Slice, reflect.String, + reflect.Bool, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, + reflect.Float32, reflect.Float64, + reflect.Interface, reflect.Pointer: + return true + } + return false +} + func capitalize(s string) string { r, size := utf8.DecodeRuneInString(s) if size <= 0 { @@ -167,3 +181,39 @@ func filter(types []typeAndName, keep func(typeAndName) bool) []typeAndName { } return filtered } + +type jsonTagInfo struct { + FieldName string + OmitEmpty bool + Skip bool +} + +func parseJSONTag(structType reflect.Type, field reflect.StructField) jsonTagInfo { + tag, ok := field.Tag.Lookup("json") + if !ok { + panic(fmt.Sprintf("(%s).%s missing json tag", structType.String(), field.Name)) + } + + options := strings.Split(tag, ",") + for i, opt := range options { + options[i] = strings.TrimSpace(opt) + } + + fieldName := options[0] + if fieldName == "" { + panic(fmt.Sprintf("(%s).%s missing json field name", structType.String(), field.Name)) + } + if fieldName == "-" && len(options) == 1 { + return jsonTagInfo{Skip: true} + } + + info := jsonTagInfo{FieldName: fieldName} + for _, opt := range options[1:] { + if opt == "omitempty" { + info.OmitEmpty = isJSONOmittableType(field.Type) + break + } + } + + return info +} diff --git a/private/apigen/docgen.go b/private/apigen/docgen.go index d4bade64c..b62979218 100644 --- a/private/apigen/docgen.go +++ b/private/apigen/docgen.go @@ -156,9 +156,9 @@ func getTypeNameRecursively(t reflect.Type, level int) string { var fields []string for i := 0; i < t.NumField(); i++ { field := t.Field(i) - jsonTag := field.Tag.Get("json") - if jsonTag != "" && jsonTag != "-" { - fields = append(fields, prefix+"\t"+jsonTag+": "+getTypeNameRecursively(field.Type, level+1)) + jsonInfo := parseJSONTag(t, field) + if !jsonInfo.Skip { + fields = append(fields, prefix+"\t"+jsonInfo.FieldName+": "+getTypeNameRecursively(field.Type, level+1)) } } return fmt.Sprintf("%s{\n%s\n%s}\n", prefix, strings.Join(fields, "\n"), prefix) diff --git a/private/apigen/example/apidocs.gen.md b/private/apigen/example/apidocs.gen.md index 5685cd9ce..3008bf895 100644 --- a/private/apigen/example/apidocs.gen.md +++ b/private/apigen/example/apidocs.gen.md @@ -1,6 +1,6 @@ # API Docs -**Description:** +**Description:** **Version:** `v0` diff --git a/private/apigen/example/client-api-mock.gen.ts b/private/apigen/example/client-api-mock.gen.ts index 93ea4791f..23f088c32 100644 --- a/private/apigen/example/client-api-mock.gen.ts +++ b/private/apigen/example/client-api-mock.gen.ts @@ -2,44 +2,25 @@ // 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; + metadata: Metadata; } export class Metadata { - owner: string; + owner?: string; tags?: string[][]; } -export class UsersGetResponseItem { +export class NewDocument { + content: string; +} + +export class User { name: string; surname: string; email: string; @@ -50,14 +31,6 @@ export class Version { 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, @@ -86,12 +59,12 @@ export class DocumentsHttpApiV0 { this.respStatusCode = respStatusCode; } - public async get(): Promise { + 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":"2001-02-03T03:05:06.000000007Z"}]}]') as DocsGetResponse; + return JSON.parse('[{"id":"00000000-0000-0000-0000-000000000000","date":"0001-01-01T00:00:00Z","pathParam":"/workspace/notes.md","body":"","version":{"date":"0001-01-01T00:00:00Z","number":0},"metadata":{"owner":"Storj","tags":[["category","general"]]}}]') as Document[]; } public async getOne(path: string): Promise { @@ -99,7 +72,7 @@ export class DocumentsHttpApiV0 { throw new APIError('mock error message: ' + this.respStatusCode, this.respStatusCode); } - return JSON.parse('{"id":"00000000-0000-0000-0000-000000000000","date":"2001-02-02T04:05:06.000000007Z","pathParam":"ID","body":"## Notes","version":{"date":"2001-02-03T03:35:06.000000007Z","number":1}}') as Document; + return JSON.parse('{"id":"00000000-0000-0000-0000-000000000000","date":"2001-02-02T04:05:06.000000007Z","pathParam":"ID","body":"## Notes","version":{"date":"2001-02-03T03:35:06.000000007Z","number":1},"metadata":{"tags":null}}') as Document; } public async getTag(path: string, tagName: string): Promise { @@ -118,12 +91,12 @@ export class DocumentsHttpApiV0 { return JSON.parse('[{"date":"2001-01-19T04:05:06.000000007Z","number":1},{"date":"2001-02-02T23:05:06.000000007Z","number":2}]') as Version[]; } - public async updateContent(request: DocsUpdateContentRequest, path: string, id: UUID, date: Time): Promise { + public async updateContent(request: NewDocument, 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":"2001-02-03T04:05:06.000000007Z","pathParam":"ID","body":"## Notes\n### General"}') as DocsUpdateContentResponse; + return JSON.parse('{"id":"00000000-0000-0000-0000-000000000000","date":"2001-02-03T04:05:06.000000007Z","pathParam":"ID","body":"## Notes\n### General","version":{"date":"0001-01-01T00:00:00Z","number":0},"metadata":{"tags":null}}') as Document; } } @@ -146,15 +119,15 @@ export class UsersHttpApiV0 { this.respStatusCode = respStatusCode; } - public async get(): Promise { + 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; + 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 User[]; } - public async create(request: UsersCreateRequest): Promise { + public async create(request: User[]): Promise { if (this.respStatusCode !== 0) { throw new APIError('mock error message: ' + this.respStatusCode, this.respStatusCode); } diff --git a/private/apigen/example/client-api.gen.ts b/private/apigen/example/client-api.gen.ts index 260eb8575..62f8d6dfd 100644 --- a/private/apigen/example/client-api.gen.ts +++ b/private/apigen/example/client-api.gen.ts @@ -4,44 +4,25 @@ 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; pathParam: string; body: string; version: Version; + metadata: Metadata; } export class Metadata { - owner: string; + owner?: string; tags?: string[][]; } -export class UsersCreateRequestItem { +export class NewDocument { + content: string; +} + +export class User { name: string; surname: string; email: string; @@ -52,14 +33,6 @@ export class Version { 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, @@ -73,11 +46,11 @@ 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 DocsGetResponse); + return response.json().then((body) => body as Document[]); } const err = await response.json(); throw new APIError(err.error, response.status); @@ -113,14 +86,14 @@ export class DocumentsHttpApiV0 { throw new APIError(err.error, response.status); } - public async updateContent(request: DocsUpdateContentRequest, path: string, id: UUID, date: Time): Promise { + public async updateContent(request: NewDocument, path: string, id: UUID, date: Time): Promise { const u = new URL(`${this.ROOT_PATH}/${path}`, window.location.href); 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 DocsUpdateContentResponse); + return response.json().then((body) => body as Document); } const err = await response.json(); throw new APIError(err.error, response.status); @@ -131,17 +104,17 @@ export class UsersHttpApiV0 { private readonly http: HttpClient = new HttpClient(); private readonly ROOT_PATH: string = '/api/v0/users'; - 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 UsersGetResponse); + return response.json().then((body) => body as User[]); } const err = await response.json(); throw new APIError(err.error, response.status); } - public async create(request: UsersCreateRequest): Promise { + public async create(request: User[]): Promise { const fullPath = `${this.ROOT_PATH}/`; const response = await this.http.post(fullPath, JSON.stringify(request)); if (response.ok) { diff --git a/private/apigen/example/myapi/types.go b/private/apigen/example/myapi/types.go index 65380dd7a..0b18725c0 100644 --- a/private/apigen/example/myapi/types.go +++ b/private/apigen/example/myapi/types.go @@ -27,7 +27,7 @@ type Version struct { // Metadata is metadata associated to a document. type Metadata struct { - Owner string `json:"owner"` + Owner string `json:"owner,omitempty"` Tags [][2]string `json:"tags"` } diff --git a/private/apigen/tstypes.go b/private/apigen/tstypes.go index aae0f3020..0845378a3 100644 --- a/private/apigen/tstypes.go +++ b/private/apigen/tstypes.go @@ -130,24 +130,17 @@ func (types *Types) GenerateTypescriptDefinitions() string { for i := 0; i < t.Type.NumField(); i++ { field := t.Type.Field(i) - attributes := strings.Fields(field.Tag.Get("json")) - if len(attributes) == 0 || attributes[0] == "" { - pathParts := strings.Split(t.Type.PkgPath(), "/") - pkg := pathParts[len(pathParts)-1] - panic(fmt.Sprintf("(%s.%s).%s missing json declaration", pkg, name, field.Name)) - } - - jsonField := attributes[0] - if jsonField == "-" { + jsonInfo := parseJSONTag(t.Type, field) + if jsonInfo.Skip { continue } isOptional := "" - if isNillableType(field.Type) { + if isNillableType(field.Type) || jsonInfo.OmitEmpty { isOptional = "?" } - pf("\t%s%s: %s;", jsonField, isOptional, TypescriptTypeName(field.Type)) + pf("\t%s%s: %s;", jsonInfo.FieldName, isOptional, TypescriptTypeName(field.Type)) } }() }