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

RFC: Vendure Storefront SDK #2621

Open
michaelbromley opened this issue Jan 12, 2024 · 8 comments
Open

RFC: Vendure Storefront SDK #2621

michaelbromley opened this issue Jan 12, 2024 · 8 comments
Labels
design 📐 This issue deals with high-level design of a feature

Comments

@michaelbromley
Copy link
Member

Background

When developing a storefront or other client app, some developers like to work with an "SDK" (software development kit). This is typically a package you can install which exposes some TS APIs that allow you to interact with the backend though convenient pre-built methods.

SDK solve a few problems:

  • They relieve the developer of the typical boilerplate needed to set up a plain HTTP client: correct headers, handling of cookies & bearer tokens etc.
  • They make the available API endpoints easily discoverable through object methods that the IDE can autocomplete like sdk.products.findAll(), and make getting started more accessible.
  • They can be statically typed without the need for a code generation step
  • They standardize interaction with an API thus making support and documentation easier

The issue with GraphQL APIs

Usually you see such SDKs backed by a REST-style API, where each method more-or-less corresponds to a resource-plus-verb, e.g.:

method api call
sdk.products.find(id) GET /products/id
sdk.products.create(input) POST /products input

This approach does not work with GraphQL for two reasons:

  • a GraphQL API is a single endpoint
  • the whole point of GraphQL queries is that you can query exactly the data that you need, including relations.

Example:

const sdk = new VendureSDK({ ... })

const result = await sdk.query.getProduct({ id });

what fields does the result have? All scalar fields? Do we join any relations? What about custom fields?

If we (the Vendure team) just decide on a "most likely" set of fields & relations to include in a given query, we will probably cover maybe 50% of cases. Good for getting started, sure; but as soon as you get into the weeds of a real storefront project you are likely gonna want to have control over the GraphQL document itself.

Giving up the ability to define your own GraphQL documents negates a lot of the point of even using GraphQL in the first place!

Proposal

I propose an approach to a Vendure SDK which combines the typical benefits of an SDK with the flexibility and power of GraphQL.

It consists of two main parts:

1. A fetch wrapper

The heart of the SDK will be a wrapper around fetch which handles all the typical boilerplate for you:

  • handling of session tokens/cookies
  • passing correct headers
  • correctly dealing with typical error modes
  • providing convenience methods for setting languageCode and channel token

2. A set of pre-defined, statically typed GraphQL documents for common tasks

We will expose all common queries and mutations through the SDK package, which will supply them in the form of a TypedDocumentNode, which means the document itself contains full static typing information about any inputs as well as the return type.

Example

Here is how it could look for some typical operations:

import { VendureClient, GetProductQuery, LoginMutation } from '@vendure/client';

const client = new VendureClient({
  apiUrl: 'http://localhost:3000/shop-api',
});

// ....

const { product } = await client.query(GetProductQuery, { id: 123 });
// `product` is correctly typed, as is the input object `{ id: 123 })`

const { login } = await client.query(LoginMutation, { username: 'foo@bar.com', password: 'hunter2' }); 

Custom queries

These provided documents like GetProductQuery, while very convenient, will still suffer the same issues as discussed above: as soon as you need control over the query fields, you cannot use them anymore. However, with this approach, supplying your own custom query is as simple as providing an alternative document.

In the most simple case this would be:

const MyGetProductQuery = `
  query GetProduct($id: ID!) {
    product(id: $id) {
      id
      name
      variants {
        id
        # ... etc
    }
  }
`;

const { product } = await client.query(MyGetProductQuery , { id: 123 });
// note: at this point you lose type safety unless you manually set up
// graphql-code-generator in your project

It is quite probably that a mature storefront solution would end up replacing most of the default documents with custom ones. So does that negate the whole point of the SDK?

I would argue no, because:

  • The SDK still did the job of getting the project rapidly up-and-running
  • It gave the developer type safe API responses from the start: IMO one of the top benefits of GraphQL yet one that can be tricky to set up initially.
  • It still means the dev never has to handle all the boilerplate stuff that our fetch wrapper deals with.

Interop

To be broadly useful, we need to make sure the SDK can be easily used with existing popular tools. Let's take React for example: probably the most popular technology for building storefronts right now.

TanStack Query

A popular library for data fetching is TanStack Query.

Following their GraphQL example, a fully type-safe GraphQL query using our SDK would look like this:

import { useQuery } from '@tanstack/react-query'
import { VendureClient, GetProductsQuery} from '@vendure/client';

const client = new VendureClient({
  apiUrl: 'http://localhost:3000/shop-api',
});

function App() {
  // `data` is fully typed!
  const { data } = useQuery({
    queryKey: ['products'],
    queryFn: async () =>
      client.query(
        GetProductsQuery,
        // variables are type-checked too!
        { take: 10 },
      ),
  })
  // ...
}

Apollo Client

Apollo Client provides a lot more than just fetching - specifically the normalized cache is one of the main selling points. Could we combine the benefits of our SDK with Apollo Client?

I suspect that the SDK package would need to expose a specific adapter which essentially encapsulates the required configuration from our apollo client docs into an easy-to-use "link":

import React from 'react';
import * as ReactDOM from 'react-dom/client';
import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client';
import { VendureClientApolloLink } from '@vendure/client';
import App from './App';

const client = new ApolloClient({
  link: new VendureClientApolloLink({
    apiUrl: 'http://localhost:3000/shop-api',
  }),
});

const root = ReactDOM.createRoot(document.getElementById('root'));

root.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>,
);

Feedback

I'd love to hear your thoughts on this topic:

  • Would this be useful to you?
  • Can you foresee any problems with the proposed solution?
  • Anything we didn't consider?
  • Any other related feedback?

Thanks for reading!

@michaelbromley michaelbromley added the design 📐 This issue deals with high-level design of a feature label Jan 12, 2024
@michaelbromley michaelbromley pinned this issue Jan 12, 2024
@DanielBiegler
Copy link
Contributor

I think a general purpose list of the most common GraphQl queries can be useful to reduce the needed boilerplate per project.

For example basically every single store wants to have the ability to log out users, so I must copy something similar to the following snippet per store:

export async function logout() {
  return gqlClient.request(
    graphql(`
      mutation logout {
        logout {
          success
        }
      }
    `)
  )
}

which you can then use in your TanStack Query hooks. But these are getting into implementation detail category because we're talking about how people structure their query keys. For example you will want to invalidate locally cached responses similar to this:

export const useLogout = () => {
  const client = useQueryClient()

  const mutation = useMutation({
    mutationFn: logout,
    onSuccess: (d) => {
      if (d.logout.success) {
        client.invalidateQueries({ queryKey: ['activeCustomer'] })
        client.invalidateQueries({ queryKey: ['currentCart'] })
        // ...
      }
    },
  })

  return mutation
}

Providing essential and reusable queries could then actually help with the initial boiler plate but users do have to customize it to their needs as well and dont forget further nuances like Cookies vs Tokens, multiple Channels (vendure-token) and i18n (languageCode).

Whats also important is to research how much you could leverage code generation, since there is a real maintenance burden here in keeping the SDK in sync.

@mschipperheyn
Copy link
Contributor

mschipperheyn commented Jan 15, 2024

This will be convenient. For me, relatively time consuming repetitive tasks are:

  1. writing the graphql fragments for CRUD methods. This PR reduces that greatly
  2. writing the provider methods wrapper. This covers that partly. I personally would prefer a codegen that generates my methods for me based on a supplied document with exported gql s. Perhaps that even already exists

A typical CRUD document for me would like this

export const FRAGMENT = gql`
  fragment trainingAssetFragment on RsvTrainingAsset {
    id
    label
    type
    createdAt
    updatedAt
  }
`

export const MUTATION_ADD_ITEM = gql`
  ${FRAGMENT}
  mutation rsv_addTrainingAsset($input: RsvTrainingAssetInput!) {
    rsv_addTrainingAsset(input: $input) {
      ... on RsvTrainingAsset {
        ...trainingAssetFragment
      }
      ... on ErrorResult {
        errorCode
        message
      }
      ... on ValidationError {
        fieldErrors
      }
    }
  }
`

export const MUTATION_UPDATE_ITEM = gql`
  ${FRAGMENT}
  mutation rsv_updateTrainingAsset($input: RsvTrainingAssetInput!, $id: ID!) {
    rsv_updateTrainingAsset(input: $input, id: $id) {
      ... on RsvTrainingAsset {
        ...trainingAssetFragment
      }
      ... on ErrorResult {
        errorCode
        message
      }
      ... on ValidationError {
        fieldErrors
      }
    }
  }
`

export const MUTATION_REMOVE_ITEM = gql`
  mutation rsv_removeTrainingAsset($id: ID!) {
    rsv_removeTrainingAsset(id: $id)
  }
`

export const QUERY_ITEM_FORM = gql`
  ${FRAGMENT}
  query rsv_trainingAssetForm($id: ID!) {
    rsv_trainingAsset(id: $id) {
      ...trainingAssetFragment
     # more fields here for admin edit form style forms
    }
  }
`
export const QUERY_ITEM = gql`
  ${FRAGMENT}
  query rsv_trainingAsset($id: ID!) {
    rsv_trainingAsset(id: $id) {
      ...trainingAssetFragment
    }
  }
`

export const QUERY_LIST = gql`
  ${FRAGMENT}
  query rsv_trainingAssets($options: RsvTrainingAssetListOptions) {
    rsv_trainingAssets(options: $options) {
      items {
        ...trainingAssetFragment
      }
      totalItems
    }
  }
`

I would like the accompanying provider to be generated. It looks like this for me

export const getForm = (
  id: string,
  options?: QueryOptions,
): Promise<Rsv_TrainingAssetFormQuery> => {
  return sdk.rsv_trainingAssetForm({ id }, options)
}

export const get = (
  id: string,
  options?: QueryOptions,
): Promise<Rsv_TrainingAssetQuery> => {
  return sdk.rsv_trainingAsset({ id }, options)
}

export const list = (
  options?: RsvTrainingAssetListOptions,
  queryOptions?: QueryOptions,
): Promise<Rsv_TrainingAssetsQuery> => {
  return sdk.rsv_trainingAssets({ options }, queryOptions)
}

export const add = (
  input: RsvTrainingAssetInput,
  options?: QueryOptions,
): Promise<Rsv_AddTrainingAssetMutation> => {
  return sdk.rsv_addTrainingAsset({ input }, options)
}

export const update = (
  id: string,
  input: RsvTrainingAssetInput,
  options?: QueryOptions,
): Promise<Rsv_UpdateTrainingAssetMutation> => {
  return sdk.rsv_updateTrainingAsset({ id, input }, options)
}

export const remove = (
  id: string,
  options?: QueryOptions,
): Promise<Rsv_RemoveTrainingAssetMutation> => {
  return sdk.rsv_removeTrainingAsset({ id }, options)
}

Obviously, a generator would have no way to name add, update, remove, list. But those could perhaps be based on the query names in the CRUD document: rsv_addTrainingAsset etc

@JamesLAllen
Copy link

This is a brilliant feature and would be great, as long as certain hooks are exposed for our custom functionality so we can generate our own sdk's?

@michaelbromley
Copy link
Member Author

@JamesLAllen can you expand a bit on what you mean by "generate our own sdks"?

@JamesLAllen
Copy link

Well, similar to the above post, when you generate your sdk I'm sure you'll put together a process for generating new versions based on shop-api changes. I would like to be able to use this same method so we can generate an sdk that includes the custom extensions we've built or plugins we've installed.

@JamesLAllen
Copy link

You might be able to develop a convention or system like the inferencer model from Refine, like this maybe? https://refine.dev/docs/examples/data-provider/nestjs-query/

@mschipperheyn
Copy link
Contributor

Wouldn't it be nice to have a cli that allows you to take graphql doc and generate a provider file containing wrappers for the supplied graphql file. Then you could use that to generate the out of the box functionality you're talking about, potentially in different flavors (apollo, etc), and we could use it to generate our custom providers. Does feel like something that prob exists in the codegen community already

@michaelbromley
Copy link
Member Author

@mschipperheyn that sounds a bit like what the typescript-generic-sdk codegen does. IMO this approach has been superseded by the direct use of TypeDocumentNodes, which eliminates the need for the wrapper methods, because one single query() method will automatically have all the type info it needs when you pass the code-generated document to it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
design 📐 This issue deals with high-level design of a feature
Projects
None yet
Development

No branches or pull requests

4 participants