Skip to content

Commit

Permalink
feat!: update oauth clients and minimal node version (#11)
Browse files Browse the repository at this point in the history
Deprecates multiple client methods and the generic PatreonClient class. Removes all seperate fetch parameters.

fix(oauth): use https protocol for URL
  Source-Link: 962e62a

feat(PatreonUserClientInstance)!: set token to readonly

feat(PatreonUserClientInstance): add `fetchDiscordId` method

feat(oauth): add methods to fetch resources

feat(types)!: narrow type of `User.social_connections` to `Record<string, string>`
  • Loading branch information
ghostrider-05 authored Apr 11, 2024
1 parent 24f2049 commit 60ea00b
Show file tree
Hide file tree
Showing 14 changed files with 291 additions and 168 deletions.
44 changes: 19 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,38 +6,34 @@

Typescript library for the V2 [Patreon API](https://docs.patreon.com/)

## Installation

> [!WARNING]
> You might be looking for [patreon-js](https://github.com/Patreon/patreon-js) for JavaScript, [patreon-api-types](https://github.com/mrTomatolegit/patreon-api-types) for less strict types and no client or another package in between.
## Installation

```sh
npm install patreon-api.ts
```

## Usage

> [!NOTE]
> [!CAUTION]
> This package does not include v1 of the Patreon API and starts with [API v2](https://docs.patreon.com/#apiv2-oauth)
The default API version for this package is `2` and might change in major versions.
When the default API version is changed, old versions will still receive updates.
You can not import this module by API version since it is unlikely that Patreon will release a new version any time soon.

```ts
import { Campaign } from 'patreon-api.ts';
```

```ts
const { Campaign } = require('patreon-api.ts');
import { type Campaign } from 'patreon-api.ts';
```

### Platform

Before deploying this, check that:
To check for compatibility with this package, look if your platform:

- [ ] In the example below, `fetch` is replaced with the fetch function of your platform.
- [ ] your platform supports ES5+
- has the globals: `fetch`, `URL` and `URLSearchParams`
- for node.js: `v18` or higher
- supports `ES2020`

### Oauth2 vs Creator token

Expand All @@ -53,15 +49,15 @@ const creatorClient = new PatreonCreatorClient({
clientId: process.env.PATREON_CLIENT_ID!,
clientSecret: process.env.PATREON_CLIENT_SECRET!,
store: new PatreonStore.Fetch('<url>'),
}, fetch)
})

// Use the token of the creator with the current app, instead of Oauth2 callback
// Will call store.put, to sync it with an external DB
// After fetching, you can directly call the V2 API and the token is stored with store.put
await creatorClient.initialize()
```

### User oauth2
#### User oauth2

For handling Oauth2 requests, add `redirectUri` and if specified a `state` to the options.
Then fetch the token for the user with request url.
Expand All @@ -75,13 +71,13 @@ const userClient = new PatreonUserClient({
clientId: process.env.PATREON_CLIENT_ID!,
clientSecret: process.env.PATREON_CLIENT_SECRET!,
redirectUri: '<uri>',
}, fetch)
})

export default {
// The Oauth2 callback request with the code parameter
fetch: async (request) => {
const instance = await userClient.createInstance(request)
await instance.fetchOauth2(Oauth2Routes.campaign(campaignId), campaignQuery)
await instance.fetchIdentity(<query>)
}
}
```
Expand All @@ -97,7 +93,7 @@ There are 3 built-in methods of retreiving and storing tokens:
```ts
// Use stored tokens in a database
// And directly call the `store.get` method on starting the client
const storeClient = new PatreonClient({
const storeClient = new PatreonCreatorClient({
clientId: process.env.PATREON_CLIENT_ID!,
clientSecret: process.env.PATREON_CLIENT_SECRET!,
name: '<application>', // The application name in the dashboard
Expand All @@ -113,34 +109,32 @@ const storeClient = new PatreonClient({
console.log(JSON.stringify(token))
}
}
}, fetch)


})
```

## Examples

> **Info**
> [!NOTE]
> In API v2, [all attributes must be explicitly requested](https://docs.patreon.com/#apiv2-oauth).
### Fetch campaigns

```ts
import { Oauth2Routes, buildQuery } from 'patreon-api.ts'
import { buildQuery } from 'patreon-api.ts'

const query = buildQuery.campaigns()({
// Include number of patrons for each campaign
campaign: ['patron_count']
})

const campaigns = await <Client>.fetchOauth2(Oauth2Routes.campaigns(), query)
const campaigns = await <Client>.fetchCampaigns(query)
console.log('The first campaign id of the current user is: ' + campaigns?.data[0].id)
```

### Fetch single campaign

```ts
import { Oauth2Routes, Type, buildQuery, type AttributeItem } from 'patreon-api.ts'
import { Type, buildQuery, type AttributeItem } from 'patreon-api.ts'

// Fetch all campaigns first, or look at the network tab to find the id
const campaignId = '<id>'
Expand All @@ -150,7 +144,7 @@ const campaignId = '<id>'
const campaignQuery = buildQuery.campaign(['tiers'])({
tier: ['amount_cents', 'title']
})
const campaign = await <Client>.fetchOauth2(Oauth2Routes.campaign(campaignId), campaignQuery)
const campaign = await <Client>.fetchCampaign(campaignId, campaignQuery)

if (campaign != undefined) {
// Filter all but tiers and get the attributes
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "patreon-api.ts",
"version": "0.2.0",
"description": "Typescript library for the Patreon API",
"description": "Typescript library for the V2 Patreon API",
"types": "./dist/index.d.ts",
"module": "./dist/index.mjs",
"main": "./dist/index.js",
Expand All @@ -13,7 +13,7 @@
"publish": "npm run test && npm run prestart && npm run lint && npm install && npm publish"
},
"engines": {
"node": ">=14.17"
"node": ">=18.17"
},
"homepage": "https://github.com/ghostrider-05/patreon-api.ts#readme",
"repository": {
Expand Down
4 changes: 4 additions & 0 deletions src/__tests__/v2/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ describe('Fetch store', () => {
} else return new Response(null, { status: 400 })
})

test('use global fetch', () => {
expect(new PatreonStore.Fetch('https://localhost:8000')).toBeDefined()
})

test('unset value', async () => {
expect(await store.get()).toBeUndefined()
})
Expand Down
126 changes: 60 additions & 66 deletions src/rest/v2/clients/base.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import ClientOAuth2 from 'client-oauth2'

import { BasePatreonQuery, GetResponsePayload } from '../query'
import { RouteBases } from '../routes'
import { BasePatreonClientMethods } from './baseMethods'

import {
PatreonOauthClient,
Expand All @@ -22,9 +21,8 @@ export interface PatreonClientOptions extends BaseOauthClientOptions {
name?: string
store?: PatreonTokenFetchOptions
refreshOnFailed?: boolean
// TODO: uncomment
// fetch?: Fetch
// token?: Token
fetch?: Fetch
token?: Token
}

export type PatreonInitializeClientOptions = PatreonClientOptions & Required<Pick<PatreonClientOptions, 'store'>>
Expand All @@ -36,38 +34,46 @@ export interface Oauth2FetchOptions {
contentType?: string
}

export class PatreonClient extends PatreonOauthClient {
export type Oauth2RouteOptions = Omit<Oauth2FetchOptions, 'method'>

export abstract class BasePatreonClient extends BasePatreonClientMethods {
private store: PatreonTokenFetchOptions | undefined = undefined
private refreshOnFailed: boolean
private _fetch: Fetch

/**
* The application name.
* Can be useful to log or something.
*/
public name: string | null = null

// TODO: remove fetch and token option
public constructor(
patreonOptions: PatreonClientOptions & (BaseOauthHandlerOptions | object),
_fetch: Fetch,
token?: Token,
) {
super(patreonOptions, token)
this._fetch = _fetch ?? fetch
super(
new PatreonOauthClient(patreonOptions,
patreonOptions.refreshOnFailed ?? false,
patreonOptions.token
),
patreonOptions.fetch ?? fetch
)

this.name = patreonOptions.name ?? null
this.store = patreonOptions.store
this.refreshOnFailed = patreonOptions.refreshOnFailed ?? false
this.oauthClient.onTokenRefreshed = async (token) => {
await this.store?.put?.(token)
}
}

// TODO: remove fetch option
public static async initialize(options: PatreonInitializeClientOptions, fetch: Fetch) {
/** @deprecated */
public static async initialize(options: PatreonInitializeClientOptions) {
const token = await this.fetchStored(options.store)
if (token) options.token ??= token

return new PatreonClient(options, fetch, token)
return new PatreonClient(options)
}

/** @deprecated */
protected static toStored = PatreonOauthClient.toStored

protected static async fetchStored(store?: PatreonTokenFetchOptions) {
const stored = await store?.get()
if (stored == undefined) return undefined
Expand All @@ -77,78 +83,66 @@ export class PatreonClient extends PatreonOauthClient {
return stored
}

/**
* Fetch the stored token with the `get` method from the client options
*/
public async fetchStoredToken() {
return PatreonClient.fetchStored(this.store)
}

// TODO: deprecate
/**
* For handling Oauth2 requests, fetch the token that is assiocated with the request code
* @param requestUrl The url with the `code` parameter
* @deprecated
*/
public override async fetchToken(requestUrl: string): Promise<StoredToken> {
const token = await this._fetchToken(requestUrl, 'code', false)
if (token) await this.store?.put(PatreonClient.toStored(token), requestUrl)
public async fetchToken(requestUrl: string): Promise<StoredToken> {
const token = await this.oauthClient._fetchToken(requestUrl, 'code', false)
if (token) await this.store?.put(BasePatreonClient.toStored(token), requestUrl)

return PatreonClient.toStored(token)
return BasePatreonClient.toStored(token)
}

protected async validateToken(token: ClientOAuth2.Token | undefined = this.cachedToken) {
protected async validateToken(token: ClientOAuth2.Token | undefined = this.oauthClient.cachedToken) {
if (token != undefined && !token.expired()) return token
if (token == undefined) throw new Error('No token found to validate!')

const refreshed = await token.refresh(this.options)
await this.store?.put(PatreonClient.toStored(refreshed))
this.cachedToken = refreshed
const refreshed = await token.refresh(this.oauthClient.options)
await this.store?.put(BasePatreonClient.toStored(refreshed))
this.oauthClient.cachedToken = refreshed

return refreshed
}

/**
* Fetch the stored token with the `get` method from the client options
*/
public async fetchStoredToken() {
return BasePatreonClient.fetchStored(this.store)
}

/**
* Save your token with the method from the client options
* @param token The token to save
* @param cache Whether to overwrite the application token cache and update it with the token
*/
public async putToken(token: StoredToken, cache?: boolean) {
public async putStoredToken(token: StoredToken, cache?: boolean) {
await this.store?.put(token)
if (cache) this.cachedToken = this.toRaw(token)
if (cache) this.oauthClient.cachedToken = this.oauthClient.toRaw(token)
}


/**
* Fetch the Patreon Oauth V2 API
* @param path The Oauth V2 API Route
* @param query The query builder with included fields and attributes
* @param options Request options
* Save your token with the method from the client options
* @deprecated Use {@link putStoredToken}
* @param token The token to save
* @param cache Whether to overwrite the application token cache and update it with the token
*/
public async fetchOauth2<Query extends BasePatreonQuery>(
path: string,
query: Query,
options?: Oauth2FetchOptions,
): Promise<GetResponsePayload<Query> | undefined> {
const token = await this.validateToken(options?.token
? this.toRaw(options.token)
: undefined
)
public async putToken(token: StoredToken, cache?: boolean) {
return this.putStoredToken(token, cache)
}

return await this._fetch(RouteBases.oauth2 + path + query.query, {
method: options?.method ?? 'GET',
headers: {
'Content-Type': options?.contentType ?? 'application/json',
'Authorization': 'Bearer ' + token.accessToken,
},
}).then(res => {
if (res.ok) return res.json()

const shouldRefetch = options?.refreshOnFailed !== false && (options?.refreshOnFailed || this.refreshOnFailed)
if (shouldRefetch && res.status === 403) {
return this.fetchOauth2(path, query, {
...options,
refreshOnFailed: false,
})
}
})
/**
* @deprecated
* @returns if the token is updated and stored, and the token
*/
public async fetchApplicationToken() {
return await this.oauthClient._fetchToken('', 'credentials', true)
.then(raw => ({ success: raw != undefined, token: BasePatreonClient.toStored(raw) }))
}
}
}

/** @deprecated */
export class PatreonClient extends BasePatreonClient {}
Loading

0 comments on commit 60ea00b

Please sign in to comment.