├── .gitignore ├── LICENSE ├── README.md ├── index.js ├── lib └── mediumClient.js ├── package.json └── test └── mediumClient_test.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Warning:** This sdk is no longer supported or maintained by Medium. 2 | 3 | 4 | # Medium SDK for NodeJS 5 | 6 | This repository contains the open source SDK for integrating [Medium](https://medium.com)'s OAuth2 API into your NodeJs app. 7 | 8 | View the full [documentation here](https://github.com/Medium/medium-api-docs). 9 | 10 | Install 11 | ------- 12 | 13 | npm install medium-sdk 14 | 15 | Usage 16 | ----- 17 | 18 | Create a client, then call commands on it. 19 | 20 | ```javascript 21 | var medium = require('medium-sdk') 22 | 23 | var client = new medium.MediumClient({ 24 | clientId: 'YOUR_CLIENT_ID', 25 | clientSecret: 'YOUR_CLIENT_SECRET' 26 | }) 27 | 28 | var redirectURL = 'https://yoursite.com/callback/medium'; 29 | 30 | var url = client.getAuthorizationUrl('secretState', redirectURL, [ 31 | medium.Scope.BASIC_PROFILE, medium.Scope.PUBLISH_POST 32 | ]) 33 | 34 | // (Send the user to the authorization URL to obtain an authorization code.) 35 | 36 | client.exchangeAuthorizationCode('YOUR_AUTHORIZATION_CODE', redirectURL, function (err, token) { 37 | client.getUser(function (err, user) { 38 | client.createPost({ 39 | userId: user.id, 40 | title: 'A new post', 41 | contentFormat: medium.PostContentFormat.HTML, 42 | content: '

A New Post

This is my new post.

', 43 | publishStatus: medium.PostPublishStatus.DRAFT 44 | }, function (err, post) { 45 | console.log(token, user, post) 46 | }) 47 | }) 48 | }) 49 | ``` 50 | 51 | Contributing 52 | ------------ 53 | 54 | Questions, comments, bug reports, and pull requests are all welcomed. If you haven't contributed to a Medium project before please head over to the [Open Source Project](https://github.com/Medium/opensource#note-to-external-contributors) and fill out an OCLA (it should be pretty painless). 55 | 56 | Authors 57 | ------- 58 | 59 | [Jamie Talbot](https://github.com/majelbstoat) 60 | 61 | License 62 | ------- 63 | 64 | Copyright 2015 [A Medium Corporation](https://medium.com) 65 | 66 | Licensed under Apache License Version 2.0. Details in the attached LICENSE 67 | file. 68 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./lib/mediumClient.js") 2 | -------------------------------------------------------------------------------- /lib/mediumClient.js: -------------------------------------------------------------------------------- 1 | // Copright 2015 A Medium Corporation 2 | 3 | var https = require('https') 4 | var qs = require('querystring') 5 | var url = require('url') 6 | var util = require('util') 7 | 8 | 9 | var DEFAULT_ERROR_CODE = -1 10 | var DEFAULT_TIMEOUT_MS = 5000 11 | 12 | 13 | /** 14 | * Valid scope options. 15 | * @enum {string} 16 | */ 17 | var Scope = { 18 | BASIC_PROFILE: 'basicProfile', 19 | LIST_PUBLICATIONS: 'listPublications', 20 | PUBLISH_POST: 'publishPost' 21 | } 22 | 23 | 24 | /** 25 | * The publish status when creating a post. 26 | * @enum {string} 27 | */ 28 | var PostPublishStatus = { 29 | DRAFT: 'draft', 30 | UNLISTED: 'unlisted', 31 | PUBLIC: 'public' 32 | } 33 | 34 | 35 | /** 36 | * The content format to use when creating a post. 37 | * @enum {string} 38 | */ 39 | var PostContentFormat = { 40 | HTML: 'html', 41 | MARKDOWN: 'markdown' 42 | } 43 | 44 | 45 | /** 46 | * The license to use when creating a post. 47 | * @enum {string} 48 | */ 49 | var PostLicense = { 50 | ALL_RIGHTS_RESERVED: 'all-rights-reserved', 51 | CC_40_BY: 'cc-40-by', 52 | CC_40_BY_ND: 'cc-40-by-nd', 53 | CC_40_BY_SA: 'cc-40-by-sa', 54 | CC_40_BY_NC: 'cc-40-by-nc', 55 | CC_40_BY_NC_ND: 'cc-40-by-nc-nd', 56 | CC_40_BY_NC_SA: 'cc-40-by-nc-sa', 57 | CC_40_ZERO: 'cc-40-zero', 58 | PUBLIC_DOMAIN: 'public-domain' 59 | } 60 | 61 | 62 | /** 63 | * An error with a code. 64 | * 65 | * @param {string} message 66 | * @param {number} code 67 | * @constructor 68 | */ 69 | function MediumError(message, code) { 70 | this.message = message 71 | this.code = code 72 | } 73 | util.inherits(MediumError, Error) 74 | 75 | 76 | /** 77 | * The core client. 78 | * 79 | * @param {{ 80 | * clientId: string, 81 | * clientSecret: string 82 | * }} options 83 | * @constructor 84 | */ 85 | function MediumClient(options) { 86 | this._enforce(options, ['clientId', 'clientSecret']) 87 | this._clientId = options.clientId 88 | this._clientSecret = options.clientSecret 89 | this._accessToken = "" 90 | } 91 | 92 | 93 | /** 94 | * Sets an access token on the client used for making requests. 95 | * 96 | * @param {string} accessToken 97 | * @return {MediumClient} 98 | */ 99 | MediumClient.prototype.setAccessToken = function (accessToken) { 100 | this._accessToken = accessToken 101 | return this 102 | } 103 | 104 | 105 | /** 106 | * Builds a URL at which you may request authorization from the user. 107 | * 108 | * @param {string} state 109 | * @param {string} redirectUrl 110 | * @param {Array.} requestedScope 111 | * @return {string} 112 | */ 113 | MediumClient.prototype.getAuthorizationUrl = function (state, redirectUrl, requestedScope) { 114 | return url.format({ 115 | protocol: 'https', 116 | host: 'medium.com', 117 | pathname: '/m/oauth/authorize', 118 | query: { 119 | client_id: this._clientId, 120 | scope: requestedScope.join(','), 121 | response_type: 'code', 122 | state: state, 123 | redirect_uri: redirectUrl 124 | } 125 | }) 126 | } 127 | 128 | 129 | /** 130 | * Exchanges an authorization code for an access token and a refresh token. 131 | * 132 | * @param {string} code 133 | * @param {string} redirectUrl 134 | * @param {NodeCallback} callback 135 | */ 136 | MediumClient.prototype.exchangeAuthorizationCode = function (code, redirectUrl, callback) { 137 | this._acquireAccessToken({ 138 | code: code, 139 | client_id: this._clientId, 140 | client_secret: this._clientSecret, 141 | grant_type: 'authorization_code', 142 | redirect_uri: redirectUrl 143 | }, callback) 144 | } 145 | 146 | 147 | /** 148 | * Exchanges a refresh token for an access token and a refresh token. 149 | * 150 | * @param {string} refreshToken 151 | * @param {NodeCallback} callback 152 | */ 153 | MediumClient.prototype.exchangeRefreshToken = function (refreshToken, callback) { 154 | this._acquireAccessToken({ 155 | refresh_token: refreshToken, 156 | client_id: this._clientId, 157 | client_secret: this._clientSecret, 158 | grant_type: 'refresh_token' 159 | }, callback) 160 | } 161 | 162 | 163 | /** 164 | * Returns the details of the user associated with the current 165 | * access token. 166 | * 167 | * Requires the current access token to have the basicProfile scope. 168 | * 169 | * @param {NodeCallback} callback 170 | */ 171 | MediumClient.prototype.getUser = function (callback) { 172 | this._makeRequest({ 173 | method: 'GET', 174 | path: '/v1/me' 175 | }, callback) 176 | } 177 | 178 | 179 | /** 180 | * Returns the publications related to the current user. Notice that 181 | * the userId needs to be passed in as an option. It can be acquired 182 | * with a call to getUser(). 183 | * 184 | * Requires the current access token to have the 185 | * listPublications scope. 186 | * 187 | * @param {{ 188 | * userId: string 189 | * }} options 190 | * @param {NodeCallback} callback 191 | */ 192 | MediumClient.prototype.getPublicationsForUser = function (options, callback) { 193 | this._enforce(options, ['userId']) 194 | this._makeRequest({ 195 | method: 'GET', 196 | path: '/v1/users/' + options.userId + '/publications' 197 | }, callback) 198 | } 199 | 200 | 201 | /** 202 | * Returns the contributors for a chosen publication. The publication is identified 203 | * by the publication ID included in the options argument. IDs for publications 204 | * can be acquired by getUsersPublications. 205 | * 206 | * Requires the current access token to have the basicProfile scope. 207 | * 208 | * @param {{ 209 | * publicationId: string 210 | * }} options 211 | * @param {NodeCallback} callback 212 | */ 213 | MediumClient.prototype.getContributorsForPublication = function (options, callback) { 214 | this._enforce(options, ['publicationId']) 215 | this._makeRequest({ 216 | method: 'GET', 217 | path: '/v1/publications/' + options.publicationId + '/contributors' 218 | }, callback) 219 | } 220 | 221 | 222 | /** 223 | * Creates a post on Medium. 224 | * 225 | * Requires the current access token to have the publishPost scope. 226 | * 227 | * @param {{ 228 | * userId: string, 229 | * title: string, 230 | * contentFormat: PostContentFormat, 231 | * content: string, 232 | * tags: Array., 233 | * canonicalUrl: string, 234 | * publishStatus: PostPublishStatus, 235 | * license: PostLicense 236 | * }} options 237 | * @param {NodeCallback} callback 238 | */ 239 | MediumClient.prototype.createPost = function (options, callback) { 240 | this._enforce(options, ['userId']) 241 | this._makeRequest({ 242 | method: 'POST', 243 | path: '/v1/users/' + options.userId + '/posts', 244 | data: { 245 | title: options.title, 246 | content: options.content, 247 | contentFormat: options.contentFormat, 248 | tags: options.tags, 249 | canonicalUrl: options.canonicalUrl, 250 | publishedAt: options.publishedAt, 251 | publishStatus: options.publishStatus, 252 | license: options.license 253 | } 254 | }, callback) 255 | } 256 | 257 | 258 | /** 259 | * Creates a post on Medium and places it under specified publication. 260 | * Please refer to the API documentation for rules around publishing in 261 | * a publication: https://github.com/Medium/medium-api-docs 262 | * 263 | * Requires the current access token to have the publishPost scope. 264 | * 265 | * @param {{ 266 | * userId: string, 267 | * publicationId: string, 268 | * title: string, 269 | * contentFormat: PostContentFormat, 270 | * content: string, 271 | * tags: Array., 272 | * canonicalUrl: string, 273 | * publishStatus: PostPublishStatus, 274 | * license: PostLicense 275 | * }} options 276 | * @param {NodeCallback} callback 277 | */ 278 | MediumClient.prototype.createPostInPublication = function (options, callback) { 279 | this._enforce(options, ['publicationId']) 280 | this._makeRequest({ 281 | method: 'POST', 282 | path: '/v1/publications/' + options.publicationId + '/posts', 283 | data: { 284 | title: options.title, 285 | content: options.content, 286 | contentFormat: options.contentFormat, 287 | tags: options.tags, 288 | canonicalUrl: options.canonicalUrl, 289 | publishedAt: options.publishedAt, 290 | publishStatus: options.publishStatus, 291 | license: options.license 292 | } 293 | }, callback) 294 | } 295 | 296 | 297 | /** 298 | * Acquires an access token for the Medium API. 299 | * 300 | * Sets the access token on the client on success. 301 | * 302 | * @param {Object} params 303 | * @param {NodeCallback} callback 304 | */ 305 | MediumClient.prototype._acquireAccessToken = function (params, callback) { 306 | this._makeRequest({ 307 | method: 'POST', 308 | path: '/v1/tokens', 309 | contentType: 'application/x-www-form-urlencoded', 310 | data: qs.stringify(params) 311 | }, function (err, data) { 312 | if (!err) { 313 | this._accessToken = data.access_token 314 | } 315 | callback(err, data) 316 | }.bind(this)) 317 | } 318 | 319 | 320 | /** 321 | * Enforces that given options object (first param) defines 322 | * all keys requested (second param). Raises an error if any 323 | * is missing. 324 | * 325 | * @param {Object} options 326 | * @param {keys} requiredKeys 327 | */ 328 | MediumClient.prototype._enforce = function (options, requiredKeys) { 329 | if (!options) { 330 | throw new MediumError('Parameters for this call are undefined', DEFAULT_ERROR_CODE) 331 | } 332 | requiredKeys.forEach(function (requiredKey) { 333 | if (!options[requiredKey]) throw new MediumError('Missing required parameter "' + requiredKey + '"', DEFAULT_ERROR_CODE) 334 | }) 335 | } 336 | 337 | 338 | 339 | /** 340 | * Makes a request to the Medium API. 341 | * 342 | * @param {Object} options 343 | * @param {NodeCallback} callback 344 | */ 345 | MediumClient.prototype._makeRequest = function (options, callback) { 346 | var requestParams = { 347 | host: 'api.medium.com', 348 | port: 443, 349 | method: options.method, 350 | path: options.path 351 | } 352 | var req = https.request(requestParams, function (res) { 353 | var body = [] 354 | 355 | res.setEncoding('utf-8') 356 | res.on('data', function (data) { 357 | body.push(data) 358 | }) 359 | res.on('end', function () { 360 | var payload 361 | var responseText = body.join('') 362 | try { 363 | payload = JSON.parse(responseText) 364 | } catch (err) { 365 | callback(new MediumError('Failed to parse response', DEFAULT_ERROR_CODE), null) 366 | return 367 | } 368 | 369 | var statusCode = res.statusCode 370 | var statusType = Math.floor(res.statusCode / 100) 371 | 372 | if (statusType == 4 || statusType == 5) { 373 | var err = payload.errors[0] 374 | callback(new MediumError(err.message, err.code), null) 375 | } else if (statusType == 2) { 376 | callback(null, payload.data || payload) 377 | } else { 378 | callback(new MediumError('Unexpected response', DEFAULT_ERROR_CODE), null) 379 | } 380 | }) 381 | }).on('error', function (err) { 382 | callback(new MediumError(err.message, DEFAULT_ERROR_CODE), null) 383 | }) 384 | 385 | req.setHeader('Content-Type', options.contentType || 'application/json') 386 | req.setHeader('Authorization', 'Bearer ' + this._accessToken) 387 | req.setHeader('Accept', 'application/json') 388 | req.setHeader('Accept-Charset', 'utf-8') 389 | 390 | req.setTimeout(DEFAULT_TIMEOUT_MS, function () { 391 | // Aborting a request triggers the 'error' event. 392 | req.abort() 393 | }) 394 | 395 | if (options.data) { 396 | var data = options.data 397 | if (typeof data == 'object') { 398 | data = JSON.stringify(data) 399 | } 400 | req.write(data) 401 | } 402 | req.end() 403 | } 404 | 405 | // Exports 406 | 407 | module.exports = { 408 | MediumClient: MediumClient, 409 | MediumError: MediumError, 410 | Scope: Scope, 411 | PostPublishStatus: PostPublishStatus, 412 | PostLicense: PostLicense, 413 | PostContentFormat: PostContentFormat 414 | } 415 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "medium-sdk" 3 | , "description": "NodeJS client for the Medium app" 4 | , "version": "0.0.4" 5 | , "homepage": "https://github.com/medium/medium-sdk-nodejs" 6 | , "author": "Jamie Talbot (https://github.com/majelbstoat)" 7 | , "keywords": ["medium", "api", "writing"] 8 | , "main": "index.js" 9 | , "repository": { 10 | "type": "git" 11 | , "url": "https://github.com/medium/medium-sdk-nodejs.git" 12 | } 13 | , "devDependencies": { 14 | "mocha": "^2.2.5" 15 | , "should": "^7.1" 16 | , "nock": "^2.17" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/mediumClient_test.js: -------------------------------------------------------------------------------- 1 | var medium = require("../") 2 | var nock = require("nock") 3 | var qs = require('querystring') 4 | var should = require("should") 5 | var url = require('url') 6 | 7 | 8 | describe('MediumClient - constructor', function () { 9 | 10 | it('should throw a MediumError when options are undefined', function (done) { 11 | (function () { new medium.MediumClient() }).should.throw(medium.MediumError) 12 | done() 13 | }) 14 | 15 | it('should throw a MediumError when options are empty', function (done) { 16 | (function () { new medium.MediumClient({}) }).should.throw(medium.MediumError) 17 | done() 18 | }) 19 | 20 | it('should throw a MediumError when only clientId is provided', function (done) { 21 | (function () { new medium.MediumClient({clientId: 'xxx'}) }).should.throw(medium.MediumError) 22 | done() 23 | }) 24 | 25 | it('should throw a MediumError when only clientSecret is provided', function (done) { 26 | (function () { new medium.MediumClient({clientSecret: 'yyy'}) }).should.throw(medium.MediumError) 27 | done() 28 | }) 29 | 30 | it('should succeed when both clientId and clientSecret are provided', function (done) { 31 | var client = new medium.MediumClient({clientId: 'xxx', clientSecret: 'yyy'}) 32 | done() 33 | }) 34 | }) 35 | 36 | 37 | describe('MediumClient - methods', function () { 38 | 39 | var clientId = 'xxx' 40 | var clientSecret = 'yyy' 41 | var client 42 | 43 | beforeEach(function () { 44 | client = new medium.MediumClient({clientId: clientId, clientSecret: clientSecret}) 45 | nock.disableNetConnect() 46 | }) 47 | 48 | afterEach(function () { 49 | nock.enableNetConnect(); 50 | delete client 51 | }) 52 | 53 | describe('#setAccessToken', function () { 54 | 55 | it ('sets the access token', function (done) { 56 | var token = "new token" 57 | client.setAccessToken(token) 58 | client._accessToken.should.be.String().and.equal(token) 59 | done() 60 | }) 61 | }) 62 | 63 | describe('#getAuthorizationUrl', function () { 64 | 65 | it ('returns a valid URL for fetching', function (done) { 66 | var state = "state" 67 | var redirectUrl = "https://example.com/callback" 68 | var scope = [medium.Scope.BASIC_PROFILE, medium.Scope.LIST_PUBLICATIONS, medium.Scope.PUBLISH_POST] 69 | var authUrlStr = client.getAuthorizationUrl(state, redirectUrl, scope) 70 | var authUrl = url.parse(authUrlStr, true) 71 | authUrl.protocol.should.equal('https:') 72 | authUrl.hostname.should.equal('medium.com') 73 | authUrl.pathname.should.equal('/m/oauth/authorize') 74 | authUrl.query.should.deepEqual({ 75 | client_id: clientId, 76 | scope: scope.join(','), 77 | response_type: 'code', 78 | state: state, 79 | redirect_uri: redirectUrl 80 | }) 81 | done() 82 | }) 83 | }) 84 | 85 | describe('#exchangeAuthorizationCode', function () { 86 | 87 | it ('makes a request for authorization_code and sets the access token from response', function (done) { 88 | var code = '12345' 89 | var grantType = 'authorization_code' 90 | var redirectUrl = 'https://example.com/callback' 91 | 92 | var requestBody = qs.stringify({ 93 | code: code, 94 | client_id: clientId, 95 | client_secret: clientSecret, 96 | grant_type: grantType, 97 | redirect_uri: redirectUrl 98 | }) 99 | // the response might have other parameters. this test only considers the ones called out 100 | // in the Medium Node SDK documentation 101 | var accessToken = 'abcdef' 102 | var refreshToken = 'ghijkl' 103 | var responseBody = { 104 | access_token: accessToken, 105 | refresh_token: refreshToken 106 | } 107 | var request = nock('https://api.medium.com/', { 108 | 'Content-Type': 'application/x-www-form-urlencoded' 109 | }) 110 | .post('/v1/tokens', requestBody) 111 | .reply(201, responseBody) 112 | 113 | client.exchangeAuthorizationCode(code, redirectUrl, function (err, data) { 114 | if (err) throw err 115 | data.access_token.should.equal(accessToken) 116 | data.refresh_token.should.equal(refreshToken) 117 | done() 118 | }) 119 | request.done() 120 | }) 121 | }) 122 | 123 | describe('#exchangeRefreshToken', function () { 124 | 125 | it ('makes a request for authorization_code and sets the access token from response', function (done) { 126 | var refreshToken = 'fedcba' 127 | var accessToken = 'lkjihg' 128 | 129 | var requestBody = qs.stringify({ 130 | refresh_token: refreshToken, 131 | client_id: clientId, 132 | client_secret: clientSecret, 133 | grant_type: 'refresh_token' 134 | }) 135 | // the response might have other parameters. this test only considers the ones called out 136 | // in the Medium Node SDK documentation 137 | var responseBody = { 138 | access_token: accessToken, 139 | refresh_token: refreshToken 140 | } 141 | var request = nock('https://api.medium.com/', { 142 | 'Content-Type': 'application/x-www-form-urlencoded' 143 | }) 144 | .post('/v1/tokens', requestBody) 145 | .reply(201, responseBody) 146 | 147 | client.exchangeRefreshToken(refreshToken, function (err, data) { 148 | if (err) throw err 149 | data.access_token.should.equal(accessToken) 150 | data.refresh_token.should.equal(refreshToken) 151 | done() 152 | }) 153 | request.done() 154 | }) 155 | }) 156 | 157 | describe('#getUser', function () { 158 | it ('gets the information from expected URL and returns contents of data envelope', function (done) { 159 | var response = { data: 'response data' } 160 | 161 | var request = nock('https://api.medium.com') 162 | .get('/v1/me') 163 | .reply(200, response) 164 | 165 | client.getUser(function (err, data) { 166 | if (err) throw err 167 | data.should.deepEqual(response['data']) 168 | done() 169 | }) 170 | request.done() 171 | }) 172 | }) 173 | 174 | describe('#getPublicationsForUser', function () { 175 | 176 | it ('throws a MediumError when no user ID is provided', function (done) { 177 | (function () { client.getPublicationsForUser({}) }).should.throw(medium.MediumError) 178 | done() 179 | }) 180 | 181 | it ('makes a proper GET request to the Medium API and returns contents of data envelope when valid options are provided', function (done) { 182 | var userId = '123456' 183 | var response = { data: 'response data' } 184 | 185 | var request = nock('https://api.medium.com/') 186 | .get('/v1/users/' + userId + '/publications') 187 | .reply(200, response) 188 | 189 | client.getPublicationsForUser({userId: userId}, function (err, data) { 190 | if (err) throw err 191 | data.should.deepEqual(response['data']) 192 | done() 193 | }) 194 | request.done() 195 | }) 196 | }) 197 | 198 | describe('#getContributorsForPublication', function () { 199 | 200 | it ('throws a MediumError when no publication ID is provided', function (done) { 201 | (function () { client.getContributorsForPublication({}) }).should.throw(medium.MediumError) 202 | done() 203 | }) 204 | 205 | it ('makes a proper GET request to the Medium API and returns contents of data envelope', function (done) { 206 | var options = { publicationId: 'abcdef' } 207 | var response = { data: 'response data' } 208 | var request = nock('https://api.medium.com/') 209 | .get('/v1/publications/' + options.publicationId + '/contributors') 210 | .reply(200, response) 211 | 212 | client.getContributorsForPublication(options, function (err, data) { 213 | if (err) throw err 214 | data.should.deepEqual(response['data']) 215 | done() 216 | }) 217 | request.done() 218 | }) 219 | }) 220 | 221 | describe('#createPost', function () { 222 | 223 | it ('makes a proper POST request to the Medium API and returns contents of data envelope', function (done) { 224 | var options = { 225 | userId: '123456', 226 | title: 'new post title', 227 | content: '

New Post!

', 228 | contentFormat: 'html', 229 | tags: ['js', 'unit tests'], 230 | canonicalUrl: 'http://example.com/new-post', 231 | publishedAt: '2004-02-12T15:19:21+00:00', 232 | publishStatus: 'draft', 233 | license: 'all-rights-reserved' 234 | } 235 | var response = { data: 'response data' } 236 | var request = nock('https://api.medium.com/') 237 | .post('/v1/users/' + options.userId + '/posts', { 238 | title: options.title, 239 | content: options.content, 240 | contentFormat: options.contentFormat, 241 | tags: options.tags, 242 | canonicalUrl: options.canonicalUrl, 243 | publishedAt: options.publishedAt, 244 | publishStatus: options.publishStatus, 245 | license: options.license 246 | }) 247 | .reply(200, response) 248 | 249 | client.createPost(options, function (err, data) { 250 | if (err) throw err 251 | data.should.deepEqual(response['data']) 252 | done() 253 | }) 254 | request.done() 255 | }) 256 | }) 257 | 258 | describe('#createPostInPublication', function () { 259 | 260 | it ('should throw an error when no publication ID is provided', function (done) { 261 | (function () { client.createPostInPublication({}) }).should.throw(medium.MediumError) 262 | done() 263 | }) 264 | 265 | it ('makes a proper POST request to the Medium API and returns contents of data envelope', function (done) { 266 | var options = { 267 | publicationId: 'abcdef', 268 | title: 'new post title', 269 | content: '

New Post!

', 270 | contentFormat: 'html', 271 | tags: ['js', 'unit tests'], 272 | canonicalUrl: 'http://example.com/new-post', 273 | publishedAt: '2004-02-12T15:19:21+00:00', 274 | publishStatus: 'draft', 275 | license: 'all-rights-reserved' 276 | } 277 | var response = { data: 'response data' } 278 | var request = nock('https://api.medium.com/') 279 | .post('/v1/publications/' + options.publicationId + '/posts', { 280 | title: options.title, 281 | content: options.content, 282 | contentFormat: options.contentFormat, 283 | tags: options.tags, 284 | canonicalUrl: options.canonicalUrl, 285 | publishedAt: options.publishedAt, 286 | publishStatus: options.publishStatus, 287 | license: options.license 288 | }) 289 | .reply(200, response) 290 | 291 | client.createPostInPublication(options, function (err, data) { 292 | if (err) throw err 293 | data.should.deepEqual(response['data']) 294 | done() 295 | }) 296 | request.done() 297 | }) 298 | }) 299 | }) 300 | --------------------------------------------------------------------------------