GithubHelp home page GithubHelp logo

thebigredgeek / apollo-resolvers Goto Github PK

View Code? Open in Web Editor NEW
432.0 13.0 41.0 132 KB

Expressive and composable resolvers for Apollostack's GraphQL server

License: MIT License

Makefile 0.99% JavaScript 73.55% TypeScript 25.46%
nodejs javascript graphql apollo-client apollo-server resolver child-resolver parent-resolver apollostack-graphql-server composible-resolvers

apollo-resolvers's Introduction

apollo-resolvers

Expressive and composable resolvers for Apollostack's GraphQL server

NPM

CircleCI

Overview

When standing up a GraphQL backend, one of the first design decisions you will undoubtedly need to make is how you will handle authentication, authorization, and errors. GraphQL resolvers present an entirely new paradigm that existing patterns for RESTful APIs fail to adequately address. Many developers end up writing duplicitous authorization checks in a vast majority of their resolver functions, as well as error handling logic to shield the client from encountering exposed internal errors. The goal of apollo-resolvers is to simplify the developer experience in working with GraphQL by abstracting away many of these decisions into a nice, expressive design pattern.

apollo-resolvers provides a pattern for creating resolvers that work, essentially, like reactive middleware. By creating a chain of resolvers to satisfy individual parts of the overall problem, you are able to compose elegant streams that take a GraphQL request and bind it to a model method or some other form of business logic with authorization checks and error handling baked right in.

With apollo-resolvers, data flows between composed resolvers in a natural order. Requests flow down from parent resolvers to child resolvers until they reach a point that a value is returned or the last child resolver is reached. Thrown errors bubble up from child resolvers to parent resolvers until an additional transformed error is either thrown or returned from an error callback or the last parent resolver is reached.

In addition to the design pattern that apollo-resolvers provides for creating expressive and composible resolvers, there are also several provided helper methods and classes for handling context creation and cleanup, combining resolver definitions for presentation to graphql-tools via makeExecutableSchema, and more.

Example from Apollo Day

Authentication and Error Handling in GraphQL

Quick start

Install the package:

npm install apollo-resolvers

Create a base resolver for last-resort error masking:

import { createResolver } from 'apollo-resolvers';
import { createError, isInstance } from 'apollo-errors';

const UnknownError = createError('UnknownError', {
  message: 'An unknown error has occurred!  Please try again later'
});

export const baseResolver = createResolver(
   //incoming requests will pass through this resolver like a no-op
  null,

  /*
    Only mask outgoing errors that aren't already apollo-errors,
    such as ORM errors etc
  */
  (root, args, context, error) => isInstance(error) ? error : new UnknownError()
);

Create a few child resolvers for access control:

import { createError } from 'apollo-errors';

import { baseResolver } from './baseResolver';

const ForbiddenError = createError('ForbiddenError', {
  message: 'You are not allowed to do this'
});

const AuthenticationRequiredError = createError('AuthenticationRequiredError', {
  message: 'You must be logged in to do this'
});

export const isAuthenticatedResolver = baseResolver.createResolver(
  // Extract the user from context (undefined if non-existent)
  (root, args, { user }, info) => {
    if (!user) throw new AuthenticationRequiredError();
  }
);

export const isAdminResolver = isAuthenticatedResolver.createResolver(
  // Extract the user and make sure they are an admin
  (root, args, { user }, info) => {
    /*
      If thrown, this error will bubble up to baseResolver's
      error callback (if present).  If unhandled, the error is returned to
      the client within the `errors` array in the response.
    */
    if (!user.isAdmin) throw new ForbiddenError();

    /*
      Since we aren't returning anything from the
      request resolver, the request will continue on
      to the next child resolver or the response will
      return undefined if no child exists.
    */
  }
)

Create a profile update resolver for our user type:

import { isAuthenticatedResolver } from './acl';
import { createError } from 'apollo-errors';

const NotYourUserError = createError('NotYourUserError', {
  message: 'You cannot update the profile for other users'
});

const updateMyProfile = isAuthenticatedResolver.createResolver(
  (root, { input }, { user, models: { UserModel } }, info) => {
    /*
      If thrown, this error will bubble up to isAuthenticatedResolver's error callback
      (if present) and then to baseResolver's error callback.  If unhandled, the error
      is returned to the client within the `errors` array in the response.
    */
    if (!user.isAdmin && input.id !== user.id) throw new NotYourUserError();
    return UserModel.update(input);
  }
);

export default {
  Mutation: {
    updateMyProfile
  }
};

Create an admin resolver:

import { createError, isInstance } from 'apollo-errors';
import { isAuthenticatedResolver, isAdminResolver } from './acl';

const ExposedError = createError('ExposedError', {
  message: 'An unknown error has occurred'
});

const banUser = isAdminResolver.createResolver(
  (root, { input }, { models: { UserModel } }, info) => UserModel.ban(input),
  (root, args, context, error) => {
    /*
      For admin users, let's tell the user what actually broke
      in the case of an unhandled exception
    */

    if (!isInstance(error)) throw new ExposedError({
      // overload the message
      message: error.message
    });
  }
);

export default {
  Mutation: {
    banUser
  }
};

Combine your resolvers into a single definition ready for use by graphql-tools:

import { combineResolvers } from 'apollo-resolvers';

import User from './user';
import Admin from './admin';

/*
  This combines our multiple resolver definition
  objects into a single definition object
*/
const resolvers = combineResolvers([
  User,
  Admin
]);

export default resolvers;

Conditional resolvers:

import { and, or } from 'apollo-resolvers';

import isFooResolver from './foo';
import isBarResolver from './bar';

const banResolver = (root, { input }, { models: { UserModel } }, info)=> UserModel.ban(input);

// Will execute banResolver if either isFooResolver or isBarResolver successfully resolve
// If none of the resolvers succeed, the error from the last conditional resolver will
// be returned
const orBanResolver = or(isFooResolver, isBarResolver)(banResolver);

// Will execute banResolver if both isFooResolver and isBarResolver successfully resolve
// If one of the condition resolvers throws an error, it will stop the execution and
// return the error
const andBanResolver = and(isFooResolver, isBarResolver)(banResolver);

// In both cases, conditions are evaluated from left to right

Resolver context

Resolvers are provided a mutable context object that is shared between all resolvers for a given request. A common pattern with GraphQL is inject request-specific model instances into the resolver context for each request. Models frequently reference one another, and unbinding circular references can be a pain. apollo-resolvers provides a request context factory that allows you to bind context disposal to server responses, calling a dispose method on each model instance attached to the context to do any sort of required reference cleanup necessary to avoid memory leaks:

import express from 'express';
import bodyParser from 'body-parser';
import { GraphQLError } from 'graphql';
import { graphqlExpress } from 'apollo-server-express';
import { createExpressContext } from 'apollo-resolvers';
import { formatError as apolloFormatError, createError } from 'apollo-errors';

import { UserModel } from './models/user';
import schema from './schema';

const UnknownError = createError('UnknownError', {
  message: 'An unknown error has occurred.  Please try again later'
});

const formatError = error => {
  let e = apolloFormatError(error);

  if (e instanceof GraphQLError) {
    e = apolloFormatError(new UnknownError({
      data: {
        originalMessage: e.message,
        originalError: e.name
      }
    }));
  }

  return e;
};

const app = express();

app.use(bodyParser.json());

app.use((req, res, next) => {
  req.user = null; // fetch the user making the request if desired
  next();
});

app.post('/graphql', graphqlExpress((req, res) => {
  const user = req.user;

  const models = {
    User: new UserModel(user)
  };

  const context = createExpressContext({
    models,
    user
  }, res);

  return {
    schema,
    formatError, // error formatting via apollo-errors
    context // our resolver context
  };
}));

export default app;

apollo-resolvers's People

Contributors

alexstrat avatar ch-andrewrhyne avatar doomsower avatar giautm avatar happylinks avatar lakhansamani avatar michieldewilde avatar mringer avatar n1ru4l avatar renovate-bot avatar rynobax avatar thebigredgeek avatar theglenn avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

apollo-resolvers's Issues

Dist is broken

It would appear that the dist file index.js is missing everything except combineResolvers and usePromise. This is the case for 1.0.0 and 1.0.1. That makes the library unusable via NPM or Yarn.

I'm guessing it could be something in the build process.

Error after updating to 1.1.0

On building with typescript, this error message is presented
error TS2339: Property 'createResolver' does not exist on type '(root: any, args?: {}, context?: {}) => Promise<any>'.
Is there anything that needs to be updated?
I've reverted to 1.0.3 for now

Static type testing in TS not working: error 2339

Example:
When calling function createResolver from baseResolver TS reports an error.
error TS2339: Property 'createResolver' does not exist on type '(root: any, args?: {}, context?: {}) => Promise'.

Example code readme.md
export const isAuthenticatedResolver = baseResolver.createResolver(

Option to stop error bubbling

Epic library!

The problem is this. When my custom base resolver has handled the error and returns it, it is thrown which leads to an unhandled error in the runtime.

Is it necessary to catch the error in the endpoint resolver? In that case, shouldn't there be an option not to throw the error?

For example, if I have an authResolver, I don't want to have to catch the UnauthorizedError every time I create a resolver with it.

Or is there any other best practise of this?

EDIT
Turns out I've misunderstood the graphql flow. It was graphql itself that threw the error so there cannot be an option for this. To get rid of the thrown error set graphqlOptions.debug = false

apollo-resolvers as subscription "subscribe"

Hi,
is it possible to use apollo-resolvers as subscription resolver?

I noticed in this case the context is not passed to resolver.

export const resolvers = {
	//...
	Subscription: {
		status: {
			subscribe:
				isAuthenticatedResolver.createResolver(
					(_, args, context, info) => {
						//...
					}
				),
			resolve: ({ data }) =>
				//...
		}
	}
};

Restify + GraphQL + Apollo + Firebase Auth Possible?

Hi, i'm right now studying those themes mentioned above. That's the question (above).

I'm creating a simple GraphQL API structure to learn about it. And I wish to integrate it with Firebase Auth. Couse I'm studying about the Auth from FB to use in a React Native client.

And I'm venturing into Restify xD

Many thanks,

Best Regards.

Project Housekeeping

Figured I'd add a couple housekeeping items.

  1. Make master a protected branch and add 'requite all status checks pass to merge'.
  2. Enable 'Require reviews to merge'.

Recommended way of passing information to the next resolver?

I'm wondering what's the best approach to achieve the following behaviour:

1st resolver: Checks if user is authenticated
2nd resolver: Checks if user has permissions to edit the document. Since we already fetched the document in this step to check permissions, it would be nice to be able to pass the document to the next resolver.
3rd resolver: Query or make the changes to the document in question.

This is what my resolvers look like so far:

const isAuthenticatedResolver = createResolver((root, args, { user }) => {
  if (!user) throw new UnauthenticatedError();
});

const isInOrganization = isAuthenticatedResolver.createResolver(
  (root, { organizationId }, { user }) => {
    return Organization.findOne({ _id: organizationId, owner: user.id })
      .then(organization => {
        if (!organization) throw new CustomError('Please check you have permission to access this organization');
        // How do I pass organization to the next stage?
        // I tried `return`ing organization at this point, but that resolves[?] the resolver and prevents
        // the next one to be executed.
      });
  }
);

const RootResolver = {
  getMembers: isInOrganization.createResolver(
    (organization) => organization.getMembers()
  )
}

Dependency Dashboard

This issue lists Renovate updates and detected dependencies. Read the Dependency Dashboard docs to learn more.

Warning

These dependencies are deprecated:

Datasource Name Replacement PR?
npm babel-eslint Unavailable
npm graphql-server-express Unavailable
npm typings Unavailable

Rate-Limited

These updates are currently rate-limited. Click on a checkbox below to force their creation now.

  • chore(deps): update circleci/node docker tag to v17
  • chore(deps): update dependency mocha to v10
  • chore(deps): update dependency rimraf to v6
  • chore(deps): update dependency sinon to v18
  • chore(deps): update dependency supertest to v7
  • chore(deps): update dependency typescript to v5
  • ๐Ÿ” Create all rate-limited PRs at once ๐Ÿ”

Edited/Blocked

These updates have been manually edited so Renovate will no longer make changes. To discard all commits and start over, click on a checkbox.

Open

These updates have all been created already. Click a checkbox below to force a retry/rebase of any.

Detected dependencies

circleci
.circleci/config.yml
  • circleci/node 8
  • circleci/node 10
  • circleci/node 12
  • circleci/node 14
npm
package.json
  • assert ^2.0.0
  • deepmerge ^3.0.0
  • babel-cli 6.26.0
  • babel-core 6.26.3
  • babel-eslint 7.2.3
  • babel-plugin-transform-object-rest-spread 6.26.0
  • babel-preset-env 1.7.0
  • babel-register 6.26.0
  • bluebird 3.5.4
  • chai 3.5.0
  • eslint 3.19.0
  • eslint-plugin-babel 3.3.0
  • express 4.16.4
  • graphql-server-express 0.6.0
  • mocha 3.5.3
  • rimraf 2.6.3
  • sinon 1.17.7
  • supertest 3.4.2
  • typescript 2.9.2
  • typings 2.1.1

  • Check this box to trigger a request for Renovate to run again on this repository

Improve typings

Hello,

I'm wondering if there is a way to improve the typings definition by having the context variable being typed :

export interface ResultFunction<ResulType> {
    (root: any, args: any, context: any, info: any): Promise<ResulType> | ResulType | void;
}

becomes

export interface ResultFunction<ResulType, ContextType> {
    (root: any, args: any, context: ContextType, info: any): Promise<ResulType> | ResulType | void;
}

The idea behind that is that if we make some modifications on the context object (like adding new properties that would be needed by the next resolver in the pipe), we would then be able to retrieve a fully typed variable in the next resolver, instead of any.
In a perfect world, each resolver context type should augment the previous one, so that the last resolver gets a merged type of all added properties.

Thanks

4th resolve parameter: info

Can you please add the 4th parameter to createResolver. The info object contains some useful information when attempting to create more advanced resolvers.

Example of baseResolver error callback

Hey lib is great! Have a question regarding errors thrown. Can you provide an example of handling the errors, I'd like to catch them before it is passed to the client and I'm struggling to find best way.

Thanks,
ron

Pass data among resolvers

Hi. I recently used this library and really enjoy it. I wonder if there's anyway if we can pass data from a resolver to another.

For example, let's say I have a resolver isStatusOwnerResolver like this:

export const isStatusOwnerResolver = isAuthenticatedResolver.createResolver(
  async (root, { statusId }, { userId, models: { Status } }) => {
    const status = await Status.findById(statusId)
    if (status.ownerId.toString() !== userId) {
      throw new NotStatusOwnerError()
    }
  },
)

Is there anyway I can pass status to the resolver that is chained from isStatusOwnerResolver?

Typescript - TypeError: Cannot read property 'createResolver' of undefined

The following error is occurring when trying to use apollo-resolvers with typescript, I noticed that several people were having the same problem and I decided to open the issue, more details below.

Error

captura de tela 2018-12-14 as 15 19 00

resolvers

captura de tela 2018-12-14 as 15 22 56

userResolvers

captura de tela 2018-12-14 as 15 20 51

package.json

  "dependencies": {
    "@sentry/node": "4.4.2",
    "apollo-errors": "1.9.0",
    "apollo-resolvers": "1.4.1",
    "apollo-server-micro": "2.3.1",
    "bcryptjs": "2.4.3",
    "bluebird": "3.5.3",
    "ccxt": "1.18.36",
    "date-fns": "1.30.1",
    "dotenv": "6.2.0",
    "graphql": "14.0.2",
    "i18nh": "0.0.4",
    "idx": "2.5.2",
    "jsonwebtoken": "8.4.0",
    "micro": "9.3.3",
    "micro-compose": "0.0.3",
    "micro-cors": "0.1.1",
    "mongoose": "5.3.16",
    "ramda": "0.26.1",
    "ts-node": "7.0.1",
    "validator": "10.9.0"
  },
  "devDependencies": {
    "@types/bcryptjs": "2.4.2",
    "@types/bluebird": "3.5.25",
    "@types/dotenv": "6.1.0",
    "@types/graphql": "14.0.3",
    "@types/jsonwebtoken": "8.3.0",
    "@types/micro": "7.3.3",
    "@types/micro-cors": "0.1.0",
    "@types/mongoose": "5.3.5",
    "@types/ramda": "0.25.43",
    "@types/validator": "9.4.4",
    "husky": "1.2.1",
    "lint-staged": "8.1.0",
    "nodemon": "1.18.8",
    "prettier": "1.15.3",
    "tslint": "5.11.0",
    "tslint-config-prettier": "1.17.0",
    "tslint-config-security": "1.13.0",
    "typescript": "3.2.2"
  },

Wrong resolvers condition when or(true, false) and final resolver throws

Hi, funny thing. Not all cases are covered in tests and there is a bug with one of these.
Here is an example of test with or condition:

// test/unit/helper_spec.js
it('when (true, false) and resolver throws, it should return exactly that error', () => {
        const resolver = or(successResolver, failureResolver);
        const testError = new Error('test');
        const finalResolver = resolver(() => {
          throw testError;
        });
        return finalResolver()
          .catch(err => {
            expect(err).to.equal(testError);
          });
      });

So, if first resolver succeed it immediately runs query. But if query throws, second resolver runs after that and if it is throws, we receive that second error, but not the error that query throws

Async function in a parent resolver

Hi,
is it possible to use an async function in a parent resolver?

I'm trying to achieve something like this:

export const isAuthenticatedResolver = baseResolver.createResolver(
	(_, args, { token }) => {
		if (!token) {
			throw new AuthenticationRequiredError();
		}

                 //async part here
                 db.models.sessions.query(token)
                     .catch(e => throw new InvalidTokenError() )
);

Passing info to Resolver

I got this error because info not passed to resolver. :(

TypeError: Cannot read property 'parentType' of undefined

on graphql-relay globalIdResolver

    (obj, args, context, info) => toGlobalId(
      typeName || info.parentType.name,
      idFetcher ? idFetcher(obj, context, info) : obj.id
    )

Nullable returns

Is it expected that returning null from a resolver should allow the request to continue to the child resolver?

Since null is a valid return type for nullable fields, it may be desired that a resolver returns an early null like so.

eg.

const isAuthenticatedResolver = baseResolver.createResolver(
  (root, args, { isAuthenticated }) => {
    if (!isAuthenticated) return null;
  }
);

Resolvers don't pass info

As best as I can tell, this implementation of resolvers does not pass along the info argument from Apollo's resolver signature. This breaks my usage of fieldASTs to skip unnecessary DB joins in the resolvers.

This would be a breaking change, but would you consider changing your resolver signature to match Apollo's? My use case represents only one of the many useful things that can be done with info.

resolver(root, args, context, info, errors) {}

Deploying using apollo-server-lambda

Do you have a deployment that isn't using express? We can't use express as we have to use Serverless and I'm a little confused to the correct way to get this working inside an apollo-server-lambda environment

Granular errors?

Hi, first let me say thank you for creating this library. It's been a very useful design pattern.

Today I've run into the problem with trying to authenticate a query that looks something like this:

query {
  open
  sensitive
}

where open is clear to anyone, but sensitive requires certain permissions.

It seems when I throw an error using the apollo-resolvers pattern, the data for open never makes it to the client. On the bright side, the "errors" data is correctly in the response.

Is it possible to have both the error data and open data in the same response using this library?

Minor typescript issues

All of them can be worked around with any and !, but it would be nice to see them fixed here.

First one:

export interface CreateResolverFunction {
  <R, E>(resFn: ResultFunction<R>, errFn?: ErrorFunction<E>): Resolver<R>
}

export const createResolver: CreateResolverFunction 

Probably resFn should be optional or nullable, because in your examples you use it like this:

export const baseResolver = createResolver(
   //incoming requests will pass through this resolver like a no-op
  null,

  /*
    Only mask outgoing errors that aren't already apollo-errors,
    such as ORM errors etc
  */
  (root, args, context, error) => isInstance(error) ? error : new UnknownError()
);

Or maybe there should be two types, one for module-level createResolver and one for Resolver's createResolver

Second is that you define Resolver as

export interface Resolver<ResulType> {
  (root, args: {}, context: {}, info: {}): Promise<ResulType>
  createResolver?: CreateResolverFunction
  compose?: ComposeResolversFunction
}

As far as I get it, the only way to create resolver is via createResolver and it will always add createResolver and compose, so they shouldn't be optional in interface.

[Examples, Question] for database connection errors using MongoDB + apollo-errors + apollo-resolvers

Hello there!

This is what I did in order to handle database connection errors using MongoDB and 2 awesome packages
apollo-errors and apollo-resolvers.

First of all I created a middleware that handles the connection to MongoDB where the client is a promise that resolves to the Connected client and the db is the database instance.

Note that I am calling the next middleware even though there is an error or not and the same time attaching the client and the db instance to request object in order to be available in the context object, here is where I'm not sure if is safe or bad doing it in this way because in my case I'm doing a signup form and well I need to open a connection to the database every time the user makes a request.

const dbConnection  = async (req, res, next) => {
    try {
        const client = await MongoClient.connect('mongodb://localhost:27017')
        const db = client.db('myproject')
        req.client = client
        req.db = db
        next()
    } catch (error) {
        req.err = error.message
        next()
    }
}

app.use('/graphql', dbConnection, bodyParser.json(), graphqlExpress(req => {
    return {
        schema,
        formatError,
        context: {
            client: req.client,
            db: req.db,
            err: req.err
        }
    }
}))

Then I create a base resolver for unknown thrown errors and another one that get all the users from the database, note that in the last resolver I am checking if there is an error, again I don't know if is good or bad doing it this way.

const baseResolver = createResolver(
    null,
    (_, args, context, error) => {
        if (isInstance(error)) return error
        return new UnknownError({
            data: {
                message: 'Oops! Something went wrong'
            }
        })
    })

const getAllUsers = baseResolver.createResolver(
    (_, args, {
        client,
        db,
        err
    }) => {
        if (err) throw new DatabaseConnectionError({
            data: {
                message: err
            }
        })
        return ...
    })

const resolvers = {
    Query: {
        getAllUsers
    },

I could do this directly in a resolver called dbConnectionResolver and throw erros from there and spam getAllUsers from the previous one like so: const getAllUsers = dbConnectionResolver.createResolver(...) the problem though is if there is no errors thrown in the dbConnectionResolver I don't get access to the client and the db instance in the next resolver.

Surely I'm doing something wrong because of my lack of knowledge if somebody have some thoughts on this I'll appreciate it. Thanks.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.