vue-jsonapi-ormdeprecated

Vue + Vuex ORM and client for any json:api server. Highly inspired by Laravel Eloquent and optimized for large project use cases.

Usage no npm install needed!

<script type="module">
  import vueJsonapiOrm from 'https://cdn.skypack.dev/vue-jsonapi-orm';
</script>

README

Vue {json:api} ORM

Prerequisites

  1. Vue or nuxt
  2. Vuex
  3. axios

Setup

Installation

npm install vue-jsonapi-orm

// or

yarn add vue-jsonapi-orm

Add vuex plugin

in the store definition, add the following line.
Before using your resource models, you need to register them in the resources array.

// store/index.js
import { jsonApiVuexPlugin } from 'vue-jsonapi-orm'
import { MyResourceClassA, MyResourceClassB } from '../my-project/models'

const resources = [
  MyResourceClassA,
  MyResourceClassB
]
export const plugins = [jsonApiVuexPlugin(resources)]

Usage

Defining Resources

Base Resource

It is recommended to declare a base resource in order to not repeat common data like the api path and axios instance.

// ApiResource.ts
import { ApiResourceBase } from 'vue-jsonapi-orm'

export class ApiResource extends ApiResourceBase {
  static apiPath = '/api/v1'
  static axios = myCustomAxiosInstance
}

Alternatively, you can specify the axios instance per resource and inject it in runtime. The nuxt $axios instance can be injected via a nuxt plugin.

Adding resources with attributes

Next, define classes for each resource you will consume from the api.
Important: override the jsonApiType for each resource.

Use the provided decorators Attr, Meta, BelongsTo, HasOne, HasMany to annotate your class properties and add types.
Note: do not initialize the properties like @Attr() body: string = 'Default text'. Instead, pass the default value as first argument to the Attr() decorator. All other decorators cannot have default values.

// Author.ts
import { Attr, HasMany } from 'vue-jsonapi-orm'

export class Author extends ApiResource {
  static jsonApiType = 'authors'
  
  // attributes (editable)
  @Attr() name: string

  // relationships
  @HasMany() posts: Post[]
}
// Post.ts
import { Attr, Meta, BelongsTo } from 'vue-jsonapi-orm/decorators'

export class Post extends ApiResource {
  static jsonApiType = 'posts'
  
  // attributes (editable)
  @Attr() slug: string | null
  @Attr('Default title') title: string
  @Attr() body: string

  // meta (read-only)
  @Meta() comment_count: number

  // relationships
  @BelongsTo() author: Author
}

Note: Classes can be extended with any custom methods or properties to help you write less code.
E.g. you can define a static query scope function that returns a query builder instance or result, like return this.api().where(...).

Fetching resources from api

Fetching single resource

// retrieve post by primary-key
let post = await Post.api().find('my-post')

// retrieve post with author
post = await Post.api().with('author').find('my-post')

Fetching a collection of resources

Resource.api() returns a query builder which can be used to add filters, pagination, sorting and sparse fieldsets according to {json:api} specs.
Calling get will return a Promise returning a results object holding the list of posts in its data property.

// retrieve collection of posts
let posts = await Post.api()
  .where('author_id', '10')
  .orderByDesc('published_at')
  .perPage(10)
  .page(1)
  .with('author')
  .get()

Eager loading relationships

You can eager load a relationship after receiving the parent resource to reduce the amount of included resources.

let author = await Author.api().find('1')
await author.load('posts')
// not you can access author.posts with typed results
// all other author instances in the app will automatically be upserted

Custom paths and actions

You can use the build in QueryBuilder to perform custom requests via the request method.

// run a custom action
let post = await Post.api().find('my-post')
await Post.api().request(`/${post.id}/like`, 'GET')

The custom path will be appended to the base api path of your resource (e.g. /api/v1/post/my-id/like)

Creating resources

Create new resources by creating a new instance of the resource class. New resources will get a temporary id for consistency throughout the client. The id and its references are automatically swapped after saving the new instance.

let post = new Post({
  title: 'My new Post'
})
post.body = 'Hello world!'
await post.save()

Editing resources

let post = await Post.api().find('my-id')
post.title = 'My new Post'
// post.isDirty = true
await post.save()
// only changed attributes and relationships will be patched

Deleting resources

Resources can be easily deleted with the destroy method. Once deleted, all relationship references are cascaded automatically.

let post = await Post.api().find('my-id')
await post.destroy()

Initializing from external data

The library supports fetching resources outside of the query builder (e.g. custom endpoints like /user). The response of the json:api server can be hydrated via resourceFromResponse or collectionFromResponse static methods.

let response = await axios.get('/api/user')
let user = User.resourceFromResponse(response.data)

Collections

Since the client does not have control over all the data but only a subset, ResourceCollections help you to power any persistent view or table with the context.
The context of a collection includes all QueryBuilder parameters:

  • Pagination
  • Filter
  • Sorting
  • Included Relations
  • Fieldsets

A ResourceCollection saves the context and a list of items that represent the result of the context when applied to the QueryBuilder.
Since the collection does not copy the data but uses the entity store, all data is synced throughout the application automatically. The collection context itself is persisted in its own vuex module, so you can provide persistent views when the user navigates through pages. The collection store is only initialized when used for the first time.

Creating a collection

Simply create a collection by passing any QueryBuilder instance and an optional name (when using multiple collections for the same endpoint).
All the config options applied to the query builder are transferred as initial options to the collection.

// create a collection from resource query builder
let collection = new ResourceCollection(Post.api(), 'my-collection')

// create a collection with options (include, page size and sorting)
collection = new ResourceCollection(Post.api().with('author').perPage(20).orderByDesc('created_at'))

// create a collection for a relationship to automatically scope the results to the parent resource
let author = Author.api().find('1')
collection = new ResourceCollection(author.relationApi('posts').orderBy('title'))

Requesting data for a collection

Initialize the data by calling requestItems on the collection. (e.g. in mounted hook)

await collection.requestItems()

The store is automatically populated, and you can access the items via collection.items.

Interacting and filtering with collections

Collections are made for interactions like changing the sorting, filtering or searching and navigating through pages.

let collection = new ResourceCollection(Post.api())

// get next page
await collection.nextPage()

// get previous page
await collection.prevPage()

// apply a new sorting
// direction is automatically inverted when called on active sorting
await collection.orderBy('title')

// set and request new filter
await collection.setFilter(myFilterObject)

// the collection provides a mutable copy of the filters for worry-less v-model binding
// to request the new filters, call applyFilter
collection.filter.search = 'My search query'
await collection.applyFilter()

Helpers

With the helpers provided, you can easily control all button statuses and loading indicators right from the collection without any boilerplate code.

let collection = new ResourceCollection(Post.api())
// collection.$isFirstPage
// collection.$isLastPage
// collection.$isLoading
// collection.pagination
// collection.sorting
// collection.include
// collection.filter

Infinite scrolling

When using an infinite scrolling view, you want to append new items to the collection instead of overwriting the items. You can do so, by passing this option to the constructor as third argument appendNewItems.

let collection = new ResourceCollection(Post.api(), 'infinite-collection-name', true)

Full Vue component example

<template>
    <div v-if="collection">
      <table>
        <thead>
          <tr>
            <th @click="collection.orderBy('title')">
              Title
            </th>
            <th @click="collection.orderBy('created_at')">
              Date <!-- v-if="collection.sorting.sort === 'created_at'" some nice sorting indicator -->
            </th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="item in collection.items">
            <!-- Nice rows here -->
          </tr>
        </tbody>
      </table>
      
      <transition>
        <div v-show="collection.$isLoading">Boring loading indicator...</div>
      </transition>
      
      <button @click="collection.prevPage" :disabled="collection.$isFirstPage">
        Previous Page
      </button>
      <button @click="collection.nextPage" :disabled="collection.$isLastPage">
        Next Page
      </button>
  </div>
</template>

<script lang="ts">
@Component()
export class MyDataComponent extends Vue {
  collection = new ResourceCollection(Post.api())
  mounted() {
    this.collection.requestItems()
  }
}
</script>

Usage with Nuxt SSR

When creating resource instances on the server side (e.g. asyncData) the results are serialized as string before being passed to the browser see Issue.
Therefore, methods and types get lost if returned from asyncData. While the main data is preserved by this package in a vuex module, you can use this easy workaround.

export default {
  async asyncData(ctx) {
    await Post.api().with('author').find(ctx.params.slug)
    // do not return result
  },
  data() {
    return {
      post: Post.fromId(this.$route.params.slug)
    }
  }
}

If the resource is retrieved by a non-primary key (e.g. id != slug), the following pattern can be applied.

export default {
  async asyncData(ctx) {
    let post = await Post.api().find(ctx.params.slug)
    return {
      postId: post.id,
    }
  },
  data() {
    return {
      post: null,
    }
  },
  created() {
    this.post = Post.fromId(this.postId)
  }
}