diff --git a/docs/docs.md b/docs/docs.md index fd13c14..7bb32c5 100644 --- a/docs/docs.md +++ b/docs/docs.md @@ -40,9 +40,9 @@ ResClient represents a client connection to a RES API. * [.on(events, handler)](#ResClient+on) * [.off(events, [handler])](#ResClient+off) * [.setOnConnect(onConnect)](#ResClient+setOnConnect) ⇒ this - * [.registerModelType(pattern, factory)](#ResClient+registerModelType) + * [.registerModelType(pattern, factory)](#ResClient+registerModelType) ⇒ this * [.unregisterModelType(pattern)](#ResClient+unregisterModelType) ⇒ [modelFactory](#ResClient..modelFactory) - * [.registerCollectionType(pattern, factory)](#ResClient+registerCollectionType) + * [.registerCollectionType(pattern, factory)](#ResClient+registerCollectionType) ⇒ this * [.unregisterCollectionType(pattern)](#ResClient+unregisterCollectionType) ⇒ [collectionFactory](#ResClient..collectionFactory) * [.get(rid, [collectionFactory])](#ResClient+get) ⇒ Promise.<(ResModel\|ResCollection)> * [.call(rid, method, params)](#ResClient+call) ⇒ Promise.<object> @@ -140,7 +140,7 @@ Sets the onConnect callback. -### resClient.registerModelType(pattern, factory) +### resClient.registerModelType(pattern, factory) ⇒ this Register a model type. The pattern may use the following wild cards: * The asterisk (*) matches any part at any level of the resource name. @@ -167,7 +167,7 @@ Unregister a previously registered model type pattern. -### resClient.registerCollectionType(pattern, factory) +### resClient.registerCollectionType(pattern, factory) ⇒ this Register a collection type. The pattern may use the following wild cards: * The asterisk (*) matches any part at any level of the resource name. @@ -352,6 +352,7 @@ ResCollection represents a collection provided over the RES API. * [new ResCollection(api, rid, [opt])](#new_ResCollection_new) * _instance_ * [.length](#ResCollection+length) + * [.list](#ResCollection+list) * [.getClient()](#ResCollection+getClient) ⇒ [ResClient](#ResClient) * [.getResourceId()](#ResCollection+getResourceId) ⇒ string * [.on([events], [handler])](#ResCollection+on) ⇒ this @@ -386,6 +387,12 @@ Creates an ResCollection instance ### resCollection.length Length of the collection +**Kind**: instance property of [ResCollection](#ResCollection) + + +### resCollection.list +Internal collection array. Do not mutate directly. + **Kind**: instance property of [ResCollection](#ResCollection) diff --git a/examples/book-collection-vue3/README.md b/examples/book-collection-vue3/README.md new file mode 100644 index 0000000..549b05a --- /dev/null +++ b/examples/book-collection-vue3/README.md @@ -0,0 +1,95 @@ +# Book Collection example - Vue 3 + +This is a Vue 3 version of [Resgate's Book Collection example](https://github.com/resgateio/resgate/tree/master/examples/book-collection). It contains both a client and a server written for *node.js*. + +The purpose of this example is to show how ResClient can be used together with Vue 3 and its built in reactivity. To learn more about writing services for Resgate, visit the [Resgate project](https://github.com/resgateio/resgate). + +## Prerequisite + +* Have [NATS Server](https://nats.io/download/nats-io/gnatsd/) and [Resgate](https://github.com/resgateio/resgate) running +* Have [node.js](https://nodejs.org/en/download/) installed + +## Running the example + +Run the following commands: +```bash +npm install +npm start +``` + +Open the client +``` +http://localhost:8083 +``` + +## Notes + +In the example, each _ResModel_ and _ResCollection_ is wrapped with `Vue.reactive(...)` to make the Vue components update without the need to listen to specific events. + +```javascript +const app = Vue.createApp({ + client: null, + created() { + // The client is stored outside the data object to prevent it from being + // wrapped in a Vue reactive Proxy. + this.client = new ResClient('ws://localhost:8080') + // The full wildcard (">") let's us wrap all ResModel/ResCollection + // instances in a Vue reactive Proxy. + .registerModelType(">", (api, rid) => Vue.reactive(new ResModel(api, rid))) + .registerCollectionType(">", (api, rid) => Vue.reactive(new ResCollection(api, rid))); + /* ... */ + }, + /* ... */ +}); +``` + +## Things to try out + +**Realtime updates** +Run the client in two separate tabs to observe realtime updates. + +**System reset** +Run the client and make some changes. Restart the server to observe resetting of resources in client. + +**Resynchronization** +Run the client on two separate devices. Disconnect one device, then make changes with the other. Reconnect the first device to observe resynchronization. + + +## Web resources + +### Get book collection +``` +GET http://localhost:8080/api/bookService/books +``` + +### Get book +``` +GET http://localhost:8080/api/bookService/book/ +``` + +### Update book properties +``` +POST http://localhost:8080/api/bookService/book//set +``` +*Body* +``` +{ "title": "Animal Farming" } +``` + +### Add new book +``` +POST http://localhost:8080/api/bookService/books/add +``` +*Body* +``` +{ "title": "Dracula", "author": "Bram Stoker" } +``` + +### Delete book +``` +POST http://localhost:8080/api/bookService/books/delete +``` +*Body* +``` +{ "id": } +``` \ No newline at end of file diff --git a/examples/book-collection-vue3/package.json b/examples/book-collection-vue3/package.json new file mode 100644 index 0000000..0b6e549 --- /dev/null +++ b/examples/book-collection-vue3/package.json @@ -0,0 +1,14 @@ +{ + "name": "book-collection-vuejs", + "description": "Resgate Book Collection Example for Vue 3", + "main": "server.js", + "dependencies": { + "connect": "^3.6.6", + "nats": "^1.0.1", + "serve-static": "^1.13.2" + }, + "devDependencies": {}, + "scripts": { + "start": "node server.js" + } +} diff --git a/examples/book-collection-vue3/server.js b/examples/book-collection-vue3/server.js new file mode 100644 index 0000000..05750b8 --- /dev/null +++ b/examples/book-collection-vue3/server.js @@ -0,0 +1,172 @@ +// Connect to NATS server +const nats = require('nats').connect("nats://localhost:4222"); + +// Predefined responses +const errorNotFound = JSON.stringify({ error: { code: "system.notFound", message: "Not found" }}); +const accessGranted = JSON.stringify({ result: { get: true, call: "*" }}); +const successResponse = JSON.stringify({ result: null }); +const errorInvalidParams = (message) => JSON.stringify({ error: { code: "system.invalidParams", message }}); + +// Map of all book models +let bookModels = { + "library.book.1": { id: 1, title: "Animal Farm", author: "George Orwell" }, + "library.book.2": { id: 2, title: "Brave New World", author: "Aldous Huxley" }, + "library.book.3": { id: 3, title: "Coraline", author: "Neil Gaiman" } +}; + +// Collection of books +var books = [ + { rid: "library.book.1" }, + { rid: "library.book.2" }, + { rid: "library.book.3" } +]; + +// ID counter for book models +let nextBookID = books.length + 1; + +// Validation function +function getError(field, value) { + // Check we actually got a string + if (typeof value !== "string") { + return errorInvalidParams(field + " must be a string"); + } + // Check value is not empty + if (!value.trim()) { + return errorInvalidParams(field + " must not be empty"); + } +} + +// Access listener for all library resources. Everyone gets full access +nats.subscribe('access.library.>', (req, reply) => { + nats.publish(reply, accessGranted); +}); + +// Book get listener +nats.subscribe('get.library.book.*', function(req, reply, subj) { + let rid = subj.substring(4); // Remove "get." to get resource ID + let model = bookModels[rid]; + if (model) { + nats.publish(reply, JSON.stringify({ result: { model }})); + } else { + nats.publish(reply, errorNotFound); + } +}); + +// Book set listener +nats.subscribe('call.library.book.*.set', (req, reply, subj) => { + let rid = subj.substring(5, subj.length - 4); // Remove "call." and ".set" to get resource ID + let model = bookModels[rid]; + if (!model) { + nats.publish(reply, errorNotFound); + return; + } + + let r = JSON.parse(req); + let p = r.params || {}; + let changed = null; + + // Check if title property was set + if (p.title !== undefined) { + let err = getError("Title", p.title); + if (err) { + nats.publish(reply, err); + return; + } + changed = Object.assign({}, changed, { title: p.title }); + } + + // Check if author property was set + if (p.title !== undefined) { + let err = getError("Author", p.author); + if (err) { + nats.publish(reply, err); + return; + } + changed = Object.assign({}, changed, { author: p.author }); + } + + // Publish update event on property changed + if (changed) { + nats.publish("event." + rid + ".change", JSON.stringify(changed)); + } + + // Reply success by sending an empty result + nats.publish(reply, successResponse); +}); + +// Books get listener +nats.subscribe('get.library.books', function(req, reply, subj) { + nats.publish(reply, JSON.stringify({ result: { collection: books }})); +}); + +// Books new listener +nats.subscribe('call.library.books.new', (req, reply) => { + let r = JSON.parse(req); + let p = r.params || {}; + + let title = p.title || ""; + let author = p.author || ""; + + // Check if title and author was set + if (!title || !author) { + nats.publish(reply, errorInvalidParams("Must provide both title and author")); + return; + } + + let id = nextBookID++; // Book ID + let rid = "library.book." + id; // Book's resource ID + let book = { id, title, author }; // Book's model + let ref = { rid }; // Reference value to the book + + bookModels[rid] = book; + // Publish add event + nats.publish("event.library.books.add", JSON.stringify({ value: ref, idx: books.length })); + books.push(ref); + + // Reply success by sending an empty result + nats.publish(reply, successResponse); +}); + +// Books delete listener +nats.subscribe('call.library.books.delete', (req, reply) => { + let r = JSON.parse(req); + let p = r.params || {}; + + let id = p.id; // Book ID + + // Check if the book ID is a number + if (typeof id !== "number") { + nats.publish(reply, errorInvalidParams("Book ID must be a number")); + return; + } + + let rid = "library.book." + id; // Book's resource ID + // Check if book exists + if (bookModels[rid]) { + // Delete the model and remove the reference from the collection + delete bookModels[rid]; + let idx = books.findIndex(v => v.rid === rid); + if (idx >= 0) { + books.splice(idx, 1); + // Publish remove event + nats.publish("event.library.books.remove", JSON.stringify({ idx })); + } + } + + // Reply success by sending an empty result + nats.publish(reply, successResponse); +}); + +// System resets tells Resgate that the service has been (re)started. +// Resgate will then update any cached resource from library +nats.publish('system.reset', JSON.stringify({ resources: [ 'library.>' ] })); + + +// Run a simple webserver to serve the client. +// This is only for the purpose of making the example easier to run +var path = require('path'); +const connect = require('connect'); +const serveStatic = require('serve-static'); +connect().use(serveStatic(path.join(__dirname, 'src'))).listen(8083, () => { + console.log('Client available at http://localhost:8083'); +}); diff --git a/examples/book-collection-vue3/src/index.html b/examples/book-collection-vue3/src/index.html new file mode 100644 index 0000000..f461cd5 --- /dev/null +++ b/examples/book-collection-vue3/src/index.html @@ -0,0 +1,132 @@ + + + + + Book Collection Example + + + + + + + + + + + +
+
+

Book Collection Example

+
+
+
+ + + +
+
{{ errMsg }}
+
+
+ +
+ + + + \ No newline at end of file diff --git a/examples/book-collection-vue3/src/index.js b/examples/book-collection-vue3/src/index.js new file mode 100644 index 0000000..fe538a5 --- /dev/null +++ b/examples/book-collection-vue3/src/index.js @@ -0,0 +1,117 @@ +const ResClient = resclient.default; +const ResModel = resclient.ResModel; +const ResCollection = resclient.ResCollection; + +// Vue app +const app = Vue.createApp({ + client: null, + data() { + return { + books: null, + newTitle: "", + newAuthor: "", + errMsg: "", + errTimer: null + }; + }, + created() { + // Create ResClient instance and store it outside of data so that it + // won't be wrapped in a Vue reactive Proxy. + this.client = new ResClient('ws://localhost:8080') + // The full wildcard (">") let's us wrap all ResModel/ResCollection + // instances in a Vue reactive Proxy. This allows Vue to be notified + // on updates without listening to specific events. + .registerModelType(">", (api, rid) => Vue.reactive(new ResModel(api, rid))) + .registerCollectionType(">", (api, rid) => Vue.reactive(new ResCollection(api, rid))); + + this.client.get('library.books').then(books => { + this.books = books; + }).catch(this.showError); + }, + methods: { + handleAddNew() { + this.client.call('library.books', 'new', { + title: this.newTitle, + author: this.newAuthor + }).then(() => { + this.newTitle = ""; + this.newAuthor = ""; + }).catch(this.showError); + }, + showError(err) { + this.errMsg = err && err.code && err.code == 'system.connectionError' + ? "Connection error. Are NATS Server and Resgate running?" + : err && err.message ? err.message : String(err); + clearTimeout(this.errTimer); + this.errTimer = setTimeout(() => this.errMsg = "", 7000); + } + } +}); + +// BookList component +app.component('book-list', { + template: "#book-list-template", + props: [ 'books' ], + created() { + // Add empty listener to prevent ResClient to unsubscribe + this.books.on(); + }, + beforeUnmount() { + // Remove empty listener to allow ResClient to unsubscribe + this.books.off(); + }, + methods: { + showError(err) { + this.$emit('error', err); + } + } +}); + +// Book component +app.component('book', { + template: "#book-template", + props: [ 'books', 'book' ], + data() { + return { + isEdit: false, + editTitle: "", + editAuthor: "", + }; + }, + created() { + // Add empty listener to prevent ResClient to unsubscribe + this.book.on(); + }, + beforeUnmount() { + // Remove empty listener to allow ResClient to unsubscribe + this.book.off(); + }, + methods: { + handleEdit() { + this.isEdit = true; + this.editTitle = this.book.title; + this.editAuthor = this.book.author; + }, + handleDelete() { + this.books.call('delete', { + id: this.book.id + }).catch(this.showError); + }, + handleOk() { + this.book.set({ + title: this.editTitle, + author: this.editAuthor + }).then(() => { + this.isEdit = false; + }).catch(this.showError); + }, + handleCancel() { + this.isEdit = false; + }, + showError(err) { + this.$emit('error', err); + } + } +}); + +window.app = app.mount('#app'); diff --git a/examples/book-collection-vuejs/src/index.html b/examples/book-collection-vuejs/src/index.html index 8ac8e3f..979ca82 100644 --- a/examples/book-collection-vuejs/src/index.html +++ b/examples/book-collection-vuejs/src/index.html @@ -3,7 +3,7 @@ Book Collection Example - +