Skip to content

Commit 34b87d0

Browse files
authored
feat(GlobalRecs): Add /v3/firefox/global-recs API (#53)
* feat(GlobalRecs): Add /v3/firefox/global-recs API * chore(GlobalRecs): Add test coverage for middleware * chore(Tests): Remove double negation from expectation * chore(GlobalRecs): Add comment to static version
1 parent b38a860 commit 34b87d0

File tree

11 files changed

+660
-18
lines changed

11 files changed

+660
-18
lines changed

openapi.yml

Lines changed: 153 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,62 @@ components:
153153
type: string
154154
description: The primary image for a Recommendation.
155155

156+
LegacyFeedItem:
157+
type: object
158+
required:
159+
- id
160+
- title
161+
- url
162+
- excerpt
163+
- domain
164+
- image_src
165+
- raw_image_src
166+
properties:
167+
id:
168+
type: integer
169+
title:
170+
type: string
171+
url:
172+
type: string
173+
excerpt:
174+
type: string
175+
domain:
176+
type: string
177+
image_src:
178+
type: string
179+
raw_image_src:
180+
type: string
181+
182+
LegacySettings:
183+
type: object
184+
properties:
185+
spocsPerNewTabs:
186+
type: number
187+
domainAffinityParameterSets:
188+
type: object
189+
timeSegments:
190+
type: array
191+
items:
192+
type: object
193+
required:
194+
- id
195+
- startTime
196+
- endTime
197+
- weightPosition
198+
properties:
199+
id:
200+
type: string
201+
startTime:
202+
type: integer
203+
endTime:
204+
type: integer
205+
weightPosition:
206+
type: number
207+
recsExpireTime:
208+
type: integer
209+
version:
210+
type: string
211+
156212
# securitySchemes roughly map to authentication middleware
157213
securitySchemes:
158214
# The following schemes prefixed with "WS" all constitute a `WebSession` auth.
@@ -199,8 +255,8 @@ paths:
199255
operationId: getRecommendations
200256
# Intentionally blank security. No auth required.
201257
security:
202-
- WSConsumerKeyAuth: []
203-
- WSConsumerKeyAuthAlias: []
258+
- WSConsumerKeyAuth: [ ]
259+
- WSConsumerKeyAuthAlias: [ ]
204260
parameters:
205261
- name: count
206262
in: query
@@ -274,21 +330,108 @@ paths:
274330
application/json:
275331
schema:
276332
$ref: "#/components/schemas/ErrorResponse"
333+
/v3/firefox/global-recs:
334+
get:
335+
deprecated: true
336+
summary: Used by older versions of Firefox to get a list of Recommendations for a Locale and Region. This operation is performed anonymously and requires no auth.
337+
description: Supports Fx desktop version 115 and below.
338+
operationId: getGlobalRecs
339+
# Intentionally blank security. No auth required.
340+
security:
341+
- WSConsumerKeyAuth: [ ]
342+
- WSConsumerKeyAuthAlias: [ ]
343+
parameters:
344+
- in: query
345+
name: version
346+
description: API version
347+
required: true
348+
schema:
349+
type: integer
350+
minimum: 3
351+
maximum: 3
352+
default: 3
353+
- in: query
354+
name: locale_lang
355+
description: Firefox locale
356+
required: true
357+
schema:
358+
type: string
359+
default: en-US
360+
- in: query
361+
name: region
362+
description: Firefox region
363+
required: false
364+
schema:
365+
type: string
366+
- in: query
367+
name: count
368+
description: Maximum number of items to return
369+
required: false
370+
schema:
371+
type: integer
372+
minimum: 1
373+
maximum: 50
374+
default: 20
375+
responses:
376+
'200':
377+
description: OK
378+
content:
379+
application/json:
380+
schema:
381+
type: object
382+
required:
383+
- status
384+
- spocs
385+
- settings
386+
- recommendations
387+
properties:
388+
status:
389+
type: integer
390+
enum:
391+
- 1
392+
spocs:
393+
type: array
394+
settings:
395+
$ref: "#/components/schemas/LegacySettings"
396+
recommendations:
397+
type: array
398+
items:
399+
$ref: "#/components/schemas/LegacyFeedItem"
400+
'400':
401+
description: Invalid request parameters
402+
content:
403+
application/json:
404+
schema:
405+
$ref: "#/components/schemas/ErrorResponse"
406+
'500':
407+
description: This proxy service encountered an unexpected error.
408+
'502':
409+
description: Services downstream from this proxy encountered an unexpected error.
410+
content:
411+
application/json:
412+
schema:
413+
$ref: "#/components/schemas/ErrorResponse"
414+
'504':
415+
description: Requests to downstream services timed out.
416+
content:
417+
application/json:
418+
schema:
419+
$ref: "#/components/schemas/ErrorResponse"
277420
/desktop/v1/recent-saves:
278421
get:
279422
summary: Gets a list of the most recent saves for a specific user.
280423
description: Supports Fx desktop version 113 and up.
281424
operationId: getRecentSaves
282425
security:
283426
# require all WS (WebSession) security schemes together
284-
- WSUserAuth: []
285-
WSSessionAuth: []
286-
WSLookupAuth: []
287-
WSConsumerKeyAuth: []
288-
- WSUserAuth: []
289-
WSSessionAuth: []
290-
WSLookupAuth: []
291-
WSConsumerKeyAuthAlias: []
427+
- WSUserAuth: [ ]
428+
WSSessionAuth: [ ]
429+
WSLookupAuth: [ ]
430+
WSConsumerKeyAuth: [ ]
431+
- WSUserAuth: [ ]
432+
WSSessionAuth: [ ]
433+
WSLookupAuth: [ ]
434+
WSConsumerKeyAuthAlias: [ ]
292435
parameters:
293436
- name: count
294437
in: query

src/api/desktop/recent-saves/response.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ describe('response', () => {
2424
// quick test to show ajv behavior, may not be familiar
2525
it('ajv returns errors when bad objects', () => {
2626
const valid = validate({});
27-
expect(!valid).toBeTruthy();
27+
expect(valid).toBeFalsy();
2828
// uncomment if you want info about how errors look
2929
// throw validate.errors
3030
expect((validate.errors as DefinedError[]).length).toBeGreaterThan(0);

src/api/desktop/recommendations/response.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const validate = ajv.compile(schema);
1818
describe('response', () => {
1919
it('ajv returns errors when bad objects', () => {
2020
const valid = validate({});
21-
expect(!valid).toBeTruthy();
21+
expect(valid).toBeFalsy();
2222
// uncomment if you want info about how errors look
2323
// throw validate.errors
2424
expect((validate.errors as DefinedError[]).length).toBeGreaterThan(0);

src/api/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const router = express.Router();
33

44
import config from '../config';
55
import Desktop from './desktop';
6+
import V3 from './v3';
67
import CacheControlHandler from './lib/cacheControlHandler';
78
import WebSessionAuthHandler from '../auth/web-session/webSessionAuthHandler';
89

@@ -18,4 +19,16 @@ router.use(
1819
Desktop
1920
);
2021

22+
// register all /desktop routes
23+
router.use(
24+
'/v3',
25+
// include auth if available
26+
WebSessionAuthHandler,
27+
// set Cache-control headers on all routes
28+
// this can be overwritten on downstream routes with another handler
29+
CacheControlHandler('private, max-age=1800', config),
30+
// register legacy v3 sub-router
31+
V3
32+
);
33+
2134
export default router;

src/api/v3/index.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import express, { NextFunction, Request, Response } from 'express';
2+
import config from '../../config';
3+
import CacheControlHandler from '../lib/cacheControlHandler';
4+
import ConsumerKeyHandler from '../../auth/consumerKeyHandler';
5+
import { GraphQLErrorHandler } from '../error/graphQLErrorHandler';
6+
import { handleQueryParameters } from './inputs';
7+
import { BFFFxError } from '../../bfffxError';
8+
import Recommendations from '../../graphql-proxy/recommendations/recommendations';
9+
import { forwardHeadersMiddleware } from '../../graphql-proxy/lib/client';
10+
import { RecommendationsQueryVariables } from '../../generated/graphql/types';
11+
import { GlobalRecsResponse, responseTransformer } from './response';
12+
13+
const router = express.Router();
14+
15+
router.get(
16+
'/firefox/global-recs',
17+
// request must include a consumer
18+
ConsumerKeyHandler,
19+
CacheControlHandler('public, max-age=1800', config),
20+
async (req: Request, res: Response, next: NextFunction) => {
21+
try {
22+
const variables = handleQueryParameters(req.query);
23+
24+
if (variables instanceof BFFFxError) {
25+
return next(variables);
26+
}
27+
28+
const graphRes = await Recommendations({
29+
auth: req.auth,
30+
consumer_key: req.consumer_key,
31+
forwardHeadersMiddleware: forwardHeadersMiddleware(res),
32+
variables: variables as RecommendationsQueryVariables,
33+
});
34+
35+
res.json(responseTransformer(graphRes) as GlobalRecsResponse);
36+
} catch (error) {
37+
const responseError = GraphQLErrorHandler(error);
38+
return next(responseError);
39+
}
40+
}
41+
);
42+
43+
export default router;

src/api/v3/inputs.spec.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { faker } from '@faker-js/faker';
2+
import assert from 'assert';
3+
4+
import { RecommendationsQueryVariables } from '../../generated/graphql/types';
5+
import {
6+
GlobalRecsQueryParameterStrings,
7+
handleQueryParameters,
8+
setDefaultsAndCoerceTypes,
9+
} from './inputs';
10+
11+
import { APIError, APIErrorResponse, BFFFxError } from '../../bfffxError';
12+
13+
describe('input.ts recommendations query parameters', () => {
14+
describe('setDefaultsAndCoerceTypes', () => {
15+
it('converts count to an integer and passes through others', () => {
16+
const res = setDefaultsAndCoerceTypes({
17+
count: '3',
18+
locale_lang: 'preValidatedLocale',
19+
region: 'preValidatedRegion',
20+
});
21+
expect(res).toMatchObject({
22+
count: 3,
23+
locale: 'preValidatedLocale',
24+
region: 'preValidatedRegion',
25+
});
26+
});
27+
28+
it('sets count to 20 if no default is provided, values without defaults are undefined', () => {
29+
const res = setDefaultsAndCoerceTypes({});
30+
// validation should return an error in this case, validating defaults though
31+
expect(res).toMatchObject({
32+
count: 20,
33+
});
34+
});
35+
});
36+
37+
describe('handleQueryParameters', () => {
38+
it('returns errors if invalid query parameters', () => {
39+
const params: GlobalRecsQueryParameterStrings = {
40+
count: '-1',
41+
};
42+
43+
const error = handleQueryParameters(params);
44+
assert(error instanceof BFFFxError);
45+
const errors = JSON.parse(error.stringResponse);
46+
expect(errors).toEqual(
47+
expect.objectContaining<APIErrorResponse>({
48+
errors: expect.arrayContaining<Array<APIError>>([
49+
expect.objectContaining<APIError>({
50+
status: '400',
51+
title: 'Bad Request',
52+
}),
53+
]),
54+
})
55+
);
56+
});
57+
58+
it('returns GraphQL query variables on success', () => {
59+
const params: GlobalRecsQueryParameterStrings = {
60+
count: faker.datatype.number({ min: 1, max: 30 }).toString(),
61+
locale_lang: 'fr',
62+
region: 'FR',
63+
};
64+
65+
const variables = handleQueryParameters(params);
66+
expect(variables).toStrictEqual(
67+
expect.objectContaining<RecommendationsQueryVariables>({
68+
count: parseInt(params.count, 10),
69+
locale: params.locale_lang,
70+
region: params.region,
71+
})
72+
);
73+
});
74+
});
75+
});

0 commit comments

Comments
 (0)