Skip to content

Commit

Permalink
Support for Next.JS projects in generated clients
Browse files Browse the repository at this point in the history
This commit adds support for generated Typescript clients to be
compitable with [Next.JS](https://nextjs.org/) applications with the new `--nextjs` argument.

The existing client was not compiatble as we where using namespaces
to mimic the packages within the Encore application, however namespaces
are somewhat depcreated and not all transpliers support them; the key one
here being Babel (which Next.JS uses under the hood).

When turned on the new `--nextjs` flag, therefore prevents the code generator
from emitting namespaces, and instead it uses the package name as a prefix for
generated types; such as `$package_$struct`.

To make NextJS development easier, this flag also tells the TypeScript code generator
to generate [SWR](https://swr.vercel.app/) wrappers for all API's, while maintaining the
promise based functions if direct calls are still required (i.e. to perform actions).

If the original api call was:
```
const api = new Client()

const rsp: Promise<Data> = api.MySvc.MyAPI("segment", { name: "bob" })
```

The SWR wrapper will be:
```
const api = new Client() // this needs to be a singleton for the application instance

const rsp: SWRResponse<Data> = api.MySvc.useMyAPI("segment", { name: "bob" }, { refreshInternval: 1000 })
```
  • Loading branch information
DomBlack committed Jun 24, 2022
1 parent f90f784 commit 82361e2
Show file tree
Hide file tree
Showing 12 changed files with 1,747 additions and 158 deletions.
21 changes: 15 additions & 6 deletions cli/cmd/encore/gen.go
Expand Up @@ -21,9 +21,10 @@ func init() {
rootCmd.AddCommand(genCmd)

var (
output string
lang string
envName string
output string
lang string
envName string
nextJsSupport bool
)

genClientCmd := &cobra.Command{
Expand Down Expand Up @@ -61,14 +62,19 @@ Supported language codes are:
lang = string(l)
}

if nextJsSupport && lang != "typescript" {
fatal("--nextjs is only supported for typescript")
}

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

daemon := setupDaemon(ctx)
resp, err := daemon.GenClient(ctx, &daemonpb.GenClientRequest{
AppId: appID,
EnvName: envName,
Lang: lang,
AppId: appID,
EnvName: envName,
Lang: lang,
NextJsSupport: nextJsSupport,
})
if err != nil {
fatal(err)
Expand Down Expand Up @@ -99,4 +105,7 @@ Supported language codes are:

genClientCmd.Flags().StringVarP(&envName, "env", "e", "", "The environment to fetch the API for (defaults to the primary environment)")
_ = genClientCmd.RegisterFlagCompletionFunc("env", autoCompleteEnvSlug)

genClientCmd.Flags().BoolVar(&nextJsSupport, "nextjs", false, "Generates a TypeScript client which is compatible with a Next.js (disable Namespaces)")
_ = genClientCmd.RegisterFlagCompletionFunc("nextjs", autoCompleteFromStaticList("true", "false"))
}
2 changes: 1 addition & 1 deletion cli/daemon/daemon.go
Expand Up @@ -103,7 +103,7 @@ func (s *Server) GenClient(ctx context.Context, params *daemonpb.GenClientReques
}

lang := codegen.Lang(params.Lang)
code, err := codegen.Client(lang, params.AppId, md)
code, err := codegen.Client(lang, params.AppId, md, params.NextJsSupport)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
Expand Down
2 changes: 1 addition & 1 deletion cli/daemon/run/run_test.go
Expand Up @@ -108,7 +108,7 @@ func TestEndToEndWithApp(t *testing.T) {

// Use golden to test that the generated clients are as expected for the echo test app
for lang, path := range map[codegen.Lang]string{codegen.LangGo: "client/client.go", codegen.LangTypeScript: "client.ts"} {
client, err := codegen.Client(lang, "slug", build.Parse.Meta)
client, err := codegen.Client(lang, "slug", build.Parse.Meta, false)
if err != nil {
fmt.Println(err.Error())
c.FailNow()
Expand Down
4 changes: 2 additions & 2 deletions cli/internal/codegen/client.go
Expand Up @@ -44,7 +44,7 @@ func Detect(path string) (lang Lang, ok bool) {
}

// Client generates an API client based on the given app metadata.
func Client(lang Lang, appSlug string, md *meta.Data) (code []byte, err error) {
func Client(lang Lang, appSlug string, md *meta.Data, nextJsSupport bool) (code []byte, err error) {
defer func() {
if e := recover(); e != nil {
err = fmt.Errorf("codegen.Client %s %s panicked: %v\n%s", lang, appSlug, e, debug.Stack())
Expand All @@ -54,7 +54,7 @@ func Client(lang Lang, appSlug string, md *meta.Data) (code []byte, err error) {
var gen generator
switch lang {
case LangTypeScript:
gen = &typescript{generatorVersion: typescriptGenLatestVersion}
gen = &typescript{generatorVersion: typescriptGenLatestVersion, noNamespaces: nextJsSupport, generateSWRHelpers: nextJsSupport}
case LangGo:
gen = &golang{generatorVersion: goGenLatestVersion}
default:
Expand Down
14 changes: 12 additions & 2 deletions cli/internal/codegen/client_test.go
Expand Up @@ -56,15 +56,25 @@ func TestClientCodeGeneration(t *testing.T) {

// Check that the trim prefix removed the expectedPrefix && there are no other underscores in the testName
if testName != file.Name() && !strings.Contains(testName, "_") {
language, ok := Detect(file.Name())

c.Run(testName, func(c *qt.C) {
language, ok := Detect(file.Name())
c.Assert(ok, qt.IsTrue, qt.Commentf("Unable to detect language type for %s", file.Name()))

generatedClient, err := Client(language, "app", res.Meta)
generatedClient, err := Client(language, "app", res.Meta, false)
c.Assert(err, qt.IsNil)

golden.TestAgainst(c, file.Name(), string(generatedClient))
})

if ok && language == LangTypeScript {
c.Run(testName+"_nextjs_support", func(c *qt.C) {
generatedClient, err := Client(language, "app", res.Meta, true)
c.Assert(err, qt.IsNil)

golden.TestAgainst(c, "nextjs_"+file.Name(), string(generatedClient))
})
}
}
}
})
Expand Down
Expand Up @@ -2,6 +2,7 @@

/* eslint-disable @typescript-eslint/no-namespace */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-empty-interface */

/**
* BaseURL is the base URL for calling the Encore application's API.
Expand Down
59 changes: 40 additions & 19 deletions cli/internal/codegen/testdata/expected_typescript.ts
Expand Up @@ -2,6 +2,7 @@

/* eslint-disable @typescript-eslint/no-namespace */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-empty-interface */

/**
* BaseURL is the base URL for calling the Encore application's API.
Expand Down Expand Up @@ -130,13 +131,21 @@ export namespace products {

// Now make the actual call to the API
const resp = await this.baseClient.callAPI("POST", `/products.Create`, JSON.stringify(body), {headers})
return await resp.json() as Product
try {
return await resp.json() as Product
} catch (err) {
throw new APIError(500, { code: ErrCode.DataLoss, message: "unable to unmarshal the response", details: err })
}
}

public async List(): Promise<ProductListing> {
// Now make the actual call to the API
const resp = await this.baseClient.callAPI("GET", `/products.List`)
return await resp.json() as ProductListing
try {
return await resp.json() as ProductListing
} catch (err) {
throw new APIError(500, { code: ErrCode.DataLoss, message: "unable to unmarshal the response", details: err })
}
}
}
}
Expand Down Expand Up @@ -255,19 +264,23 @@ export namespace svc {

// Now make the actual call to the API
const resp = await this.baseClient.callAPI("GET", `/svc.GetRequestWithAllInputTypes`, undefined, {headers, query})
try {

//Populate the return object from the JSON body and received headers
const rtn = await resp.json() as HeaderOnlyStruct
rtn.Boolean = mustBeSet("Header `x-boolean`", resp.headers.get("x-boolean")).toLowerCase() === "true"
rtn.Int = parseInt(mustBeSet("Header `x-int`", resp.headers.get("x-int")), 10)
rtn.Float = Number(mustBeSet("Header `x-float`", resp.headers.get("x-float")))
rtn.String = mustBeSet("Header `x-string`", resp.headers.get("x-string"))
rtn.Bytes = mustBeSet("Header `x-bytes`", resp.headers.get("x-bytes"))
rtn.Time = mustBeSet("Header `x-time`", resp.headers.get("x-time"))
rtn.Json = JSON.parse(mustBeSet("Header `x-json`", resp.headers.get("x-json")))
rtn.UUID = mustBeSet("Header `x-uuid`", resp.headers.get("x-uuid"))
rtn.UserID = mustBeSet("Header `x-user-id`", resp.headers.get("x-user-id"))
return rtn
//Populate the return object from the JSON body and received headers
const rtn = await resp.json() as HeaderOnlyStruct
rtn.Boolean = mustBeSet("Header `x-boolean`", resp.headers.get("x-boolean")).toLowerCase() === "true"
rtn.Int = parseInt(mustBeSet("Header `x-int`", resp.headers.get("x-int")), 10)
rtn.Float = Number(mustBeSet("Header `x-float`", resp.headers.get("x-float")))
rtn.String = mustBeSet("Header `x-string`", resp.headers.get("x-string"))
rtn.Bytes = mustBeSet("Header `x-bytes`", resp.headers.get("x-bytes"))
rtn.Time = mustBeSet("Header `x-time`", resp.headers.get("x-time"))
rtn.Json = JSON.parse(mustBeSet("Header `x-json`", resp.headers.get("x-json")))
rtn.UUID = mustBeSet("Header `x-uuid`", resp.headers.get("x-uuid"))
rtn.UserID = mustBeSet("Header `x-user-id`", resp.headers.get("x-user-id"))
return rtn
} catch (err) {
throw new APIError(500, { code: ErrCode.DataLoss, message: "unable to unmarshal the response", details: err })
}
}

public async HeaderOnlyRequest(params: HeaderOnlyStruct): Promise<void> {
Expand Down Expand Up @@ -309,11 +322,15 @@ export namespace svc {

// Now make the actual call to the API
const resp = await this.baseClient.callAPI("POST", `/svc.RequestWithAllInputTypes`, JSON.stringify(body), {headers, query})
try {

//Populate the return object from the JSON body and received headers
const rtn = await resp.json() as AllInputTypes<number>
rtn.A = mustBeSet("Header `x-alice`", resp.headers.get("x-alice"))
return rtn
//Populate the return object from the JSON body and received headers
const rtn = await resp.json() as AllInputTypes<number>
rtn.A = mustBeSet("Header `x-alice`", resp.headers.get("x-alice"))
return rtn
} catch (err) {
throw new APIError(500, { code: ErrCode.DataLoss, message: "unable to unmarshal the response", details: err })
}
}

/**
Expand All @@ -323,7 +340,11 @@ export namespace svc {
public async TupleInputOutput(params: Tuple<string, WrappedRequest>): Promise<Tuple<boolean, Foo>> {
// Now make the actual call to the API
const resp = await this.baseClient.callAPI("POST", `/svc.TupleInputOutput`, JSON.stringify(params))
return await resp.json() as Tuple<boolean, Foo>
try {
return await resp.json() as Tuple<boolean, Foo>
} catch (err) {
throw new APIError(500, { code: ErrCode.DataLoss, message: "unable to unmarshal the response", details: err })
}
}

public async Webhook(method: string, a: string, b: string[], body?: BodyInit, options?: CallParameters): Promise<Response> {
Expand Down

0 comments on commit 82361e2

Please sign in to comment.