Giter Club home page Giter Club logo

tectonic's Introduction

Tectonic

Declarative data loading for REST APIs: https://tonyhb.github.io/tectonic/


What does it do?

Great question! It:

  • Queries data via your existing REST API with adaptable query drivers
  • Stores state and data within Redux' reducers automatically
  • Respects and manages caching
  • And, best of all, passes fetched data and loading state into your components

What does that mean?

Never define an action for loading data again. And also, never write a reducer again. Which means no normalization of data! And no writing reselect queries! It happens automatically for you.

Cool. How do I use it?

First you need to define some models to hold and query data:

import { Model } from 'tectonic';

class User extends Model {
  // modelName is important; it's used to differentiate models
  static modelName = 'user';

  // fields are used to create an immutable.js record which holds data for an
  // instance of a model. All fields must be defined here with defaults
  static fields = {
    id: 0,
    email: '',
    name: '',
  }

  // idField defines which field is used as the model identifier. This defaults
  // to 'id' and should only be set if it's different.
  // Note that a model must always have an ID; this is how we look up data in
  // reducer.
  static idField = 'id';
}

Then you define your REST API as sources. It's quick and easy, but let's skip it to get to the juicy part. Which is declaratively asking for data!

Check it out (I'll tell you what's going on after):

import React, { Component, PropTypes } from 'react';
import load, { Status } from 'tectonic';
import { User, Post } from './models.js'; // your models
const { instanceOf, arrayOf, shape, string } = PropTypes;

@load((props) => ({
  user: User.getItem({ id: props.params.userId }),
  posts: Post.getList({ email: props.user.email })
}))
class UserInfo extends Component {
  static propTypes = {
    // btw, these are the same keys we passed to '@load'
    user: instanceOf(User),
    posts: arrayOf(instanceOf(User)),

    status: shape({
      user: instanceOf(Status), // Status is a predefined Tectonic class :)
      posts: instanceOf(Status),
    })
  }

  render() {
    const { status } = this.props;
    if (status.user.isPending()) {
      return <Loading />;
    }
    // ...
  }
}

Hella cool right?! Here's what's happening:

You say what props you want within the @load decorator. The @load decorator gets the component's props, so you can use props in the router or from parents to load data.

Plus, it automatically handles what we call "dependent data loading". Here, posts depends on the user's email. We don't get that until the user has loaded. Don't worry; this is handled automatically behind the scenes.

Tectonic also adds loading statuses for each of the props to your component!

You can see whether it's pending, successful, or errored using built in functions (the actual status is at .status, so this.props.status.user.status). Plus, if there's errors, you get the error message at .error, so this.props.status.user.error. Same goes for the HTTP code.

And as a bonus all of the requests are automatically cached and stored according to the server's cache headers. So if your server tells us to store something for an hour we're not going to make a request for this data for, like, one hour and one minute!

Super, super basic interface, and super, super powerful stuff behind the scenes. I know, not as cool as GraphQL and relay. But still, if you gotta REST you gotta deal, baby.

Bonus: Guess what? If three components asked for the same data we'll automatically dedupe requests for you. We'll only ask the API once. So don't worry. Spam @load like you're obsessed!

Mind blown. You mentioned defining API endpoints as sources?

That's right. See, behind the scenes we need to figure out how to actually load your data. This is done by a "resolver".

In order for us to figure that out you need to tell us where your endpoints are; what they return; and what required parameters they have.

Here's an example:

import { Manager, BaseResolver } from 'tectonic';
import TectonicSuperagent from 'tectonic-superagent';

// Step 1: create your manager (which brings everything together)
const manager = new Manager({
  resolver: new BaseResolver(),
  drivers: {
    // Drivers are modular functions that request data for us.
    // This one uses the awesome superagent ajax library.
    // See packages/tectonic-superagent for more info :)
    fromSuperagent: new TectonicSuperagent(),
  },
  store, // Oh, the manager needs your redux store
});

// Step 2: Define some API endpoints as sources.
// Note that each driver becomes a function on `manager` - this
// is how we know which driver to use when requesting data.
manager.drivers.fromSuperagent([
  // Each driver takes an array of API endpoints
  {
    // LMK what the endpoint returns. In this case it's a single
    // user item.
    returns: User.item(),
    // To get a single user the API endpoint needs a user's ID
    params: ['id'],
    meta: {
      // meta is driver-specific. In this case the superagent driver
      // needs to know the URL of the API endpoint. It's going to
      // replace `:id` with the ID parameter when loading data.
      url: '/api/v1/users/:id',
    }
  },
  {
    // This returns a list of posts
    returns: Post.list(),
    // Each param item is the name of the param you pass into @load. EG:
    // @load({
    //    posts: Post.getList({ userId: 1 })
    //  })
    params: ['userId'],
    meta: {
      url: '/api/v1/users/:userId/posts',
    },
  },
]); 

A lot of concepts.

The manager makes everything tick. It passes "queries" from @load into the "resolver", which then goes through your sources above to figure out which requests to make.

Once we've got data, the manager takes that and puts it into the cache, which is an abstraction over a Redux reducer in the store to manage caching.

What happens if I make a request without a source?

We'll throw an error which you can see in your console. Also, we use the debug npm package which you can enable via:

tdebug.enable('*');

How do I add the manager to my app?

Wrap your app with a component which passes context. We call it a "loader":

import { Provider } from 'react-redux';
import { Loader } from 'tectonic';
import store from './store.js';
import manager from './manager.js'; // your manager with sources defined

const App = () => (
  <Provider store={ store }>
    <Loader manager={ manager }>
      {/* Your app goes here */}
    </Loader>
  </Provider>
);

export default App;

Sweet potato. But can I CRUD?

Hell yeah baby!

The @load decorator also adds a query function to your components:

@load() // just gimme these functions please!
class YourForm extends Component {
  static propTypes = {
    query: PropTypes.func,
  }

  // imagine onSubmit is called with an object containing model
  // data...
  onSubmit(data) {
    // Each function takes two arguments: an object of options and a
    // second callback for tracking the status of the request 
    this.props.query({
      model: User,
      body: data,
      queryType: 'CREATE', // tells us to use a source definition to CREATE a model
    }, this.afterSubmit);
  }
  
  afterSubmit = (err, result) => {
    if (err !== null) {
      // poo ๐Ÿ’ฉ
      return;
    }
  }
}

๐Ÿ’ฅ๐Ÿ’ฅ๐Ÿ’ฅ! This is automatically gonna populate the cache, too.

Can I see documentation?

Sure thing, partner. Head here.

License: MIT.

tectonic's People

Contributors

aerykk avatar aindeev avatar alexfreska avatar andreyluiz avatar christopherbiscardi avatar fzaninotto avatar kyhy avatar maluen avatar myrlund avatar tonyhb 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

tectonic's Issues

Defaulting model.fields.id to undefined produces error

Example

import { Model } from 'tectonic';

class SomethingModel extends Model {
  static modelName = 'something';

  static fields = {
    description: undefined,
    id: undefined,
    name: undefined,
  };
}

export default SomethingModel;

results in Error: Must supply an ID field for this model. The docs provide an example with id: undefined. Is this valid or no?

Request caching: add etag support

We should add etag support to queries.

Plan:

  1. Store the source ID and source response headers for each request (we currently only store the model IDs returned from the source)
  2. When a query is generated that has already been called and has an etag in its header send an If-None-Match header alongside the request
  3. If we get a 304 pull data from the cache

Questions:

The driver needs to call success() to indicate the request worked. success() currently updates the data in the reducer and the model IDs returned from the source. In this instance, no models are returned from the source โ€” we're using what was previously there.

How should a driver indicate that:

  • the call was successful
  • we should use the data from a previous API request, and not undefined as passed into success()

Discovered APIs

First of all, I think the concept is wonderful, keep up the great work.
While dealing with REST and Redux, I've also ended up writing abstractions on top of data fetching actions, to make everything more consistent, but didn't quite get to the component decorator yet :).

I would be really glad to swap my implementation with tectonic, I tried to go through docs to find some answers to my questions, but couldn't find them, so posting them here:

  1. Is there any way to handle discovered API resources? The implicit way of defining dependencies is cool, but let's say the whole URL of the next request is unavailable before the first request continues?
// GET http://some-url
{
    "href": "http://some-other-url"
}

// GET http://some-other-url
{
    "href": "http://some-yet-another-url"
}

// ...etc

I've solved it currently with ability to chain action creators, but they are defined explicitly.

  1. Let's say I want to handle the network related action in my reducer, is there any way of doing this? I understand, that most of the times having abstraction like this will solve that issue, but in case of migration f.e. migrating some existing logic might be not very straightforward all at once.

  2. Is there any way to normalize data somehow?

Thanks!

Complex API result ordering (eg searches, polymorphic and multiple model returns)

Here's an example of the result of a search call:

[
  {
    type: 'post',
    data: {
      id: 1,
      title: 'some post'
    }
  },
  {
    type: 'user',
    data: {
      id: 5,
      title: 'Foo McBar'
    }
  }
]

We might define the search endpoint as such:

{
  params: ['query'],
  returns: {
    posts: Post.list(),
    users: User.list()
  }
}

How do we keep the ordering of the API results across many models?

Drivers should have "default params"

Each source definition should have a defaultParams object. The base resolver should inspect the query params and add defaults before passing to the driver.

load decorator throwing error when props update

Ok this is a complex one so bear with me... I have a RecordBuilder component which is rendered by a route with path 'some-models/:someModelId'. This component handles the creation of a record when someModelId is new and the update of a record when someModelId is any other value. I've created a container that utilizes load like this:

const selectId = get('match.params.adapterId');

const selectIsNewRecord = createSelector(
  selectId,
  eq('new'),
);

const container = compose(
  load(props => {
    if (selectIsNewRecord(props)) return {};
    return { record: Adapter.getItem({ id: selectId(props) }) };
  }),
  mapProps(props => {
    const id = selectId(props);
    const isNewRecord = selectIsNewRecord(props);
    const createModel = data => props.query(
      {
        model: Adapter,
        body: data,
        queryType: 'CREATE',
      },
      (err, result) => {
        if (!err) {
          props.replace(`/adapters/${result.id}`);
          props.push(`/adapters/${result.id}/operations`);
        }
      },
    );
    const updateModel = data => props.query({
      body: data,
      model: Adapter,
      modelId: id,
      params: { id },
      queryType: 'UPDATE',
    });
    return {
      isNewRecord,
      onSubmit: isNewRecord ? createModel : updateModel,
    };
  }),
  spinnerWhileLoading(props => (
    props.isNewRecord ? false : props.status.record.isPending()
  )),
);

The key part of this is that load function returns an empty object when we're dealing with a new record. Everything works as expected except when handling the callback after createModel. I get the error Uncaught TypeError: Cannot set property 'params' of undefined which points to this block of code

// Assign the props newQueries to this.queries; this is gonna retain
// query statuses for successful queries and not re-query them even if
// the cache is now invalid
Object.keys(newQueries).forEach(function (q) {
  _this2.queries[q].params = newQueries[q].params;
});

It seems that when load is expecting a previous query to already be present but since I return an object previously it fails? Starting to dive into your codebase so let me know and I'm more than happy to help. Or if you have a better way to handle this case I'm all ears.

Create a websocket driver

In order to get live feeds for data we should create a websocket component.

Questions:

  • How do we differentiate feeds within queries and source definitions?

If the websocket endpoint returns a live feed for use with subscriptions (#35) we should indicate that the source definition returns a feed:

manager.fromSocket([
  {
    meta: {
      url: '/api/v0/eents',
      // tbd... 
  },
  returns: Event.feed(), // TBD: is this a feed for a single item or a list of items?
])

Add `mutate` to Query model

mutate will mutate data before being passed into the component:

@load({
  users: User.getList().mutate(users => {
    const byId = {};
    users.forEach(u => byId[u.id] = u);
    return byId;
  }),
})
class Users extends Component {
}

Each mutator can be kept in a separate file for reusability. We should let mutator functions chain to keep composability of mutator functions optimal. Can we also automatically memoize these functions, or store the resulting data in a reducer?

Create a `query` prop in decorator

The query prop should be the parent of createModel, updateModel etc. using the same options but with no default queryType parameter.

All params in query should be supported from these options.

Error when querytype is update/delete and no modelID is specified

Implement global caching and per-source caching.

Source caching

Considerations:

  • If an API response has Cache-Control: no-cache headers or max-age=0 we should always re-request data
  • When resolving a component with dependent data, we should ignore cache-control headers for re-requesting data (or we'll have an infinite loop).
  • The manager's .props method could ignore cache information - this is more for the resolver to know whether we should re-request

Angular 2 support

Hi,

Sounds like a very interesting lib especially for those who cannot use graphql at the moment. My question is, could this lib be split up into something like tectonic.core and techtonic.react so that we could utilize it in other redux env like ng2-redux revue, etc? Is this lib heavy dependent on react? I think you would find fans of this approach outside of react too :).

Seems the component wrapper and the react decorator are the only react dependencies. Not too bad :)

Returning records from cache seems to not work.

We check each query to see if we have cached data already available to skip unnecessary API requests. If so, this data is passed to the component is what I'm referencing. In my case I have a Table component which loads a list and renders each TableRow with an id prop. Each TableRow loads an item and display its content. The problem I'm experiencing is that a network request is being made for each TableRow rather than the record being passed from the store.

query.hash() is not function

Here I am again. :P

I'm getting this error while loading my page:

TypeError: query.hash is not a function
   at Cache.getQueryStatus (/home/andrey/Projects/Datarisk/app-frontend/node_modules/tectonic/transpiled/cache/index.js:409:49)
   at /home/andrey/Projects/Datarisk/app-frontend/node_modules/tectonic/transpiled/manager/index.js:165:28
   at Array.forEach (native)
   at Manager.props (/home/andrey/Projects/Datarisk/app-frontend/node_modules/tectonic/transpiled/manager/index.js:162:28)
   at PropInspector.computeDependencies (/home/andrey/Projects/Datarisk/app-frontend/node_modules/tectonic/transpiled/decorator/propInspector.js:80:37)
   at new TectonicComponent (/home/andrey/Projects/Datarisk/app-frontend/node_modules/tectonic/transpiled/decorator/index.js:102:43)
   at /home/andrey/Projects/Datarisk/app-frontend/node_modules/react-dom/lib/ReactCompositeComponent.js:295:18
   at measureLifeCyclePerf (/home/andrey/Projects/Datarisk/app-frontend/node_modules/react-dom/lib/ReactCompositeComponent.js:75:12)
   at ReactCompositeComponentWrapper._constructComponentWithoutOwner (/home/andrey/Projects/Datarisk/app-frontend/node_modules/react-dom/lib/ReactCompositeComponent.js:294:16)
   at ReactCompositeComponentWrapper._constructComponent (/home/andrey/Projects/Datarisk/app-frontend/node_modules/react-dom/lib/ReactCompositeComponent.js:280:21)
   at ReactCompositeComponentWrapper.mountComponent (/home/andrey/Projects/Datarisk/app-frontend/node_modules/react-dom/lib/ReactCompositeComponent.js:188:21)
   at Object.mountComponent (/home/andrey/Projects/Datarisk/app-frontend/node_modules/react-dom/lib/ReactReconciler.js:46:35)
   at ReactCompositeComponentWrapper.performInitialMount (/home/andrey/Projects/Datarisk/app-frontend/node_modules/react-dom/lib/ReactCompositeComponent.js:371:34)
   at ReactCompositeComponentWrapper.mountComponent (/home/andrey/Projects/Datarisk/app-frontend/node_modules/react-dom/lib/ReactCompositeComponent.js:258:21)
   at Object.mountComponent (/home/andrey/Projects/Datarisk/app-frontend/node_modules/react-dom/lib/ReactReconciler.js:46:35)
   at ReactCompositeComponentWrapper.performInitialMount (/home/andrey/Projects/Datarisk/app-frontend/node_modules/react-dom/lib/ReactCompositeComponent.js:371:34)

Did I miss something?

load decorator causing React.PropTypes validation warnings

Currently using in a 15.x.x project and receiving Warning: You are manually calling a React.PropTypes validation... on components wrapper with the load decorator. Took a brief look into the src but couldn't see where this is happening. Probably worth upgrading React to the latest when fixing this issue.

Make dumbResolver call the sourcedefinition's driver with the given query

IE:

sourceDef.driverFunc({
  query,
  sourceDef,
  success,
  fail
});

Where:

  • The driver function gets the source and query, plus success and fail functions
  • The driver calls success function with response data
  • The driver calls the fail function with error data
  • The success function must also know the query and source definition BUT it should only be called with the data in driver (partial application):

IE:

// driver.js
const apiDriver = ({ query, sourceDef, success, error }) => {
  /// do driver shit
  sourceDef.meta.apiCall().then( response => {
    success(response.body);
  });
}

// resolver

class Resolver {
  success:(query, sourceDef, data) => {
  }
  resolve: () {
    // somewhere query and sourceDef are defined
    let query, sourceDef;
    const driverSuccess = (data) => {
      return this.success({ query, sourceDef, data });
    }
  }

}

Sideloading Data

We're looking for a way to support sideloaded data that our API returns. So far we've made a custom driver that allows us to pass params to our API like /posts?include[]=creators.. That in turn returns a payload in the shape of:

{
  posts: [{
    title: โ€ฆ,
    creator: 111,
    โ€ฆ
  }],
  users: [{
    id: 111,
    โ€ฆ
  }]
}

Our models associate post.creator with users. And we have a custom transform in our sources that returns response.body.posts. So two questions:

  1. How do we store and consume the other models returned?
  2. How would we cache those models?

Ideally a subsequent query to users like users.getItem({ id: 111 }), would return the cached result from the original response.

One idea we're considering... perhaps could we dispatch the sideloaded data into the store with a query key that matches what we know a subsequent query would be looking for? If so, where in tectonic would we put that logic?

More granular tectonic Query Statuses

Right now the Query Statuses have just a string for each state:

Query(Model: team, Fields: *, Params: {}, Body: undefined, QueryType: GET), ReturnType: list):"SUCCESS"

Instead of a simple string, it would be good to have something like:

// { type: 'SUCCESS', status: '200', message: '' }
// { type: 'ERROR', status: '404', message: 'Object not found: <blah id>' }
// { type: 'ERROR', status: '500', message: 'Internal server error' }
// { type: 'ERROR', status: '400', message: 'The name of that thing should this' }

FAQs

q: I deleted an item but it's still in my list
a: the model ID was not passed as a parmater to the delete function, therefore we could not figure out which model to remove from the reducer state

Create model fails with no source definition found in some cases

Scenario:
I have a model/spec, that I use to create an item. But, the structure of the object that is returned is different.

CreateUserSpec -> create model with tectonic -> 201 created/200 ok -> response is User object.

So, if I use the sourcedef, returnType-> User.item(), I get the:

baseResolver.js:41 There is no source definition which resolves the query Query(Model: userCreateSpec, Fields: *, Params: {}, Body: {"Name":"test-net4","Role":"view-only", ReturnType: )

Outdated README

Tony, comparing the startup guide in the site and the README, it seems to me that README is very outdated. For example, in README me nothing is mentioned about registering the tectonic in the reducers.

Which of the guides I can follow?

Cannot read property 'fromSuperagent' of undefined

I have a very basic setup:

models/User.js

import { Model } from 'tectonic';

export default class User extends Model {
  static modelName = 'user';

  static fields = {
    id: undefined,
    name: '',
    email: '',
    position: '',
    phone: '',
    password: '',
    createdAt: 0,
    updatedAt: 0,
  }
}

export const endpoints = [
  {
    returns: User.getList(),
    meta: {
      url: '/api/users',
    },
  },
];

setupManager.js

import { Manager, BaseResolver } from 'tectonic';
import TectonicSuperagent from 'tectonic-superagent';
import { endpoints as userEndpoints } from './models/User';

export default (store) => {
  const manager = new Manager({
    resolver: new BaseResolver(),
    drivers: {
      fromSuperagent: new TectonicSuperagent(),
    },
    store,
  });

  manager.drivers.fromSuperagent([
    ...userEndpoints,
  ]);

  return manager;
};

Simple! However, the manager.drivers in setupManager is undefined. Then it show me an error:
TypeError: Cannot read property 'fromSuperagent' of undefined.

I have looked in the manager variable and found out that there's a function called fromSuperagent. But nothing of drivers. Look:

Manager {
  cache:
   Cache {
     store:
      { dispatch: [Function],
        subscribe: [Function: subscribe],
        getState: [Function: getState],
        replaceReducer: [Function: replaceReducer] } },
  store:
   { dispatch: [Function],
     subscribe: [Function: subscribe],
     getState: [Function: getState],
     replaceReducer: [Function: replaceReducer] },
  resolver:
   BaseResolver {
     satisfiabilityChain:
      [ [Function: doesSourceSatisfyQueryParams],
        [Function: doesSourceSatisfyQueryModel],
        [Function: doesSourceSatisfyAllQueryFields],
        [Function: doesSourceSatisfyQueryReturnType],
        [Function: doesSourceSatisfyQueryType] ],
     queries: {},
     queriesInFlight: {},
     statusMap: {},
     store:
      { dispatch: [Function],
        subscribe: [Function: subscribe],
        getState: [Function: getState],
        replaceReducer: [Function: replaceReducer] },
     cache: Cache { store: [Object] } },
  sources: Sources { definitions: Map {} },
  fromSuperagent: [Function] }

I tried to call the function:

manager.fromSuperagent([...]);

But this also throws me an error Source definition must be comrpised of models, such as Model.list().

Am I doing something wrong?

EDIT
I'm using v1.3.2.

How do we know when to re-call failed queries?

We have a component requests data. The request fails. We make sure we don't re-request this data in an infinite loop when the status is updated by not rerequesting failed GET queries.

How do we know when to retry failed GET queries?

@load({
  user: User.getItem({ id: 3 })
})
...

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.