redwoodjs / redwood

The App Framework for Startups
https://redwoodjs.com
MIT License
16.95k stars 975 forks source link

Explore REST API from GraphQL Schema using Sofa #5480

Open dthyresson opened 2 years ago

dthyresson commented 2 years ago

See: https://www.sofa-api.com

Sofa takes your GraphQL Schema, looks for available queries, mutations and subscriptions and turns all of that into REST API.

Task is to explore how Redwood might offer a REST API for the entire schema (or subset) based on the GraphQL schema.

Ie - is it possible -- a proof of concept.

This feature has been requested by @BurnedChris and others.

Deliverable is a document or RFC for how one might implement it in the framework (is this a how to, or a setup command, etc).

Later:

  1. Also, consider how one might secure this API using an API Key rather than standard auth.
  2. How best to setup in framework and/or a project
dthyresson commented 1 year ago

Having updated to Yoga v3, configuration is rather straightforward.

Here is an example of have the api with Swagger interactive docs UI on the Test App.

image

Some to dos:

arimendelow commented 1 year ago

ah love this!!! do you have any kind of timeline on a guide/etc? thanks!!

BurnedChris commented 1 year ago

This looks great! Also @burnsy -> @burnedchris

wizztjh commented 1 year ago

how to configure it?

import { useSofaWithSwaggerUI } from '@graphql-yoga/plugin-sofa'

import { createGraphQLHandler } from '@redwoodjs/graphql-server'

import directives from 'src/directives/**/*.{js,ts}'
import sdls from 'src/graphql/**/*.sdl.{js,ts}'
import services from 'src/services/**/*.{js,ts}'

import { db } from 'src/lib/db'
import { logger } from 'src/lib/logger'

export const handler = createGraphQLHandler({
  loggerConfig: { logger, options: {} },
  directives,
  sdls,
  services,
  extraPlugins: [
    useSofaWithSwaggerUI({
      basePath: '/rest',
      swaggerUIEndpoint: '/swagger',
      servers: [
        {
          url: '/', // Specify Server's URL.
          description: 'Development server',
        },
      ],
      info: {
        title: 'Example API',
        version: '1.0.0',
      },
    }),
  ],
  onException: () => {
    // Disconnect from your database with an unhandled exception.
    db.$disconnect()
  },
})
dthyresson commented 1 year ago

Hi. In my test I elected to have a separate function just for the rest api as this simplified paths (at the moment).

I made a api/src/functions/rest.ts function that is effectively another graphql handler/server

// api/src/functions/rest.ts
import { useSofaWithSwaggerUI } from '@graphql-yoga/plugin-sofa'
import { CurrencyDefinition, CurrencyResolver } from 'graphql-scalars'

import { authDecoder } from '@redwoodjs/auth-dbauth-api'
import { createGraphQLHandler } from '@redwoodjs/graphql-server'

import directives from 'src/directives/**/*.{js,ts}'
import sdls from 'src/graphql/**/*.sdl.{js,ts}'
import services from 'src/services/**/*.{js,ts}'

import { getCurrentUser } from 'src/lib/auth'
import { db } from 'src/lib/db'
import { logger } from 'src/lib/logger'

const restApi = () => {
  return useSofaWithSwaggerUI({
    basePath: '/rest/api',
    swaggerUIEndpoint: '/rest/swagger',
    servers: [
      {
        url: '/', // Specify Server's URL.
        description: 'Development server',
      },
    ],
    info: {
      title: 'RedwoodJS REST API',
      version: '1.0.0',
    },
  })
}

export const handler = createGraphQLHandler({
  authDecoder,
  getCurrentUser,
  loggerConfig: { logger, options: {} },
  directives,
  sdls,
  services,
  graphiQLEndpoint: '/rest',
  extraPlugins: [restApi()],
  schemaOptions: {
    typeDefs: [CurrencyDefinition],
    resolvers: { Currency: CurrencyResolver },
  },

  onException: () => {
    // Disconnect from your database with an unhandled exception.
    db.$disconnect()
  },
})

and then I can access the Swagger UI via:

http://localhost:8911/rest/swagger

and do basic curl at

curl -X 'GET' \
  'http://localhost:8911/rest/api/redwood' \
  -H 'accept: application/json'

Also, you could let me pick and choose (eventually) which operations/resolvers are public and are part of the REST api rather then the app GraphQL api.

codekrafter commented 1 year ago

I'd be happy to help implement this! I think it could best be presented as a setup command (rw setup rest), which could setup a new function similar to the one above. There are a few different enhancements discussed above, and I think providing helper functions through the framework would be the best way to provide those to the user.

Select queries/mutations to include (with an allowlist or blocklist) From doing some research into the @graphql-yoga/plugin-sofa package, it re-initializes SOFA each time the schema changes. This could be a good point to rebuild the schema by removing some queries/mutations, but I am not sure the best way to go about this.

Error Handling Right now every error is a 500. There is the ability to provide a custom error handler function to the SOFA instance, and it seems fairly straight forward to build a function that works with RedwoodErrors.

API Key Authentication From my understanding of how this is all laid together, it should be fairly straightforward to just use a custom auth implementation (pass in a custom authDecoder and getCurrentUser) to protect these endpoints with API authentication instead of standard user authentication. This might be a good place to include a basic reference implementation on the documentation page, but leave the actual full implementation to the user so they can craft a solution that fits their requirements.

Overall, in my opinion it seems simplest to create a clone of the @graphql-yoga/plugin-sofa package (which is a fairly basic plugin that connects SOFA to yoga) so that we can have direct control over the SOFA instance. I am happy to work on development for this, I just don't want to go down the wrong path.

thedavidprice commented 1 year ago

Thanks @codekrafter These are great ideas.

Nudging @dthyresson

dthyresson commented 7 months ago

Not an issue, but a future task. Devs can use Sofa for a REST api currently. In future can explore a guide or setup command.

shansmith01 commented 2 months ago

Hey all,

Just stumbled across this. it seems the @graphql-yoga/plugin-sofa plugin has changed a little bit and it's usage is meant to be something like this:

useSofa({
    basePath: 'rest',
    swaggerUI: {
      endpoint: '/swagger'
    },
  })

So I dumped that into my graphql function file like this

import { createGraphQLHandler } from '@redwoodjs/graphql-server'

import directives from 'src/directives/**/*.{js,ts}'
import sdls from 'src/graphql/**/*.sdl.{js,ts}'
import services from 'src/services/**/*.{js,ts}'

import { db } from 'src/lib/db'
import { logger } from 'src/lib/logger'
import { useSofa } from '@graphql-yoga/plugin-sofa'

export const handler = createGraphQLHandler({
  loggerConfig: { logger, options: {} },
  directives,
  sdls,
  services,
  extraPlugins:[ useSofa({
    basePath: 'rest',
    swaggerUI: {
      endpoint: '/swagger'
    },
  })],
  onException: () => {
    // Disconnect from your database with an unhandled exception.
    db.$disconnect()? 
  },
})

But it does not seem to do anything.

Was there any other config when you were playing with it?

I also applied the same function the example that @dthyresson gave above with no luck

dthyresson commented 2 months ago

Hi @shansmith01 its been a little bit since I set that up and Yoga has a new way to configure yoga via a plugin: see https://the-guild.dev/graphql/yoga-server/docs/features/sofa-api

Some thing to note is the path and endpoint. Since if you deploy GraohQL as a function then it takes the function name plus and path config you make. This is why you could create a new function called “rest” and duplicate the GraphQL handler with other imports or settings.

if you deploy with the sever file the plugin is a bit easier to setup as have more control of the paths.

I’ll try an example a little later today and try again as share.

dthyresson commented 2 months ago

Hi @shansmith01 - I finally got around to testing today and here's how I configured my GraphQL handler:

Be sure to install the plugin:

yarn workspace api add @graphql-yoga/plugin-sofa
import { useSofa } from '@graphql-yoga/plugin-sofa'

import { createGraphQLHandler } from '@redwoodjs/graphql-server'

import directives from 'src/directives/**/*.{js,ts}'
import sdls from 'src/graphql/**/*.sdl.{js,ts}'
import services from 'src/services/**/*.{js,ts}'

import { db } from 'src/lib/db'
import { logger } from 'src/lib/logger'

export const handler = createGraphQLHandler({
  loggerConfig: { logger, options: {} },
  directives,
  sdls,
  services,
  extraPlugins: [
    useSofa({
      basePath: '/graphql',
      swaggerUI: {
        endpoint: '/swagger',
      },
    }),
  ],
  onException: () => {
    // Disconnect from your database with an unhandled exception.
    db.$disconnect()
  },
})

Then, visit http://localhost:8911/graphql/swagger

image

Note: I scaffolded a Country model with 4 seeded countries.

image

And curl:

~ % curl -X 'GET' \
  'http://localhost:8911/graphql/countries' \
  -H 'accept: application/json'
[{"id":1,"name":"Sweden","code":"SE"},{"id":2,"name":"Finland","code":"FI"},{"id":3,"name":"USA","code":"US"},{"id":4,"name":"Canada","code":"CA"}]%
~ %

Note: I think there are some other ways to configure to get an endpoint you may prefer.

For example, you can leave your graphql.ts function as is, and the duplicate it in functions as rest.ts, and then:

import { useSofa } from '@graphql-yoga/plugin-sofa'

import { createGraphQLHandler } from '@redwoodjs/graphql-server'

import directives from 'src/directives/**/*.{js,ts}'
import sdls from 'src/graphql/**/*.sdl.{js,ts}'
import services from 'src/services/**/*.{js,ts}'

import { db } from 'src/lib/db'
import { logger } from 'src/lib/logger'

export const handler = createGraphQLHandler({
  loggerConfig: { logger, options: {} },
  directives,
  sdls,
  services,
  graphiQLEndpoint: 'rest',
  extraPlugins: [
    useSofa({
      basePath: '/rest/api',
      swaggerUI: {
        endpoint: '/swagger',
      },
    }),
  ],
  onException: () => {
    // Disconnect from your database with an unhandled exception.
    db.$disconnect()
  },
})
```a

Then you can curl at:

```bash
~ % curl -X 'GET' \
  'http://localhost:8911/rest/api/countries' \
  -H 'accept: application/json'
[{"id":1,"name":"Sweden","code":"SE"},{"id":2,"name":"Finland","code":"FI"},{"id":3,"name":"USA","code":"US"},{"id":4,"name":"Canada","code":"CA"}]%
~ %

If you want status codes for auth, we haven't set those in RW by default for Authentication errors etc, but, in your service you can throw

  throw new RedwoodGraphQLError('This is a custom auth message', {
    http: {
      status: 401,
    },
  })
image

Might just need some added extension info in https://github.com/redwoodjs/redwood/blob/e798075ca6e81655f8ae7869664004ccf94633d2/packages/graphql-server/src/errors.ts#L42 but that might break the ApolloClient error handling.

If perhaps you have dedicated services for REST endpoints, probably can just throw errors you need for 401 or 4xx as needed.

Hope this helps!

shansmith01 commented 2 months ago

Thanks @dthyresson.

This is interesting stuff, had a bit of a mess around with the idea of making different parts of my schema available instead of the whole thing and had a quick play with error codes. If I explore further I will share some info on auth

Where this is super interesting for me is with RSC. If I am building an app where my product has an API that needs to be consumed I can use RSC to make the UI for my app and then use schema files to generate the API in rest/graphql to be consumed by my customers.