honojs / hono

Web framework built on Web Standards
https://hono.dev
MIT License
19.08k stars 544 forks source link

Apollo Server Middleware #434

Open yusukebe opened 2 years ago

yusukebe commented 2 years ago

How about building Apollo Server Middleware as third-party middleware?

GraphQL middleware is available, but Apollo Sever middleware would be nice to have. Repository and npm repository name will be:

We can use this issue https://github.com/apollographql/apollo-server/issues/6034#issuecomment-1198712473 as a reference.

metrue commented 2 years ago

I tried months ago in Hono, but it failed with some dependencies in edge environment (but I already forgot what exactly it's now), Let me check it again to have a try.

yusukebe commented 2 years ago

Ah, I see. Maybe it's not so straightforward.

ronny commented 1 year ago

I think basically the blocker is in @apollo/server itself, it assumes a Node.js runtime in many places by importing things like os, util, zlib, and so on.

https://github.com/apollographql/apollo-server/issues/6034#issuecomment-1312468412

metrue commented 1 year ago

Thanks @ronny ,yeah, I had been doing the the same experiment, and saw the same thing. There're not only node runtime anymore, there're bunch of services running on bun, deno, or workerd , the @apollo/server should make some changes to support those runtimes.

dimik commented 1 year ago

I tested edge runtime apollo integration for cloudflare workers recently. but TTFB is too long as it has to start server for a single request and shut it down after it reply. So not make much sense IMO

rafaell-lycan commented 1 year ago

I believe a proper wrapper for both Apollo and/or GraphQL Yoga would be a nice touch.

FaureAlexis commented 6 months ago

Hey @yusukebe ! Can I do it ?

yusukebe commented 6 months ago

Hi @FaureAlexis

Thanks. But perhaps we can run Apollo Server with app.mount() on the Hono app. Though this issue is open, since we would like to have as little middleware managed in the @hono namespace as possible, it may not be necessary to create @hono/apollo-server.

Of course, you can create it in your personal repository.

obedm503 commented 6 months ago

I'm using hono with apollo server. @FaureAlexis this may serve as a starting point.

import { StatusCode } from 'hono/utils/http-status';

const apollo = new ApolloServer();
await apollo.start();
app.on(['GET', 'POST', 'OPTIONS'], '/graphql', async ctx => {
  if (ctx.req.method === 'OPTIONS') {
    // prefer status 200 over 204
    // https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS#status_code
    return ctx.text('');
  }

  const httpGraphQLResponse = await apollo.executeHTTPGraphQLRequest({
    httpGraphQLRequest: {
      body: ctx.req.method === 'POST' ? await ctx.req.json() : undefined,
      headers: new HeaderMap(Object.entries(ctx.req.header())),
      method: ctx.req.method,
      search: new URL(ctx.req.url).search,
    },
    context: async () => getContext(ctx), // getContext is my own graphql context factory
  });

  const { headers, body, status } = httpGraphQLResponse;

  for (const [headerKey, headerValue] of headers) {
    ctx.header(headerKey, headerValue);
  }

  ctx.status((status as StatusCode) ?? 200);

  if (body.kind === 'complete') {
    return ctx.body(body.string);
  }

  return ctx.body(body.asyncIterator);
});

@yusukebe What is the benefit of app.mount() over app.on()?

yusukebe commented 6 months ago

@obedm503 That's great!!

@yusukebe What is the benefit of app.mount() over app.on()?

I did not try but if we use integration like cloudflare integration, we can write just like this:

app.mount('/grahph', handleGraphQLRequest)

But your way is better than it since we don't have to use integrations and app.mount() can't manage each handler of the adapted app.

obedm503 commented 6 months ago

Sounds good.

I still have a few questions about that might help @FaureAlexis and anyone else trying to do this.

How does hono handle duplicate headers? I am naively converting the object returned by ctx.req.header() into apollo's HeaderMap but this might not work with duplicates. Node's headers can be either a single string of a list of strings. Apollo integrations (cloudflare, koa) usually handle this by doing something like

const headerMap = new HeaderMap();
headers.forEach((value, key) => {
  headerMap.set(key, Array.isArray(value) ? value.join(', ') : value);
});

but this would not be needed if hono already normalizes them.

Also, is ctx.body() the right way to return a streamed response? Does it need to be converted to a Readable stream beforehand? Should stream.pipe() from hono/streaming be used instead? httpGraphQLResponse.body.asyncIterator is of type AsyncIterableIterator<string>

yusukebe commented 6 months ago

@obedm503

How does hono handle duplicate headers?

It uses the Header object to manage header values. The append method allows it to handle multiple values, so we don't do anything special for duplicated headers.

Also, is ctx.body() the right way to return a streamed response?

Yes! But is stream ReadableStream? You can return a RadableStream content with c.body():

return c.body(stream)

If not, you may have to use hono/streaming.

metrue commented 6 months ago

I tested edge runtime apollo integration for cloudflare workers recently. but TTFB is too long as it has to start server for a single request and shut it down after it reply. So not make much sense IMO

Yeah, that's why I started the project https://github.com/metrue/EdgeQL aiming to provide a fast way to have GraphQL on edge.

FaureAlexis commented 6 months ago

Thanks you very much @obedm503 ! Works like a charm

FaureAlexis commented 6 months ago

For headers, I was doing like this :

const headers = new HeaderMap();
 c.req.raw.headers.forEach((value: string, key: string) => {
    if (value) {
        headers.set(key, Array.isArray(value) ? value.join(', ') : value);
    }
  })
obedm503 commented 6 months ago

@FaureAlexis Since the Headers class already handles duplicates, it's not necessary to join header values manually. As per MDN. I tested it to confirm.

When Header values are iterated over, they are automatically sorted in lexicographical order, and values from duplicate header names are combined.

Here's my current implementation of a honoApollo middleware factory function. Feel free to use it and even create an @honojs/apollo-server or @as-integrations/hono package.

The honoApollo would be used as such:

import { honoApollo } from './hono-apollo';

const app = new Hono();
const apolloServer = new ApolloServer();
await apolloServer.start();
app.route(
  '/graphql',
  honoApollo(apolloServer, async ctx => getContext(ctx)),
);

hono-apollo.ts would look like this:

import {
  HeaderMap,
  type ApolloServer,
  type BaseContext,
  type ContextFunction,
  type HTTPGraphQLRequest,
} from '@apollo/server';
import type { Context as HonoContext } from 'hono';
import { stream } from 'hono/streaming';
import { Hono } from 'hono/tiny';
import type { BlankSchema, Env } from 'hono/types';
import type { StatusCode } from 'hono/utils/http-status';

export function honoApollo(
  server: ApolloServer<BaseContext>,
  getContext?: ContextFunction<[HonoContext], BaseContext>,
): Hono<Env, BlankSchema, '/'>;
export function honoApollo<TContext extends BaseContext>(
  server: ApolloServer<TContext>,
  getContext: ContextFunction<[HonoContext], TContext>,
): Hono<Env, BlankSchema, '/'>;
export function honoApollo<TContext extends BaseContext>(
  server: ApolloServer<TContext>,
  getContext?: ContextFunction<[HonoContext], TContext>,
) {
  const app = new Hono();

  // Handle `OPTIONS` request
  // Prefer status 200 over 204
  // https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS#status_code
  app.options('/', ctx => ctx.text(''));

  // This `any` is safe because the overload above shows that context can
  // only be left out if you're using BaseContext as your context, and {} is a
  // valid BaseContext.
  const defaultContext: ContextFunction<[HonoContext], any> = async () => ({});
  const context = getContext ?? defaultContext;

  app.on(['GET', 'POST'], '/', async ctx => {
    const headerMap = new HeaderMap();
    // Use `ctx.req.raw.headers` to avoid multiple loops and intermediate objects
    ctx.req.raw.headers.forEach((value, key) => {
      // When Header values are iterated over, they are automatically sorted in
      // lexicographical order, and values from duplicate header names are combined.
      // https://developer.mozilla.org/en-US/docs/Web/API/Headers
      headerMap.set(key, value);
    });
    const httpGraphQLRequest: HTTPGraphQLRequest = {
      // Avoid parsing the body unless necessary
      body: ctx.req.method === 'POST' ? await ctx.req.json() : undefined,
      headers: headerMap,
      method: ctx.req.method,
      search: new URL(ctx.req.url).search,
    };

    const httpGraphQLResponse = await server.executeHTTPGraphQLRequest({
      httpGraphQLRequest,
      context: () => context(ctx),
    });

    for (const [key, value] of httpGraphQLResponse.headers) {
      ctx.header(key, value);
    }

    ctx.status((httpGraphQLResponse.status as StatusCode) ?? 200);

    if (httpGraphQLResponse.body.kind === 'complete') {
      return ctx.body(httpGraphQLResponse.body.string);
    }

    // This should work but remains untested
    const asyncIterator = httpGraphQLResponse.body.asyncIterator;
    return stream(ctx, async stream => {
      for await (const part of asyncIterator) {
        await stream.write(part);
      }
    });
  });

  return app;
}

@yusukebe Do you have any recommendations on the best router to use here? Does it matter?

yusukebe commented 6 months ago

@obedm503

Sorry for the super delayed response.

@yusukebe Do you have any recommendations on the best router to use here? Does it matter?

I don't think it matters that much which router you choose. In this case, hono is fine, not hono/tiny in particular. Bundle size will change, but not by that much.