vendure-ecommerce / vendure

The commerce platform with customization in its DNA.
https://www.vendure.io
Other
5.78k stars 1.03k forks source link

RFC: Vendure Storefront SDK #2621

Open michaelbromley opened 10 months ago

michaelbromley commented 10 months ago

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:

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:

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:

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:

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:

Thanks for reading!

DanielBiegler commented 10 months ago

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 commented 10 months ago

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 commented 9 months ago

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 commented 9 months ago

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

JamesLAllen commented 9 months ago

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 commented 9 months ago

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 commented 9 months ago

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 commented 9 months ago

@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.