goa icon indicating copy to clipboard operation
goa copied to clipboard

feat: provide better `NewClient` methods with `baseURL`/`basePath`

Open gabyx opened this issue 7 months ago • 5 comments

Hi there,

Could we make a pull request to add an essential part which is missing on instantiating a client from the Goa generated code:

The client takes the following signature:

func NewClient(
	scheme string,
	host string,
	doer goahttp.Doer,
	enc func(*http.Request) goahttp.Encoder,
	dec func(*http.Response) goahttp.Decoder,
	restoreBody bool,
) *Client

which leaves no room to set the base url like schema: https , baseURL: domain.com/a/b/c and all endpoints will start with https://domain.com/a/b/c/..... The host parameter cannot be misused as its used in url.URL{...} which has different semantics.

We would like to add another non-breaking function NewClientWithOpts(opts ... ClientOption) which lets us chose all relevant options, and defaulting the ones not set.

NewClientWithOpts(
  WithSchema("https"), 
  WithHost("domain.com:8080"), 
  WithURL(url.Parse("https://domain.com/a/b/c?a=asdf), // here only taking host and schema and path.
  WithDoer(...), 
  WithDecoder(...), 
  WithEncoder(...), 
  WithRestoreBody(true))

etc.

The old function NewClient can then forward to NewClientsWithOpts(WithBaseURL(url.URL{Scheme: scheme, Host: host})).

We really need this somewhat soon.

Implementation

type Client struct {
   ...
	scheme  string
	host    string
    basePath string // this is probably enough to add (prefix with `/` if not given)

	encoder func(*http.Request) goahttp.Encoder
	decoder func(*http.Response) goahttp.Decoder
}

gabyx avatar Jul 01 '25 14:07 gabyx

Have you tried Path DSL?

https://pkg.go.dev/goa.design/goa/v3/dsl#Path

tchssk avatar Jul 01 '25 15:07 tchssk

Didnt know about that, but it does not help that hardcodes after generation time.

What we are looking for is a runtime changeable base path for instanciating the client which is not only "hostname" (which is too limiting), also I have never seen a client constructor which cannot take a full URL.

We have to following:

A Software ComponentA wants to define what exact external endpoints (client) it will contact when running. When starting (at deploy time) ComponentA we want to configure this client URL of course: http://componentB.com/a/b/c. This ComponentA specifies that external endpoints in a Goa design (Go module) and generates client code once.

A software ComponentB of course needs to provide the server implementation for ComponentA to work. It will generate server code with its own Goa where it reuses some common Goa function (defining the GET method e.g.) from ComponentA's Go packages.

The problem: ComponentA has client code generated which does not allow to set a proper host URL, only hostname. Also we do not want and cant regenerate and compile ComponentA on the fly with dsl.Path.

client.NewClient is too limiting and not modular enough (no matter the Goa design), thats why I suggest to add proper options. Does that make sense or maybe there is a better way.

gabyx avatar Jul 03 '25 06:07 gabyx

You can use a path parameter in the Path DSL. The base path cannot be included in the client, but can be dynamically provided via the request payload. See Param DSL for an example.

https://pkg.go.dev/goa.design/goa/v3/dsl#Param

request := foo.GetPayload{
        BasePath: basePath,  // <-
        // ...
}
client := fooclient.NewClient(
        scheme,
        host,
        doer,
        encoder,
        decoder,
        restoreBody,
)
endpoint := client.Get()
response, err := endpoint(ctx, request)

tchssk avatar Jul 03 '25 08:07 tchssk

@tchssk : Thanks for the pointer, thats neat 👍

Just to clarify: do I understand correctly that would be dsl.Path(/{basePath}) like:


var GetPayload = Type("GetPayload", func() {
    Attribute("id", string, "id")
    Attribute("basePath", string, "base path for the request")
})

var _ = Service("componentA", func() {
    HTTP(func() {
        Path("/{basePath}")
    })
    Method("Get", func() {  // default response type.
        Payload(GetPayload)

        HTTP(func() {
            GET("/get/{id}")      
            
            Params(func() {
                Param("basePath:base")
            })
        })
    })
})

would let me: choose basePath on the client as you say:

request := foo.GetPayload{
        BasePath: basePath,  
        Id: "myid"
}

client := fooclient.NewClient(
        scheme,
        host,
        doer,
        encoder,
        decoder,
        restoreBody,
)
endpoint := client.Get()
response, err := endpoint(ctx, request)

gabyx avatar Jul 03 '25 09:07 gabyx

Yes, that's correct.

tchssk avatar Jul 03 '25 09:07 tchssk