Skip to content

Commit

Permalink
Switch [OpenCollective] badges to use GraphQL and auth (#9387)
Browse files Browse the repository at this point in the history
* [OpenCollective] update opencollective to api v2 (#9346)

* update opencollective to api v2

* fix tests

* fix: do not filter by accountType for opencollective/all

* remove 404

* remove required in schema

* cnt -> count

* keep by-tier code as-is

---------

Co-authored-by: repo-ranger[bot] <39074581+repo-ranger[bot]@users.noreply.github.com>

* allow calling OpenCollective api with an auth token

* add test for opencollective auth

* cache OpenCollective badges for longer

---------

Co-authored-by: xxchan <xxchan22f@gmail.com>
Co-authored-by: repo-ranger[bot] <39074581+repo-ranger[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Aug 20, 2023
1 parent 692829f commit 8f76982
Show file tree
Hide file tree
Showing 12 changed files with 229 additions and 241 deletions.
1 change: 1 addition & 0 deletions config/custom-environment-variables.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ private:
obs_user: 'OBS_USER'
obs_pass: 'OBS_PASS'
redis_url: 'REDIS_URL'
opencollective_token: 'OPENCOLLECTIVE_TOKEN'
postgres_url: 'POSTGRES_URL'
sentry_dsn: 'SENTRY_DSN'
sl_insight_userUuid: 'SL_INSIGHT_USER_UUID'
Expand Down
1 change: 1 addition & 0 deletions core/server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ const privateConfigSchema = Joi.object({
obs_user: Joi.string(),
obs_pass: Joi.string(),
redis_url: Joi.string().uri({ scheme: ['redis', 'rediss'] }),
opencollective_token: Joi.string(),
postgres_url: Joi.string().uri({ scheme: 'postgresql' }),
sentry_dsn: Joi.string(),
sl_insight_userUuid: Joi.string(),
Expand Down
10 changes: 9 additions & 1 deletion doc/server-secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ installation access to private npm packages

[npm token]: https://docs.npmjs.com/getting-started/working_with_tokens

## Open Build Service
### Open Build Service

- `OBS_USER` (yml: `private.obs_user`)
- `OBS_PASS` (yml: `private.obs_user`)
Expand All @@ -246,6 +246,14 @@ they can only be scoped to execute specific actions on a POST request. This
means however, that an actual account is required to read the build status
of a package.

### OpenCollective

- `OPENCOLLECTIVE_TOKEN` (yml: `opencollective_token`)

OpenCollective's GraphQL API only allows 10 reqs/minute for anonymous users.
An [API token](https://graphql-docs-v2.opencollective.com/access)
can be provided to access a higher rate limit of 100 reqs/minute.

### SymfonyInsight (formerly Sensiolabs)

- `SL_INSIGHT_USER_UUID` (yml: `private.sl_insight_userUuid`)
Expand Down
8 changes: 7 additions & 1 deletion services/opencollective/opencollective-all.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,18 @@ export default class OpencollectiveAll extends OpencollectiveBase {
},
}

static _cacheLength = 900

static defaultBadgeData = {
label: 'backers and sponsors',
}

async handle({ collective }) {
const { backersCount } = await this.fetchCollectiveInfo(collective)
const data = await this.fetchCollectiveInfo({
collective,
accountType: [],
})
const backersCount = this.getCount(data)
return this.constructor.render(backersCount)
}
}
36 changes: 2 additions & 34 deletions services/opencollective/opencollective-all.tester.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,49 +2,17 @@ import { nonNegativeInteger } from '../validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()

t.create('renders correctly')
.get('/shields.json')
.intercept(nock =>
nock('https://opencollective.com/').get('/shields.json').reply(200, {
slug: 'shields',
currency: 'USD',
image:
'https://opencollective-production.s3-us-west-1.amazonaws.com/44dcbb90-1ee9-11e8-a4c3-7bb1885c0b6e.png',
balance: 105494,
yearlyIncome: 157371,
backersCount: 35,
contributorsCount: 276,
}),
)
.expectBadge({
label: 'backers and sponsors',
message: '35',
color: 'brightgreen',
})
t.create('gets amount of backers and sponsors')
.get('/shields.json')
.expectBadge({
label: 'backers and sponsors',
message: nonNegativeInteger,
})

t.create('renders not found correctly')
.get('/nonexistent-collective.json')
.intercept(nock =>
nock('https://opencollective.com/')
.get('/nonexistent-collective.json')
.reply(404, 'Not found'),
)
.expectBadge({
label: 'backers and sponsors',
message: 'collective not found',
color: 'red',
})

t.create('handles not found correctly')
.get('/nonexistent-collective.json')
.expectBadge({
label: 'backers and sponsors',
message: 'collective not found',
color: 'red',
message: 'No collective found with slug nonexistent-collective',
color: 'lightgrey',
})
10 changes: 7 additions & 3 deletions services/opencollective/opencollective-backers.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,19 @@ export default class OpencollectiveBackers extends OpencollectiveBase {
},
}

static _cacheLength = 900

static defaultBadgeData = {
label: 'backers',
}

async handle({ collective }) {
const { backersCount } = await this.fetchCollectiveBackersCount(
const data = await this.fetchCollectiveInfo({
collective,
{ userType: 'users' },
)
accountType: ['INDIVIDUAL'],
})
const backersCount = this.getCount(data)

return this.constructor.render(backersCount)
}
}
78 changes: 2 additions & 76 deletions services/opencollective/opencollective-backers.tester.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,80 +2,6 @@ import { nonNegativeInteger } from '../validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()

t.create('renders correctly')
.get('/shields.json')
.intercept(nock =>
nock('https://opencollective.com/')
.get('/shields/members/users.json')
.reply(200, [
{ MemberId: 8685, type: 'USER', role: 'ADMIN' },
{ MemberId: 8686, type: 'USER', role: 'ADMIN' },
{ MemberId: 8682, type: 'USER', role: 'ADMIN' },
{ MemberId: 10305, type: 'USER', role: 'BACKER', tier: 'backer' },
{ MemberId: 10396, type: 'USER', role: 'BACKER', tier: 'backer' },
{ MemberId: 10733, type: 'USER', role: 'BACKER' },
{ MemberId: 8684, type: 'USER', role: 'ADMIN' },
{ MemberId: 10741, type: 'USER', role: 'BACKER' },
{
MemberId: 10756,
type: 'USER',
role: 'BACKER',
tier: 'monthly backer',
},
{ MemberId: 11578, type: 'USER', role: 'CONTRIBUTOR' },
{ MemberId: 13459, type: 'USER', role: 'CONTRIBUTOR' },
{
MemberId: 13507,
type: 'USER',
role: 'BACKER',
tier: 'monthly backer',
},
{ MemberId: 13512, type: 'USER', role: 'BACKER' },
{ MemberId: 13513, type: 'USER', role: 'FUNDRAISER' },
{ MemberId: 13984, type: 'USER', role: 'BACKER', tier: 'backer' },
{ MemberId: 14916, type: 'USER', role: 'BACKER' },
{
MemberId: 16326,
type: 'USER',
role: 'BACKER',
tier: 'monthly backer',
},
{ MemberId: 18252, type: 'USER', role: 'BACKER', tier: 'backer' },
{ MemberId: 17631, type: 'USER', role: 'BACKER', tier: 'backer' },
{
MemberId: 16420,
type: 'USER',
role: 'BACKER',
tier: 'monthly backer',
},
{ MemberId: 17186, type: 'USER', role: 'BACKER', tier: 'backer' },
{ MemberId: 18791, type: 'USER', role: 'BACKER', tier: 'backer' },
{
MemberId: 19279,
type: 'USER',
role: 'BACKER',
tier: 'monthly backer',
},
{ MemberId: 19863, type: 'USER', role: 'BACKER', tier: 'backer' },
{ MemberId: 21451, type: 'USER', role: 'BACKER', tier: 'backer' },
{ MemberId: 22718, type: 'USER', role: 'BACKER' },
{ MemberId: 23561, type: 'USER', role: 'BACKER', tier: 'backer' },
{ MemberId: 25092, type: 'USER', role: 'CONTRIBUTOR' },
{ MemberId: 24473, type: 'USER', role: 'BACKER', tier: 'backer' },
{ MemberId: 25439, type: 'USER', role: 'BACKER', tier: 'backer' },
{ MemberId: 24483, type: 'USER', role: 'BACKER', tier: 'backer' },
{ MemberId: 25090, type: 'USER', role: 'CONTRIBUTOR' },
{ MemberId: 26404, type: 'USER', role: 'BACKER', tier: 'backer' },
{ MemberId: 27026, type: 'USER', role: 'BACKER', tier: 'backer' },
{ MemberId: 27132, type: 'USER', role: 'CONTRIBUTOR' },
]),
)
.expectBadge({
label: 'backers',
message: '25',
color: 'brightgreen',
})

t.create('gets amount of backers').get('/shields.json').expectBadge({
label: 'backers',
message: nonNegativeInteger,
Expand All @@ -85,6 +11,6 @@ t.create('handles not found correctly')
.get('/nonexistent-collective.json')
.expectBadge({
label: 'backers',
message: 'collective not found',
color: 'red',
message: 'No collective found with slug nonexistent-collective',
color: 'lightgrey',
})
124 changes: 70 additions & 54 deletions services/opencollective/opencollective-base.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,38 @@
import gql from 'graphql-tag'
import Joi from 'joi'
import { BaseGraphqlService } from '../index.js'
import { nonNegativeInteger } from '../validators.js'
import { BaseJsonService } from '../index.js'
import { metric } from '../text-formatters.js'

// https://developer.opencollective.com/#/api/collectives?id=get-info
const collectiveDetailsSchema = Joi.object().keys({
slug: Joi.string().required(),
backersCount: nonNegativeInteger,
})
const schema = Joi.object({
data: Joi.object({
account: Joi.object({
name: Joi.string(),
slug: Joi.string(),
members: Joi.object({
totalCount: nonNegativeInteger,
nodes: Joi.array().items(
Joi.object({
tier: Joi.object({
legacyId: Joi.number(),
name: Joi.string(),
}).allow(null),
}),
),
}).required(),
}).required(),
}).required(),
}).required()

// https://developer.opencollective.com/#/api/collectives?id=get-members
function buildMembersArraySchema({ userType, tierRequired }) {
const keys = {
MemberId: Joi.number().required(),
type: userType || Joi.string().required(),
role: Joi.string().required(),
}
if (tierRequired) keys.tier = Joi.string().required()
return Joi.array().items(Joi.object().keys(keys))
}

export default class OpencollectiveBase extends BaseJsonService {
export default class OpencollectiveBase extends BaseGraphqlService {
static category = 'funding'

static auth = {
passKey: 'opencollective_token',
authorizedOrigins: ['https://api.opencollective.com'],
isRequired: false,
}

static buildRoute(base, withTierId) {
return {
base: `opencollective${base ? `/${base}` : ''}`,
Expand All @@ -38,45 +48,51 @@ export default class OpencollectiveBase extends BaseJsonService {
}
}

async fetchCollectiveInfo(collective) {
return this._requestJson({
schema: collectiveDetailsSchema,
// https://developer.opencollective.com/#/api/collectives?id=get-info
url: `https://opencollective.com/${collective}.json`,
httpErrors: {
404: 'collective not found',
},
})
async fetchCollectiveInfo({ collective, accountType }) {
return this._requestGraphql(
this.authHelper.withQueryStringAuth(
{ passKey: 'personalToken' },
{
schema,
url: 'https://api.opencollective.com/graphql/v2',
query: gql`
query account($slug: String, $accountType: [AccountType]) {
account(slug: $slug) {
name
slug
members(accountType: $accountType, role: BACKER) {
totalCount
nodes {
tier {
legacyId
name
}
}
}
}
}
`,
variables: {
slug: collective,
accountType,
},
options: {
headers: { 'content-type': 'application/json' },
},
},
),
)
}

async fetchCollectiveBackersCount(collective, { userType, tierId }) {
const schema = buildMembersArraySchema({
userType:
userType === 'users'
? 'USER'
: userType === 'organizations'
? 'ORGANIZATION'
: undefined,
tierRequired: tierId,
})
const members = await this._requestJson({
schema,
// https://developer.opencollective.com/#/api/collectives?id=get-members
// https://developer.opencollective.com/#/api/collectives?id=get-members-per-tier
url: `https://opencollective.com/${collective}/members/${
userType || 'all'
}.json${tierId ? `?TierId=${tierId}` : ''}`,
httpErrors: {
404: 'collective not found',
getCount(data) {
const {
data: {
account: {
members: { totalCount },
},
},
})
} = data

const result = {
backersCount: members.filter(member => member.role === 'BACKER').length,
}
// Find the title of the tier
if (tierId && members.length > 0)
result.tier = members.map(member => member.tier)[0]
return result
return totalCount
}
}
Loading

0 comments on commit 8f76982

Please sign in to comment.