Merge pull request #17157 from cmdkeen/cmdkeen/stackoverflow-pat

Stack Overflow plugin support for PAT authentication
This commit is contained in:
Emma Indal
2023-04-14 11:17:15 +02:00
committed by GitHub
6 changed files with 87 additions and 5 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-stack-overflow-backend': minor
---
Adding support for v2.3 API and PAT authentication
+9 -2
View File
@@ -17,9 +17,9 @@ stackoverflow:
### Stack Overflow for Teams
If you have a private Stack Overflow instance and/or a private Stack Overflow Team you will need to supply an API key. You can read more about how to set this up by going to [Stack Overflow's Help Page](https://stackoverflow.help/en/articles/4385859-stack-overflow-for-teams-api).
If you have a private Stack Overflow instance and/or a private Stack Overflow Team you will need to supply an API key or Personal Access Token. You can read more about how to set this up by going to [Stack Overflow's Help Page](https://stackoverflow.help/en/articles/4385859-stack-overflow-for-teams-api).
A private Stack Overflow Team requires both an API key and an API Access Token. Step 3 ("Generate an Access Token via OAuth") from the previously mentioned Help Page explains the process for generating an API Access Token with no expiration.
The existing API key approach remains the default, to support the new v2.3 API and PAT authentication model you need to pass the team name and the new PAT into the existing apiAccessToken parameter to the new URL. See [15770](https://github.com/backstage/backstage/issues/15770) for more details.
```yaml
stackoverflow:
@@ -28,6 +28,13 @@ stackoverflow:
apiAccessToken: $STACK_OVERFLOW_API_ACCESS_TOKEN
```
```yaml
stackoverflow:
baseUrl: https://api.stackoverflowteams.com/2.3 # alternative: your internal stack overflow instance
teamName: $STACK_OVERFLOW_TEAM_NAME
apiAccessToken: $STACK_OVERFLOW_API_ACCESS_TOKEN
```
## Areas of Responsibility
This stack overflow backend plugin is primarily responsible for the following:
@@ -44,6 +44,7 @@ export type StackOverflowQuestionsCollatorFactoryOptions = {
maxPage?: number;
apiKey?: string;
apiAccessToken?: string;
teamName?: string;
requestParams: StackOverflowQuestionsRequestParams;
logger: Logger;
};
+5
View File
@@ -30,6 +30,11 @@ export interface Config {
*/
apiKey?: string;
/**
* The name of the team for a Stack Overflow for Teams account
*/
teamName?: string;
/**
* The API Access Token to authenticate to Stack Overflow API
* @visibility secret
@@ -139,5 +139,49 @@ describe('StackOverflowQuestionsCollatorFactory', () => {
expect(documents).toHaveLength(mockOverrideQuestion.items.length);
});
it('uses API key when provided', async () => {
worker.use(
rest.get('http://stack.overflow.override/questions', (req, res, ctx) =>
req.url.searchParams.get('key') === 'abcdefg'
? res(ctx.status(200), ctx.json(mockOverrideQuestion))
: res(ctx.status(401), ctx.json({})),
),
);
const factory = StackOverflowQuestionsCollatorFactory.fromConfig(config, {
logger,
baseUrl: 'http://stack.overflow.override',
apiKey: 'abcdefg',
requestParams: defaultOptions.requestParams,
});
const collator = await factory.getCollator();
const pipeline = TestPipeline.fromCollator(collator);
const { documents } = await pipeline.execute();
expect(documents).toHaveLength(mockOverrideQuestion.items.length);
});
it('uses teamName when provided', async () => {
worker.use(
rest.get('http://stack.overflow.override/questions', (req, res, ctx) =>
req.url.searchParams.get('team') === 'abcdefg'
? res(ctx.status(200), ctx.json(mockOverrideQuestion))
: res(ctx.status(401), ctx.json({})),
),
);
const factory = StackOverflowQuestionsCollatorFactory.fromConfig(config, {
logger,
baseUrl: 'http://stack.overflow.override',
teamName: 'abcdefg',
requestParams: defaultOptions.requestParams,
});
const collator = await factory.getCollator();
const pipeline = TestPipeline.fromCollator(collator);
const { documents } = await pipeline.execute();
expect(documents).toHaveLength(mockOverrideQuestion.items.length);
});
});
});
@@ -53,6 +53,7 @@ export type StackOverflowQuestionsCollatorFactoryOptions = {
maxPage?: number;
apiKey?: string;
apiAccessToken?: string;
teamName?: string;
requestParams: StackOverflowQuestionsRequestParams;
logger: Logger;
};
@@ -69,6 +70,7 @@ export class StackOverflowQuestionsCollatorFactory
private readonly baseUrl: string | undefined;
private readonly apiKey: string | undefined;
private readonly apiAccessToken: string | undefined;
private readonly teamName: string | undefined;
private readonly maxPage: number | undefined;
private readonly logger: Logger;
public readonly type: string = 'stack-overflow';
@@ -77,6 +79,7 @@ export class StackOverflowQuestionsCollatorFactory
this.baseUrl = options.baseUrl;
this.apiKey = options.apiKey;
this.apiAccessToken = options.apiAccessToken;
this.teamName = options.teamName;
this.maxPage = options.maxPage;
this.requestParams = options.requestParams;
this.logger = options.logger.child({ documentType: this.type });
@@ -90,15 +93,17 @@ export class StackOverflowQuestionsCollatorFactory
const apiAccessToken = config.getOptionalString(
'stackoverflow.apiAccessToken',
);
const teamName = config.getOptionalString('stackoverflow.teamName');
const baseUrl =
config.getOptionalString('stackoverflow.baseUrl') ||
'https://api.stackexchange.com/2.2';
'https://api.stackexchange.com/2.3';
const maxPage = options.maxPage || 100;
return new StackOverflowQuestionsCollatorFactory({
baseUrl,
maxPage,
apiKey,
apiAccessToken,
teamName,
...options,
});
}
@@ -114,10 +119,16 @@ export class StackOverflowQuestionsCollatorFactory
);
}
if (this.apiKey && this.teamName) {
this.logger.debug(
'Both stackoverflow.apiKey and stackoverflow.teamName configured in your app-config.yaml, apiKey must be removed before teamName will be used',
);
}
try {
if (Object.keys(this.requestParams).indexOf('key') >= 0) {
this.logger.warn(
'The API Key should be passed as a seperate param to bypass encoding',
'The API Key should be passed as a separate param to bypass encoding',
);
delete this.requestParams.key;
}
@@ -134,6 +145,15 @@ export class StackOverflowQuestionsCollatorFactory
? `${params ? '&' : '?'}key=${this.apiKey}`
: '';
const teamParam = this.teamName
? `${params ? '&' : '?'}team=${this.teamName}`
: '';
// PAT change requires team name as a parameter
const requestUrl = this.apiKey
? `${this.baseUrl}/questions${params}${apiKeyParam}`
: `${this.baseUrl}/questions${params}${teamParam}`;
let hasMorePages = true;
let page = 1;
while (hasMorePages) {
@@ -144,7 +164,7 @@ export class StackOverflowQuestionsCollatorFactory
break;
}
const res = await fetch(
`${this.baseUrl}/questions${params}${apiKeyParam}&page=${page}`,
`${requestUrl}&page=${page}`,
this.apiAccessToken
? {
headers: {