@anny.co/vue-jsonapi-orm

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 annyCoVueJsonapiOrm from 'https://cdn.skypack.dev/@anny.co/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'
import { myCustomAxiosInstance } from '../services/myAxiosInstances'

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'
import { ApiResource } from './ApiResource'

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

  // relationships
  @HasMany() posts: Post[]
  // For Morphing: simply define alternative types, like (Post | Article)
  // For ManyToMany: simply use HasMany
}
// Post.ts
import { Attr, Meta, BelongsTo } from 'vue-jsonapi-orm/decorators'
import { ApiResource } from './ApiResource'

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(...).
You can also use the QueryBuilder instance to fully customize the request, like shown below.

// User.ts
import { Attr } from 'vue-jsonapi-orm/decorators'
import { ApiResource } from './ApiResource'

export class User extends ApiResource {
  static jsonApiType = 'users'
  
  @Attr() name: string
  @Attr() email: string
  
  /**
   * Request active User
   */
  static async requestActiveUser(): Promise<User> {
    const builder = User.api()
      .with(['roles', 'profileImage'])
    builder.path = User.apiPath
    const response = await builder.request('user')
    return User.resourceFromResponse(response.data).data
  }
}

Pivot Resource

It is common to use pivot resources as connector for ManyToMany relationships. In most cases, you don't need to model those resources separately, since you can use the @HasMany decorator and forget about the pivot model.
In special cases, you need extra attributes on the pivot model, like an orderIndex. For these special cases, you can define pivot resources with a static isPivotResource flag.

// PostTag.ts
import { Attr, BelongsTo } from 'vue-jsonapi-orm/decorators'
import { ApiResource } from './ApiResource'
import { Post } from './Post'
import { Tag } from './Tag'

export class PostTag extends ApiResource {
  static jsonApiType = 'post-tags'
  static isPivotResource = true
  
  @Attr() orderIndex: number
  @BelongsTo() post: Post
  @BelongsTo() post: Tag
}

The isPivotResource flag is used for auto-deleting the pivot resource, when a relation, e.g. the post or tag is being deleted. This is required to match the database cascading of pivot models.

Fetching resources from api

Fetching single resource

import { Post } from '../models/Post'

// 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.

import { Post } from '../models/Post'

// retrieve collection of posts
let posts = await Post.api()
  .where('authorId', '10') // single filter
  .filter({ publishedAt: '2021-01-01' }) // multiple filters
  .orderByDesc('publishedAt') // sorting
  .perPage(10) // pagination size
  .page(1) // pagination page (defaults to 1)
  .with('author') // equivalent to .include()
  .query({ appId: '1' }) // additional query parameters
  .get()

Lazy loading relationships

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

import { Author } from '../models/Auhtor'

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

To prevent redundant request, you can use loadMissing to only load relationships which are not defined yet.

import { Author } from '../models/Auhtor'

let author = await Author.api().find('1')
await author.loadMissing(['posts'])

For more control over the request or for paginating the relationship results, use resource.relationApi(). This returns a fully qualified QueryBuilder instance and supports filtering, pagination, sorting, etc.
Keep in mind that the results will not be mapped to the parent resource.

import { Author } from '../models/Auhtor'
import { Post } from '../models/Post'

let author = await Author.api().find('1')
let posts = await author.relationApi<Post>('posts').where('publishedAt', '2021-01-01').orderBy('publishedAt').get()
// results are NOT available via author.posts. Use ResourceCollection to save the collection of posts (see below)

Custom paths and actions

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

import { Post } from '../models/Post'

// 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.
You can pass an object with attributes to the constructor. Additionally, all defaults will be set on new instances.

import { Post } from '../models/Post'

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

By default, any unsaved relation will also be saved or created recursively. You can customize the save options saveRecursively = true, shouldThrow = true (throw exception on failure), instantUpdate = true (instantly update vuex state and revert if saving fails), include? = [], fields?, query?, axiosConfig?.

Editing resources

import { Post } from '../models/Post'

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

// or
post.title = 'My new Post 2'
post.discardChanges() // discard any changes after last save
console.log(post.title) // 'My new Post'

// or
post.fill({
  title: 'Test',
  body: 'Lorem ipsum'
}) // mass assign attributes via fill
await post.save()

Deleting resources

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

let author = await Author.api().with('posts').find('1')
console.log(author.posts.length) // 3
let post = await Post.api().find('my-id')
await post.destroy()
console.log(author.posts.length) // 2

When destroying a resource, all parent resources are notified and will remove the reference instantly. BelongsTo and HasMany will update to null. The array of HasMany will be updated. So you can happily destroy instances don't need wo worry about removing any references.

Deleting relationships (indicating deletion until save)

In more complex forms, you want to enable the user to delete relations of an instance without destroying them immediately. The user may expect the changes to be reversible until a global save button is clicked. For this purpose, simply set relation.$markedForDeletion = true on any nested relation.
Once marked for deletion, the relation will be excluded in a HasMany array or set to null for BelongsTo via the orm getters. Calling .save() on the parent will automatically handle with final deletion.

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.
Both methods return a results object, which hold data as well as meta.

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

Resource Instance Methods

resource.clone() // get copy of instance (NO duplicate - for persisted items, the id will be kept)
resource.discardChanges() // reset all changes
resource.api() // get `ModelQueryBuilder` for the instance to perdorm custom actions
resource.relationApi('relationName') // get `QueryBuilder` for relationship
await resource.load('relationName')
await resource.loadMissing('relationName' | ['relation1', 'relation2.nested'])
await resource.refresh() // reload model from api, optionally pass included
await resource.save()
await resource.destroy()

Resource Helpers

resource.persisted // true if instance was persisted in api / database
resource.$isDirty // indicates if instance was changed
resource.$anyDirty // indicates if instance or any nested relation was changed
resource.$isSaving // indicates if instance is saving
resource.$isDeleting // indicates if instance is destroying
resource.$isLoading // indicates if instance is requesting via .api()
resource.$markedForDeletion // true if instance will be deleted on save

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 (e.g. from static Model.api() or non-static model.relationApi('...')) 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').perPage(20))

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()

collection.newItem({ ...anyAttributes }) // returns new instance of the type within the collection
collection.createItem({ ...anyAttributes }) // returns, saves and appends a new instance to the collection

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
// collection.paginationMeta = { from, to, total, 'current-page', 'last-page', ... }

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)

Reusing collections

Reuse collections by simply using the same collection identifier twice. Try to not overload the builder options for the same collection identifier to prevent conflicts.

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">
// import vue decorators

@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)
  }
}

Collections

// use shared reference or use same collection name and options in asyncData and data
let collection = new ResourceCollection(Post.api(), 'infinite-collection-name', true)

export default {
  async asyncData(ctx) {
    await collection.requestItems()
  },
  data() {
    return {
      postCollection: collection
    }
  },
}