diff --git a/README.md b/README.md index 220e8f49e78c..eedc84f7e4f6 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This client supports the following Google Cloud Platform services: * [Google Cloud Datastore](#google-cloud-datastore) * [Google Cloud Storage](#google-cloud-storage) * [Google Cloud Pub/Sub](#google-cloud-pubsub-beta) (Beta) +* [Google Cloud Search](#google-cloud-search) (Alpha) If you need support for other Google APIs, check out the [Google Node.js API Client library][googleapis]. @@ -238,6 +239,48 @@ topic.subscribe('new-subscription', function(err, subscription) { }); ``` +## Google Cloud Search (Alpha) +> This is an *Alpha* release of Google Cloud Search. This feature is not covered by any SLA or deprecation policy and may be subject to backward-incompatible changes. + +[Google Cloud Search][cloud-search] ([docs][cloud-search-docs]) allows you to quickly perform full-text and geospatial searches against your data without having to spin up your own instances and without the hassle of managing and maintaining a search service. + +See the [gcloud-node Search API documentation][gcloud-search-docs] to learn how to store and query your indexes and documents using this library. + +```js +var gcloud = require('gcloud'); + +// Authorizing on a per-API-basis. You don't need to do this if you auth on a +// global basis (see Authorization section above). + +var search = gcloud.search({ + keyFilename: '/path/to/keyfile.json', + projectId: 'my-project' +}); + +// Create a document in a new index. +var index = search.index('memberData'); + +var document = index.document('member-id-34211'); +document.addField('preferredContactForm').addTextValue('phone'); + +index.createDocument(document, function(err, document) { + console.log(err || document); +}); + +// Search an index and get the results as a readable object stream. +var index = search.index('memberData'); + +index.search('preferredContactForm:phone') + .on('error', console.error) + .on('data', function(document) { + // document.id = 'member-id-34211'; + }) + .on('end', function() { + // All results consumed. + }); +``` + + ## Contributing Contributions to this library are always welcome and highly encouraged. @@ -253,6 +296,7 @@ Apache 2.0 - See [COPYING](COPYING) for more information. [gcloud-bigquery-docs]: https://googlecloudplatform.github.io/gcloud-node/#/docs/bigquery [gcloud-datastore-docs]: https://googlecloudplatform.github.io/gcloud-node/#/docs/datastore [gcloud-pubsub-docs]: https://googlecloudplatform.github.io/gcloud-node/#/docs/pubsub +[gcloud-search-docs]: https://googlecloudplatform.github.io/gcloud-node/#/docs/search [gcloud-storage-docs]: https://googlecloudplatform.github.io/gcloud-node/#/docs/storage [gcloud-todos]: https://github.com/GoogleCloudPlatform/gcloud-node-todos [gitnpm]: https://github.com/stephenplusplus/gitnpm @@ -273,8 +317,12 @@ Apache 2.0 - See [COPYING](COPYING) for more information. [cloud-pubsub]: https://cloud.google.com/pubsub/ [cloud-pubsub-docs]: https://cloud.google.com/pubsub/docs +[cloud-search]: https://cloud.google.com/search/ +[cloud-search-docs]: https://cloud.google.com/search/ + [cloud-storage]: https://cloud.google.com/storage/ [cloud-storage-docs]: https://cloud.google.com/storage/docs/overview [cloud-storage-create-bucket]: https://cloud.google.com/storage/docs/cloud-console#_creatingbuckets + [hya-wave]: https://wav.hya.io [hya-io]: https://hya.io diff --git a/docs/json/master/search/.gitkeep b/docs/json/master/search/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/docs/site/components/docs/docs-values.js b/docs/site/components/docs/docs-values.js index 4767cc9c66f6..2b30f1da72ea 100644 --- a/docs/site/components/docs/docs-values.js +++ b/docs/site/components/docs/docs-values.js @@ -88,6 +88,25 @@ angular.module('gcloud.docs') ] }, + search: { + title: 'Search', + _url: '{baseUrl}/search', + pages: [ + { + title: 'Index', + url: '/index' + }, + { + title: 'Document', + url: '/document' + }, + { + title: 'Field', + url: '/field' + } + ] + }, + storage: { title: 'Storage', _url: '{baseUrl}/storage' @@ -135,6 +154,10 @@ angular.module('gcloud.docs') // introduce new storage api. '>=0.9.0': ['storageWithFiles'], - '>=0.10.0': ['bigquery'] + // introduce bigquery api. + '>=0.10.0': ['bigquery'], + + // introduce search api. + '>=0.16.0': ['search'] } }); diff --git a/docs/site/components/docs/docs.html b/docs/site/components/docs/docs.html index def79b9928e0..0b5d4c650ce3 100644 --- a/docs/site/components/docs/docs.html +++ b/docs/site/components/docs/docs.html @@ -44,7 +44,7 @@


-
diff --git a/docs/site/components/docs/docs.js b/docs/site/components/docs/docs.js index b78f3c3d02fd..c166986a09d5 100644 --- a/docs/site/components/docs/docs.js +++ b/docs/site/components/docs/docs.js @@ -244,6 +244,9 @@ angular path.push('index.json'); } else if (module && cl) { path.push(module); + if (cl === 'index') { + cl = 'index-class'; + } path.push(cl + '.json'); } return $http.get(path.join('/')) diff --git a/docs/site/components/docs/search-overview.html b/docs/site/components/docs/search-overview.html new file mode 100644 index 000000000000..8747fc62ffab --- /dev/null +++ b/docs/site/components/docs/search-overview.html @@ -0,0 +1,7 @@ +

Search Overview

+

+ The object returned from gcloud.search gives you complete access to store and search your indexes and documents. +

+

+ To learn more about Search, see What is Google Cloud Search? +

diff --git a/lib/common/StreamRouter.js b/lib/common/StreamRouter.js new file mode 100644 index 000000000000..a79499413578 --- /dev/null +++ b/lib/common/StreamRouter.js @@ -0,0 +1,82 @@ +/*! + * Copyright 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/*! + * @module common/StreamRouter + */ + +'use strict'; + +var streamEvents = require('stream-events'); +var through = require('through2'); + +/** + * @type {module:common/util} + * @private + */ +var util = require('../common/util.js'); + +var StreamRouter = {}; + +StreamRouter.extend = function(Class, methodNames) { + methodNames = util.arrayize(methodNames); + + methodNames.forEach(function(methodName) { + var originalMethod = Class.prototype[methodName]; + + Class.prototype[methodName] = function() { + return StreamRouter.router_(arguments, originalMethod.bind(this)); + }; + }); +}; + +StreamRouter.router_ = function(args, originalMethod) { + args = util.toArray(args); + var callback = args[args.length - 1]; + var isStreamMode = !util.is(callback, 'function'); + + if (isStreamMode) { + var stream = streamEvents(through.obj()); + + var onResultSet = function(err, results, nextQuery) { + if (err) { + stream.emit('error', err); + stream.end(); + return; + } + + results.forEach(function(result) { + stream.push(result); + }); + + if (nextQuery) { + originalMethod(nextQuery, onResultSet); + } else { + stream.end(); + } + }; + + stream.once('reading', function() { + originalMethod.apply(null, args.concat(onResultSet)); + }); + + return stream; + } else { + originalMethod.apply(null, args); + } +}; + +module.exports = StreamRouter; diff --git a/lib/common/util.js b/lib/common/util.js index 5a1d4a7030f5..dbb41e657ef2 100644 --- a/lib/common/util.js +++ b/lib/common/util.js @@ -23,12 +23,12 @@ var extend = require('extend'); var GoogleAuth = require('google-auth-library'); +var nodeutil = require('util'); var request = require('request').defaults({ pool: { maxSockets: Infinity } }); -var nodeutil = require('util'); var uuid = require('node-uuid'); /** @const {object} gcloud-node's package.json file. */ diff --git a/lib/index.js b/lib/index.js index ac242add2a9b..11408bb0e323 100644 --- a/lib/index.js +++ b/lib/index.js @@ -38,6 +38,12 @@ var Datastore = require('./datastore'); */ var PubSub = require('./pubsub'); +/** + * @type {module:search} + * @private + */ +var Search = require('./search'); + /** * @type {module:storage} * @private @@ -120,6 +126,10 @@ function gcloud(config) { options = options || {}; return new PubSub(util.extendGlobalConfig(config, options)); }, + search: function(options) { + options = options || {}; + return new Search(util.extendGlobalConfig(config, options)); + }, storage: function(options) { options = options || {}; return new Storage(util.extendGlobalConfig(config, options)); @@ -173,11 +183,9 @@ gcloud.datastore = Datastore; * reliable, many-to-many, asynchronous messaging service from Google Cloud * Platform. * - * Note: Google Cloud Pub/Sub API is available as a Limited Preview and the - * client library we provide is currently experimental. The API and/or the - * client might be changed in backward-incompatible ways. This API is not - * subject to any SLA or deprecation policy. Request to be whitelisted to use it - * by filling the [Limited Preview application form](http://goo.gl/sO0wTu). + * Note: This is a *Beta* release of Google Cloud Pub/Sub. This feature is not + * covered by any SLA or deprecation policy and may be subject to backward- + * incompatible changes. * * @type {module:pubsub} * @@ -194,6 +202,33 @@ gcloud.pubsub = function(config) { return new PubSub(config); }; +/** + * **Experimental** + * + * [Google Cloud Search](https://cloud.google.com/search/) allows you to quickly + * perform full-text and geospatial searches against your data without having to + * spin up your own instances and without the hassle of managing and maintaining + * a search service. + * + * Note: This is an *Alpha* release of Google Cloud Search. This feature is not + * covered by any SLA or deprecation policy and may be subject to backward- + * incompatible changes. + * + * @type {module:search} + * + * @return {module:search} + * + * @example + * var gcloud = require('gcloud'); + * var seach = gcloud.search({ + * projectId: 'project-id', + * keyFilename: '/path/to/keyfile.json' + * }); + */ +gcloud.search = function (config) { + return new Search(config); +}; + /** * Google Cloud Storage allows you to store data on Google infrastructure. * Read [Google Cloud Storage API docs](https://developers.google.com/storage/) diff --git a/lib/search/document.js b/lib/search/document.js new file mode 100644 index 000000000000..e714c16d9c01 --- /dev/null +++ b/lib/search/document.js @@ -0,0 +1,213 @@ +/*! + * Copyright 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/*! + * @module search/document + */ + +'use strict'; + +/** + * @type {module:search/field} + * @private + */ +var Field = require('./field.js'); + +/** + * @type {module:common/util} + * @private + */ +var util = require('../common/util.js'); + +/** + * Create a Document object to create or manipulate a document from your index. + * + * @constructor + * @alias module:search/document + * + * @param {string=} id - ID of the document. + * + * @example + * var gcloud = require('gcloud'); + * + * var search = gcloud.search({ + * projectId: 'grape-spaceship-123' + * }); + * + * var document = search.index('records').document('stephen'); + */ +function Document(index, id) { + this.search_ = index.search_; + this.index_ = index; + + this.id = id; + this.fields = {}; +} + +/** + * Return just the document detail properties of this Document instance. + * + * @example + * document.toJSON(); + * // { + * // docId: 'this-document-id', + * // fields: { + * // // ... + * // }, + * // rank: 8 + * // } + */ +Document.prototype.toJSON = function() { + var documentObject = { + fields: this.fields + }; + + if (util.is(this.id, 'string')) { + documentObject.docId = this.id; + } + + if (util.is(this.rank, 'number')) { + documentObject.rank = this.rank; + } + + return documentObject; +}; + +/** + * Add a field to this document. + * + * @throws {error} if a name is not provided. + * + * @param {string} name - This field's name. + * @return {module:search/field} + * + * @example + * var scoreField = document.addField('score'); + * // scoreField is a Field object. + */ +Document.prototype.addField = function(name) { + if (!util.is(name, 'string')) { + throw new Error('`name` is required to add a field to this document.'); + } + + this.fields[name] = new Field(); + + return this.fields[name]; +}; + +/** + * Delete this document. + * + * @param {function=} callback - The callback function. + * + * @example + * document.delete(function(err, apiResponse) {}); + */ +Document.prototype.delete = function(callback) { + this.makeReq_('DELETE', '', null, null, function(err, apiResponse) { + (callback || util.noop)(err, apiResponse); + }); +}; + +/** + * Get the details of this document. After running, the Document instance will + * update the `fields` and `rank` properties to their most recent values at the + * time of this call. + * + * @param {function} callback - The callback function. + * + * @example + * document.getMetadata(function(err, doc, apiResponse) { + * if (err) { + * console.error(err); + * return; + * } + * + * // `doc` is a reference to `document`, both of which are now up to date. + * // + * // document.fields = Array of Field objects. + * // document.rank = Document's numeric rank. + * }); + */ +Document.prototype.getMetadata = function(callback) { + var self = this; + + callback = callback || util.noop; + + this.makeReq_('GET', '/', null, null, function(err, resp) { + if (err) { + callback(err, null, resp); + return; + } + + self.fields = {}; + self.rank = null; + + if (util.is(resp.fields, 'object')) { + Object.keys(resp.fields).forEach(function(fieldName) { + var fieldInstance = self.addField(fieldName); + fieldInstance.values = resp.fields[fieldName].values; + }); + } + + if (util.is(resp.rank, 'number')) { + self.rank = resp.rank; + } + + callback(null, self, resp); + }); +}; + +/** + * Set the rank for this document. The rank of a document is a positive integer + * which determines the default ordering of documents returned from a search. By + * default, the rank is set at the time the document is created to the number of + * seconds since January 1, 2011. + * + * @throws {error} If a rank is not a number. + * + * @param {number} rank - The rank of this document. + * + * @example + * document.setRank(8); + */ +Document.prototype.setRank = function(rank) { + if (!util.is(rank, 'number') || rank < 0) { + throw new Error('`rank` should be a positive integer.'); + } + + this.rank = rank; +}; + +/** + * Make a new request object from the provided arguments and wrap the callback + * to intercept non-successful responses. + * + * @private + * + * @param {string} method - Action. + * @param {string} path - Request path. + * @param {*} query - Request query object. + * @param {*} body - Request body contents. + * @param {function} callback - The callback function. + */ +Document.prototype.makeReq_ = function(method, path, query, body, callback) { + path = '/documents/' + this.id + path; + + this.index_.makeReq_(method, path, query, body, callback); +}; + +module.exports = Document; diff --git a/lib/search/field.js b/lib/search/field.js new file mode 100644 index 000000000000..b3b98c856fc3 --- /dev/null +++ b/lib/search/field.js @@ -0,0 +1,169 @@ +/*! + * Copyright 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/*! + * @module search/field + */ + +'use strict'; + +/** + * @type {module:common/util} + * @private + */ +var util = require('../common/util.js'); + +/** + * Create a Field object to easily format a Cloud Search index's field. + * + * @constructor + * @alias module:search/field + * + * @example + * var gcloud = require('gcloud'); + * + * var search = gcloud.search({ + * projectId: 'grape-spaceship-123' + * }); + * + * var document = search.index('records').document('stephen'); + * var field = document.addField('alias'); + */ +function Field() { + this.values = []; +} + +/** + * An atom value is a string value that is treated as a single token. A query + * will not match if it includes only a substring rather than the full field + * value. + * + * @param {string} atom - String value. + * + * @example + * field.addAtomValue('ryanseys'); + */ +Field.prototype.addAtomValue = function(atom) { + this.addTextValue(atom, { format: 'ATOM' }); +}; + +/** + * A geo value is a point on earth with latitude and longitude coordinates. + * + * @param {string|object} geo - Coordinate value as a string or object. String + * format: `'40.6894, -74.0447'`. + * @param {number} geo.latitude - Latitudinal value. + * @param {number} geo.longitude - Longitudinal value. + * + * @example + * var coordinates = '40.6894, -74.0447'; + * field.addGeoValue(coordinates); + * + * //- + * // Use an object. + * //- + * var coordinates = { + * latitude: 40.6894, + * longitude: -74.0447 + * }; + * + * field.addGeoValue(coordinates); + */ +Field.prototype.addGeoValue = function(geo) { + if (util.is(geo, 'object')) { + geo = util.format('{latitude}, {longitude}', geo); + } + + this.values.push({ + geoValue: geo + }); +}; + +/** + * An HTML value is an HTML-formatted string. Text out of markup tags are + * tokenized and markup tags are considered metadata. + * + * @param {string} html - HTML value. + * + * @example + * var html = 'hello, world'; + * field.addHtmlValue(html); + */ +Field.prototype.addHtmlValue = function(html) { + this.addTextValue(html, { format: 'HTML' }); +}; + +/** + * A number value is a double precision floating point value. + * + * @param {number} number - Number value. + * + * @example + * field.addNumberValue(8); + */ +Field.prototype.addNumberValue = function(number) { + this.values.push({ + numberValue: number + }); +}; + +/** + * A text value is a string that is tokenized as plain text. + * + * @param {string} text - Text value. + * @param {object=} options - Configuration object. + * @param {string} options.format - One of `ATOM`, `HTML`, or `TEXT`. (Default: + * `TEXT`). + * + * @example + * field.addTextValue('Hello!'); + */ +Field.prototype.addTextValue = function(text, options) { + options = options || {}; + var format = options.format || 'TEXT'; + + this.values.push({ + stringValue: text.toLowerCase(), + stringFormat: format.toUpperCase() + }); +}; + +/** + * A timestamp value is a date-time value with millisecond precision. + * + * @param {string|date} timestamp - Timestamp value. + * + * @example + * var timestamp = '2014-08-18T21:19:55.000Z'; + * field.addTimestampValue(timestamp); + * + * //- + * // Use a Date object. + * //- + * var now = Date.now(); + * field.addTimestampValue(now); + */ +Field.prototype.addTimestampValue = function(timestamp) { + if (timestamp instanceof Date) { + timestamp = timestamp.toJSON(); + } + + this.values.push({ + timestampValue: timestamp + }); +}; + +module.exports = Field; diff --git a/lib/search/index-class.js b/lib/search/index-class.js new file mode 100644 index 000000000000..e7e617fb8756 --- /dev/null +++ b/lib/search/index-class.js @@ -0,0 +1,381 @@ +/*! + * Copyright 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/*! + * @module search/index + */ + +'use strict'; + +var extend = require('extend'); + +/** + * @type {module:search/document} + * @private + */ +var Document = require('./document.js'); + +/** + * @type {module:common/StreamRouter} + * @private + */ +var StreamRouter = require('../common/StreamRouter.js'); + +/** + * @type {module:common/util} + * @private + */ +var util = require('../common/util.js'); + +/** + * Create an Index object to interact with a Google Cloud Search index. + * + * @constructor + * @alias module:search/index + * + * @param {string} id - ID of the index. + * + * @example + * var gcloud = require('gcloud'); + * + * var search = gcloud.search({ + * projectId: 'grape-spaceship-123' + * }); + * + * var index = search.index('records'); + */ +function Index(search, id) { + this.search_ = search; + this.id = id; + + if (!this.id) { + throw new Error('An ID is needed to access a Google Cloud Search index'); + } +} + +/** + * Create a document in the index. + * + * @param {object|module:search/document} documentObj - A properly-formed + * Document object as outlined in the + * [official docs](https://goo.gl/AYhSgI). + * @param {function} callback - The callback function. + * + * @example + * //- + * // A document can be built using the {module:search/document} object. + * //- + * var newDocument = index.document('new-document-id'); + * newDocument.addField('person').addTextValue('Stephen'); + * + * index.createDocument(newDocument, function(err, document, apiResponse) {}); + * + * //- + * // A document can also be created from a properly-formed object as outlined + * // in the official docs. + * // + * // This will create the same resulting document object as the example above. + * //- + * var newDocument = { + * docId: 'new-document-id', + * fields: { + * person: { + * values: [ + * { + * stringFormat: 'TEXT', + * stringValue: 'Stephen' + * } + * ] + * } + * } + * }; + * + * index.createDocument(newDocument, function(err, document, apiResponse) {}); + * + * //- + * // Specifying an ID for your new document isn't required. In both of the + * // scenarios above, simply don't specify 'new-document-id' and one will be + * // generated for you. + * //- + */ +Index.prototype.createDocument = function(documentObj, callback) { + var document; + + if (documentObj instanceof Document) { + document = documentObj; + documentObj = document.toJSON(); + } else { + document = this.documentFromObject_(documentObj); + } + + this.makeReq_('POST', '/documents', null, documentObj, function(err, resp) { + if (err) { + callback(err, null, resp); + return; + } + + callback(null, document, resp); + }); +}; + +/** + * Access a {module:search/document} object. + * + * @param {string} id - The id of the document. + * @return {module:search/document} + * + * @example + * var myDocument = index.document('my-document'); + */ +Index.prototype.document = function(id) { + return new Document(this, id); +}; + +/** + * Get {module:search/document} objects for all of the documents in this index. + * + * @param {object=} query - Query object. + * @param {string} query.pageSize - The maximum number of indexes to return per + * page. If not specified, 100 indexes are returned per page. + * @param {string} query.pageToken - A previously-returned page token + * representing part of the larger set of results to view. + * @param {string} query.view - One of `INDEX_VIEW_UNSPECIFIED`, `ID_ONLY`, or + * `FULL`. See [this table](https://goo.gl/sY6Lpt) for more details. + * @param {function} callback - The callback function. + * + * @example + * function onApiResponse(err, documents, nextQuery, apiResponse) { + * if (err) { + * console.error(err); + * return; + * } + * + * // `documents` is an array of Document objects in this index. + * + * if (nextQuery) { + * index.getDocuments(nextQuery, onApiResponse); + * } + * } + * + * index.getDocuments(onApiResponse); + * + * //- + * // Customize the request using a query object. + * //- + * index.getDocuments({ + * pageSize: 10 + * }, onApiResponse); + * + * //- + * // Get the documents as a readable object stream. + * //- + * index.getDocuments() + * .on('error', console.error) + * .on('data', function(document) { + * // document is a Document object. + * }) + * .on('end', function() { + * // All documents retrieved. + * }); + */ +Index.prototype.getDocuments = function(query, callback) { + var self = this; + + if (util.is(query, 'function')) { + callback = query; + query = {}; + } + + this.makeReq_('GET', '/documents', query, null, function(err, resp) { + if (err) { + callback(err, null, null, resp); + return; + } + + var nextQuery = null; + + if (resp.nextPageToken) { + nextQuery = extend({}, query, { + pageToken: resp.nextPageToken + }); + } + + var documents = (resp.documents || []) + .map(self.documentFromObject_.bind(self)); + + callback(null, documents, nextQuery, resp); + }); +}; + +/** + * Run a query against the documents in this index. + * + * For a full list of supported query parameters, see the + * [JSON API documentation](https://goo.gl/706zrP). + * + * @param {string|object} query - A query object or simply a string query. + * @param {string} query.pageSize - The maximum number of indexes to return per + * page. If not specified, 100 indexes are returned per page. + * @param {string} query.pageToken - A previously-returned page token + * representing part of the larger set of results to view. + * @param {string} query.query = A query string using the syntax described by + * the [official docs](https://goo.gl/2SYl3S). + * @param {function} callback - The callback function. + * + * @example + * function onApiResponse(err, documents, nextQuery, apiResponse) { + * if (err) { + * console.error(err); + * return; + * } + * + * // `documents` is an array of Document objects that matched your query. + * + * if (nextQuery) { + * index.search(nextQuery, onApiResponse); + * } + * } + * + * //- + * // Run a simple query against all documents. + * //- + * var query = 'person:stephen'; + * + * index.search(query, onApiResponse); + * + * //- + * // Configure the query. + * //- + * var query = { + * query: 'person:stephen', + * pageSize: 10 + * }; + * + * index.search(query, onApiResponse); + * + * //- + * // Get the documents that match your query as a readable object stream. + * //- + * index.search('person:stephen') + * .on('error', console.error) + * .on('data', function(document) { + * // document is a Document object. + * }) + * .on('end', function() { + * // All search results retrieved. + * }); + */ +Index.prototype.search = function(query, callback) { + var self = this; + + if (util.is(query, 'string')) { + query = { + query: query + }; + } + + query = query || {}; + + this.makeReq_('GET', '/search', query, null, function(err, resp) { + if (err) { + callback(err, null, null, resp); + return; + } + + var nextQuery = null; + + if (resp.nextPageToken) { + nextQuery = extend({}, query, { + pageToken: resp.nextPageToken + }); + } + + var documents = (resp.results || []) + .map(self.documentFromObject_.bind(self)); + + callback(null, documents, nextQuery, resp); + }); +}; + +/** + * Convert an object to a {module:search/document} object. + * + * @private + * + * @param {object} documentObj - Object describing a document. + * @param {object} documentObj.fields - Fields the document contains. + * @param {number=} documentObj.rank - The rank of the document. + * @return {module:search/document} + * + * @example + * var documentObject = { + * docId: 'new-document-id', + * fields: { + * person: { + * values: [ + * { + * stringFormat: 'TEXT', + * stringValue: 'Stephen' + * } + * ] + * } + * } + * }; + * + * var document = index.documentFromObject_(documentObject); + * // document is a {module:search/document} object. + */ +Index.prototype.documentFromObject_ = function(documentObj) { + var document = this.document(documentObj.docId); + + if (util.is(documentObj.fields, 'object')) { + document.fields = documentObj.fields; + } + + if (util.is(documentObj.rank, 'number')) { + document.rank = documentObj.rank; + } + + return document; +}; + +/** + * Make a new request object from the provided arguments and wrap the callback + * to intercept non-successful responses. + * + * @private + * + * @param {string} method - Action. + * @param {string} path - Request path. + * @param {*} query - Request query object. + * @param {*} body - Request body contents. + * @param {function} callback - The callback function. + */ +Index.prototype.makeReq_ = function(method, path, query, body, callback) { + path = '/indexes/' + this.id + path; + + this.search_.makeReq_(method, path, query, body, callback); +}; + +/*! Developer Documentation + * + * {module:search/index#getDocuments} and {module:search/index#search} can be + * used with either a callback or as a readable object stream. `StreamRouter` is + * used to add this dual behavior to these methods. + */ +StreamRouter.extend(Index, ['getDocuments', 'search']); + +module.exports = Index; diff --git a/lib/search/index.js b/lib/search/index.js new file mode 100644 index 000000000000..bfae4cf16ebe --- /dev/null +++ b/lib/search/index.js @@ -0,0 +1,221 @@ +/*! + * Copyright 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/*! + * @module search + */ + +'use strict'; + +var extend = require('extend'); + +/** + * @type {module:search/index} + * @private + */ +var Index = require('./index-class.js'); + +/** + * @type {module:common/StreamRouter} + * @private + */ +var StreamRouter = require('../common/StreamRouter.js'); + +/** + * @type {module:common/util} + * @private + */ +var util = require('../common/util.js'); + +/** + * @const {array} Required scopes for the Search API. + * @private + */ +var SCOPES = [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/cloudsearch', + 'https://www.googleapis.com/auth/userinfo.email' +]; + +/** + * @const {string} Base URL for the Search API. + * @private + */ +var SEARCH_BASE_URL = 'https://cloudsearch.googleapis.com/v1/'; + +/** + * Create a Search object to Interact with the Cloud Search API. Using this + * object, you can access your indexes with {module:search/index} and documents + * with {module:search/document}. + * + * Follow along with the examples to see how to do everything from creating + * documents to searching indexes. + * + * @alias module:search + * @constructor + * + * @param {object} options - [Configuration object](#/docs/?method=gcloud). + * + * @example + * var gcloud = require('gcloud')({ + * keyFilename: '/path/to/keyfile.json', + * projectId: 'grape-spaceship-123' + * }); + * + * var search = gcloud.search(); + */ +function Search(options) { + options = options || {}; + + if (!options.projectId) { + throw util.missingProjectIdError; + } + + this.makeAuthorizedRequest_ = util.makeAuthorizedRequestFactory({ + credentials: options.credentials, + keyFile: options.keyFilename, + scopes: SCOPES, + email: options.email + }); + + this.projectId = options.projectId; + this.projectName = 'projects/' + this.projectId; +} + +/** + * Get {module:search/index} objects for all of the indexes in your project. + * + * @param {object=} query - Query object. + * @param {string} query.pageSize - The maximum number of indexes to return per + * page. If not specified, 100 indexes are returned per page. + * @param {string} query.pageToken - A previously-returned page token + * representing part of the larger set of results to view. + * @param {string} query.prefix - The prefix of the index name. It is used to + * list all indexes with names that have this prefix. + * @param {string} query.view - See [this table](https://goo.gl/sY6Lpt) for a + * list of accepted values and what each will do. + * @param {function} callback - The callback function. + * + * @example + * function onApiResponse(err, indexes, nextQuery, apiResponse) { + * if (err) { + * console.error(err); + * return; + * } + * + * if (nextQuery) { + * search.getIndexes(nextQuery, onApiResponse); + * } + * } + * + * search.getIndexes(onApiResponse); + * + * //- + * // Customize the request using a query object. + * //- + * search.getIndexes({ + * pageSize: 10 + * }, onApiResponse); + */ +Search.prototype.getIndexes = function(query, callback) { + var self = this; + + if (util.is(query, 'function')) { + callback = query; + query = {}; + } + + if (query.prefix) { + query.indexNamePrefix = query.prefix; + delete query.prefix; + } + + this.makeReq_('GET', '/indexes', query, null, function(err, resp) { + if (err) { + callback(err, null, null, resp); + return; + } + + var nextQuery = null; + + if (resp.nextPageToken) { + nextQuery = extend({}, query, { + pageToken: resp.nextPageToken + }); + } + + var indexes = (resp.indexes || []).map(function(indexObject) { + var index = self.index(indexObject.indexId); + + if (util.is(resp.indexedField, 'object')) { + index.fields = resp.indexedField; + } + + return index; + }); + + callback(null, indexes, nextQuery, resp); + }); +}; + +/** + * Get a reference to a Google Cloud Search index. + * + * @param {string} id - Name of the index. + * @return {module:search/index} + */ +Search.prototype.index = function(id) { + return new Index(this, id); +}; + +/** + * Make a new request object from the provided arguments and wrap the callback + * to intercept non-successful responses. + * + * @private + * + * @param {string} method - Action. + * @param {string} path - Request path. + * @param {*} query - Request query object. + * @param {*} body - Request body contents. + * @param {function} callback - The callback function. + */ +Search.prototype.makeReq_ = function(method, path, query, body, callback) { + var reqOpts = { + method: method, + qs: query, + uri: util.format('{base}projects/{projectId}{path}', { + base: SEARCH_BASE_URL, + projectId: this.projectId, + path: path + }) + }; + + if (body) { + reqOpts.json = body; + } + + this.makeAuthorizedRequest_(reqOpts, callback); +}; + +/*! Developer Documentation + * + * {module:search#getIndexes} can be used with either a callback or as a + * readable object stream. `StreamRouter` is used to add this dual behavior. + */ +StreamRouter.extend(Search, 'getIndexes'); + +module.exports = Search; diff --git a/lib/storage/bucket.js b/lib/storage/bucket.js index 435d98c7f08c..57489be8447c 100644 --- a/lib/storage/bucket.js +++ b/lib/storage/bucket.js @@ -90,7 +90,7 @@ function Bucket(storage, name) { this.storage = storage; if (!this.name) { - throw Error('A bucket name is needed to use Google Cloud Storage.'); + throw new Error('A bucket name is needed to use Google Cloud Storage.'); } /** diff --git a/scripts/docs.sh b/scripts/docs.sh index b797fa86140d..24a477ad27ce 100755 --- a/scripts/docs.sh +++ b/scripts/docs.sh @@ -31,6 +31,11 @@ ./node_modules/.bin/dox < lib/pubsub/subscription.js > docs/json/master/pubsub/subscription.json & ./node_modules/.bin/dox < lib/pubsub/topic.js > docs/json/master/pubsub/topic.json & +./node_modules/.bin/dox < lib/search/index.js > docs/json/master/search/index.json & +./node_modules/.bin/dox < lib/search/index-class.js > docs/json/master/search/index-class.json & +./node_modules/.bin/dox < lib/search/document.js > docs/json/master/search/document.json & +./node_modules/.bin/dox < lib/search/field.js > docs/json/master/search/field.json & + ./node_modules/.bin/dox < lib/storage/acl.js > docs/json/master/storage/acl.json & ./node_modules/.bin/dox < lib/storage/bucket.js > docs/json/master/storage/bucket.json & ./node_modules/.bin/dox < lib/storage/file.js > docs/json/master/storage/file.json & diff --git a/system-test/search.js b/system-test/search.js new file mode 100644 index 000000000000..4d059633e1c9 --- /dev/null +++ b/system-test/search.js @@ -0,0 +1,301 @@ +/** + * Copyright 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var assert = require('assert'); +var async = require('async'); +var uuid = require('node-uuid'); + +var env = require('./env.js'); +var gcloud = require('../lib')(env); + +var TEST_DOCUMENT_JSON = require('../test/testdata/search-document.json'); + +var MAX_PARALLEL = 10; + +var search = gcloud.search(); + +function deleteDocument(document, callback) { + document.delete(callback); +} + +function deleteIndexContents(index, callback) { + function handleResp(err, documents, nextQuery) { + if (err) { + callback(err); + return; + } + + async.eachLimit(documents, MAX_PARALLEL, deleteDocument, function(err) { + if (err) { + callback(err); + return; + } + + if (nextQuery) { + index.getDocuments(nextQuery, handleResp); + return; + } + + callback(); + }); + } + + index.getDocuments(handleResp); +} + +function deleteAllDocuments(callback) { + function handleResp(err, indexes, nextQuery) { + if (err) { + callback(err); + return; + } + + async.eachLimit(indexes, MAX_PARALLEL, deleteIndexContents, function(err) { + if (err) { + callback(err); + return; + } + + if (nextQuery) { + search.getIndexes(nextQuery, handleResp); + return; + } + + callback(); + }); + } + + search.getIndexes(handleResp); +} + +function generateIndexName() { + return 'gcloud-test-index-' + uuid.v1(); +} + +function generateDocumentName() { + return 'gcloud-test-document-' + uuid.v1(); +} + +describe('Search', function() { + var INDEX_NAME = generateIndexName(); + var index = search.index(INDEX_NAME); + + before(function(done) { + deleteAllDocuments(done); + }); + + after(function(done) { + deleteAllDocuments(done); + }); + + describe('creating an index', function() { + it('should create a document in a new index', function(done) { + var newIndexName = generateIndexName(); + var newIndex = search.index(newIndexName); + + newIndex.createDocument(TEST_DOCUMENT_JSON, function(err, document) { + assert.ifError(err); + document.delete(done); + }); + }); + }); + + describe('listing indexes', function() { + before(function(done) { + // Creating a new document in a new index will create the index at the + // same time. Immediately delete the document, as we just need the index + // to exist. + var newIndexName = generateIndexName(); + var newIndex = search.index(newIndexName); + + newIndex.createDocument(TEST_DOCUMENT_JSON, function(err, document) { + if (err) { + done(err); + return; + } + + document.delete(done); + }); + }); + + it('should get all indexes', function(done) { + search.getIndexes(function(err, indexes) { + assert.ifError(err); + assert(indexes.length > 0); + done(); + }); + }); + + it('should get all indexes in stream mode', function(done) { + var resultsMatched = 0; + + search.getIndexes() + .on('error', done) + .on('data', function() { resultsMatched++; }) + .on('end', function() { + assert(resultsMatched > 0); + done(); + }); + }); + }); + + describe('listing documents', function() { + var document; + + before(function(done) { + async.series([ + deleteAllDocuments, + + function(next) { + index.createDocument(TEST_DOCUMENT_JSON, function(err, doc) { + if (err) { + next(err); + return; + } + + document = doc; + next(); + }); + } + ], done); + }); + + after(function(done) { + document.delete(done); + }); + + it('should get all documents', function(done) { + index.getDocuments(function(err, documents) { + assert.ifError(err); + assert.strictEqual(documents.length, 1); + done(); + }); + }); + + it('should get all documents in stream mode', function(done) { + var resultsMatched = 0; + + index.getDocuments() + .on('error', done) + .on('data', function() { resultsMatched++; }) + .on('end', function() { + assert.strictEqual(resultsMatched, 1); + done(); + }); + }); + }); + + describe('creating documents', function() { + it('should create a document from the doc builder', function(done) { + // This recreates the test JSON file with the document builder. + var newDocument = index.document(TEST_DOCUMENT_JSON.docId); + + newDocument.setRank(TEST_DOCUMENT_JSON.rank); + + Object.keys(TEST_DOCUMENT_JSON.fields).forEach(function(fieldName) { + var field = newDocument.addField(fieldName); + + TEST_DOCUMENT_JSON.fields[fieldName].values.forEach(function(value) { + if (value.geoValue) { + field.addGeoValue(value.geoValue); + } + + if (value.numberValue) { + field.addNumberValue(value.numberValue); + } + + if (value.stringValue) { + var options = {}; + if (value.stringFormat) { + options.format = value.stringFormat; + } + field.addTextValue(value.stringValue, options); + } + + if (value.timestampValue) { + field.addTimestampValue(value.timestampValue); + } + }); + }); + + index.createDocument(newDocument, function(err, document) { + assert.ifError(err); + document.getMetadata(function(err) { + assert.ifError(err); + assert.deepEqual(document.toJSON(), TEST_DOCUMENT_JSON); + done(); + }); + }); + }); + + it('should create a document from JSON', function(done) { + index.createDocument(TEST_DOCUMENT_JSON, function(err, document) { + assert.ifError(err); + document.getMetadata(function(err) { + assert.ifError(err); + assert.deepEqual(document.toJSON(), TEST_DOCUMENT_JSON); + done(); + }); + }); + }); + }); + + describe('search', function() { + var query = 'ryan'; + var DOCUMENT_NAME = generateDocumentName(); + var document; + + before(function(done) { + document = index.document(DOCUMENT_NAME); + + var questions = document.addField('question'); + questions.addTextValue('Where did Ryan go?'); + questions.addTextValue('Where did Silvano go?'); + + index.createDocument(document, done); + }); + + after(function(done) { + document.delete(done); + }); + + it('should search document', function(done) { + index.search(query, function(err, results) { + assert.ifError(err); + assert.equal(results.length, 1); + assert.equal(results[0].id, DOCUMENT_NAME); + done(); + }); + }); + + it('should search document in stream mode', function(done) { + var results = []; + + index.search(query) + .on('error', done) + .on('data', function(result) { + results.push(result); + }) + .on('end', function() { + assert.equal(results.length, 1); + assert.equal(results[0].id, DOCUMENT_NAME); + done(); + }); + }); + }); +}); diff --git a/test/docs.js b/test/docs.js index dc145939657a..de9b9af1d0c0 100644 --- a/test/docs.js +++ b/test/docs.js @@ -78,6 +78,7 @@ describe('documentation', function() { gcloud: gcloud, require: require, process: process, + console: console, Buffer: Buffer, Date: Date, Array: Array diff --git a/test/search.index.js b/test/search.index.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/test/search/index.js b/test/search/index.js new file mode 100644 index 000000000000..8d7589f13e7c --- /dev/null +++ b/test/search/index.js @@ -0,0 +1,38 @@ +/*! + * Copyright 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var assert = require('assert'); + +var Search = require('../../lib/search/index.js'); + +describe('Search', function() { + var PROJECT_ID = 'project-id'; + var search; + + beforeEach(function() { + search = new Search({ + projectId: PROJECT_ID + }); + }); + + describe('instantiation', function() { + it('should work', function() { + assert(true); + }); + }); +}); diff --git a/test/testdata/search-document.json b/test/testdata/search-document.json new file mode 100644 index 000000000000..82742d8ccf46 --- /dev/null +++ b/test/testdata/search-document.json @@ -0,0 +1,31 @@ +{ + "docId": "doc1", + "fields": { + "field2": { + "values": [ + { + "stringValue": "helloworld", + "stringFormat": "HTML" + } + ] + }, + "field1": { + "values": [ + { + "stringValue": "helloworld", + "stringFormat": "TEXT" + }, + { + "timestampValue": "2014-08-18T21:19:55.000Z" + }, + { + "numberValue": 10 + }, + { + "geoValue": "40.6894, -74.0447" + } + ] + } + }, + "rank": 8 +}