Create base REST API client

This commit is contained in:
AkiraFukushima 2018-06-09 01:13:19 +09:00
parent ba688293af
commit 34ea44e48d
15 changed files with 555 additions and 27 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
node_modules
lib

39
example/authorization.js Normal file
View file

@ -0,0 +1,39 @@
/**
* simple authorizaion tool via command line
*/
const readline = require('readline')
const Mastodon = require( '../lib/mastodon')
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
const SCOPES = 'read write follow'
let clientId
let clientSecret
Mastodon.registerApp('Test App', {
scopes: SCOPES
}).then(appData => {
clientId = appData.clientId,
clientSecret = appData.clientSecret
console.log('Authorization URL is generated.')
console.log(appData.url)
console.log()
return new Promise(resolve => {
rl.question('Enter the authorization code from website: ', code => {
resolve(code)
rl.close()
})
})
}).then(code => Mastodon.fetchAccessToken(clientId, clientSecret, code))
.then(tokenData => {
console.log('\naccess_token:')
console.log(tokenData.accessToken)
console.log()
})
.catch(err => console.error(err))

0
lib/.gitkeep Normal file
View file

57
package-lock.json generated
View file

@ -4,11 +4,58 @@
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"postinstall-build": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/postinstall-build/-/postinstall-build-5.0.1.tgz",
"integrity": "sha1-uRepB5smF42aJK9aXNjLSpkdEbk=",
"dev": true
"@types/node": {
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.3.2.tgz",
"integrity": "sha512-9NfEUDp3tgRhmoxzTpTo+lq+KIVFxZahuRX0LHF/9IzKHaWuoWsIrrJ61zw5cnnlGINX8lqJzXYfQTOICS5Q+A=="
},
"@types/oauth": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.0.tgz",
"integrity": "sha512-1oouefxKPGiDkb5m6lNxDkFry3PItCOJ+tlNtEn/gRvWShb2Rb3y0pccOIGwN/AwHUpwsuwlRwSpg7aoCN3bQQ==",
"requires": {
"@types/node": "10.3.2"
}
},
"axios": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.18.0.tgz",
"integrity": "sha1-MtU+SFHv3AoRmTts0AB4nXDAUQI=",
"requires": {
"follow-redirects": "1.5.0",
"is-buffer": "1.1.6"
}
},
"debug": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"requires": {
"ms": "2.0.0"
}
},
"follow-redirects": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.0.tgz",
"integrity": "sha512-fdrt472/9qQ6Kgjvb935ig6vJCuofpBUD14f9Vb+SLlm7xIe4Qva5gey8EKtv8lp7ahE1wilg3xL1znpVGtZIA==",
"requires": {
"debug": "3.1.0"
}
},
"is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
},
"oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha1-vR/vr2hslrdUda7VGWQS/2DPucE="
},
"typescript": {
"version": "2.9.1",

View file

@ -2,10 +2,10 @@
"name": "megalodon",
"version": "0.1.0",
"description": "Mastodon client for node.js",
"main": "index.js",
"main": "./lib/mastodon.js",
"typings": "./lib/mastodon.d.ts",
"scripts": {
"build": "tsc",
"postinstall": "postinstall-build dist",
"build": "tsc -p ./",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
@ -22,9 +22,10 @@
},
"homepage": "https://github.com/h3poteto/megalodon#readme",
"dependencies": {
"@types/oauth": "^0.9.0",
"axios": "^0.18.0",
"oauth": "^0.9.15",
"typescript": "^2.9.1"
},
"devDependencies": {
"postinstall-build": "^5.0.1"
}
"devDependencies": {}
}

18
src/entities/account.ts Normal file
View file

@ -0,0 +1,18 @@
export default interface Account {
acct: string,
avatar: string,
avatar_static: string,
created_at: string,
display_name: string,
followers_count: number,
following_count: number,
header: string,
header_static: string,
id: number,
locked: boolean,
note: string,
source?: object, // Source
statuses_count: number,
url: string,
username: string
}

View file

@ -0,0 +1,4 @@
export default interface Application {
name: string,
website: string | null
}

6
src/entities/mention.ts Normal file
View file

@ -0,0 +1,6 @@
export default interface Mention {
id: number,
username: string,
url: string,
acct: string
}

View file

@ -0,0 +1,10 @@
import Account from './account'
import Status from './status'
export default interface Notification {
account: Account,
created_at: string,
id: number,
status: Status | null,
type: 'mention' | 'reblog' | 'favourite' | 'follow'
}

29
src/entities/status.ts Normal file
View file

@ -0,0 +1,29 @@
import Account from './account'
import Application from './application'
import Mention from './mention'
import Tag from './tag'
export default interface Status {
account: Account,
application: Application | null,
content: string,
created_at: string,
favourited: boolean | null,
favourites_count: number,
id: number,
in_reply_to_account_id: number | null,
in_reply_to_id: number | null,
language: string | null,
media_attachments: object[], // Attachments
mentions: Mention[],
muted: boolean | null,
reblog: Status | null,
reblogged: boolean | null,
reblogs_count: number,
sensitive: boolean,
spoiler_text: string,
tags: Tag[],
uri: string,
url: string,
visibility: string
}

4
src/entities/tag.ts Normal file
View file

@ -0,0 +1,4 @@
export default interface Tag {
name: string,
url: string
}

254
src/mastodon.ts Normal file
View file

@ -0,0 +1,254 @@
import { OAuth2 } from 'oauth'
import axios from 'axios'
// import StreamListener from './streamlistener'
import OAuth from './oauth'
const NO_REDIRECT = 'urn:ietf:wg:oauth:2.0:oob'
const DEFAULT_URL = 'https://mastodon.social'
const DEFAULT_SCOPE = 'read write follow'
/**
* for Mastodon API
*
* using superagent for request, you will handle promises
*/
class Mastodon {
static DEFAULT_SCOPE = DEFAULT_SCOPE
static DEFAULT_URL = DEFAULT_URL
static NO_REDIRECT = NO_REDIRECT
private accessToken: string
private baseUrl: string
/**
* @param accessToken access token from OAuth2 authorization
* @param baseUrl hostname or base URL
*/
constructor(accessToken: string, baseUrl = DEFAULT_URL) {
this.accessToken = accessToken
this.baseUrl = baseUrl
}
/**
* unauthorized GET request to mastodon REST API
* @param path relative path from ${baseUrl}/api/v1/ or absolute path
* @param params Query parameters
* @param baseUrl base URL of the target
*/
public static get(path: string, params = {}, baseUrl = DEFAULT_URL): Promise<object> {
const apiUrl = baseUrl
return axios
.get(apiUrl + path, {
params
})
.then(resp => resp.data)
}
private static _post(path: string, params = {}, baseUrl = DEFAULT_URL): Promise<object> {
const apiUrl = baseUrl
return axios
.post(apiUrl + path, params)
.then(resp => resp.data)
}
/**
* Wrapper for personal OAuth Application (createApp and generateAuthUrl)
*
* First, [POST /api/v1/apps](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#apps)
* only client_name is required, so others are optional.
* Secound, generate an authorization url.
* finally, return promise of OAuth.AppData instance, which has client_id, client_secret, url, and so on.
* @param client_name Form Data, which is sent to /api/v1/apps
* @param options Form Data, which is sent to /api/v1/apps. and properties should be **snake_case**
* @param baseUrl base URL of the target
*/
public static registerApp(
client_name: string,
options: Partial<{ scopes: string, redirect_uris: string, website: string }> = {
scopes: DEFAULT_SCOPE,
redirect_uris: NO_REDIRECT
},
baseUrl = DEFAULT_URL
): Promise<OAuth.AppData> {
return this.createApp(client_name, options, baseUrl)
.then(appData => {
return this.generateAuthUrl(appData.client_id, appData.client_secret, {
redirect_uri: NO_REDIRECT,
scope: options.scopes
})
.then(url => {
appData.url = url
return appData
})
})
}
/**
* Create an application
*
* First, [POST /api/v1/apps](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#apps)
* @param client_name your application's name
* @param options Form Data
* @param baseUrl target of base URL
*/
public static createApp(
client_name: string,
options: Partial<{ redirect_uris: string, scopes: string, website: string }> = {
redirect_uris: NO_REDIRECT,
scopes: DEFAULT_SCOPE
},
baseUrl = DEFAULT_URL
): Promise<OAuth.AppData> {
const redirect_uris = options.redirect_uris || NO_REDIRECT
const scopes = options.scopes || DEFAULT_SCOPE
const params: {
client_name: string,
redirect_uris: string,
scopes: string,
website?: string
} = {
client_name,
redirect_uris,
scopes
}
if (options.website) params.website = options.website
return this._post('/api/v1/apps', params, baseUrl)
.then(data => OAuth.AppData.from(data as OAuth.AppDataFromServer))
}
/**
* generate authorization url
*
* @param clientId your OAuth app's client ID
* @param clientSecret your OAuth app's client Secret
* @param options as property, redirect_uri and scope are available, and must be the same as when you register your app
* @param baseUrl base URL of the target
*/
public static generateAuthUrl(
clientId: string,
clientSecret: string,
options: Partial<{ redirect_uri: string, scope: string }> = {
redirect_uri: NO_REDIRECT,
scope: DEFAULT_SCOPE
},
baseUrl = DEFAULT_URL
): Promise<string> {
return new Promise((resolve) => {
const oauth = new OAuth2(clientId, clientSecret, baseUrl, undefined, '/oauth/token')
const url = oauth.getAuthorizeUrl({
redirect_uri: options.redirect_uri,
response_type: 'code',
client_id: clientId,
scope: options.scope
})
resolve(url)
})
}
/**
* Fetch OAuth access token
* @param client_id will be generated by #createApp or #registerApp
* @param client_secret will be generated by #createApp or #registerApp
* @param code will be generated by the link of #generateAuthUrl or #registerApp
* @param redirect_uri must be the same uri as the time when you register your OAuth application
* @param baseUrl base URL of the target
*/
public static fetchAccessToken(
client_id: string,
client_secret: string,
code: string,
redirect_uri = NO_REDIRECT,
baseUrl = DEFAULT_URL
): Promise<OAuth.TokenData> {
return this._post('/oauth/token', {
client_id,
client_secret,
code,
redirect_uri,
grant_type: 'authorization_code'
}, baseUrl).then(data => OAuth.TokenData.from(data as OAuth.TokenDataFromServer))
}
/**
* GET request to mastodon REST API
* @param path relative path from ${baseUrl}/api/v1/ or absolute path
* @param params Query parameters
*/
public get<T>(path: string, params = {}): Promise<T> {
return axios
.get(this.baseUrl + path, {
headers: {
'Authorization': `Bearer ${this.accessToken}`
},
params
})
.then(resp => resp.data as T)
}
/**
* PATCH request to mastodon REST API
* @param path relative path from ${baseUrl}/api/v1/ or absolute path
* @param params Form data
*/
public patch<T>(path: string, params = {}): Promise<T> {
return axios
.patch(this.baseUrl + path, params, {
headers: {
'Authorization': `Bearer ${this.accessToken}`
}
})
.then(resp => resp.data as T)
}
/**
* POST request to mastodon REST API
* @param path relative path from ${baseUrl}/api/v1/ or absolute path
* @param params Form data
*/
public post<T>(path: string, params = {}): Promise<T> {
return axios
.post(this.baseUrl + path, params, {
headers: {
'Authorization': `Bearer ${this.accessToken}`
}
})
.then(resp => resp.data as T)
}
/**
* DELETE request to mastodon REST API
* @param path relative path from ${baseUrl}/api/v1/ or absolute path
*/
public del(path: string): Promise<{}> {
return axios
.delete(this.baseUrl + path, {
headers: {
'Authorization': `Bearer ${this.accessToken}`
}
})
.then(resp => resp.data as {})
}
/**
* receive Server-sent Events from Mastodon Streaming API
* @param path relative path from ${baseUrl}/api/v1/streaming/ or absolute path
* 'public', 'public/local', 'user' and 'hashtag?tag=${tag}' are available.
* @param reconnectInterval interval of reconnect
* @returns streamListener, which inherits from EventEmitter and has event, 'update', 'notification', 'delete', and so on.
*/
// public stream(path: string, reconnectInterval = 1000) {
// const headers = {
// 'Cache-Control': 'no-cache',
// 'Accept': 'text/event-stream',
// 'Authorization': `Bearer ${this.accessToken}`
// }
// const url = resolveUrl(this.baseUrl, path)
// return new StreamListener(url, headers, reconnectInterval)
// }
}
module.exports = Mastodon

90
src/oauth.ts Normal file
View file

@ -0,0 +1,90 @@
namespace OAuth {
export interface AppDataFromServer {
id: number,
name: string,
website: string | null,
redirect_uri: string,
client_id: string,
client_secret: string
}
export interface TokenDataFromServer {
access_token: string,
token_type: string,
scope: string,
created_at: number
}
export class AppData {
public url: string | null
constructor(
public id: number,
public name: string,
public website: string | null,
public redirect_uri: string,
public client_id: string,
public client_secret: string
) {
this.url = null
}
/**
* Serialize raw application data from server
* @param raw from server
*/
static from(raw: AppDataFromServer) {
return new this(raw.id, raw.name, raw.website, raw.redirect_uri, raw.client_id, raw.client_secret)
}
get redirectUri() {
return this.redirect_uri
}
get clientId() {
return this.client_id
}
get clientSecret() {
return this.client_secret
}
}
export class TokenData {
public _scope: string
constructor(
public access_token: string,
public token_type: string,
scope: string,
public created_at: number
) {
this._scope = scope
}
/**
* Serialize raw token data from server
* @param raw from server
*/
static from(raw: TokenDataFromServer) {
return new this(raw.access_token, raw.token_type, raw.scope, raw.created_at)
}
/**
* OAuth Aceess Token
*/
get accessToken() {
return this.access_token
}
get tokenType() {
return this.token_type
}
get scope() {
return this._scope
}
/**
* Application ID
*/
get createdAt() {
return this.created_at
}
}
}
export default OAuth

24
src/utils.ts Normal file
View file

@ -0,0 +1,24 @@
/**
* @param hostname should be a hostname like 'example.com'.
* This parameter can also include protocol like 'https://example.com',
* @returns are a baseURL like 'https://example.com'
*/
export function normalizeBaseUrl (hostname: string): string {
if (/^http/.test(hostname)) {
return hostname
}
return `https://${hostname}`
}
export function includes<T> (wanted: T): (sequence: Iterable<T>) => boolean {
return sequence => {
let result = false
for (const element of sequence) {
if (element === wanted) {
result = true
break
}
}
return result
}
}

View file

@ -3,7 +3,7 @@
/* Basic Options */
"target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
"lib": ["es6"], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
@ -11,29 +11,29 @@
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
// "outDir": "./", /* Redirect output structure to the directory. */
"outDir": "./lib", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "removeComments": true, /* Do not emit comments to output. */
"removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
"downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"strictNullChecks": true, /* Enable strict null checks. */
"strictFunctionTypes": true, /* Enable strict checking of function types. */
"strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
"noUnusedLocals": true, /* Report errors on unused locals. */
"noUnusedParameters": true, /* Report errors on unused parameters. */
"noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
"noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
@ -55,5 +55,6 @@
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
}
}
},
"include": ["./src"]
}