package clientgen import ( "bytes" "fmt" "regexp" "sort" "strings" "unicode" "github.com/cockroachdb/errors" . "github.com/dave/jennifer/jen" "github.com/fatih/structtag" "encr.dev/internal/gocodegen" "encr.dev/internal/version" "encr.dev/parser/encoding" "encr.dev/pkg/clientgen/clientgentypes" "encr.dev/pkg/idents" meta "encr.dev/proto/encore/parser/meta/v1" schema "encr.dev/proto/encore/parser/schema/v1" ) // goGenVersion allows us to introduce breaking changes in the generated code but behind a switch // meaning that people with client code reliant on the old behaviour can continue to generate the // old code. type goGenVersion int const ( // GoInitial is the originally released Go client generator GoInitial goGenVersion = iota // GoExperimental can be used to lock experimental or uncompleted features in the generated code // It should always be the last item in the enum GoExperimental ) const goGenLatestVersion = GoExperimental - 1 type golang struct { md *meta.Data enc *gocodegen.MarshallingCodeGenerator generatorVersion goGenVersion skipDocs bool skipPkgTypePrefix bool seenSlicePath bool seenLiteralNull bool } func GenTypes(md *meta.Data, typs ...*schema.Decl) ([]byte, error) { g := &golang{ generatorVersion: goGenLatestVersion, md: md, enc: gocodegen.NewMarshallingCodeGenerator(gocodegen.UnknownPkgPath, "serde", true), skipDocs: true, skipPkgTypePrefix: true, } rtn := &bytes.Buffer{} stmt := Add() g.generateTypeDefinitions(stmt, typs) err := stmt.Render(rtn) return rtn.Bytes(), err } func (g *golang) Generate(p clientgentypes.GenerateParams) (err error) { g.md = p.Meta g.enc = gocodegen.NewMarshallingCodeGenerator(gocodegen.UnknownPkgPath, "serde", true) namedTypes := getNamedTypes(p.Meta, p.Services) // Create a new client file file := NewFile("client") file.HeaderComment(doNotEditHeader()) // Generate the parent Client struct g.generateClient(file, p.AppSlug, p.Services) // Generate the types and service client structs seenNs := make(map[string]bool) for _, service := range p.Meta.Svcs { nsName := service.Name g.generateTypeDefinitions(file, namedTypes.Decls(nsName)) seenNs[nsName] = true if hasPublicRPC(service) && p.Services.Has(service.Name) { if err := g.generateServiceClient(file, service, p.Tags); err != nil { return errors.Wrapf(err, "unable to generate service client for service: %s", service) } } } for _, ns := range namedTypes.Namespaces() { if !seenNs[ns] { g.generateTypeDefinitions(file, namedTypes.Decls(ns)) } } // Generate the base client if err := g.generateBaseClient(file); err != nil { return errors.Wrap(err, "unable to generate base client") } g.writeExtraHelpers(file) // Write the APIError type g.writeErrorType(file) // Generate the serializer g.enc.WriteToFile(file) // Finally, render the client if err := file.Render(p.Buf); err != nil { return errors.Wrap(err, "unable to generate go client") } return nil } func (g *golang) Version() int { return int(g.generatorVersion) } func (g *golang) cleanServiceName(service *meta.Service) string { return strings.Title(strings.ToLower(service.Name)) } // generateClient creates the Client struct, Option type and New function func (g *golang) generateClient(file *File, appSlug string, set clientgentypes.ServiceSet) { // List all services which have public RPCs or types we need fieldDef := make([]Code, 0, len(set.List())) fieldInit := make(Dict) for _, service := range g.md.Svcs { if !hasPublicRPC(service) || !set.Has(service.Name) { continue } name := g.cleanServiceName(service) fieldDef = append(fieldDef, Id(name).Id(fmt.Sprintf("%sClient", name)), ) fieldInit[Id(name)] = Op("&").Id(fmt.Sprintf("%sClient", strings.ToLower(name))).Values(Id("base")) } // The client struct file.Commentf("Client is an API client for the %s Encore application.", appSlug) file.Add( Type().Id("Client").Struct(fieldDef...), Line(), Line(), ) file.Comment("BaseURL is the base URL for calling the Encore application's API.") file.Type().Id("BaseURL").String() file.Line() file.Const().Id("Local").Id("BaseURL").Op("=").Lit("http://localhost:4000") file.Line() file.Comment("Environment returns a BaseURL for calling the cloud environment with the given name.") file.Func().Id("Environment"). Params(Id("name").String()). Id("BaseURL"). Block(Return( Id("BaseURL").Call( Qual("fmt", "Sprintf").Call( Lit(fmt.Sprintf("https://%%s-%s.encr.app", appSlug)), Id("name"), ), ), )) file.Comment("PreviewEnv returns a BaseURL for calling the preview environment with the given PR number.") file.Func().Id("PreviewEnv"). Params(Id("pr").Int()). Id("BaseURL"). Block(Return(Id("Environment").Call( Qual("fmt", "Sprintf").Call(Lit("pr%d"), Id("pr")), ))) // Option type alias file.Comment("Option allows you to customise the baseClient used by the Client") file.Add( Type().Id("Option").Op("="). Func().Params(Id("client").Op("*").Id("baseClient")).Error(), Line(), Line(), ) // New Function file.Comment("New returns a Client for calling the public and authenticated APIs of your Encore application.") file.Comment("You can customize the behaviour of the client using the given Option functions, such as WithHTTPClient or WithAuthFunc.") file.Add( Func().Id("New"). Params( Id("target").Id("BaseURL"), Id("options").Id("...Option"), ). Params(Op("*").Id("Client"), Error()). Block( Comment("Parse the base URL where the Encore application is being hosted"), List(Id("baseURL"), Err()). Op(":="). Qual("net/url", "Parse").Call(String().Call(Id("target"))), If(Err().Op("!=").Nil()).Block( Return( Nil(), Qual("fmt", "Errorf").Call(Lit("unable to parse base url: %w"), Err()), ), ), Line(), Comment("Create a client with sensible defaults"), Id("base").Op(":=").Op("&").Id("baseClient").Values(Dict{ Id("baseURL"): Id("baseURL"), Id("httpClient"): Qual("net/http", "DefaultClient"), Id("userAgent"): Lit(fmt.Sprintf("%s-Generated-Go-Client (Encore/%s)", appSlug, version.Version)), }), Line(), Comment("Apply any given options"), For(List(Id("_"), Id("option")).Op(":=").Range().Id("options")).Block( If( Id("err").Op(":=").Id("option").Call(Id("base")), Id("err").Op("!=").Nil(), ).Block( Return( Nil(), Qual("fmt", "Errorf").Call( Lit("unable to apply client option: %w"), Id("err"), ), ), ), ), Line(), Return(Op("&").Id("Client").Values(fieldInit), Nil()), ), ) // Generate the WithHttpClient function g.generateOptionFunc( file, "HTTPClient", `can be used to configure the underlying HTTP client used when making API calls. Defaults to http.DefaultClient`, &Statement{Id("client").Id("HTTPDoer")}, &Statement{ Id("base").Dot("httpClient").Op("=").Id("client"), Return(Nil()), }, ) if g.md.AuthHandler != nil { typ := g.getType(g.md.AuthHandler.Params) rawType := typ funcName := "AuthToken" paramName := "bearerToken" pointer := Id(paramName) comment := "an authentication token to be used for each request.\n\nThis token will be sent as a Bearer token in the Authorization header." if g.md.AuthHandler.Params.GetBuiltin() != schema.Builtin_STRING { funcName = "Auth" paramName = "auth" pointer = Id("auth") typ = rawType comment = "the authentication data to be used with each request" } // Generate the WithAuth function g.generateOptionFunc( file, funcName, "allows you to set "+comment, // Note we take the auth data by value rather than a pointer to ensure it's safe // to use inside multiple goroutines &Statement{Id(paramName).Add(rawType)}, &Statement{ Id("base").Dot("authGenerator").Op("=").Func(). Params(Id("_").Qual("context", "Context")). Params(typ, Error()). Block(Return(pointer, Nil())), Return(Nil()), }, ) g.generateOptionFunc( file, "AuthFunc", "allows you to pass a function which is called for each request to return "+comment, &Statement{ Id("authGenerator").Func(). Params(Id("ctx").Qual("context", "Context")). Params(typ, Error()), }, &Statement{ &Statement{Id("base").Dot("authGenerator").Op("=").Id("authGenerator")}, Return(Nil()), }, ) } } // generateOptionFunc is a helper for reducing the boilerplate we have when creating the option functions func (g *golang) generateOptionFunc(file *File, optionName string, doc string, params *Statement, block *Statement) { for i, line := range strings.Split(doc, "\n") { if i == 0 { file.Commentf("With%s %s", optionName, line) } else { file.Comment(line) } } file.Func(). Id(fmt.Sprintf("With%s", optionName)). Params(*params...). Id("Option"). Block( Return(Func().Params(Id("base").Op("*").Id("baseClient")).Error().Block( *block..., )), ) } func (g *golang) generateServiceClient(file *File, service *meta.Service, tags clientgentypes.TagSet) error { name := g.cleanServiceName(service) interfaceName := fmt.Sprintf("%sClient", name) structName := fmt.Sprintf("%sClient", strings.ToLower(name)) // The interface if doc := getServiceDoc(g.md, service); doc != "" && !g.skipDocs { for _, line := range strings.Split(strings.TrimSpace(doc), "\n") { file.Comment(line) } file.Comment("") } file.Commentf("%s Provides you access to call public and authenticated APIs on %s. The concrete implementation is %s.", interfaceName, service.Name, structName) file.Comment("It is setup as an interface allowing you to use GoMock to create mock implementations during tests.") var interfaceMethods []Code for _, rpc := range service.Rpcs { if rpc.AccessType == meta.RPC_PRIVATE || !tags.IsRPCIncluded(rpc) { continue } // streaming endpoints not supported yet if rpc.StreamingRequest || rpc.StreamingResponse { continue } // Add the documentation for the API to the interface method if rpc.Doc != nil && !g.skipDocs { // Add a newline if this is not the first method if len(interfaceMethods) > 0 { interfaceMethods = append(interfaceMethods, Line()) } for _, line := range strings.Split(strings.TrimSpace(*rpc.Doc), "\n") { interfaceMethods = append(interfaceMethods, Comment(line)) } } interfaceMethods = append(interfaceMethods, Id(idents.Convert(rpc.Name, idents.PascalCase)).Add(g.rpcParams(rpc)).Add(g.rpcReturnType(rpc, false)), ) } file.Type().Id(interfaceName).Interface(interfaceMethods...) file.Line() // The struct file.Type().Id(structName).Struct( Id("base").Op("*").Id("baseClient"), ) file.Line() file.Var().Id("_").Id(interfaceName).Op("=").Params(Op("*").Id(structName)).Params(Nil()) // The API functions for _, rpc := range service.Rpcs { if rpc.AccessType == meta.RPC_PRIVATE { continue } // streaming endpoints not supported yet if rpc.StreamingRequest || rpc.StreamingResponse { continue } if rpc.Doc != nil && *rpc.Doc != "" && !g.skipDocs { for _, line := range strings.Split(strings.TrimSpace(*rpc.Doc), "\n") { if line != "" { file.Comment(line) } } } callSite, err := g.rpcCallSite(rpc) if err != nil { return errors.Wrapf(err, "rpc: %s", rpc.Name) } file.Func(). Params(Id("c").Op("*").Id(structName)). Id(idents.Convert(rpc.Name, idents.PascalCase)). Add( g.rpcParams(rpc), g.rpcReturnType(rpc, true), ).Block(callSite...) file.Line() } return nil } func (g *golang) rpcParams(rpc *meta.RPC) Code { params := []Code{ Id("ctx").Qual("context", "Context"), } if rpc.Path != nil && len(rpc.Path.Segments) > 0 { for _, segment := range rpc.Path.Segments { if segment.Type == meta.PathSegment_LITERAL { continue } // We'll default to strings for most things typ := String() switch segment.ValueType { case meta.PathSegment_BOOL: typ = Bool() case meta.PathSegment_INT8: typ = Int8() case meta.PathSegment_INT16: typ = Int16() case meta.PathSegment_INT32: typ = Int32() case meta.PathSegment_INT64: typ = Int64() case meta.PathSegment_INT: typ = Int() case meta.PathSegment_UINT8: typ = Uint8() case meta.PathSegment_UINT16: typ = Uint16() case meta.PathSegment_UINT32: typ = Uint32() case meta.PathSegment_UINT64: typ = Uint64() case meta.PathSegment_UINT: typ = Uint() } if segment.Type == meta.PathSegment_WILDCARD || segment.Type == meta.PathSegment_FALLBACK { typ = Index().Add(typ) } params = append(params, Id(g.nonReservedId(segment.Value)).Add(typ), ) } } if rpc.Proto == meta.RPC_RAW { params = append(params, Id("request").Op("*").Qual("net/http", "Request")) } else { if rpc.RequestSchema != nil { params = append(params, Id("params").Add(g.getType(rpc.RequestSchema))) } } return Params(params...) } // nonReservedId returns the given ID, unless we have it a reserved within the client function _or_ it's a reserved Go keyword func (g *golang) nonReservedId(id string) string { switch id { // our reserved keywords (or ID's we use within the generated client functions) case "c", "ctx", "request", "resp", "err", "reqEncoder", "headers", "queryString", "body", "respBody", "respHeaders", "respDecoder": return "_" + id // Go keywords case "break", "default", "func", "interface", "select", "case", "defer", "go", "map", "struct", "chan", "else", "goto", "package", "switch", "const", "fallthrough", "if", "range", "type", "continue", "for", "import", "return", "var": return "_" + id // Go predeclared identifiers case "append", "bool", "byte", "cap", "close", "complex", "complex64", "complex128", "uint16", "copy", "false", "float32", "float64", "imag", "int", "int8", "int16", "uint32", "int32", "int64", "iota", "len", "make", "new", "nil", "panic", "uint64", "print", "println", "real", "recover", "string", "true", "uint", "uint8", "uintptr": return "_" + id default: return id } } func (g *golang) rpcReturnType(rpc *meta.RPC, concreteImpl bool) Code { if rpc.Proto == meta.RPC_RAW { return Params(Op("*").Qual("net/http", "Response"), Error()) } if rpc.ResponseSchema == nil { return Error() } if concreteImpl { // For the concrete implementation we want the response type to be named so we can // refer to it without having to define a variable. return Params(Id("resp").Add(g.getType(rpc.ResponseSchema)), Err().Error()) } else { return Params(g.getType(rpc.ResponseSchema), Error()) } } func (g *golang) rpcCallSite(rpc *meta.RPC) (code []Code, err error) { // Work out how we're going to encode and call this RPC rpcEncoding, err := encoding.DescribeRPC(g.md, rpc, nil) if err != nil { return nil, errors.Wrapf(err, "rpc %s", rpc.Name) } // Raw end points just pass through the request // and need no further code generation if rpc.Proto == meta.RPC_RAW { code = append( code, Id("request").Op("=").Id("request").Dot("WithContext").Call(Id("ctx")), Line(), Comment("Check the request has the method set, as we can't guess what method is required"), If(Id("request").Dot("Method").Op("==").Lit("")).Block( Return( Nil(), Qual("errors", "New").Call(Lit("request.Method must be set")), ), ), Line(), Comment("Set the relative URL for the API call"), List(Id("path"), Err()).Op(":=").Qual("net/url", "Parse").Call(g.createApiPath(rpc, false)), If(Err().Op("!=").Nil()).Block( Return( Nil(), Qual("fmt", "Errorf").Call(Lit("unable to parse api url: %w"), Err()), ), ), If(Id("request").Dot("URL").Op("!=").Nil()).Block( Comment("If the request already has a URL associated, we'll keep any fields set inside it, and just override the schema, "), Comment("host and path to ensure the final URL which hit the right BaseURL"), Id("request").Dot("URL").Dot("Scheme").Op("=").Id("path").Dot("Scheme"), Id("request").Dot("URL").Dot("Host").Op("=").Id("path").Dot("Host"), Id("request").Dot("URL").Dot("Path").Op("=").Id("path").Dot("Path"), ).Else().Block( Id("request").Dot("URL").Op("=").Id("path"), ), Line(), Line(), Return(Id("c").Dot("base").Dot("Do").Call(Id("request"))), ) return } headers := Nil() body := Nil() withQueryString := false // Work out how we encode the Request Schema if rpc.RequestSchema != nil { reqEnc := rpcEncoding.DefaultRequestEncoding if len(reqEnc.HeaderParameters) > 0 || len(reqEnc.QueryParameters) > 0 { code = append(code, Comment("Convert our params into the objects we need for the request")) } enc := g.enc.NewPossibleInstance("reqEncoder") // Generate the headers if len(reqEnc.HeaderParameters) > 0 { values := Dict{} for _, field := range reqEnc.HeaderParameters { slice, err := enc.ToStringSlice( field.Type, Id("params").Dot(field.SrcName), ) if err != nil { return nil, errors.Wrapf(err, "unable to encode header %s", field.SrcName) } values[Lit(field.WireFormat)] = slice } headers = Id("headers") enc.Add(Id("headers").Op(":=").Qual("net/http", "Header").Values(values), Line()) } // Generate the query string if len(reqEnc.QueryParameters) > 0 { withQueryString = true values := Dict{} // Check the request schema for fields we can put in the query string for _, field := range reqEnc.QueryParameters { slice, err := enc.ToStringSlice( field.Type, Id("params").Dot(field.SrcName), ) if err != nil { return nil, errors.Wrapf(err, "unable to encode query fields %s", field.SrcName) } values[Lit(field.WireFormat)] = slice } enc.Add(Id("queryString").Op(":=").Qual("net/url", "Values").Values(values), Line()) } if rpc.ResponseSchema != nil { code = append(code, enc.Finalize( Id("err").Op("=").Qual("fmt", "Errorf").Call( Lit("unable to marshal parameters: %w"), enc.LastError(), ), Return(), )...) } else { code = append(code, enc.Finalize( Return(Qual("fmt", "Errorf").Call( Lit("unable to marshal parameters: %w"), enc.LastError(), )), )...) } // Generate the body if len(reqEnc.BodyParameters) > 0 { if len(reqEnc.HeaderParameters) == 0 && len(reqEnc.QueryParameters) == 0 { // In the simple case we can just encode the params as the body directly body = Id("params") } else { // Else we need a new struct called "body" body = Id("body") types, err := g.generateAnonStructTypes(reqEnc.BodyParameters, "json") if err != nil { return nil, err } values := Dict{} for _, field := range reqEnc.BodyParameters { values[Id(field.SrcName)] = Id("params").Dot(field.SrcName) } code = append(code, Comment("Construct the body with only the fields which we want encoded within the body (excluding query string or header fields)"), Id("body").Op(":=").Struct(types...).Values(values), Line(), ) } } } // Make the request resp := Nil() apiCallCode := func() Code { return Id("callAPI").Call( Id("ctx"), Id("c").Dot("base"), Lit(rpcEncoding.DefaultMethod), g.createApiPath(rpc, withQueryString), headers, body, resp, ) } // If there's no response schema, we can just return the call to the API directly if rpc.ResponseSchema == nil { code = append(code, List(Id("_"), Err()).Op(":=").Add(apiCallCode()), Return(Err()), ) return } hasAnonResponseStruct := false respEnc := rpcEncoding.ResponseEncoding // If we have a response object, we need if len(respEnc.BodyParameters) > 0 { if len(respEnc.HeaderParameters) == 0 { // If there are no other fields, we can just take the return type and pass it straight through resp = Op("&").Id("resp") } else { hasAnonResponseStruct = true resp = Op("&").Id("respBody") // we need to construct an anonymous struct types, err := g.generateAnonStructTypes(respEnc.BodyParameters, "json") if err != nil { return nil, errors.Wrap(err, "response unmarshal") } code = append(code, Comment("We only want the response body to marshal into these fields and none of the header fields,"), Comment("so we'll construct a new struct with only those fields."), Id("respBody").Op(":=").Struct(types...).Values(), Line(), ) } } // The API Call itself code = append(code, Comment("Now make the actual call to the API")) headersId := "_" if len(respEnc.HeaderParameters) > 0 { headersId = "respHeaders" code = append(code, Var().Id(headersId).Qual("net/http", "Header")) } code = append(code, List(Id(headersId), Err()).Op("=").Add(apiCallCode()), If(Err().Op("!=").Nil()).Block( Return(), ), Line(), ) // In we have an anonymous response struct, we need to copy the results into the full response struct if hasAnonResponseStruct || len(respEnc.HeaderParameters) > 0 { code = append(code, Comment("Copy the unmarshalled response body into our response struct")) enc := g.enc.NewPossibleInstance("respDecoder") for _, field := range respEnc.HeaderParameters { str, err := enc.FromString( field.Type, field.SrcName, Id(headersId).Dot("Get").Call(Lit(field.WireFormat)), Id(headersId).Dot("Values").Call(Lit(field.WireFormat)), true, ) if err != nil { return nil, errors.Wrapf(err, "unable to convert %s to string in response header", field.SrcName) } enc.Add(Id("resp").Dot(field.SrcName).Op("=").Add(str)) } for _, field := range respEnc.BodyParameters { enc.Add(Id("resp").Dot(field.SrcName).Op("=").Id("respBody").Dot(field.SrcName)) } code = append(code, enc.Finalize( Id("err").Op("=").Qual("fmt", "Errorf").Call( Lit("unable to unmarshal headers: %w"), enc.LastError(), ), Return(), )...) code = append(code, Line()) } code = append(code, Return()) return code, err } // goIdentifier converts a string into a valid Go identifier. func goIdentifier(input string) string { if input == "" { return "_" } // Convert string to rune slice for proper handling of Unicode characters runes := []rune(input) // Ensure the first character is a valid Go identifier start (letter or `_`) if !unicode.IsLetter(runes[0]) && runes[0] != '_' { runes[0] = '_' } // Regex to replace invalid characters (anything that isn't a letter, number, or `_`) invalidChars := regexp.MustCompile(`[^\p{L}\p{N}_]`) output := invalidChars.ReplaceAllString(string(runes), "_") return output } func (g *golang) declToID(decl *schema.Decl) *Statement { if g.skipPkgTypePrefix { return Id(goIdentifier(strings.Title(decl.Name))) } else { return Id(goIdentifier(fmt.Sprintf("%s%s", strings.Title(decl.Loc.PkgName), strings.Title(decl.Name)))) } } func (g *golang) getType(typ *schema.Type) Code { switch typ := typ.Typ.(type) { case *schema.Type_Named: decl := g.md.Decls[typ.Named.Id] named := g.declToID(decl) if len(typ.Named.TypeArguments) == 0 { return named } // Add the type arguments types := make([]Code, len(typ.Named.TypeArguments)) for i, t := range typ.Named.TypeArguments { types[i] = g.getType(t) } return named.Types(types...) case *schema.Type_List: return Index().Add(g.getType(typ.List.Elem)) case *schema.Type_Map: return Map(g.getType(typ.Map.Key)).Add(g.getType(typ.Map.Value)) case *schema.Type_Literal: switch typ.Literal.Value.(type) { case *schema.Literal_Str: return String() case *schema.Literal_Boolean: return Bool() case *schema.Literal_Int: return Int() case *schema.Literal_Float: return Float64() case *schema.Literal_Null: // Can't use nil as a type, so use *bool g.seenLiteralNull = true return Id("null") default: return Any() } case *schema.Type_Union: // There's no good way of representing unions in Go. // Use 'any' for now. return Any() case *schema.Type_Builtin: switch typ.Builtin { case schema.Builtin_ANY: return Any() case schema.Builtin_BOOL: return Bool() case schema.Builtin_INT: return Int() case schema.Builtin_INT8: return Int8() case schema.Builtin_INT16: return Int16() case schema.Builtin_INT32: return Int32() case schema.Builtin_INT64: return Int64() case schema.Builtin_UINT: return Uint() case schema.Builtin_UINT8: return Uint8() case schema.Builtin_UINT16: return Uint16() case schema.Builtin_UINT32: return Uint32() case schema.Builtin_UINT64: return Uint64() case schema.Builtin_FLOAT32: return Float32() case schema.Builtin_FLOAT64: return Float64() case schema.Builtin_STRING: return String() case schema.Builtin_BYTES: return Index().Byte() case schema.Builtin_TIME: return Qual("time", "Time") case schema.Builtin_JSON: return Qual("encoding/json", "RawMessage") case schema.Builtin_UUID, schema.Builtin_USER_ID, schema.Builtin_DECIMAL: // we don't want to add any custom deps, so these come in as strings return String() default: return Any() } case *schema.Type_Pointer: return Op("*").Add(g.getType(typ.Pointer.Base)) case *schema.Type_Option: // Avoid the dependency by using a pointer type instead of encore.dev/types/option.Option. value := typ.Option.Value // Avoid double pointer for ptr := value.GetPointer(); ptr != nil; ptr = value.GetPointer() { value = ptr.Base } return Op("*").Add(g.getType(value)) case *schema.Type_Struct: fields := make([]Code, 0, len(typ.Struct.Fields)) for _, field := range typ.Struct.Fields { // Skip over hidden fields if encoding.IgnoreField(field) { continue } // The base field name and type fieldTyp := Id(idents.Convert(field.Name, idents.PascalCase)).Add(g.getType(field.Typ)) // Add the field tags if field.RawTag != "" { tags, err := structtag.Parse(field.RawTag) if err != nil { panic("raw tags failed to parse") // This shouldn't happen at runtime, because the parser should have caught this } tagsForJen := make(map[string]string) for _, tag := range tags.Tags() { tagsForJen[tag.Key] = tag.Value() } fieldTyp = fieldTyp.Tag(tagsForJen) } // Add the docs for the field if field.Doc != "" && !g.skipDocs { lines := strings.Split(strings.TrimSpace(field.Doc), "\n") if len(lines) == 1 { fieldTyp = fieldTyp.Comment(lines[0]) } else { fields = append(fields, Line()) for _, line := range lines { fields = append(fields, Comment(line)) } } } // Finally, add the field to the list of fields on the struct fields = append(fields, fieldTyp) } return Struct(fields...) case *schema.Type_TypeParameter: decl := g.md.Decls[typ.TypeParameter.DeclId] typeParam := decl.TypeParams[typ.TypeParameter.ParamIdx] return Id(typeParam.Name) case *schema.Type_Config: // Config types are invisible outside of the Encore app return g.getType(typ.Config.Elem) default: return Any() } } func (g *golang) createApiPath(rpc *meta.RPC, withQueryString bool) (urlPath *Statement) { var url strings.Builder params := make([]Code, 0) for _, segment := range rpc.Path.Segments { url.WriteByte('/') if segment.Type == meta.PathSegment_LITERAL { url.WriteString(segment.Value) } else { paramID := Id(g.nonReservedId(segment.Value)) switch segment.ValueType { case meta.PathSegment_STRING, meta.PathSegment_UUID: url.WriteString("%s") if segment.Type == meta.PathSegment_WILDCARD || segment.Type == meta.PathSegment_FALLBACK { g.seenSlicePath = true paramID = Id("pathEscapeSlice").Call(paramID) } else { paramID = Qual("net/url", "PathEscape").Call(paramID) } case meta.PathSegment_BOOL: url.WriteString("%t") case meta.PathSegment_INT8, meta.PathSegment_INT16, meta.PathSegment_INT32, meta.PathSegment_INT64, meta.PathSegment_INT, meta.PathSegment_UINT8, meta.PathSegment_UINT16, meta.PathSegment_UINT32, meta.PathSegment_UINT64, meta.PathSegment_UINT: url.WriteString("%d") default: url.WriteString("%v") } params = append(params, paramID) } } // Construct the query string if withQueryString { url.WriteString("?%s") params = append(params, Id("queryString").Dot("Encode").Call()) } if len(params) == 0 { urlPath = Lit(url.String()) } else { // Prepend the string format params = append([]Code{Lit(url.String())}, params...) urlPath = Qual("fmt", "Sprintf").Call(params...) } return urlPath } // FileStatement is an interface implemented by both jen.File and jen.Statement type FileStatement interface { Comment(string) *Statement Line() *Statement Type() *Statement } func (g *golang) generateTypeDefinitions(stmt FileStatement, decls []*schema.Decl) { sort.Slice(decls, func(i, j int) bool { return decls[i].Name < decls[j].Name }) for _, decl := range decls { // Write the docs if decl.Doc != "" && !g.skipDocs { for _, line := range strings.Split(strings.TrimSpace(decl.Doc), "\n") { stmt.Comment(line) } } else { stmt.Line() } // Create the base type definition; `type X[T]` typ := stmt.Type().Add(g.declToID(decl)) if len(decl.TypeParams) > 0 { types := make([]Code, len(decl.TypeParams)) for i, param := range decl.TypeParams { types[i] = Id(param.Name).Any() } typ = typ.Types(types...) } // All types which are not structs should be aliases if decl.Type.GetStruct() == nil && len(decl.TypeParams) == 0 { typ = typ.Op("=") } // Add the type typ.Add(g.getType(decl.Type)) } } func (g *golang) generateAnonStructTypes(fields []*encoding.ParameterEncoding, encodingTag string) (types []Code, err error) { for _, field := range fields { var tagValue strings.Builder tagValue.WriteString(field.Name) // Parse the tags and extract the encoding tag tags, err := structtag.Parse(field.RawTag) if err != nil { return nil, errors.Wrapf(err, "parse tags: %s", field.SrcName) } if tag, err := tags.Get(encodingTag); err == nil { options := strings.Join(tag.Options, ",") if options != "" { tagValue.WriteRune(',') tagValue.WriteString(options) } } types = append( types, Id(idents.Convert(field.SrcName, idents.PascalCase)).Add(g.getType(field.Type)).Tag(map[string]string{encodingTag: tagValue.String()}), ) } return } func (g *golang) generateBaseClient(file *File) (err error) { // Add the interface file.Comment("HTTPDoer is an interface which can be used to swap out the default") file.Comment("HTTP client (http.DefaultClient) with your own custom implementation.") file.Comment("This can be used to inject middleware or mock responses during unit tests.") file.Type().Id("HTTPDoer").Interface( Id("Do"). Params(Id("req").Op("*").Qual("net/http", "Request")). Params(Op("*").Qual("net/http", "Response"), Error()), ) // Add the base client struct file.Line() file.Comment("baseClient holds all the information we need to make requests to an Encore application") file.Type().Id("baseClient").StructFunc(func(grp *Group) { if g.md.AuthHandler != nil { typ := g.getType(g.md.AuthHandler.Params) grp.Id("authGenerator").Func(). Params(Id("ctx").Qual("context", "Context")). Params(typ, Error()). Comment("The function which will add the authentication data to the requests") } grp.Id("httpClient").Id("HTTPDoer"). Comment("The HTTP client which will be used for all API requests") grp.Id("baseURL").Op("*").Qual("net/url", "URL"). Comment("The base URL which API requests will be made against") grp.Id("userAgent").String(). Commentf("What user agent we will use in the API requests") }) // Add the Do method for th base client file.Line() file.Comment("Do sends the req to the Encore application adding the authorization token as required.") file.Func(). Params(Id("b").Op("*").Id("baseClient")). Id("Do"). Params(Id("req").Op("*").Qual("net/http", "Request")). Params(Op("*").Qual("net/http", "Response"), Error()). BlockFunc(func(grp *Group) { grp.Id("req").Dot("Header").Dot("Set").Call( Lit("Content-Type"), Lit("application/json"), ) grp.Id("req").Dot("Header").Dot("Set").Call( Lit("User-Agent"), Id("b").Dot("userAgent"), ) grp.Line() if g.md.AuthHandler != nil { err = g.addAuthData(grp) if err != nil { return } } grp.Comment("Merge the base URL and the API URL") grp.Id("req").Dot("URL").Op("="). Id("b").Dot("baseURL").Dot("ResolveReference").Call(Id("req").Dot("URL")) grp.Id("req").Dot("Host").Op("=").Id("req").Dot("URL").Dot("Host") grp.Line() grp.Comment("Finally, make the request via the configured HTTP Client") grp.Return( Id("b").Dot("httpClient").Dot("Do").Call(Id("req")), ) }) if err != nil { return } // Add the call API function file.Line() file.Comment("callAPI is used by each generated API method to actually make request and decode the responses") file.Func(). Id("callAPI"). Params( Id("ctx").Qual("context", "Context"), Id("client").Op("*").Id("baseClient"), Id("method"), Id("path").String(), Id("headers").Qual("net/http", "Header"), List(Id("body"), Id("resp")).Any(), ). Params(Qual("net/http", "Header"), Error()). Block( Comment("Encode the API body"), Var().Id("bodyReader").Qual("io", "Reader"), If(Id("body").Op("!=").Nil()).Block( List(Id("bodyBytes"), Err()).Op(":="). Qual("encoding/json", "Marshal"). Call(Id("body")), If(Err().Op("!=").Nil()).Block( Return(Nil(), Qual("fmt", "Errorf").Call(Lit("marshal request: %w"), Err())), ), Id("bodyReader").Op("=").Qual("bytes", "NewReader").Call(Id("bodyBytes")), ), Line(), Comment("Create the request"), List(Id("req"), Err()).Op(":="). Qual("net/http", "NewRequestWithContext"). Call( Id("ctx"), Id("method"), Id("path"), Id("bodyReader"), ), If(Err().Op("!=").Nil()).Block( Return(Nil(), Qual("fmt", "Errorf").Call(Lit("create request: %w"), Err())), ), Line(), Comment("Add any headers to the request"), For(List(Id("header"), Id("values")).Op(":=").Range().Id("headers")).Block( For(List(Id("_"), Id("value")).Op(":=").Range().Id("values")).Block( Id("req").Dot("Header").Dot("Add").Call(Id("header"), Id("value")), ), ), Line(), Comment("Make the request via the base client"), List(Id("rawResponse"), Err()).Op(":="). Id("client").Dot("Do").Call(Id("req")), If(Err().Op("!=").Nil()).Block( Return(Nil(), Qual("fmt", "Errorf").Call(Lit("request failed: %w"), Err())), ), Defer().Func().Params().Block( Id("_").Op("=").Id("rawResponse").Dot("Body").Dot("Close").Call(), ).Call(), If(Id("rawResponse").Dot("StatusCode").Op(">=").Lit(400)).Block( Comment("Read the full body sent back"), List(Id("body"), Err()).Op(":=").Qual("io", "ReadAll").Call(Id("rawResponse").Dot("Body")), If(Err().Op("!=").Nil()).Block( Return( Nil(), Op("&").Id("APIError").Values(Dict{ Id("Code"): Id("ErrUnknown"), Id("Message"): Qual("fmt", "Sprintf").Call(Lit("got error response without readable body: %s"), Id("rawResponse").Dot("Status")), }), ), ), Line(), Comment("Attempt to decode the error response as a structured APIError"), Id("apiError").Op(":=").Op("&").Id("APIError").Block(), If( Err().Op(":=").Qual("encoding/json", "Unmarshal"). Call(Id("body"), Id("apiError")), Err().Op("!=").Nil(), ).Block( Comment("If the error is not a parsable as an APIError, then return an error with the raw body"), Return( Nil(), Op("&").Id("APIError").Values(Dict{ Id("Code"): Id("ErrUnknown"), Id("Message"): Qual("fmt", "Sprintf").Call(Lit("got error response: %s"), String().Call(Id("body"))), }), ), ), Return(Nil(), Id("apiError")), ), Line(), Comment("Decode the response"), If( Id("resp").Op("!=").Nil(), ).Block( If( Err().Op(":=").Qual("encoding/json", "NewDecoder"). Call(Id("rawResponse").Dot("Body")). Dot("Decode"). Call(Id("resp")), Err().Op("!=").Nil(), ).Block( Return(Nil(), Qual("fmt", "Errorf").Call(Lit("decode response: %w"), Err())), ), ), Return( Id("rawResponse").Dot("Header"), Nil(), ), ) return nil } func (g *golang) writeErrorType(file *File) { const ErrPrefix = "Err" // Create the error type file.Line() file.Comment("APIError is the error type returned by the API") file.Type().Id("APIError").Struct( Id("Code").Id("ErrCode").Tag(map[string]string{"json": "code"}), Id("Message").String().Tag(map[string]string{"json": "message"}), Id("Details").Any().Tag(map[string]string{"json": "details"}), ) file.Func().Params(Id("e").Op("*").Id("APIError")).Id("Error").Params().String().Block( Return(Qual("fmt", "Sprintf").Call(Lit("%s: %s"), Id("e").Dot("Code"), Id("e").Dot("Message"))), ) file.Line() file.Line() // Create the ErrCode type and list file.Type().Id("ErrCode").Int() errTypes := make([]Code, 0) for i, err := range errorCodes { for _, line := range strings.Split(strings.TrimSpace(err.Comment), "\n") { // Fix the comment with the prefix if strings.HasPrefix(line, err.Name) { line = ErrPrefix + line } errTypes = append(errTypes, Comment(line)) } errTypes = append( errTypes, Id(ErrPrefix+err.Name).Id("ErrCode").Op("=").Lit(i), Line(), ) } file.Const().Defs(errTypes...) // Create the functions to convert an error to string file.Comment("// String returns the string representation of the error code") file.Func().Params(Id("c").Id("ErrCode")).Id("String").Params().String().Block( Switch(Id("c")).BlockFunc(func(g *Group) { for _, err := range errorCodes { g.Case(Id(ErrPrefix + err.Name)).Block( Return(Lit(idents.Convert(err.Name, idents.SnakeCase))), ) } g.Default().Block( Return(Lit("unknown")), ) }), ) file.Line() file.Comment("MarshalJSON converts the error code to a human-readable string") file.Func().Params(Id("c").Id("ErrCode")).Id("MarshalJSON").Params().Params(Index().Byte(), Error()).Block( Return(Index().Byte().Call(Qual("fmt", "Sprintf").Call(Lit("\"%s\""), Id("c"))), Nil()), ) file.Line() file.Comment("UnmarshalJSON converts the human-readable string to an error code") file.Func().Params(Id("c").Op("*").Id("ErrCode")).Id("UnmarshalJSON").Params(Id("b").Index().Byte()).Error().Block( Switch(String().Call(Id("b"))).BlockFunc(func(g *Group) { for _, err := range errorCodes { g.Case(Lit(fmt.Sprintf("\"%s\"", idents.Convert(err.Name, idents.SnakeCase)))).Block( Op("*").Id("c").Op("=").Id(ErrPrefix + err.Name), ) } g.Default().Block( Op("*").Id("c").Op("=").Id(ErrPrefix + "Unknown"), ) }), Return(Nil()), ) } func (g *golang) writeExtraHelpers(file *File) { if g.seenSlicePath { file.Line() file.Comment("// pathEscapeSlice escapes a slice of strings and then joins them into a single string") file.Func().Id("pathEscapeSlice").Params(Id("paths").Index().String()).String().Block( Var().Id("escapedPaths").Qual("strings", "Builder"), For(List(Id("i"), Id("path")).Op(":=").Range().Id("paths")).Block( If(Id("i").Op(">").Lit(0)).Block( Id("escapedPaths").Dot("WriteString").Call(Lit("/")), ), Id("escapedPaths").Dot("WriteString").Call(Qual("net/url", "PathEscape").Call(Id("path"))), ), Return(Id("escapedPaths").Dot("String").Call()), ) } if g.seenLiteralNull { file.Line() file.Comment("null is a helper type to indicate a null value in JSON.") file.Type().Id("null").Op("=").Op("*").Bool() } } func (g *golang) addAuthData(grp *Group) (err error) { grp.Comment("If a authorization data generator is present, call it and add the returned token to the request") // If the auth data is a string, then we want to add it as a bearer token if g.md.AuthHandler.Params.GetBuiltin() == schema.Builtin_STRING { grp.If(Id("b").Dot("authGenerator").Op("!=").Nil()).Block( If( List(Id("token"), Err()).Op(":="). Id("b").Dot("authGenerator").Call( Id("req").Dot("Context").Call(), ), Err().Op("!=").Nil(), ).Block( Return(Nil(), Qual("fmt", "Errorf").Call(Lit("unable to create authorization token for api request: %w"), Err())), ).Else().If(Id("token").Op("!=").Lit("")).Block( Id("req").Dot("Header").Dot("Set").Call( Lit("Authorization"), Qual("fmt", "Sprintf"). Call(Lit("Bearer %s"), Id("token")), ), ), ) grp.Line() return nil } // Otherwise, we need to add the complex data type auth, err := encoding.DescribeAuth(g.md, g.md.AuthHandler.Params, nil) if err != nil { return errors.Wrap(err, "unable to describe auth data") } authGeneratorCodeBlock := If( List(Id("authData"), Err()).Op(":="). Id("b").Dot("authGenerator").Call( Id("req").Dot("Context").Call(), ), Err().Op("!=").Nil(), ).Block( Return(Nil(), Qual("fmt", "Errorf").Call(Lit("unable to create authorization token for api request: %w"), Err())), ).Else() if g.md.AuthHandler.AuthData.GetPointer() != nil { authGeneratorCodeBlock = authGeneratorCodeBlock.If(Id("authData").Op("!=").Nil()) } authGeneratorCodeBlock = authGeneratorCodeBlock.BlockFunc(func(grp *Group) { enc := g.enc.NewPossibleInstance("authEncoder") enc.Add(Line()) if len(auth.QueryParameters) > 0 { enc.Add(Comment("Add the auth fields to the query string"), Line()) enc.Add(Id("query").Op(":=").Id("req").Dot("URL").Dot("Query").Call(), Line()) // Check the request schema for fields we can put in the query string for _, field := range auth.QueryParameters { if field.Type.GetList() != nil { // If we have a slice, we need to encode each bit slice, err := enc.ToStringSlice( field.Type, Id("authData").Dot(idents.Convert(field.SrcName, idents.PascalCase)), ) if err != nil { err = errors.Wrapf(err, "unable to encode query fields %s", field.SrcName) return } enc.Add(For(List(Id("_"), Id("v")).Op(":=").Range().Add(slice)).Block( Id("query").Dot("Add").Call( Lit(field.WireFormat), Id("v"), ), ), Line()) } else if opt := field.Type.GetOption(); opt != nil { // We encode options as *T, so check if it's non-nil. enc.Add(If( Id("val").Op(":=").Add(Id("authData").Dot(idents.Convert(field.SrcName, idents.PascalCase))), Id("val").Op("!=").Nil(), ).BlockFunc(func(g *Group) { val, err := enc.ToString( field.Type, Id("val"), ) if err != nil { err = errors.Wrapf(err, "unable to encode query field %s", field.SrcName) return } g.Add(Id("query").Dot("Set").Call( Lit(field.WireFormat), val, ), Line()) })) } else { // Otherwise, we can just append the field val, err := enc.ToString( field.Type, Id("authData").Dot(idents.Convert(field.SrcName, idents.PascalCase)), ) if err != nil { err = errors.Wrapf(err, "unable to encode query field %s", field.SrcName) return } enc.Add(Id("query").Dot("Set").Call( Lit(field.WireFormat), val, ), Line()) } } enc.Add(Id("req").Dot("URL").Dot("RawQuery").Op("=").Id("query").Dot("Encode").Call(), Line(), Line()) } if len(auth.HeaderParameters) > 0 { enc.Add(Comment("Add the auth fields to the headers"), Line()) // Check the request schema for fields we can put in the query string for _, field := range auth.HeaderParameters { if field.Type.GetList() != nil { // If we have a slice, we need to encode each bit slice, err := enc.ToStringSlice( field.Type, Id("authData").Dot(idents.Convert(field.SrcName, idents.PascalCase)), ) if err != nil { err = errors.Wrapf(err, "unable to encode header fields %s", field.SrcName) return } enc.Add(For(List(Id("_"), Id("v")).Op(":=").Range().Add(slice)).Block( Id("req").Dot("Header").Dot("Add").Call( Lit(field.WireFormat), Id("v"), ), ), Line()) } else if opt := field.Type.GetOption(); opt != nil { // We encode options as *T, so check if it's non-nil. enc.Add(If( Id("val").Op(":=").Add(Id("authData").Dot(idents.Convert(field.SrcName, idents.PascalCase))), Id("val").Op("!=").Nil(), ).BlockFunc(func(g *Group) { val, err := enc.ToString( field.Type, Id("val"), ) if err != nil { err = errors.Wrapf(err, "unable to encode header field %s", field.SrcName) return } enc.Add(Id("req").Dot("Header").Dot("Set").Call( Lit(field.WireFormat), val, ), Line()) })) } else { // Otherwise, we can just append the field val, err := enc.ToString( field.Type, Id("authData").Dot(idents.Convert(field.SrcName, idents.PascalCase)), ) if err != nil { err = errors.Wrapf(err, "unable to encode header field %s", field.SrcName) return } enc.Add(Id("req").Dot("Header").Dot("Set").Call( Lit(field.WireFormat), val, ), Line()) } } } grp.Add(enc.Finalize(Return(Nil(), Qual("fmt", "Errorf").Call( Lit("unable to marshal authentication data: %w"), enc.LastError(), )))...) }) grp.If(Id("b").Dot("authGenerator").Op("!=").Nil()).Block( authGeneratorCodeBlock, ) grp.Line() return }