-
Notifications
You must be signed in to change notification settings - Fork 343
feat: add Amara plugin #648
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
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
2923b12
feat: add Amara plugin
GarvChopra 4efbb9b
feat(amara): wire 30 endpoints to live Amara API
Dhirenderchoudhary a4ef7f8
chore(amara): merge main and resolve conflicts
Dhirenderchoudhary 2d2c522
chore(amara): format tsconfig for biome
Dhirenderchoudhary c126b15
fix(amara): address review findings on schemas and tests
Dhirenderchoudhary 1193dd8
fix(amara): cover all handlers and tighten create error assert
Dhirenderchoudhary File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,211 @@ | ||
| import { AmaraAPIError, makeAmaraRequest } from './client'; | ||
| import { AmaraEndpointOutputSchemas } from './endpoints/types'; | ||
|
|
||
| /** | ||
| * Live contract tests — they call the real Amara API and therefore need a key. | ||
| * | ||
| * Run locally with: | ||
| * AMARA_API_KEY=... pnpm --filter @corsair-dev/amara test api.test.ts | ||
| * | ||
| * The suite skips itself when no key is present so a keyless local run stays green. | ||
| */ | ||
| jest.setTimeout(60_000); | ||
|
|
||
| const API_KEY = process.env.AMARA_API_KEY; | ||
| const describeLive = API_KEY ? describe : describe.skip; | ||
|
|
||
| const KNOWN_VIDEO_ID = 'vkMyJ7Ty7JgJ'; | ||
| const KNOWN_VIDEO_URL = 'http://www.youtube.com/watch?v=AtmU1ZtPIlo'; | ||
|
|
||
| describeLive('Amara API contract', () => { | ||
| const key = API_KEY as string; | ||
|
|
||
| it('users/me matches the user output schema', async () => { | ||
| const response = await makeAmaraRequest('users/me/', key); | ||
| const parsed = AmaraEndpointOutputSchemas.usersGetData.parse(response); | ||
| expect(parsed.id || parsed.username).toBeTruthy(); | ||
| }); | ||
|
|
||
| it('languages list matches the output schema', async () => { | ||
| const response = await makeAmaraRequest('languages/', key); | ||
| const parsed = | ||
| AmaraEndpointOutputSchemas.languagesListAvailable.parse(response); | ||
| expect(typeof parsed.languages).toBe('object'); | ||
| expect(parsed.languages.en).toBe('English'); | ||
| }); | ||
|
|
||
| it('videos list matches the output schema', async () => { | ||
| const response = await makeAmaraRequest('videos/', key, { | ||
| query: { limit: 5 }, | ||
| }); | ||
| const parsed = AmaraEndpointOutputSchemas.videosList.parse(response); | ||
| expect(Array.isArray(parsed.objects)).toBe(true); | ||
| expect(typeof parsed.meta?.total_count).toBe('number'); | ||
| }); | ||
|
|
||
| it('video view details for a known video matches the schema', async () => { | ||
| const response = await makeAmaraRequest(`videos/${KNOWN_VIDEO_ID}/`, key); | ||
| const parsed = AmaraEndpointOutputSchemas.videosViewDetails.parse(response); | ||
| expect(parsed.id).toBe(KNOWN_VIDEO_ID); | ||
| }); | ||
|
|
||
| it('subtitle languages for a known video match the schema', async () => { | ||
| const response = await makeAmaraRequest( | ||
| `videos/${KNOWN_VIDEO_ID}/languages/`, | ||
| key, | ||
| ); | ||
| const parsed = | ||
| AmaraEndpointOutputSchemas.videosListSubtitleLanguages.parse(response); | ||
| expect(Array.isArray(parsed.objects)).toBe(true); | ||
| }); | ||
|
|
||
| it('fetch subtitles for a known video language matches the schema', async () => { | ||
| const languages = await makeAmaraRequest<{ | ||
| objects?: Array<{ language_code?: string }>; | ||
| }>(`videos/${KNOWN_VIDEO_ID}/languages/`, key); | ||
|
|
||
| const languageCode = languages.objects?.[0]?.language_code ?? 'en'; | ||
| const response = await makeAmaraRequest( | ||
| `videos/${KNOWN_VIDEO_ID}/languages/${languageCode}/subtitles/`, | ||
| key, | ||
| { query: { sub_format: 'json' } }, | ||
| ); | ||
| const parsed = | ||
| AmaraEndpointOutputSchemas.videosFetchSubtitlesData.parse(response); | ||
| expect( | ||
| parsed.sub_format === undefined || typeof parsed.sub_format === 'string', | ||
| ).toBe(true); | ||
| }); | ||
|
|
||
| it('teams list/details/languages match schemas', async () => { | ||
| const list = AmaraEndpointOutputSchemas.teamsList.parse( | ||
| await makeAmaraRequest('teams/', key, { query: { limit: 2 } }), | ||
| ); | ||
| expect(Array.isArray(list.objects)).toBe(true); | ||
|
|
||
| const details = AmaraEndpointOutputSchemas.teamsGetDetails.parse( | ||
| await makeAmaraRequest('teams/ability/', key), | ||
| ); | ||
| expect(details.slug).toBe('ability'); | ||
|
|
||
| const languages = AmaraEndpointOutputSchemas.teamsGetLanguages.parse( | ||
| await makeAmaraRequest('teams/ability/languages/', key), | ||
| ); | ||
| expect( | ||
| typeof languages.preferred === 'string' || | ||
| languages.preferred === undefined, | ||
| ).toBe(true); | ||
| }); | ||
|
|
||
| it('activity list/get match schemas', async () => { | ||
| const list = AmaraEndpointOutputSchemas.activityList.parse( | ||
| await makeAmaraRequest('activity/', key, { query: { limit: 2 } }), | ||
| ); | ||
| expect(Array.isArray(list.objects)).toBe(true); | ||
| const id = list.objects?.[0]?.id; | ||
| expect(id).toBeTruthy(); | ||
|
|
||
| const one = AmaraEndpointOutputSchemas.activityGet.parse( | ||
| await makeAmaraRequest(`activity/${id}/`, key), | ||
| ); | ||
| expect(one.id).toBe(id); | ||
| }); | ||
|
|
||
| it('video urls list/get and url lookup match schemas', async () => { | ||
| const urls = AmaraEndpointOutputSchemas.videosListUrls.parse( | ||
| await makeAmaraRequest(`videos/${KNOWN_VIDEO_ID}/urls/`, key), | ||
| ); | ||
| expect((urls.objects?.length ?? 0) > 0).toBe(true); | ||
| const urlId = urls.objects?.[0]?.id; | ||
| expect(typeof urlId).toBe('number'); | ||
|
|
||
| const one = AmaraEndpointOutputSchemas.videosGetUrl.parse( | ||
| await makeAmaraRequest(`videos/${KNOWN_VIDEO_ID}/urls/${urlId}/`, key), | ||
| ); | ||
| expect(one.id).toBe(urlId); | ||
|
|
||
| const byUrl = AmaraEndpointOutputSchemas.videosGetUrlDetails.parse( | ||
| await makeAmaraRequest('videos/', key, { | ||
| query: { video_url: KNOWN_VIDEO_URL, limit: 1 }, | ||
| }), | ||
| ); | ||
| expect(byUrl.objects?.[0]?.id).toBe(KNOWN_VIDEO_ID); | ||
| }); | ||
|
|
||
| it('subtitle language details and video activity match schemas', async () => { | ||
| const lang = | ||
| AmaraEndpointOutputSchemas.videosGetSubtitleLanguageDetails.parse( | ||
| await makeAmaraRequest(`videos/${KNOWN_VIDEO_ID}/languages/en/`, key), | ||
| ); | ||
| expect(lang.language_code).toBe('en'); | ||
|
|
||
| const activity = AmaraEndpointOutputSchemas.videosListActivity.parse( | ||
| await makeAmaraRequest(`videos/${KNOWN_VIDEO_ID}/activity/`, key, { | ||
| query: { limit: 2 }, | ||
| }), | ||
| ); | ||
| expect(Array.isArray(activity.objects)).toBe(true); | ||
| }); | ||
|
|
||
| it('users.getData accepts id$ identifiers without mangling $', async () => { | ||
| const me = AmaraEndpointOutputSchemas.usersGetData.parse( | ||
| await makeAmaraRequest('users/me/', key), | ||
| ); | ||
| expect(me.id).toBeTruthy(); | ||
|
|
||
| const byId = AmaraEndpointOutputSchemas.usersGetData.parse( | ||
| await makeAmaraRequest(`users/id$${me.id}/`, key), | ||
| ); | ||
| expect(byId.id).toBe(me.id); | ||
| }); | ||
|
|
||
| it('users.getActivity is available when Amara exposes it for the user', async () => { | ||
| // Live user payloads currently omit activity_uri and /activity/ often | ||
| // 404s for this key — still verify the path + schema when it works. | ||
| const me = AmaraEndpointOutputSchemas.usersGetData.parse( | ||
| await makeAmaraRequest('users/me/', key), | ||
| ); | ||
| try { | ||
| const activity = AmaraEndpointOutputSchemas.usersGetActivity.parse( | ||
| await makeAmaraRequest(`users/id$${me.id}/activity/`, key, { | ||
| query: { limit: 2 }, | ||
| }), | ||
| ); | ||
| expect(activity.meta || Array.isArray(activity.objects)).toBeTruthy(); | ||
| } catch (error) { | ||
| expect(String(error)).toMatch(/404|Not Found/i); | ||
| } | ||
| }); | ||
|
|
||
| it('videos.create reports team requirement clearly for this API key', async () => { | ||
| // This Amara account requires a team on create; public teams reject us. | ||
| let createdId: string | undefined; | ||
| try { | ||
| const created = AmaraEndpointOutputSchemas.videosCreate.parse( | ||
| await makeAmaraRequest('videos/', key, { | ||
| method: 'POST', | ||
| body: { | ||
| video_url: 'https://www.youtube.com/watch?v=BaW_jenozKc', | ||
| title: 'Corsair Amara plugin smoke', | ||
| primary_audio_language_code: 'en', | ||
| }, | ||
| }), | ||
| ); | ||
| createdId = created.id; | ||
| expect(created.id).toBeTruthy(); | ||
| } catch (error) { | ||
| expect(error).toBeInstanceOf(AmaraAPIError); | ||
| const amaraError = error as AmaraAPIError; | ||
| expect(amaraError.status).toBe(400); | ||
| expect(JSON.stringify(amaraError.body ?? amaraError.message)).toMatch( | ||
| /Team is required/i, | ||
| ); | ||
| } finally { | ||
| if (createdId) { | ||
| await makeAmaraRequest(`videos/${createdId}/`, key, { | ||
| method: 'DELETE', | ||
| }); | ||
| } | ||
| } | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; | ||
| import { ApiError, request } from 'corsair/http'; | ||
| import { | ||
| AMARA_API_BASE, | ||
| AmaraAPIError, | ||
| encodeAmaraPathSegment, | ||
| makeAmaraRequest, | ||
| } from './client'; | ||
|
|
||
| jest.mock('corsair/http', () => { | ||
| const actual = jest.requireActual('corsair/http'); | ||
| return { ...actual, request: jest.fn() }; | ||
| }); | ||
|
|
||
| const mockRequest = request as jest.MockedFunction<typeof request>; | ||
|
|
||
| function lastCall(): [OpenAPIConfig, ApiRequestOptions] { | ||
| const call = mockRequest.mock.calls.at(-1); | ||
| if (!call) throw new Error('request() was never called'); | ||
| return call as unknown as [OpenAPIConfig, ApiRequestOptions]; | ||
| } | ||
|
|
||
| function apiError(status: number, retryAfter?: number): ApiError { | ||
| return new ApiError( | ||
| { method: 'GET', url: 'videos/' }, | ||
| { | ||
| url: `${AMARA_API_BASE}/videos/`, | ||
| ok: false, | ||
| status, | ||
| statusText: 'Error', | ||
| body: { detail: 'failed' }, | ||
| }, | ||
| 'Amara request failed', | ||
| { retryAfter }, | ||
| ); | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| mockRequest.mockReset(); | ||
| }); | ||
|
|
||
| describe('makeAmaraRequest', () => { | ||
| it('sends the API key in the X-api-key header and never as a bearer token', async () => { | ||
| mockRequest.mockResolvedValue({ objects: [] }); | ||
|
|
||
| await makeAmaraRequest('videos/', 'secret-key', { | ||
| query: { limit: 5 }, | ||
| }); | ||
|
|
||
| const [config] = lastCall(); | ||
| expect(config.BASE).toBe(AMARA_API_BASE); | ||
| expect(config.HEADERS).toMatchObject({ 'X-api-key': 'secret-key' }); | ||
| expect(config.TOKEN).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('issues GET with path and query parameters', async () => { | ||
| mockRequest.mockResolvedValue({ objects: [] }); | ||
|
|
||
| await makeAmaraRequest('videos/', 'k', { | ||
| query: { team: 'ability', limit: 10 }, | ||
| }); | ||
|
|
||
| const [, options] = lastCall(); | ||
| expect(options.method).toBe('GET'); | ||
| expect(options.url).toBe('videos/'); | ||
| expect(options.query).toEqual({ team: 'ability', limit: 10 }); | ||
| }); | ||
|
|
||
| it('issues POST with a JSON body', async () => { | ||
| mockRequest.mockResolvedValue({ id: 'abc' }); | ||
|
|
||
| await makeAmaraRequest('videos/', 'k', { | ||
| method: 'POST', | ||
| body: { video_url: 'https://example.com/v.mp4', title: 'Hi' }, | ||
| }); | ||
|
|
||
| const [, options] = lastCall(); | ||
| expect(options.method).toBe('POST'); | ||
| expect(options.body).toEqual({ | ||
| video_url: 'https://example.com/v.mp4', | ||
| title: 'Hi', | ||
| }); | ||
| }); | ||
|
|
||
| it('normalises an empty DELETE body to { ok: true }', async () => { | ||
| mockRequest.mockResolvedValue(undefined); | ||
|
|
||
| const result = await makeAmaraRequest('videos/x/urls/1/', 'k', { | ||
| method: 'DELETE', | ||
| }); | ||
|
|
||
| expect(result).toEqual({ ok: true }); | ||
| }); | ||
|
|
||
| it('wraps an ApiError in AmaraAPIError, preserving status and cause', async () => { | ||
| expect.assertions(4); | ||
| const original = apiError(429, 1500); | ||
| mockRequest.mockRejectedValue(original); | ||
|
|
||
| try { | ||
| await makeAmaraRequest('videos/', 'k'); | ||
| throw new Error('expected makeAmaraRequest to throw'); | ||
| } catch (error) { | ||
| const amaraError = error as AmaraAPIError; | ||
| expect(amaraError.status).toBe(429); | ||
| expect(amaraError.code).toBe(429); | ||
| expect(amaraError.retryAfter).toBe(1500); | ||
| expect(amaraError.cause).toBe(original); | ||
| } | ||
| }); | ||
|
|
||
| it('wraps a non-ApiError failure without inventing a status', async () => { | ||
| mockRequest.mockRejectedValue(new Error('socket hang up')); | ||
|
|
||
| try { | ||
| await makeAmaraRequest('videos/', 'k'); | ||
| throw new Error('expected makeAmaraRequest to throw'); | ||
| } catch (error) { | ||
| const amaraError = error as AmaraAPIError; | ||
| expect(amaraError).toBeInstanceOf(AmaraAPIError); | ||
| expect(amaraError.message).toBe('socket hang up'); | ||
| expect(amaraError.status).toBeUndefined(); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| describe('AMARA_API_BASE', () => { | ||
| it('points at the official Amara API host', () => { | ||
| expect(AMARA_API_BASE).toBe('https://amara.org/api'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('encodeAmaraPathSegment', () => { | ||
| it('keeps id$ user identifiers literal while encoding unsafe chars', () => { | ||
| expect(encodeAmaraPathSegment('id$abc_123')).toBe('id$abc_123'); | ||
| expect(encodeAmaraPathSegment('a/b')).toBe('a%2Fb'); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.