private/apigen: handle omitempty JSON option in structs

This change makes the TypeScript API code generator properly handle
struct fields with the "omitempty" option in the JSON struct tag.

Change-Id: I9b22ce33a8b8c39c115ec827a8e5b7e85d856f83
This commit is contained in:
Jeremy Wharton 2023-11-14 18:11:41 -06:00
parent cbc82690d7
commit 0c591fa25a
7 changed files with 88 additions and 99 deletions

View File

@ -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
}

View File

@ -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)

View File

@ -1,6 +1,6 @@
# API Docs
**Description:**
**Description:**
**Version:** `v0`

View File

@ -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<DocsGetResponseItem>
export type DocsGetResponseItemLastRetrievals = Array<DocsGetResponseItemLastRetrievalsItem>
export type UsersCreateRequest = Array<UsersGetResponseItem>
export type UsersGetResponse = Array<UsersGetResponseItem>
class APIError extends Error {
constructor(
public readonly msg: string,
@ -86,12 +59,12 @@ export class DocumentsHttpApiV0 {
this.respStatusCode = respStatusCode;
}
public async get(): Promise<DocsGetResponse> {
public async get(): 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","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<Document> {
@ -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<string[]> {
@ -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<DocsUpdateContentResponse> {
public async updateContent(request: NewDocument, path: string, id: UUID, date: Time): 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":"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<UsersGetResponse> {
public async get(): Promise<User[]> {
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<void> {
public async create(request: User[]): Promise<void> {
if (this.respStatusCode !== 0) {
throw new APIError('mock error message: ' + this.respStatusCode, this.respStatusCode);
}

View File

@ -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<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,
@ -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<DocsGetResponse> {
public async get(): Promise<Document[]> {
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<DocsUpdateContentResponse> {
public async updateContent(request: NewDocument, path: string, id: UUID, date: Time): Promise<Document> {
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<UsersGetResponse> {
public async get(): Promise<User[]> {
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<void> {
public async create(request: User[]): Promise<void> {
const fullPath = `${this.ROOT_PATH}/`;
const response = await this.http.post(fullPath, JSON.stringify(request));
if (response.ok) {

View File

@ -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"`
}

View File

@ -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))
}
}()
}