diff --git a/.changeset/tricky-wombats-explode.md b/.changeset/tricky-wombats-explode.md new file mode 100644 index 0000000000..aeb5b7849f --- /dev/null +++ b/.changeset/tricky-wombats-explode.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-stack-overflow-backend': minor +--- + +Adding support for v2.3 API and PAT authentication diff --git a/plugins/stack-overflow-backend/README.md b/plugins/stack-overflow-backend/README.md index 4bcc755404..4575ab58c0 100644 --- a/plugins/stack-overflow-backend/README.md +++ b/plugins/stack-overflow-backend/README.md @@ -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: diff --git a/plugins/stack-overflow-backend/api-report.md b/plugins/stack-overflow-backend/api-report.md index f6c7530de9..8c601399c1 100644 --- a/plugins/stack-overflow-backend/api-report.md +++ b/plugins/stack-overflow-backend/api-report.md @@ -44,6 +44,7 @@ export type StackOverflowQuestionsCollatorFactoryOptions = { maxPage?: number; apiKey?: string; apiAccessToken?: string; + teamName?: string; requestParams: StackOverflowQuestionsRequestParams; logger: Logger; }; diff --git a/plugins/stack-overflow-backend/config.d.ts b/plugins/stack-overflow-backend/config.d.ts index 54e517c5d8..b97f237c19 100644 --- a/plugins/stack-overflow-backend/config.d.ts +++ b/plugins/stack-overflow-backend/config.d.ts @@ -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 diff --git a/plugins/stack-overflow-backend/src/search/StackOverflowQuestionsCollatorFactory.test.ts b/plugins/stack-overflow-backend/src/search/StackOverflowQuestionsCollatorFactory.test.ts index bdc5be125c..5d2f65923c 100644 --- a/plugins/stack-overflow-backend/src/search/StackOverflowQuestionsCollatorFactory.test.ts +++ b/plugins/stack-overflow-backend/src/search/StackOverflowQuestionsCollatorFactory.test.ts @@ -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); + }); }); }); diff --git a/plugins/stack-overflow-backend/src/search/StackOverflowQuestionsCollatorFactory.ts b/plugins/stack-overflow-backend/src/search/StackOverflowQuestionsCollatorFactory.ts index 807d6637c1..c5f8a8c16b 100644 --- a/plugins/stack-overflow-backend/src/search/StackOverflowQuestionsCollatorFactory.ts +++ b/plugins/stack-overflow-backend/src/search/StackOverflowQuestionsCollatorFactory.ts @@ -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: {