Giter VIP home page Giter VIP logo

laravel-api-handler's Introduction

Laravel API Handler

Build Status Latest Stable Version Total Downloads License

This helper package provides functionality for parsing the URL of a REST-API request.

Installation

Note: This version is for Laravel 5. When using Laravel 4 you need to use version 0.4.x.

Install the package through composer by running

composer require marcelgwerder/laravel-api-handler

Once composer finished add the service provider to the providers array in app/config/app.php:

Marcelgwerder\ApiHandler\ApiHandlerServiceProvider::class,

Now import the ApiHandler facade into your classes:

use Marcelgwerder\ApiHandler\Facades\ApiHandler;

Or set an alias in app.php:

'ApiHandler' => Marcelgwerder\ApiHandler\Facades\ApiHandler::class,

That's it!

Migrate from 0.3.x to >= 0.4.x

Relation annotations

Relation methods now need an @Relation annotation to prove that they are relation methods and not any other methods (see issue #11).

/**
 * @Relation
 */
public function author() {
    return $this->belongsTo('Author');  
}

Custom identification columns

If you pass an array as the second parameter to parseSingle, there now have to be column/value pairs. This allows us to pass multiple conditions like:

ApiHandler::parseSingle($books, array('id_origin' => 'Random Bookstore Ltd', 'id' => 1337));

Configuration

To override the configuration, create a file called apihandler.php in the config folder of your app.
Check out the config file in the package source to see what options are available.

URL parsing

Url parsing currently supports:

  • Limit the fields
  • Filtering
  • Full text search
  • Sorting
  • Define limit and offset
  • Append related models
  • Append meta information (counts)

There are two kind of api resources supported, a single object and a collection of objects.

Single object

If you handle a GET request on a resource representing a single object like for example /api/books/1, use the parseSingle method.

parseSingle($queryBuilder, $identification, [$queryParams]):

  • $queryBuilder: Query builder object, Eloquent model or Eloquent relation
  • $identification: An integer used in the id column or an array column/value pair(s) (array('isbn' => '1234')) used as a unique identifier of the object.
  • $queryParams: An array containing the query parameters. If not defined, the original GET parameters are used.
ApiHandler::parseSingle($book, 1);

Collection of objects

If you handle a GET request on a resource representing multiple objects like for example /api/books, use the parseMultiple method.

parseMultiple($queryBuilder, $fullTextSearchColumns, [$queryParams]):

  • $queryBuilder: Query builder object, Eloquent model or Eloquent relation
  • $fullTextSearchColumns: An array which defines the columns used for full text search.
  • $queryParams: An array containing the query parameters. If not defined, the original GET parameters are used.
ApiHandler::parseMultiple($book, array('title', 'isbn', 'description'));

Result

Both parseSingle and parseMultiple return a Result object with the following methods available:

getBuilder(): Returns the original $queryBuilder with all the functions applied to it.

getResult(): Returns the result object returned by Laravel's get() or first() functions.

getResultOrFail(): Returns the result object returned by Laravel's get() function if you expect multiple objects or firstOrFail() if you expect a single object.

getResponse($resultOrFail = false): Returns a Laravel Response object including body, headers and HTTP status code. If $resultOrFail is true, the getResultOrFail() method will be used internally instead of getResult().

getHeaders(): Returns an array of prepared headers.

getMetaProviders(): Returns an array of meta provider objects. Each of these objects provide a specific type of meta data through its get() method.

cleanup($cleanup): If true, the resulting array will get cleaned up from unintentionally added relations. Such relations can get automatically added if they are accessed as properties in model accessors. The global default for the cleanup can be defined using the config option cleanup_relations which defaults to false.

ApiHandler::parseSingle($books, 42)->cleanup(true)->getResponse();

Filtering

Every query parameter, except the predefined functions _fields, _with, _sort, _limit, _offset, _config and _q, is interpreted as a filter. Be sure to remove additional parameters not meant for filtering before passing them to parseMultiple.

/api/books?title=The Lord of the Rings

All the filters are combined with an AND operator.

/api/books?title-lk=The Lord*&created_at-min=2014-03-14 12:55:02

The above example would result in the following SQL where:

WHERE `title` LIKE "The Lord%" AND `created_at` >= "2014-03-14 12:55:02"

Its also possible to use multiple values for one filter. Multiple values are separated by a pipe |. Multiple values are combined with OR except when there is a -not suffix, then they are combined with AND. For example all the books with the id 5 or 6:

/api/books?id=5|6

Or all the books except the ones with id 5 or 6:

/api/books?id-not=5|6

The same could be achieved using the -in suffix:

/api/books?id-in=5,6

Respectively the not-in suffix:

/api/books?id-not-in=5,6
Suffixes
Suffix Operator Meaning
-lk LIKE Same as the SQL LIKE operator
-not-lk NOT LIKE Same as the SQL NOT LIKE operator
-in IN Same as the SQL IN operator
-not-in NOT IN Same as the SQL NOT IN operator
-min >= Greater than or equal to
-max <= Smaller than or equal to
-st < Smaller than
-gt > Greater than
-not != Not equal to

Sorting

Two ways of sorting, ascending and descending. Every column which should be sorted descending always starts with a -.

/api/books?_sort=-title,created_at

Fulltext search

Two implementations of full text search are supported. You can choose which one to use by changing the fulltext option in the config file to either default or native.

Note: When using an empty _q param the search will always return an empty result.

Limited custom implementation (default)

A given text is split into keywords which then are searched in the database. Whenever one of the keyword exists, the corresponding row is included in the result set.

/api/books?_q=The Lord of the Rings

The above example returns every row that contains one of the keywords The, Lord, of, the, Rings in one of its columns. The columns to consider in full text search are passed to parseMultiple.

Native MySQL implementation

If your MySQL version supports fulltext search for the engine you use you can use this advanced search in the api handler.
Just change the fulltext config option to native and make sure that there is a proper fulltext index on the columns you pass to parseMultiple.

Each result will also contain a _score column which allows you to sort the results according to how well they match with the search terms. E.g.

/api/books?_q=The Lord of the Rings&_sort=-_score

You can adjust the name of this column by modifying the fulltext_score_column setting in the config file.

Limit the result set

To define the maximum amount of datasets in the result, use _limit.

/api/books?_limit=50

To define the offset of the datasets in the result, use _offset.

/api/books?_offset=20&_limit=50

Be aware that in order to use offset you always have to specify a limit too. MySQL throws an error for offset definition without a limit.

Include related models

The api handler also supports Eloquent relationships. So if you want to get all the books with their authors, just add the authors to the _with parameter.

/api/books?_with=author

Relationships, can also be nested:

/api/books?_with=author.awards

To get this to work though you have to add the @Relation annotation to each of your relation methods like:

/**
 * @Relation
 */
public function author() {
    return $this->belongsTo('Author');  
}

This is necessary for security reasons, so that only real relation methods can be invoked by using _with.

Note: Whenever you limit the fields with _fields in combination with _with. Under the hood the fields are extended with the primary/foreign keys of the relation. Eloquent needs the linking keys to get related models.

Include meta information

It's possible to add additional information to a response. There are currently two types of counts which can be added to the response headers.

The total-count which represents the count of all elements of a resource or to be more specific, the count on the originally passed query builder instance. The filter-count which additionally takes filters into account. They can for example be useful to implement pagination.

/api/books?id-gt=5&_config=meta-total-count,meta-filter-count

All meta fields are provided in the response header by default. The following custom headers are used:

Config Header
meta-total-count Meta-Total-Count
meta-filter-count Meta-Filter-Count

Use an envelope for the response

By default meta data is included in the response header. If you want to have everything together in the response body you can request a so called "envelope" either by including response-envelope in the _config parameter or by overriding the default config.php of the package.

The envelope has the following structure:

{
  "meta": {

  },
  "data": [

  ]
}

laravel-api-handler's People

Contributors

diego-betalabs avatar diegohq avatar fernandobandeira avatar jeremykenedy avatar lloy0076 avatar marcelgwerder 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

laravel-api-handler's Issues

Proposal - Appends

It would be nice to add ability to make appends on results.
For example if user model has getFullNameAttribute() we could use something like:

/users?_appends=fullName

I make workaround in my Repository Base Class (parserResult is method from Repository library and its called with ->all()):

    /**
     * @param array $fullTextSearchColumns Columns to search in fulltext search
     * @param array|boolean $queryParams A list of query parameter
     * @return $this
     */
    public function scopeQueryParametersMultiple($fullTextSearchColumns = array(), $queryParams = false)
    {
        if ($queryParams === false) {
            $queryParams = request()->except('_appends');
        }

        return $this->scopeQuery(function($query) use ($fullTextSearchColumns, $queryParams) {
            return ApiHandler::parseMultiple($query, $fullTextSearchColumns, $queryParams)->getBuilder();
        });
    }

    public function scopeQueryParametersSingle()
    {
        return $this->scopeQuery(function($query) {
            return ApiHandler::parseSingle($query, [])->getBuilder();
        });
    }

    /**
     * Wrapper result data
     *
     * @param mixed $result
     *
     * @return mixed
     */
    public function parserResult($result)
    {
        $result  = parent::parserResult($result);

        $appends = request()->filled('_appends') ? explode(',', request('_appends')) : null;

        if ($appends) {
            if ($result instanceof Collection) {
                $result->each(function($row) use ($appends) {
                    $row->setAppends($appends);
                });
            } elseif ($result instanceof Model) {
                $result->setAppends($appends);
            }
        }

        return $result;
    }

But would be nice to transfer this logic to Your library.

Allow to selectively use soft-deleted entities

It would be great to have a flag to allow the API handler to also use soft-deleted entities (deleted_at not null) as Laravel allows it easily with:

Model::withTrashed()->get();

I guess you have an idea on which project this could help ;)

Cheers, Dan

Returning relationship model data even when not requested when Accessor is used in model

I have a Model:
image

Then an index method in my controller which returns all DayExercise(s) using this wonderfull package

    public function index()
    {
        $model = new \App\Models\DayExercise;
        return ApiHandler::parseMultiple($model)->getResult();
    }

All is working fine, I am getting my data like so:

[
  {
    "id": 27,
    "exercise_id": 28,
    "day_id": 15,
    "sets": 3,
    "reps": 5,
    "weight": null,
    "seq": 1,
    "increment": "2.50",
    "one_rm": "67.50",
    "one_rm_updated_at": "2016-08-15 00:00:00",
    "auto_reps": 1,
    "auto_weight": 1,
    "day": {
      "id": 15,
      "name": "Day 1: Chest/Biceps",
      "program_id": 39,
      "seq": 1,
      "program": {
        "id": 39,
        "name": "Chest/Back Legs/Shoulders",
        "user_id": 2,
        "reps_per_set": 4,
        "reps_per_set_updated_at": "2016-08-29 00:00:00",
        "difficulity": 1,
        "volume_cache": null,
        "created_at": "2016-08-14 19:08:00",
        "updated_at": "2016-08-29 21:46:44",
        "rank": 1
      }
    }
  },
  {
    "id": 28,
    "exercise_id": 155,
    "day_id": 15,
    "sets": 3,
    "reps": "12",
    "weight": "12.5",
    "seq": 2,
    "increment": "2.50",

However notice the

    "auto_weight": 1,
    "day": {
      "id": 15,
      "name": "Day 1: Chest/Biceps",
      "program_id": 39,
      "seq": 1,
      "program": {
        "id": 39,
        "name": "Chest/Back Legs/Shoulders",
        "user_id": 2,
        "reps_per_set": 4,
        "reps_per_set_updated_at": "2016-08-29 00:00:00",
        "difficulity": 1,
        "volume_cache": null,
        "created_at": "2016-08-14 19:08:00",
        "updated_at": "2016-08-29 21:46:44",
        "rank": 1
      }

I didn't request for the day and program object to be included in the response. I did some debuging and realized this happen due to the following Accessor in my model. The line that is causing the problem is the $this->day->program->reps_per_set; part.

    public function getRepsAttribute($value)
    {
        if($this->auto_reps) {
            $this->day->program->reps_per_set;
            return 5;
        }
        return $value;
    }

If I comment out the line, changing the function to:

    public function getRepsAttribute($value)
    {
        if($this->auto_reps) {
            //$this->day->program->reps_per_set;
            return 5;
        }
        return $value;
    }

All works fine and I get proper data returned like so:

[
  {
    "id": 27,
    "exercise_id": 28,
    "day_id": 15,
    "sets": 3,
    "reps": 5,
    "weight": null,
    "seq": 1,
    "increment": "2.50",
    "one_rm": "67.50",
    "one_rm_updated_at": "2016-08-15 00:00:00",
    "auto_reps": 1,
    "auto_weight": 1
  },
  {
    "id": 28,
    "exercise_id": 155,
    "day_id": 15,
    "sets": 3,
    "reps": "12",
    "weight": "12.5",

Whereby the whole day and program objects are not included.

Just some extra info. A DayExercise belongs to a Day, and a Day belongs to a Program.

Not able to use on Lumen without Facades

Not a issue, but a limitation. While trying to stay away from Facades, these kind of calls in the package forces the host app to be bound to them:

$this->prefix = Config::get('apihandler.prefix');
$this->envelope = Config::get('apihandler.envelope');

There are more in the package, this is just a sample.

[question] Use library with repository

Hello,

Has anyone tried use this library with Repository Pattern in one moment?
I use https://github.com/andersao/l5-repository and i would still use it.

For example i have method:
$this->myRepository->getItemsByUser($loggedInApi);
And now i would to add queries to this method in my api.
Is it possible? I am not sure how can i inject QB to api-handler or another side - get QB from my repository and inject to parseMultiple().

Predefined filter partially not working ?

Hey there.
I'm new to Laravel (5.1) and the whole plugin stuff and would appreciate a little help. :)

Following the instructions i got the ApiHandler to work with regular parameters and its awesome, but some of the predefined functions throw sql erros since they seem to be interpreted as table columns.

Specifically _sort, _fields and _with don't seem to work, while _offset, _limit and _q for example work as intended.
I didn't change anything in the config/apihandler.php, prefix is still '_' and i added @Relation annotations to the needed relation as described. Do i have to register these params anywhere?

Error when using sort: /api/posts?_sort=name

QueryException in Connection.php line 664:
SQLSTATE[42S22]: Column not found: Unknown column '_sort' in 'where clause' (SQL: select * from `posts` where `posts`.`deleted_at` is null and `_sort` = name order by `name` asc)

Error when using with: /api/posts?_with=user

QueryException in Connection.php line 664:
SQLSTATE[42S22]: Column not found: Unknown column '_with' in 'where clause' (SQL: select * from `posts` where `posts`.`deleted_at` is null and `_with` = user)

Any ideas what i am missing, or where to look for the source of failure would be highly appreciated! :)

[Next] Setting sortable columns does not work properly

This issue occurs only on the "next" branch.

It looks like it is not possible to set the sortable columns on the APIHandler itself as it is possible for filterable:

return ApiHandler::from($builder)
    ->filterable('type')
    ->sortable('id')
    ->asResourceCollection()
;

While the filterable call will make the type column filterable, the sortable call will have no effect and then calls on the API endpoint using that sort order will cause an exception:

Sort path "id" is not allowed on this endpoint.

Looking to the code I noticed that if you comment out in ApiHandler.php the following function, then the above code works and properly sort the entities.

//    /**
//     * Define the columns that should be sortable.
//     *
//     * @param  array|string|dynamic  $sortables
//     * @return $this
//     */
//    public function sortable($sortables): self
//    {
//        $this->sortables = is_array($sortables) ? $sortables : func_get_args();
//
//        return $this;
//    }

This is because there is an __call function in the APIHandler that will intercept the call to sortable just as it does for filterable.

But then it is only possible to pass a single sortable column, the following code will not work:

    ...
    ->sortable(['id', 'recipient'])
    ...

The above will cause the following exception when accessing the endpoint:

preg_quote() expects parameter 1 to be string, array given

And by the way the following syntax will only make the last field sortable (here: recipient):

    ->sortable('id')
    ->sortable('recipient')

Meta Information not working

There were no meta information returned from the _config=meta-total-count,meta-filter-count added on the URL.

Please provide more information on how this works.

Why parseSingle

The "parseMultiple" method makes much sense to me with filters, offset, limit, etc...

On the other hand, the "parseSingle" method seems more accessory. Yes, it makes a simpler response code in many scenarios. I was wondering what kind of uses where it would shine.

The only thing I've found was to detect if the id is a number or text and finding in the appropriate field. ie:

/api/users/1  (by id)
/api/users/Administrator  (by name)
class UsersController extends RestfulController
{
    public function show(Request $request, $id)
    {
        return ApiHandler::parseSingle(User::query(), is_numeric($id) ? $id : array('name'=>$id))->getResponse();
    }
}

Include Meta Information on the response json

Hi my I suggest if you can add the meta information from the response. The purpose was to use this on javascript tables such as jquery DataTable as a source. If you can make another method to have a parameter or a flag that indicate if the meta information be included in the response.

Again thanks for this wonderful API handler.

Remote Function Call with '_with' query parameter possible

== Security Problem

Inside the function parseWith you use the following code:

//Throw a new ApiHandlerException if the relation doesn't exist
try
{
    $relation = call_user_func(array($previousModel, $part));
}

This makes it possible to call any function without parameter existing in the model.

== Example

Lets say you have a function like this in your user model:

function bang() {
    die('BANG!!!');
}

Now you call the url for users resource like:

/api/v1/users?_with=bang

You will see: "BANG!!!" on your screen. This is just an example, but this function could have some business logic inside.

Great job!

Wow, really great job, friend! Not an issue, just wanted to say thank you, this package saves months of life!

better if this package support more than two layer "_fields" nesting

Api like:

http://mba.cn/api/tests/users?_with=class,class.headteacher,nation&nation.id-in=1,2&_fields=class.name,class.headteacher.name

cause:

"SQLSTATE[42S22]: Column not found: 1054 Unknown column 'headteacher.name' in 'field list' (SQL: select `name`, `headteacher`.`name`, `id`, `headteacher_id` from `mba_classes` where `mba_classes`.`id` in (1))"

Filter min and null combined

Stumbled upon a usercase where I would have to make an comparison on a nullable datetime field ended_at-min OR null.

Wonder if there is any way to make that possible?

Example

Can there be a small example on how it's used. I'm fairly new, so trying to get my head around it.

I have my route set-up

Route::group(['prefix' => 'api/v1'], function()
{

    Route::resource('users', 'Api\UsersController');
    Route::resource('notifications', 'Api\NotificationsController');

});

And my controller

public function index()
    {
        $notifications = Notifications::all();
        return response()->json(['notifications' => $notifications]);
    }

This works fine just pulling data into my ember app, but I'm not sure how I would update my controller to work with this package.

Security question

Dynamic relationships are very powerfull but also bit unsecure.
Any experience with securing nested resources?
For example user is able to get posts
posts?with=author
but ..
posts?with=author.privateSettings
this should be able to download only for author or a supervisor.
Any option to set available relations?

Envelope/Headers

In the README it says that two custom headers will be returned indicating total and filter count or, if you prefer, you could include them in the body with a configuration variable. So far, neither of these seem to be working. FWIW, it doesn't appear in the config/api.php file that there is a variable for envelope anyway.

Here's an example piece of code that returns a JSON object via my Fractal transformer:

// API Handler Call
$campaigns = ApiHandler::parseMultiple(new Campaign, Campaign::getSearchedFields(), $request->all())->getBuilder();

// Simple Scope to Keep Records to the Account Calling
$response = $campaigns->inAccount($this->account, $this->user, $request->get('user_id'), null !== $request->input('me'))->get();

// Provided by Dingo API package but is basically just a helper for sending back JSON.
return $this->response()->collection($response, new CampaignTransformer);

Here's the headers I get back:

Cache-Control → no-cache
Connection → keep-alive
Content-Type → application/json
Date → Fri, 23 Oct 2015 15:00:08 GMT
Server → nginx/1.8.0
Transfer-Encoding → chunked

And note that nothing appears in the response body even if I pass the _config param explicitly.

Am I missing something? Thanks so much for a fantastic package, Marcel. Much appreciated sir!

Sorting datetime field

First of all, this work is one more godsend from the community. Thx

I get a weird behavior when sorting datetime fields ..have a look
(below, just showing the field value, in the order it appears in the json response)

/api/users?_sort=created_at

"created_at": "2016-07-29 02:06:34"
"created_at": "2016-07-28 02:47:30"
"created_at": "2016-07-28 06:57:49"
"created_at": "2016-07-28 18:32:33"

/api/users?_sort=-created_at

"created_at": "2016-07-28 18:32:33"
"created_at": "2016-07-28 06:57:49"
"created_at": "2016-07-28 02:47:30"
"created_at": "2016-07-29 02:10:03"

So by looking at this, it seems that the sort is done on the date ..and the time, but the time sorting is reversed.

Below, my simple controller..

class UsersController extends RestfulController {
    public function index(Request $request) {
        return ApiHandler::parseMultiple(User::query(), array('name','email'))->getResponse();
    }
}

How to get an of fields provided in the query

Hi,

it's possible to retrieve a list of fields that were passed to the request for the use with fractal?

E.g.:

<?php

use League\Fractal;

/**
 * UsersController Class
 *
 * Implements actions regarding user management
 */
class UsersController extends Controller
{

    public function __construct()
    {
        $this->fractal = new Fractal\Manager();
    }

    /**
     * Returns all users
     * 
     * @return Users
     */
    public function index()
    {
        $user = new User();
        $users = ApiHandler::parseMultiple($user, array('id', 'username', 'email', 'created_at'));
        // Pass this array (collection) into a resource, which will also have a "Transformer"
        // This "Transformer" can be a callback or a new instance of a Transformer object
        // We type hint for array, because each item in the $books var is an array
        $resource = new Fractal\Resource\Collection($users->getResult()->toArray(), function(array $user) {

           /*
This parts needs to be automated by the _fields that were passed to the query
*/
           /*
            return [
                'id' => (int) $user['id'],
                'username' => $user['username'],
                'email' => $user['email'],
                'created_at' => $user['created_at'],
                'links' => [
                    [
                        'rel' => 'self',
                    ]
                ]
            ];
           */
        });
        return $this->fractal->createData($resource)->toJson();

}

Thanks in advance

Search in joined table

I need to search in a joined column name field.
By default it searches in the main table, can I change this so it searches in products_content.name=Test instead of products.name

How can i use custom get param

I have simply index() method to get all resources but i would to use custom filter for example:

/books?category=type

But category is not a column name, but just my custom attribute to use in if/else.

if ($category) etc...

Library is taking category and use it in WHERE condition so it is causing SQL error. I tried with _category too but same result.

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.