Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: API improvements #220

Closed
srikrsna-buf opened this issue Oct 18, 2023 · 4 comments · Fixed by #224
Closed

Proposal: API improvements #220

srikrsna-buf opened this issue Oct 18, 2023 · 4 comments · Fixed by #224

Comments

@srikrsna-buf
Copy link
Member

srikrsna-buf commented Oct 18, 2023

The user for facing API today looks like:

import { useQuery } from '@tanstack/react-query';
import { example } from 'your-generated-code/example-ExampleService_connectquery';

export const Example: FC = () => {
  const { data } = useQuery(example.useQuery({}));
  return <div>{data}</div>;
};

The generated code is as follows:

import {
  createQueryService,
  createUnaryHooks,
} from "@connectrpc/connect-query";
import { MethodKind } from "@bufbuild/protobuf";
import { ExampleRequest, ExampleResponse } from "./example_pb.js";

export const typeName = "your.company.com.example.v1.ExampleService";

export const ExampleService = {
  methods: {
    example: {
      name: "Example",
      kind: MethodKind.Unary,
      I: ExampleRequest,
      O: ExampleResponse,
    },
  },
  typeName,
} as const;

const $queryService = createQueryService({ service: ExampleService });

export const say = {
  ...$queryService.example,
  ...createUnaryHooks($queryService.example),
};

Issues with the above API and generated code:

  • The use of useQuery twice is verbose and confusing, if one has to set other fields that the query APIs accept it gets even more verbose with a slightly different looking call: useQuery({example.useQuery({})..., extra: value})
  • It is not very apparent what values are set by the generated hook and which of them are safe to override.
  • Query APIs are endpoint oriented and as such the generated code correctly exports a unique symbol for each rpc. However, the way the code is generated today is not ideal for tree-shaking as it will include the entire service object (including all other methods). Not only that but the entire runtime is included even if only one of the hooks is used.
  • For contributors to add support for other frameworks the example set by the react approach is to write a framework specific plugin and accompanying runtime package. This is a barrier for new contributors as they have to know about protobuf plugins and how to write one.
  • Probably a consequence of how the code is generated, the API surface of the runtime package is more than ideal.
  • There are multiple ways to create the query hooks. One can use the generated code or they can simple use the generated code by the connect plugin and use the runtime to create their own hooks.
  • Mocking the API requires them to use the service definition, this is different from how users use the hooks.

Possible solution

I propose a framework agnostic plugin accompanied by a new react specific API that wraps the original hooks. The generated code will look like:

import { MethodKind } from "@bufbuild/protobuf";
import { ExampleRequest, ExampleResponse } from "./example_pb.js";

export const typeName = "your.company.com.example.v1.ExampleService";

export const example = {
  localName: "example",
  name: "Example",
  kind: MethodKind.Unary,
  I: ExampleRequest,
  O: ExampleResponse,
  service: { typeName },
} as const;

This solves the tree-shaking problem by just generating method specific definitions of the rpcs. This is also framework agnostic as it only generates the rpc definitions without any imports of query APIs.

The runtime API will take the form:

import { useQuery } from '@connectrpc/connect-react-query';
import { example } from 'your-generated-code/example-ExampleService_connectquery';

export const Example: FC = () => {
  const { data } = useQuery(example, {});
  return <div>{data}</div>;
};

In this API, the second parameter is for the request, the optional third is to provide additional parameters to the underlying Hooks API. This way we can only expose safe to override properties. This will also make wrapping the hooks to modify the types easier (connectrpc/connect-es#694 (comment)). We should also expose supplementary API for things like calculating query keys and other bits that help us construct the hooks.

Along with this we should expose a mocking wrapper similar to createRouterTransport that is rpc oriented where users can import the connect-query generated constants to mock rpcs.

With this structure contributors only need to create the runtime package and not bother about the plugin.

Implementation

This is a breaking change and a significant shift from our current API. I am not entirely sure if we can write a migrator but even if we did it won't be trivial.

One option is to hold off on #219 and #179 and release a parallel package for Tanstack v5. We continue to support the current version for Tanstack v4 and users wanting to upgrade to Tanstack v5 can update to the new react specific package. This gives us some time to work on the migrator and at the same time new users will start using the new APIs.

@paul-sachs @timostamm thoughts?

@paul-sachs
Copy link
Collaborator

I like the suggested approach from a tree shaking perspective, as well as removing more more required plugins. Migration is a concern but, as you mentioned, we are pre v1, so if there's any time to do it, it's now. The suggested API looks like it shares a little with the experimental plugin's DX but without the problem of creating too many APIs in order to support tanstack/query@v5.

That said, this new approach would mean we'd have to wrap tanstack/query. I think we can get away with peerDependencies but it'll definitely need some examination and experimentation to see how that works out in practice.

I am in favour of exploring in this direction.

@paul-sachs
Copy link
Collaborator

paul-sachs commented Oct 20, 2023

So far, I'm pretty encouraged by my exploration down this direction. It certainly simplifies the library by quite a bit. As expected, the API is very different from the original API but initial thoughts are it's far less verbose and more approachable. The one thing we'll need (as mentioned) is an additional API in @connectrpc/connect's createConnectRouter that supports passing in a single method descriptor. The type we'd want looks like:

import type { Message, MethodInfoUnary } from "@bufbuild/protobuf";

/** Defines a standalone method and associated service. TBD the proper name for this thing.  */
export type MethodDescriptorUnary<
  I extends Message<I>,
  O extends Message<O>,
> = MethodInfoUnary<I, O> & {
  localName: string;
  service: {
    typeName: string;
  };
};

Ideally, the ConnectRouter.rpc method could accept a single one of those as well as the service + method. That should enable us to leverage this across all the testing/mocking libraries for connect.

Not exactly sure what we'd want to call this type. I'm open to suggestions.

@paul-sachs
Copy link
Collaborator

paul-sachs commented Oct 20, 2023

Side note, this also makes it easier for us to separate our streaming methods to their own dedicated hooks + types (whenever we figure out a proper way of handling those.)

This was referenced Oct 23, 2023
@paul-sachs
Copy link
Collaborator

@srikrsna-buf I've published a PR #224 with a tentative implementation (minus docs). Let me know if it looks good so far.

paul-sachs added a commit that referenced this issue Dec 7, 2023
Introducing a whole new API for connect-query. This API ties itself more
tightly with the fantastic `@tanstack/react-query` in order to improve
developer experience and reduce bundle size.

At the core of this change is the new `MethodUnaryDescriptor` type,
which is essentially just a self contained description of a service
method. The new code generator just outputs one of these per method and
those are passed to the hooks/methods. This new additional type brings a
huge level of flexibility about how we write additional methods, and
it's trivial to write your own hooks/methods around these simple types.

## Example usage

```tsx
import { useQuery } from "@connectrpc/connect-react-query";
import { say } from "./gen/eliza-ElizaService_connectquery";

...

const { data } = useQuery(say);
```

## Reasoning

There are a number of reasons we've decided to make this major breaking
change.

1. The API surface is much smaller, leading to less confusion about how
to use each method.
2. Smaller core code size and more modular organization leads to better
tree shaking (since each generated method doesn't come along with a
generated hook).
3. Package names are now explicit about their dependencies, making it
easier to develop other packages based on these packages/generated code.
4. More tightly integrating with `@tanstack/react-query@5` provides
better usage of Suspense versions of the API.
5. New generated code is easier to expand and build your own custom
hooks for.

## Migration

The migration process is easy:

### Before

```tsx
import { getUserOrganization } from "@apigen/org/alpha/registry/v1alpha1/organization-OrganizationService_connectquery";
import { useQuery } from "@tanstack/react-query";

...

const getUserOrganizationQuery = useQuery({
    ...getUserOrganization.useQuery({
      userId: currentUser?.id,
      organizationId,
    }),
    useErrorBoundary: false,
    enabled: currentUser !== null,
});
```

### After

```tsx
import { getUserOrganization } from "@apigen/org/alpha/registry/v1alpha1/organization-OrganizationService_connectquery";
import { useQuery } from "@connectrpc/connect-react-query";

...

const getUserOrganizationQuery = useQuery(getUserOrganization, {
      userId: currentUser?.id,
      organizationId,
    },
    {
      useErrorBoundary: false,
      enabled: currentUser !== null
    }
});
```

Fixes #220

---------

Co-authored-by: Timo Stamm <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants