Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Switch [OpenCollective] badges to use GraphQL and auth #9387

Merged
merged 5 commits into from
Aug 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't really related to this PR. I just noticed this heading level was wrong while I was editing the file.


- `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
}
}