diff --git a/packages/apininjas/api.test.ts b/packages/apininjas/api.test.ts new file mode 100644 index 000000000..bbe799f02 --- /dev/null +++ b/packages/apininjas/api.test.ts @@ -0,0 +1,126 @@ +/** + * Live checks against api.api-ninjas.com. + * + * Skipped unless `APININJAS_API_KEY` is set, and excluded from CI by the test + * command's `--testPathIgnorePatterns`. What it verifies is the thing mocks + * cannot: that the routes, versions and parameter names in this package still + * match the API, and that the output schemas still accept what it sends. + * + * Deliberately small. The free tier allows 3,000 calls a month, so this spends + * about a dozen and covers one endpoint per version, both HTTP methods, a + * premium rejection and an unknown route. + */ +import type { z } from 'zod'; +import { makeApiNinjasRequest } from './client'; +import { ApiNinjasEndpointOutputSchemas } from './endpoints/types'; + +/** + * Parses a live response and fails with the validation issues when it does not + * match. Asserting only on `success` reports "expected true, received false", + * which says nothing about which field the provider changed. + */ +function expectParses(schema: z.ZodType, value: unknown, operation: string) { + const result = schema.safeParse(value); + if (!result.success) { + throw new Error( + `${operation} no longer matches its schema: ${JSON.stringify( + result.error.issues.slice(0, 5), + null, + 2, + )}`, + ); + } + expect(result.success).toBe(true); +} + +const API_KEY = process.env.APININJAS_API_KEY; +const describeLive = API_KEY ? describe : describe.skip; + +describeLive('live API', () => { + jest.setTimeout(30_000); + const key = API_KEY as string; + + it('answers a v1 GET and returns the declared shape', async () => { + const result = await makeApiNinjasRequest('sentiment', key, { + query: { text: 'this integration works' }, + }); + + expectParses( + ApiNinjasEndpointOutputSchemas.textSentiment, + result, + 'textSentiment', + ); + }); + + it('answers a v2 GET on a route that does not exist under v1', async () => { + // `quoteoftheday` 404s on v1 - the version prefix is load-bearing. + const result = await makeApiNinjasRequest('quoteoftheday', key, { + version: 'v2', + }); + + expectParses( + ApiNinjasEndpointOutputSchemas.entertainmentQuoteOfTheDay, + result, + 'entertainmentQuoteOfTheDay', + ); + }); + + it('answers a v3 GET', async () => { + const result = await makeApiNinjasRequest('recipe', key, { + version: 'v3', + query: { title: 'pasta' }, + }); + + expectParses( + ApiNinjasEndpointOutputSchemas.healthRecipes, + result, + 'healthRecipes', + ); + }); + + it('answers a POST with a JSON body', async () => { + const result = await makeApiNinjasRequest('textsimilarity', key, { + method: 'POST', + body: { text_1: 'hello there', text_2: 'hi there' }, + }); + + expectParses( + ApiNinjasEndpointOutputSchemas.textSimilarity, + result, + 'textSimilarity', + ); + }); + + it('still masks premium fields the way the schemas expect', async () => { + // If the provider ever stops masking, the schemas keep working; if it + // starts masking a new field, this is where it shows up. + const result = await makeApiNinjasRequest('stockprice', key, { + query: { ticker: 'AAPL' }, + }); + + expectParses( + ApiNinjasEndpointOutputSchemas.marketsStockPrice, + result, + 'marketsStockPrice', + ); + }); + + it('reports a premium-gated endpoint as a rejection, not as data', async () => { + await expect( + makeApiNinjasRequest('inflation', key, { + version: 'v2', + query: { country: 'united states' }, + }), + ).rejects.toThrow(); + }); + + it('reports an unknown route rather than answering it', async () => { + await expect(makeApiNinjasRequest('nosuchendpoint', key)).rejects.toThrow(); + }); + + it('rejects a request with no credential', async () => { + // The provider answers 400 "Missing API Key." rather than 401, which is why + // the error handlers read the body. + await expect(makeApiNinjasRequest('bitcoin', '')).rejects.toThrow(); + }); +}); diff --git a/packages/apininjas/behaviour.test.ts b/packages/apininjas/behaviour.test.ts new file mode 100644 index 000000000..1d0bd24fc --- /dev/null +++ b/packages/apininjas/behaviour.test.ts @@ -0,0 +1,525 @@ +/** + * Exercises what the endpoints do with a response once they have it: which rows + * they mirror, which values they refuse to mirror, what reaches the audit log, + * and how the image operations wrap a non-JSON payload. + */ +import { + Economics, + Internet, + Location, + Markets, + Reference, + Text, + Transport, + Utility, + Validation, +} from './endpoints'; +import { auditPayload, withCount } from './endpoints/logging'; +import { CAPTURED_RESPONSES } from './fixtures'; + +const TEST_KEY = 'test-api-key-not-a-real-credential'; + +type Store = { + upsertByEntityId: jest.Mock; + deleteByEntityId: jest.Mock; +}; + +function makeStore(): Store { + return { + upsertByEntityId: jest.fn(async () => undefined), + deleteByEntityId: jest.fn(async () => true), + }; +} + +type Ctx = Parameters[0]; + +function makeCtx() { + const db = { + airports: makeStore(), + airlines: makeStore(), + aircraft: makeStore(), + vehicles: makeStore(), + countries: makeStore(), + cities: makeStore(), + universities: makeStore(), + stockExchanges: makeStore(), + sp500: makeStore(), + emoji: makeStore(), + animals: makeStore(), + planets: makeStore(), + stars: makeStore(), + }; + const ctx = { + key: TEST_KEY, + db, + database: undefined, + $getAccountId: async () => 'test-account', + } as unknown as Ctx; + return { ctx, db }; +} + +function mockResponse(body: unknown, contentType = 'application/json') { + global.fetch = (async (url: string) => ({ + ok: true, + status: 200, + statusText: 'OK', + url, + headers: new Headers({ 'Content-Type': contentType }), + json: async () => body, + text: async () => + contentType.includes('json') ? JSON.stringify(body) : String(body), + })) as unknown as typeof global.fetch; +} + +/** The captured response for an operation, as the provider sent it. */ +function captured(key: keyof typeof CAPTURED_RESPONSES): unknown { + const body = CAPTURED_RESPONSES[key]; + if (body === undefined) throw new Error(`no captured response for ${key}`); + return body; +} + +describe('mirroring reference data', () => { + it('stores an airport under its ICAO identifier', async () => { + const { ctx, db } = makeCtx(); + mockResponse(captured('transportAirports')); + + await Transport.airports(ctx, { iata: 'LHR' }); + + expect(db.airports.upsertByEntityId).toHaveBeenCalledTimes(1); + const [id, row] = db.airports.upsertByEntityId.mock.calls[0] as [ + string, + Record, + ]; + expect(id).toBe('egll'); + expect(row.iata).toBe('LHR'); + expect(row.captured_at).toBeInstanceOf(Date); + }); + + it('stores a country under its ISO code and keeps the official currency object', async () => { + const { ctx, db } = makeCtx(); + mockResponse(captured('locationCountry')); + + await Location.country(ctx, { name: 'Germany' }); + + const [id, row] = db.countries.upsertByEntityId.mock.calls[0] as [ + string, + Record, + ]; + expect(id).toBe('de'); + expect((row.currency as { code: string }).code).toBe('EUR'); + }); + + it('keeps the three vehicle endpoints apart in one store', async () => { + const { ctx, db } = makeCtx(); + + mockResponse(captured('transportCars')); + await Transport.cars(ctx, { model: 'corolla' }); + mockResponse(captured('transportMotorcycles')); + await Transport.motorcycles(ctx, { make: 'Kawasaki' }); + mockResponse(captured('transportElectricVehicles')); + await Transport.electricVehicles(ctx, { make: 'Tesla' }); + + const kinds = db.vehicles.upsertByEntityId.mock.calls.map( + (call) => (call[1] as { kind: string }).kind, + ); + const ids = db.vehicles.upsertByEntityId.mock.calls.map((call) => call[0]); + + expect(kinds).toEqual(['car', 'motorcycle', 'electric']); + // The natural keys collide across the three endpoints without the prefix. + expect( + ids.every((id, index) => id.startsWith(kinds[index] as string)), + ).toBe(true); + }); + + it('does not mirror a premium placeholder as if it were data', async () => { + const { ctx, db } = makeCtx(); + mockResponse([ + { + manufacturer: 'Boeing', + model: '737 Max 7', + engine_type: 'This field is for premium subscribers only.', + }, + ]); + + await Transport.aircraft(ctx, { manufacturer: 'Boeing' }); + + const [, row] = db.aircraft.upsertByEntityId.mock.calls[0] as [ + string, + Record, + ]; + // Storing the sentence would leave a row that outlives the plan that + // produced it, and reads as data to anything downstream. + expect(row.engine_type).toBeUndefined(); + expect(row.manufacturer).toBe('Boeing'); + }); + + it('skips a row with no identifier rather than storing a blank key', async () => { + const { ctx, db } = makeCtx(); + mockResponse([{ name: null, country: null }]); + + await Location.cities(ctx, { name: 'Nowhere' }); + + expect(db.cities.upsertByEntityId).not.toHaveBeenCalled(); + }); + + it('mirrors nothing for a lookup that is not reference data', async () => { + const { ctx, db } = makeCtx(); + mockResponse(captured('marketsStockPrice')); + + await Markets.stockPrice(ctx, { ticker: 'AAPL' }); + + for (const store of Object.values(db)) { + expect(store.upsertByEntityId).not.toHaveBeenCalled(); + } + }); + + it('never evicts on a read', async () => { + const { ctx, db } = makeCtx(); + mockResponse(captured('referenceAnimals')); + + await Reference.animals(ctx, { name: 'cheetah' }); + + for (const store of Object.values(db)) { + expect(store.deleteByEntityId).not.toHaveBeenCalled(); + } + }); + + it('survives a cache store that throws', async () => { + const { ctx, db } = makeCtx(); + db.planets.upsertByEntityId.mockRejectedValueOnce(new Error('disk full')); + mockResponse(captured('referencePlanets')); + + // A lookup must not fail because the local mirror could not be written. + await expect( + Reference.planets(ctx, { name: 'Mars' }), + ).resolves.toBeDefined(); + }); +}); + +describe('audit payloads', () => { + it('records the length of caller text, never the text', () => { + const payload = auditPayload( + { text: 'a sentence a caller wrote' }, + [] as const, + ); + + expect(payload).toEqual({ + supplied_fields: ['text'], + text_length: 25, + }); + expect(JSON.stringify(payload)).not.toContain('sentence'); + }); + + it('drops an email even when it is named as an identifier', () => { + const payload = auditPayload({ email: 'someone@example.com' }, [ + 'email', + ] as const); + + expect(payload.email).toBeUndefined(); + expect(JSON.stringify(payload)).not.toContain('example.com'); + }); + + it('drops a phone number and an IP address', () => { + const phone = auditPayload({ number: '+15550100' }, ['number'] as const); + const ip = auditPayload({ address: '203.0.113.7' }, ['address'] as const); + + expect(JSON.stringify({ phone, ip })).not.toContain('15550100'); + expect(JSON.stringify({ phone, ip })).not.toContain('203.0.113.7'); + }); + + it('keeps impersonal lookup keys, which are the point of the log', () => { + const payload = auditPayload({ ticker: 'AAPL', limit: 5 }, [ + 'ticker', + 'limit', + ] as const); + + expect(payload).toEqual({ + ticker: 'AAPL', + limit: 5, + supplied_fields: ['ticker', 'limit'], + }); + }); + + it('counts rows returned without recording them', () => { + const payload = withCount({ ticker: 'AAPL' }, [{ a: 1 }, { b: 2 }]); + + expect(payload).toEqual({ ticker: 'AAPL', result_count: 2 }); + }); +}); + +describe('operations that take caller data', () => { + /** + * Captures the rows the core would write to `corsair_events`. + * + * `logEventFromContext` resolves an account id and then calls `logEvent`, + * which inserts through `ctx.database`. Watching that insert is the only way + * to see what would actually be stored - an earlier version of this test + * stubbed a `ctx.logEvent` method that the core never calls, so its array + * stayed empty and the assertion passed no matter what leaked. + */ + function makeEventLog() { + const rows: Record[] = []; + const database = { + db: { + insertInto: (table: string) => ({ + values: (row: Record) => ({ + execute: async () => { + rows.push({ table, ...row }); + }, + }), + }), + }, + }; + return { database, rows }; + } + + it.each([ + [ + 'text.sentiment', + async (ctx: Ctx) => Text.sentiment(ctx, { text: 'private words here' }), + 'private words here', + ], + [ + 'validation.email', + async (ctx: Ctx) => + Validation.email(ctx, { email: 'someone@example.com' }), + 'someone@example.com', + ], + [ + 'internet.ipLookup', + async (ctx: Ctx) => Internet.ipLookup(ctx, { address: '203.0.113.7' }), + '203.0.113.7', + ], + [ + 'validation.iban', + async (ctx: Ctx) => + Validation.iban(ctx, { iban: 'DE89370400440532013000' }), + 'DE89370400440532013000', + ], + [ + 'economics.incomeTaxCalculator', + async (ctx: Ctx) => + Economics.incomeTaxCalculator(ctx, { + country: 'us', + region: 'California', + income: 125000, + filing_status: 'single', + }), + '125000', + ], + ])('%s keeps its input out of the event log', async (_name, run, secret) => { + const { ctx } = makeCtx(); + const { database, rows } = makeEventLog(); + const loggingCtx = { + ...(ctx as unknown as Record), + database, + } as unknown as Ctx; + mockResponse({}); + + await run(loggingCtx); + + // Assert the row was written before asserting what is not in it, or the + // check below passes on an empty list. + expect(rows).toHaveLength(1); + expect(rows[0]?.event_type).toBe(`apininjas.${_name}`); + expect(JSON.stringify(rows[0]?.payload)).not.toContain(secret); + }); + + it('still records the operation and its impersonal arguments', async () => { + // Redaction has to leave the log useful: an operator needs to see which + // operation ran and what kind of thing it asked for. + const { ctx } = makeCtx(); + const { database, rows } = makeEventLog(); + const loggingCtx = { + ...(ctx as unknown as Record), + database, + } as unknown as Ctx; + mockResponse([{ ticker: 'AAPL' }]); + + await Markets.secFilings(loggingCtx, { ticker: 'AAPL', filing: '10-K' }); + + expect(rows).toHaveLength(1); + expect(rows[0]?.event_type).toBe('apininjas.markets.secFilings'); + expect(rows[0]?.payload).toMatchObject({ + ticker: 'AAPL', + filing: '10-K', + supplied_fields: ['ticker', 'filing'], + }); + }); +}); + +describe('image operations', () => { + it('defaults the QR code format to the one that survives the transport', async () => { + const { ctx } = makeCtx(); + let requested = ''; + global.fetch = (async (url: string) => { + requested = url; + return { + ok: true, + status: 200, + statusText: 'OK', + url, + headers: new Headers({ 'Content-Type': 'image/svg+xml' }), + json: async () => ({}), + text: async () => '', + }; + }) as unknown as typeof global.fetch; + + const result = await Utility.qrCode(ctx, { data: 'https://example.com' }); + + expect(requested).toContain('format=svg'); + expect(result).toEqual({ + content_type: 'image/svg+xml', + // SVG is text, so what came back is exactly what the provider sent. + encoding: 'text', + data: '', + }); + }); + + it('passes a caller-chosen raster format through and says so', async () => { + const { ctx } = makeCtx(); + global.fetch = (async (url: string) => ({ + ok: true, + status: 200, + statusText: 'OK', + url, + headers: new Headers({ 'Content-Type': 'image/png' }), + json: async () => ({}), + text: async () => 'binary-ish', + })) as unknown as typeof global.fetch; + + const result = await Utility.barcode(ctx, { + text: '012345678905', + format: 'png', + }); + + expect(result.content_type).toBe('image/png'); + // The bytes did not survive the transport's text decode, and the result + // says so rather than presenting them as a usable PNG. + expect(result.encoding).toBe('lossy-text'); + }); + + it('honours an explicit QR format instead of the safe default', async () => { + const { ctx } = makeCtx(); + let requested = ''; + global.fetch = (async (url: string) => { + requested = url; + return { + ok: true, + status: 200, + statusText: 'OK', + url, + headers: new Headers({ 'Content-Type': 'image/png' }), + json: async () => ({}), + text: async () => 'png-bytes-as-text', + }; + }) as unknown as typeof global.fetch; + + const result = await Utility.qrCode(ctx, { + data: 'https://example.com', + format: 'png', + size: 300, + fg_color: '000000', + bg_color: 'ffffff', + }); + + expect(requested).toContain('format=png'); + expect(requested).toContain('size=300'); + expect(result.content_type).toBe('image/png'); + expect(result.encoding).toBe('lossy-text'); + }); + + it('defaults the barcode format the same way as the QR code', async () => { + const { ctx } = makeCtx(); + let requested = ''; + global.fetch = (async (url: string) => { + requested = url; + return { + ok: true, + status: 200, + statusText: 'OK', + url, + headers: new Headers({ 'Content-Type': 'image/svg+xml' }), + json: async () => ({}), + text: async () => 'barcode', + }; + }) as unknown as typeof global.fetch; + + const result = await Utility.barcode(ctx, { + text: 'hello', + type: 'code128', + include_text: true, + }); + + expect(requested).toContain('format=svg'); + expect(requested).toContain('type=code128'); + expect(result.data).toBe('barcode'); + expect(result.encoding).toBe('text'); + }); + + it('labels the random image as JPEG, the only format it returns', async () => { + const { ctx } = makeCtx(); + let requested = ''; + global.fetch = (async (url: string) => { + requested = url; + return { + ok: true, + status: 200, + statusText: 'OK', + url, + headers: new Headers({ 'Content-Type': 'image/jpeg' }), + json: async () => ({}), + text: async () => 'jpeg-bytes-as-text', + }; + }) as unknown as typeof global.fetch; + + const result = await Utility.randomImage(ctx, { + category: 'nature', + width: 640, + height: 480, + }); + + expect(requested).toContain('category=nature'); + expect(result).toEqual({ + content_type: 'image/jpeg', + // JPEG is the only format this endpoint offers, so it is always lossy + // until the core transport can carry binary responses. + encoding: 'lossy-text', + data: 'jpeg-bytes-as-text', + }); + }); + + it.each([ + [ + 'the QR code', + (ctx: Ctx) => Utility.qrCode(ctx, { data: 'x', format: 'svg' }), + ], + [ + 'the barcode', + (ctx: Ctx) => Utility.barcode(ctx, { text: 'x', format: 'svg' }), + ], + ['the random image', (ctx: Ctx) => Utility.randomImage(ctx, {})], + ])( + 'returns an empty payload rather than "undefined" when %s body is missing', + async (_label, run) => { + // The core transport yields `undefined` when a response carries no + // content type at all. Without the fallback that would be stringified + // into the literal text "undefined" and handed back as if it were an + // image. + const { ctx } = makeCtx(); + global.fetch = (async (url: string) => ({ + ok: true, + status: 200, + statusText: 'OK', + url, + headers: new Headers({}), + json: async () => undefined, + text: async () => '', + })) as unknown as typeof global.fetch; + + const result = (await run(ctx)) as { data: string }; + + expect(result.data).toBe(''); + }, + ); +}); diff --git a/packages/apininjas/build.test.ts b/packages/apininjas/build.test.ts new file mode 100644 index 000000000..6d8b346d7 --- /dev/null +++ b/packages/apininjas/build.test.ts @@ -0,0 +1,237 @@ +/** + * Checks the built bundle, not the source. + * + * A package can typecheck and test green and still ship a broken artifact: an + * external that got inlined, an export that got renamed, a registry that got + * tree-shaken because nothing statically referenced it. These 129 endpoints are + * reachable only through an object literal, which is the shape a bundler is + * most likely to prune. + * + * The bundle is ESM and these tests run as CommonJS, so the runtime checks are + * executed in a real Node ESM process and this file asserts on what it reports. + * Everything else is a text check against the bundle itself. + * + * Skipped when `dist/` has not been built, so `jest` on a fresh checkout still + * passes; CI builds before it tests, so it runs there. + */ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const DIST = join(__dirname, 'dist', 'index.js'); +const describeBuild = existsSync(DIST) ? describe : describe.skip; + +/** Runs a module in a real Node process and returns the JSON it printed. */ +function inNodeEsm(body: string): Record { + const source = ` + const { apininjas } = await import(${JSON.stringify(pathToFileURL(DIST).href)}); + const plugin = apininjas(); + ${body} + `; + + let output: string; + try { + output = execFileSync( + process.execPath, + ['--input-type=module', '-e', source], + { + encoding: 'utf8', + // stderr is captured rather than inherited: a subprocess must not be + // able to print into the middle of the jest report. It is re-raised + // below if the process actually failed. + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + } catch (error) { + const failure = error as { stderr?: string; message: string }; + throw new Error( + `the bundle failed to run: ${failure.stderr?.trim() || failure.message}`, + ); + } + + const lastLine = output.trim().split('\n').pop() ?? ''; + try { + return JSON.parse(lastLine); + } catch { + // Anything the bundle printed before its JSON - a warning, a stray log - + // would otherwise surface only as an opaque parse error. + throw new Error( + `the bundle did not print parseable JSON. Full output:\n${output.trim()}`, + ); + } +} + +/** + * The parts of an endpoint context these checks need. + * + * `$getAccountId` matters: the core's event logger calls it on every operation + * and reports its own failure to stderr rather than throwing, so a context + * without it produces a passing test and a page of stack traces. + */ +const CTX = `{ + key: 'bundle-check-key', + database: undefined, + $getAccountId: async () => 'build-check', +}`; + +describeBuild('the built bundle', () => { + const bundle = existsSync(DIST) ? readFileSync(DIST, 'utf8') : ''; + + it('exports a plugin factory that builds a complete registry', () => { + const report = inNodeEsm(` + const groups = Object.keys(plugin.endpoints); + const operations = groups.flatMap((group) => + Object.keys(plugin.endpoints[group]).map((leaf) => group + '.' + leaf), + ); + console.log(JSON.stringify({ + id: plugin.id, + groups: groups.length, + operations: operations.length, + callable: operations.every((path) => { + const [group, leaf] = path.split('.'); + return typeof plugin.endpoints[group][leaf] === 'function'; + }), + schemas: Object.keys(plugin.endpointSchemas).length, + meta: Object.keys(plugin.endpointMeta).length, + entities: Object.keys(plugin.schema.entities).length, + })); + `); + + expect(report).toEqual({ + id: 'apininjas', + groups: 12, + operations: 129, + callable: true, + schemas: 129, + meta: 129, + entities: 13, + }); + }); + + it('keeps the auth config, the webhook stance and the handler order', () => { + const report = inNodeEsm(` + console.log(JSON.stringify({ + authConfig: plugin.authConfig, + webhooks: plugin.webhooks, + matchesWebhooks: plugin.pluginWebhookMatcher(), + handlers: Object.keys(plugin.errorHandlers), + })); + `); + + expect(report).toEqual({ + authConfig: { api_key: { account: ['one'] } }, + webhooks: {}, + matchesWebhooks: false, + handlers: [ + 'RATE_LIMIT_ERROR', + 'AUTH_ERROR', + 'PERMISSION_ERROR', + 'NOT_FOUND_ERROR', + 'BAD_REQUEST_ERROR', + 'SERVER_ERROR', + 'NETWORK_ERROR', + 'DEFAULT', + ], + }); + }); + + it('ships zod schemas that still validate', () => { + const report = inNodeEsm(` + const schema = plugin.endpointSchemas['text.sentiment']; + console.log(JSON.stringify({ + acceptsResponse: schema.output.safeParse({ score: 0.59, sentiment: 'POSITIVE' }).success, + rejectsEmptyInput: schema.input.safeParse({}).success === false, + })); + `); + + expect(report).toEqual({ acceptsResponse: true, rejectsEmptyInput: true }); + }); + + it('issues a versioned, authenticated request from the bundle', () => { + const report = inNodeEsm(` + let call; + globalThis.fetch = async (url, init) => { + call = { url, key: new Headers(init.headers).get('X-Api-Key'), method: init.method }; + return { + ok: true, status: 200, statusText: 'OK', url, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => ({ score: 0.5, sentiment: 'NEUTRAL' }), + text: async () => '{"score":0.5,"sentiment":"NEUTRAL"}', + }; + }; + const ctx = { ...${CTX}, db: {} }; + await plugin.endpoints.text.sentiment(ctx, { text: 'hello' }); + const v1 = call; + await plugin.endpoints.entertainment.quoteOfTheDay(ctx, {}); + const v2 = call; + await plugin.endpoints.health.recipes(ctx, { title: 'pasta' }); + const v3 = call; + console.log(JSON.stringify({ + v1: v1.url.split('?')[0], + v2: v2.url.split('?')[0], + v3: v3.url.split('?')[0], + keyInHeader: v1.key === 'bundle-check-key', + keyInUrl: v1.url.includes('bundle-check-key'), + })); + `); + + expect(report).toEqual({ + v1: 'https://api.api-ninjas.com/v1/sentiment', + v2: 'https://api.api-ninjas.com/v2/quoteoftheday', + v3: 'https://api.api-ninjas.com/v3/recipe', + keyInHeader: true, + keyInUrl: false, + }); + }); + + it('still mirrors reference data from the bundle', () => { + const report = inNodeEsm(` + const written = []; + globalThis.fetch = async (url) => ({ + ok: true, status: 200, statusText: 'OK', url, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => [{ ident: 'EGLL', iata: 'LHR', name: 'London Heathrow' }], + text: async () => '[]', + }); + const ctx = { + ...${CTX}, + db: { airports: { upsertByEntityId: async (id) => { written.push(id); } } }, + }; + await plugin.endpoints.transport.airports(ctx, { iata: 'LHR' }); + console.log(JSON.stringify({ written })); + `); + + expect(report).toEqual({ written: ['egll'] }); + }); + + it('leaves corsair and zod as external imports', () => { + // Inlining either would ship a second copy of the core to every consumer. + expect(bundle).toMatch(/from\s*["']corsair\/(core|http)["']/); + expect(bundle).toMatch(/from\s*["']zod["']/); + }); + + it('does not ship the test fixtures or the documentation contract', () => { + // Both exist for the tests. Either becoming reachable from `index.ts` would + // put a few hundred kilobytes of captured responses into every install. + expect(bundle).not.toContain('CAPTURED_RESPONSES'); + expect(bundle).not.toContain('DOCUMENTED_OPERATIONS'); + }); + + it('contains no credential', () => { + // Real keys contain more than letters and digits, so the character class + // has to allow the punctuation providers use. + expect(bundle).not.toMatch( + /X-Api-Key["']\s*:\s*["'][A-Za-z0-9\-_+/=]{20,}/, + ); + }); + + it('stays a reasonable size for what it carries', () => { + // 129 operations with their schemas. A sudden jump means something got + // inlined that should have stayed external. + const kilobytes = statSync(DIST).size / 1024; + + expect(kilobytes).toBeGreaterThan(50); + expect(kilobytes).toBeLessThan(400); + }); +}); diff --git a/packages/apininjas/client.test.ts b/packages/apininjas/client.test.ts new file mode 100644 index 000000000..81ddd915f --- /dev/null +++ b/packages/apininjas/client.test.ts @@ -0,0 +1,221 @@ +/** + * Exercises the transport: how the version prefix is chosen, how parameters are + * serialised, where the credential travels, and what happens on a rejection. + */ +import { buildQuery, makeApiNinjasRequest } from './client'; + +const TEST_KEY = 'test-api-key-not-a-real-credential'; + +let calls: { url: string; init: RequestInit }[] = []; + +/** Stubs fetch with a queue of responses and records every request sent. */ +function mockResponses( + responses: { + status?: number; + body?: unknown; + contentType?: string; + headers?: Record; + }[], +) { + let index = 0; + global.fetch = (async (url: string, init: RequestInit) => { + calls.push({ url, init }); + const response = responses[Math.min(index, responses.length - 1)]; + index++; + const status = response?.status ?? 200; + const contentType = response?.contentType ?? 'application/json'; + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + url, + headers: new Headers({ + 'Content-Type': contentType, + ...(response?.headers ?? {}), + }), + json: async () => response?.body ?? {}, + text: async () => + contentType.includes('json') + ? JSON.stringify(response?.body ?? {}) + : String(response?.body ?? ''), + }; + }) as unknown as typeof global.fetch; +} + +const realFetch = global.fetch; + +beforeEach(() => { + calls = []; + // Each test stubs fetch; restoring it first keeps a stub from leaking into a + // test that meant to observe the unstubbed transport. + global.fetch = realFetch; +}); + +describe('buildQuery', () => { + it('drops unset parameters rather than sending them empty', () => { + // The provider treats an empty string as a supplied-but-blank value, so an + // omitted optional parameter has to disappear entirely. + expect(buildQuery({ city: 'London', state: undefined, zip: null })).toEqual( + { + city: 'London', + }, + ); + }); + + it('keeps a parameter whose value is legitimately falsy', () => { + expect(buildQuery({ offset: 0, hit: false, name: '' })).toEqual({ + offset: '0', + hit: 'false', + name: '', + }); + }); + + it('JSON-encodes a structured value', () => { + // The Sudoku solver documents its grid as a JSON array in the query string. + expect( + buildQuery({ + puzzle: [ + [1, 0], + [0, 2], + ], + }), + ).toEqual({ puzzle: '[[1,0],[0,2]]' }); + }); +}); + +describe('versioned routing', () => { + it.each([ + ['v1', 'sentiment', 'https://api.api-ninjas.com/v1/sentiment'], + ['v2', 'quoteoftheday', 'https://api.api-ninjas.com/v2/quoteoftheday'], + ['v3', 'recipe', 'https://api.api-ninjas.com/v3/recipe'], + ] as const)( + 'sends %s endpoints to the %s prefix', + async (version, endpoint, expected) => { + mockResponses([{ body: {} }]); + + await makeApiNinjasRequest(endpoint, TEST_KEY, { version }); + + expect(calls[0]?.url).toBe(expected); + }, + ); + + it('defaults to v1 when no version is given', async () => { + mockResponses([{ body: {} }]); + + await makeApiNinjasRequest('weather', TEST_KEY, { + query: { lat: 51.5, lon: -0.12 }, + }); + + expect(calls[0]?.url).toBe( + 'https://api.api-ninjas.com/v1/weather?lat=51.5&lon=-0.12', + ); + }); +}); + +describe('credentials', () => { + it('sends the key in the X-Api-Key header', async () => { + mockResponses([{ body: {} }]); + + await makeApiNinjasRequest('bitcoin', TEST_KEY); + + const headers = new Headers(calls[0]?.init.headers); + expect(headers.get('X-Api-Key')).toBe(TEST_KEY); + }); + + it('never puts the key in the query string', async () => { + mockResponses([{ body: {} }]); + + await makeApiNinjasRequest('bitcoin', TEST_KEY, { + query: { symbol: 'BTCUSDT' }, + }); + + // A key in a URL ends up in every log that records request URLs, and this + // provider's key is not in the core's redaction list. + expect(calls[0]?.url).not.toContain(TEST_KEY); + expect(calls[0]?.url).not.toMatch(/api[-_]?key/i); + }); +}); + +describe('request shape', () => { + it('sends a JSON body for the POST endpoints', async () => { + mockResponses([{ body: { similarity: 0.9 } }]); + + await makeApiNinjasRequest('textsimilarity', TEST_KEY, { + method: 'POST', + body: { text_1: 'a', text_2: 'b' }, + }); + + const call = calls[0]; + expect(call?.init.method).toBe('POST'); + expect(call?.init.body).toBe(JSON.stringify({ text_1: 'a', text_2: 'b' })); + expect(new Headers(call?.init.headers).get('Content-Type')).toContain( + 'application/json', + ); + }); + + it('does not attach a body to a GET', async () => { + mockResponses([{ body: {} }]); + + await makeApiNinjasRequest('sentiment', TEST_KEY, { + query: { text: 'hello' }, + body: { ignored: true }, + }); + + expect(calls[0]?.init.body).toBeUndefined(); + }); + + it('overrides Accept for the image endpoints', async () => { + mockResponses([{ body: '', contentType: 'image/svg+xml' }]); + + await makeApiNinjasRequest('qrcode', TEST_KEY, { + query: { data: 'x', format: 'svg' }, + accept: 'image/svg+xml', + }); + + expect(new Headers(calls[0]?.init.headers).get('Accept')).toBe( + 'image/svg+xml', + ); + }); +}); + +describe('rate limiting', () => { + it('retries a 429 and returns the eventual success', async () => { + // The client waits a second before the first retry. Fake timers keep that + // out of the suite's runtime while still exercising the delay. + jest.useFakeTimers(); + mockResponses([ + { status: 429, body: { error: 'Too Many Requests' } }, + { status: 200, body: { price: '63115.00' } }, + ]); + + try { + const pending = makeApiNinjasRequest<{ price: string }>( + 'cryptoprice', + TEST_KEY, + { query: { symbol: 'BTCUSDT' } }, + ); + + // Let the rejected attempt settle, then run the backoff timer out. + await jest.advanceTimersByTimeAsync(2000); + const result = await pending; + + expect(calls).toHaveLength(2); + expect(result.price).toBe('63115.00'); + } finally { + jest.useRealTimers(); + } + }); + + it('does not retry a 400', async () => { + mockResponses([ + { status: 400, body: { error: 'Invalid text parameter.' } }, + ]); + + await expect( + makeApiNinjasRequest('sentiment', TEST_KEY, { query: { text: '' } }), + ).rejects.toThrow(); + + // A rejected request is a caller error here, and every retry spends quota. + expect(calls).toHaveLength(1); + }); +}); diff --git a/packages/apininjas/client.ts b/packages/apininjas/client.ts new file mode 100644 index 000000000..7b987eef2 --- /dev/null +++ b/packages/apininjas/client.ts @@ -0,0 +1,128 @@ +import type { + ApiRequestOptions, + OpenAPIConfig, + RateLimitConfig, +} from 'corsair/http'; +import { request } from 'corsair/http'; + +/** + * API Ninjas serves every endpoint from one host under a version prefix. Most + * endpoints are v1, a handful were moved to v2, and the recipe endpoint is v3 - + * so the version travels with the endpoint rather than being fixed here. + * + * @see https://api-ninjas.com/api + */ +const API_NINJAS_HOST = 'https://api.api-ninjas.com'; + +export type ApiNinjasVersion = 'v1' | 'v2' | 'v3'; + +/** + * The documented free-tier allowance is 3,000 calls a month and 100 an hour, + * but responses carry no rate-limit headers at all - not `RateLimit-Limit`, not + * `RateLimit-Remaining`, not `Retry-After` - so a client cannot pace itself + * from the response and can only react to a rejection. Neither documented + * figure is encoded here: 177 calls in one hour during development were never + * throttled, so hardcoding either number would be guessing. + * + * `Retry-After` is still declared, so that a header would be honoured if the + * provider starts sending one. + */ +const API_NINJAS_RATE_LIMIT_CONFIG: RateLimitConfig = { + enabled: true, + maxRetries: 5, + initialRetryDelay: 1000, + backoffMultiplier: 2, + headerNames: { + retryAfter: 'Retry-After', + }, +}; + +/** + * Serialises a query value. + * + * Booleans and numbers are stringified because the provider matches on the + * literal text, and arrays are JSON-encoded because the two endpoints that take + * a structured value - the Sudoku solver's grid - document a JSON array in the + * query string. + */ +function queryValue(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (Array.isArray(value) || typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); +} + +/** + * Drops unset parameters and stringifies the rest. + * + * Every input on this API is a query parameter, and the provider treats an + * empty string as a supplied-but-blank value rather than as an omission, so + * `undefined` has to be removed rather than serialised. + */ +export function buildQuery( + params: Record, +): Record { + const query: Record = {}; + for (const [key, value] of Object.entries(params)) { + const serialised = queryValue(value); + if (serialised !== undefined) { + query[key] = serialised; + } + } + return query; +} + +export type ApiNinjasRequestOptions = { + /** Version prefix the endpoint lives under. Defaults to v1. */ + version?: ApiNinjasVersion; + method?: 'GET' | 'POST'; + /** Query parameters; unset entries are dropped by {@link buildQuery}. */ + query?: Record; + /** JSON body, used by the two NLP endpoints that accept long text. */ + body?: Record; + /** + * Overrides the `Accept` header. The image endpoints need this: the provider + * selects its response format from `Accept` as well as from `format`. + */ + accept?: string; +}; + +/** + * Issues an API Ninjas request with the account key, rate-limit retries and + * this plugin's error handlers. + * + * The key travels in the `X-Api-Key` header and never in the query string, so + * it cannot leak into a logged URL or into an `ApiError` message. + */ +export async function makeApiNinjasRequest( + endpoint: string, + apiKey: string, + options: ApiNinjasRequestOptions = {}, +): Promise { + const { version = 'v1', method = 'GET', query, body, accept } = options; + + const config: OpenAPIConfig = { + BASE: `${API_NINJAS_HOST}/${version}`, + VERSION: version, + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: undefined, + HEADERS: { + 'X-Api-Key': apiKey, + ...(accept ? { Accept: accept } : {}), + }, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + query: query ? buildQuery(query) : undefined, + body: method === 'POST' ? body : undefined, + mediaType: method === 'POST' ? 'application/json' : undefined, + }; + + return await request(config, requestOptions, { + rateLimitConfig: API_NINJAS_RATE_LIMIT_CONFIG, + }); +} diff --git a/packages/apininjas/docs-contract.ts b/packages/apininjas/docs-contract.ts new file mode 100644 index 000000000..b27ed54f5 --- /dev/null +++ b/packages/apininjas/docs-contract.ts @@ -0,0 +1,3319 @@ +/** + * The documented contract for every operation, transcribed from + * https://api-ninjas.com/api on 2026-08-15. + * + * This exists so `schema.test.ts` can check the zod schemas against the + * provider's documentation instead of against themselves: every documented + * parameter has to appear in the input schema, every documented response field + * has to appear in the output schema, and the version prefix and method have to + * match the ones the endpoints call. + * + * `combination: true` marks the endpoints the documentation describes as a + * choice of parameter combinations (weather by coordinates or by city, for + * example). Those parameters are all optional in the input schema, because only + * the provider can validate which combination was supplied. + * + * Two endpoints have no documentation page - sp500 and airlines - and are absent from both the sitemap and the API directory. Their parameters were confirmed one at a time against live calls, and `documented` is null for them. + */ +export type DocumentedParameter = { + name: string; + /** What the documentation's parameter table says. */ + required: boolean; + premium: boolean; + /** + * Whether the provider actually rejects the call when this parameter is + * missing, probed one parameter at a time on 2026-08-15. `null` where the + * documentation does not call it required, so it was never probed. + * + * Three parameters are documented as required and are not enforced: the QR + * code `format` (which defaults to PNG), and the car endpoint's `make` and + * `trim` (any one filter is enough). The input schemas follow the provider + * rather than the table, because a schema that demanded them would reject + * calls the API answers. + */ + enforced: boolean | null; +}; + +export type DocumentedOperation = { + catalogOperation: string; + path: string; + version: string; + endpoint: string; + method: string; + combination: boolean; + documented: string | null; + params: DocumentedParameter[]; + responseFields: string[]; +}; + +export const DOCUMENTED_OPERATIONS: Record = { + locationGeocode: { + catalogOperation: 'GET_GEOCODING', + path: 'location.geocode', + version: 'v1', + endpoint: 'geocoding', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/geocoding', + params: [ + { name: 'city', required: true, premium: false, enforced: true }, + { name: 'state', required: false, premium: false, enforced: null }, + { name: 'country', required: false, premium: false, enforced: null }, + { name: 'zipcode', required: false, premium: false, enforced: null }, + ], + responseFields: ['name', 'latitude', 'longitude', 'country', 'state'], + }, + locationReverseGeocode: { + catalogOperation: 'GET_REVERSE_GEOCODING', + path: 'location.reverseGeocode', + version: 'v1', + endpoint: 'reversegeocoding', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/reversegeocoding', + params: [ + { name: 'lat', required: true, premium: false, enforced: true }, + { name: 'lon', required: true, premium: false, enforced: true }, + ], + responseFields: ['name', 'country', 'state'], + }, + locationCities: { + catalogOperation: 'GET_CITY', + path: 'location.cities', + version: 'v1', + endpoint: 'city', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/city', + params: [ + { name: 'name', required: false, premium: false, enforced: null }, + { name: 'country', required: false, premium: false, enforced: null }, + { name: 'min_lat', required: false, premium: false, enforced: null }, + { name: 'max_lat', required: false, premium: false, enforced: null }, + { name: 'min_lon', required: false, premium: false, enforced: null }, + { name: 'max_lon', required: false, premium: false, enforced: null }, + { + name: 'min_population', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_population', + required: false, + premium: false, + enforced: null, + }, + { name: 'limit', required: false, premium: true, enforced: null }, + { name: 'offset', required: false, premium: true, enforced: null }, + ], + responseFields: [ + 'name', + 'latitude', + 'longitude', + 'country', + 'population', + 'is_capital', + ], + }, + locationCountry: { + catalogOperation: 'GET_COUNTRY_INFO', + path: 'location.country', + version: 'v1', + endpoint: 'country', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/country', + params: [ + { name: 'name', required: false, premium: false, enforced: null }, + { name: 'currency', required: false, premium: false, enforced: null }, + { name: 'min_gdp', required: false, premium: false, enforced: null }, + { name: 'max_gdp', required: false, premium: false, enforced: null }, + { + name: 'min_population', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_population', + required: false, + premium: false, + enforced: null, + }, + { name: 'min_area', required: false, premium: false, enforced: null }, + { name: 'max_area', required: false, premium: false, enforced: null }, + { + name: 'min_unemployment', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_unemployment', + required: false, + premium: false, + enforced: null, + }, + { + name: 'min_gdp_growth', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_gdp_growth', + required: false, + premium: false, + enforced: null, + }, + { + name: 'min_infant_mortality', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_infant_mortality', + required: false, + premium: false, + enforced: null, + }, + { + name: 'min_fertility', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_fertility', + required: false, + premium: false, + enforced: null, + }, + { + name: 'min_urban_pop_rate', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_urban_pop_rate', + required: false, + premium: false, + enforced: null, + }, + { name: 'limit', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'name', + 'iso2', + 'capital', + 'region', + 'currency', + 'gdp', + 'gdp_per_capita', + 'gdp_growth', + 'population', + 'pop_density', + 'pop_growth', + 'surface_area', + 'urban_population', + 'urban_population_growth', + 'unemployment', + 'fertility', + 'infant_mortality', + 'life_expectancy_male', + 'life_expectancy_female', + 'sex_ratio', + 'employment_services', + 'employment_industry', + 'employment_agriculture', + 'imports', + 'exports', + 'co2_emissions', + 'forested_area', + 'tourists', + 'homicide_rate', + 'threatened_species', + 'internet_users', + 'refugees', + 'primary_school_enrollment_female', + 'primary_school_enrollment_male', + 'secondary_school_enrollment_female', + 'secondary_school_enrollment_male', + 'post_secondary_enrollment_female', + 'post_secondary_enrollment_male', + 'telephone_country_codes', + ], + }, + locationCounty: { + catalogOperation: 'GET_COUNTY', + path: 'location.county', + version: 'v1', + endpoint: 'county', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/county', + params: [ + { name: 'county', required: false, premium: false, enforced: null }, + { name: 'zipcode', required: false, premium: false, enforced: null }, + { name: 'state', required: false, premium: false, enforced: null }, + { name: 'limit', required: false, premium: true, enforced: null }, + { name: 'offset', required: false, premium: true, enforced: null }, + ], + responseFields: [ + 'county_name', + 'county_fips', + 'state_code', + 'state_name', + 'latitude', + 'longitude', + 'timezone', + 'zip_codes', + 'population', + 'median_age', + ], + }, + locationZipCode: { + catalogOperation: 'GET_ZIPCODE', + path: 'location.zipCode', + version: 'v1', + endpoint: 'zipcode', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/zipcode', + params: [ + { name: 'zip', required: false, premium: false, enforced: null }, + { name: 'city', required: false, premium: true, enforced: null }, + { name: 'state', required: false, premium: true, enforced: null }, + ], + responseFields: [ + 'zip_code', + 'valid', + 'city', + 'state', + 'county', + 'timezone', + 'area_codes', + 'country', + 'lat', + 'lon', + ], + }, + locationPostalCode: { + catalogOperation: 'GET_POSTAL_CODE', + path: 'location.postalCode', + version: 'v1', + endpoint: 'postalcode', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/postalcode', + params: [ + { name: 'postal_code', required: false, premium: false, enforced: null }, + { name: 'city', required: false, premium: true, enforced: null }, + { name: 'province', required: false, premium: true, enforced: null }, + ], + responseFields: [ + 'city', + 'province', + 'postal_code', + 'area_code', + 'timezone', + 'lat', + 'lon', + ], + }, + locationUniversities: { + catalogOperation: 'GET_UNIVERSITY', + path: 'location.universities', + version: 'v1', + endpoint: 'university', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/university', + params: [ + { name: 'name', required: false, premium: false, enforced: null }, + { name: 'country', required: false, premium: false, enforced: null }, + { name: 'city', required: false, premium: true, enforced: null }, + { name: 'state', required: false, premium: true, enforced: null }, + { + name: 'min_faculty_ratio', + required: false, + premium: true, + enforced: null, + }, + { + name: 'max_faculty_ratio', + required: false, + premium: true, + enforced: null, + }, + { name: 'min_enrolled', required: false, premium: true, enforced: null }, + { name: 'max_enrolled', required: false, premium: true, enforced: null }, + { name: 'min_tuition', required: false, premium: true, enforced: null }, + { name: 'max_tuition', required: false, premium: true, enforced: null }, + { name: 'offset', required: false, premium: true, enforced: null }, + { name: 'limit', required: false, premium: true, enforced: null }, + ], + responseFields: [ + 'name', + 'degree_types', + 'address', + 'city', + 'state', + 'postal_code', + 'country', + 'county', + 'timezone', + 'latitude', + 'longitude', + 'phone', + 'email', + 'website', + 'institution_type', + 'years', + 'enrollment', + 'student_faculty_ratio', + 'tuition', + ], + }, + locationHospitals: { + catalogOperation: 'GET_HOSPITALS', + path: 'location.hospitals', + version: 'v1', + endpoint: 'hospitals', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/hospitals', + params: [ + { name: 'name', required: false, premium: false, enforced: null }, + { name: 'city', required: false, premium: false, enforced: null }, + { name: 'state', required: false, premium: false, enforced: null }, + { name: 'zipcode', required: false, premium: false, enforced: null }, + { name: 'county', required: false, premium: false, enforced: null }, + { name: 'min_latitude', required: false, premium: false, enforced: null }, + { name: 'max_latitude', required: false, premium: false, enforced: null }, + { + name: 'min_longitude', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_longitude', + required: false, + premium: false, + enforced: null, + }, + { name: 'limit', required: false, premium: true, enforced: null }, + { name: 'offset', required: false, premium: true, enforced: null }, + ], + responseFields: [ + 'name', + 'care_type', + 'address, city, state, zipcode', + 'county', + 'location_area_code', + 'fips_code', + 'timezone', + 'latitude, longitude', + 'phone_number', + 'website', + 'ownership', + 'bedcount', + ], + }, + locationEvChargers: { + catalogOperation: 'FIND_EV_CHARGING_STATIONS', + path: 'location.evChargers', + version: 'v1', + endpoint: 'evcharger', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/evcharger', + params: [ + { name: 'lat', required: true, premium: false, enforced: true }, + { name: 'lon', required: true, premium: false, enforced: true }, + { name: 'distance', required: false, premium: false, enforced: null }, + { name: 'level', required: false, premium: false, enforced: null }, + { name: 'limit', required: false, premium: true, enforced: null }, + { name: 'offset', required: false, premium: true, enforced: null }, + ], + responseFields: [ + 'is_active', + 'name', + 'address', + 'city', + 'region', + 'country', + 'latitude, longitude', + 'connections', + 'type_official', + 'level', + 'num_connectors', + ], + }, + locationWeather: { + catalogOperation: 'GET_WEATHER', + path: 'location.weather', + version: 'v1', + endpoint: 'weather', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: true, + documented: 'https://api-ninjas.com/api/weather', + params: [ + { name: 'lat', required: false, premium: false, enforced: null }, + { name: 'lon', required: false, premium: false, enforced: null }, + { name: 'zip', required: false, premium: true, enforced: null }, + { name: 'city', required: false, premium: true, enforced: null }, + { name: 'state', required: false, premium: true, enforced: null }, + { name: 'country', required: false, premium: true, enforced: null }, + ], + responseFields: [], + }, + locationWeatherForecast: { + catalogOperation: 'GET_WEATHER_FORECAST', + path: 'location.weatherForecast', + version: 'v1', + endpoint: 'weatherforecast', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: true, + documented: 'https://api-ninjas.com/api/weather', + params: [ + { name: 'lat', required: true, premium: false, enforced: true }, + { name: 'lon', required: true, premium: false, enforced: true }, + { name: 'zip', required: true, premium: true, enforced: null }, + { name: 'city', required: true, premium: true, enforced: null }, + { name: 'state', required: false, premium: true, enforced: null }, + { name: 'country', required: false, premium: true, enforced: null }, + ], + responseFields: [], + }, + locationAirQuality: { + catalogOperation: 'GET_AIR_QUALITY', + path: 'location.airQuality', + version: 'v1', + endpoint: 'airquality', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/airquality', + params: [ + { name: 'lat', required: false, premium: false, enforced: null }, + { name: 'lon', required: false, premium: false, enforced: null }, + { name: 'city', required: false, premium: false, enforced: null }, + { name: 'state', required: false, premium: false, enforced: null }, + { name: 'country', required: false, premium: false, enforced: null }, + ], + responseFields: ['overall_aqi', 'CO', 'NO2', 'O3', 'SO2', 'PM2.5', 'PM10'], + }, + calendarTimezone: { + catalogOperation: 'GET_TIMEZONE', + path: 'calendar.timezone', + version: 'v1', + endpoint: 'timezone', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/timezone', + params: [ + { name: 'timezone', required: false, premium: false, enforced: null }, + { name: 'lat', required: false, premium: true, enforced: null }, + { name: 'lon', required: false, premium: true, enforced: null }, + { name: 'city', required: false, premium: true, enforced: null }, + { name: 'state', required: false, premium: true, enforced: null }, + { name: 'country', required: false, premium: true, enforced: null }, + ], + responseFields: ['timezone', 'utc_offset', 'local_time', 'city'], + }, + calendarWorldTime: { + catalogOperation: 'GET_WORLDTIME', + path: 'calendar.worldTime', + version: 'v1', + endpoint: 'worldtime', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/worldtime', + params: [ + { name: 'timezone', required: false, premium: false, enforced: null }, + { name: 'lat', required: false, premium: true, enforced: null }, + { name: 'lon', required: false, premium: true, enforced: null }, + { name: 'city', required: false, premium: true, enforced: null }, + { name: 'state', required: false, premium: true, enforced: null }, + { name: 'country', required: false, premium: true, enforced: null }, + ], + responseFields: [ + 'timezone', + 'datetime', + 'date', + 'year', + 'month', + 'day', + 'hour', + 'minute', + 'second', + 'day_of_week', + ], + }, + calendarHolidays: { + catalogOperation: 'GET_HOLIDAYS', + path: 'calendar.holidays', + version: 'v2', + endpoint: 'holidays', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/holidays', + params: [ + { name: 'country', required: true, premium: false, enforced: true }, + { name: 'year', required: false, premium: false, enforced: null }, + { name: 'type', required: false, premium: false, enforced: null }, + ], + responseFields: ['country', 'iso', 'year', 'date', 'day', 'name', 'type'], + }, + calendarPublicHolidays: { + catalogOperation: 'GET_PUBLIC_HOLIDAYS', + path: 'calendar.publicHolidays', + version: 'v1', + endpoint: 'publicholidays', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/publicholidays', + params: [ + { name: 'country', required: true, premium: false, enforced: true }, + { name: 'year', required: false, premium: true, enforced: null }, + ], + responseFields: [ + 'name', + 'local_name', + 'date', + 'country', + 'year', + 'regions', + 'federal', + ], + }, + calendarIsPublicHoliday: { + catalogOperation: 'CHECK_IS_PUBLIC_HOLIDAY', + path: 'calendar.isPublicHoliday', + version: 'v1', + endpoint: 'ispublicholiday', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/publicholidays', + params: [ + { name: 'country', required: true, premium: false, enforced: true }, + { name: 'date', required: true, premium: false, enforced: true }, + ], + responseFields: [ + 'is_public_holiday', + 'public_holiday_name', + 'date', + 'country', + ], + }, + calendarIsWorkingDay: { + catalogOperation: 'CHECK_IS_WORKING_DAY', + path: 'calendar.isWorkingDay', + version: 'v1', + endpoint: 'isworkingday', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/workingdays', + params: [ + { name: 'country', required: true, premium: false, enforced: true }, + { name: 'date', required: true, premium: false, enforced: true }, + { name: 'weekend', required: false, premium: false, enforced: null }, + { + name: 'public_holidays', + required: false, + premium: false, + enforced: null, + }, + ], + responseFields: [ + 'date', + 'country', + 'day_of_week', + 'is_workday', + 'non_working_reason', + 'public_holiday_name', + ], + }, + calendarWorkingDays: { + catalogOperation: 'GET_WORKING_DAYS', + path: 'calendar.workingDays', + version: 'v1', + endpoint: 'workingdays', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/workingdays', + params: [ + { name: 'country', required: true, premium: false, enforced: true }, + { name: 'year', required: false, premium: true, enforced: null }, + { name: 'month', required: false, premium: false, enforced: null }, + { name: 'weekend', required: false, premium: false, enforced: null }, + { + name: 'public_holidays', + required: false, + premium: false, + enforced: null, + }, + ], + responseFields: [ + 'num_working_days', + 'num_non_working_days', + 'working_days', + 'non_working_days', + ], + }, + internetDomain: { + catalogOperation: 'CHECK_DOMAIN', + path: 'internet.domain', + version: 'v1', + endpoint: 'domain', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/domain', + params: [ + { name: 'domain', required: true, premium: false, enforced: true }, + ], + responseFields: [ + 'domain', + 'available', + 'creation_date', + 'expiration_date', + 'age_days', + 'registrar', + 'updated_date', + 'domain_status', + 'has_mx', + 'is_free_email_provider', + 'risky_tld', + 'is_disposable_email_domain', + 'is_custom_domain', + 'mx_provider', + 'is_parked', + 'ip', + 'hosting_provider', + 'country', + ], + }, + internetDnsRecords: { + catalogOperation: 'DNS_LOOKUP', + path: 'internet.dnsRecords', + version: 'v1', + endpoint: 'dnslookup', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/dnslookup', + params: [ + { name: 'domain', required: true, premium: false, enforced: true }, + ], + responseFields: [ + 'record_type', + 'AAAA', + 'CNAME', + 'MX', + 'NS', + 'PTR', + 'SRV', + 'SOA', + 'TXT', + 'CAA', + 'value', + 'priority', + 'mname, rname, serial, refresh, retry, expire, ttl', + ], + }, + internetMxRecords: { + catalogOperation: 'GET_MX_RECORDS', + path: 'internet.mxRecords', + version: 'v1', + endpoint: 'mxlookup', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/mxlookup', + params: [ + { name: 'domain', required: true, premium: false, enforced: true }, + ], + responseFields: ['priority', 'value'], + }, + internetWhois: { + catalogOperation: 'GET_WHOIS', + path: 'internet.whois', + version: 'v1', + endpoint: 'whois', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/whois', + params: [ + { name: 'domain', required: true, premium: false, enforced: true }, + ], + responseFields: [ + 'domain_name', + 'registrar', + 'registrar_url', + 'whois_server', + 'updated_date', + 'creation_date', + 'expiration_date', + 'name_servers', + 'dnssec', + 'emails', + ], + }, + internetIpLookup: { + catalogOperation: 'GET_IP_LOOKUP', + path: 'internet.ipLookup', + version: 'v1', + endpoint: 'iplookup', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/iplookup', + params: [ + { name: 'address', required: true, premium: false, enforced: true }, + ], + responseFields: [ + 'address', + 'timezone', + 'lat', + 'lon', + 'zip', + 'city', + 'region', + 'region_code', + 'country', + 'country_code', + 'is_valid', + 'isp', + 'is_datacenter', + 'is_hosting', + 'is_tor', + 'is_vpn', + 'is_icloud_relay', + 'is_bogon', + 'is_abuser', + 'threat_level', + 'asn', + 'asn_name', + 'route', + 'abuse_email', + ], + }, + internetUrlLookup: { + catalogOperation: 'GET_URL_LOOKUP', + path: 'internet.urlLookup', + version: 'v1', + endpoint: 'urllookup', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/urllookup', + params: [{ name: 'url', required: true, premium: false, enforced: true }], + responseFields: [ + 'url', + 'country', + 'country_code', + 'region', + 'region_code', + 'city', + 'zip', + 'lat', + 'lon', + 'timezone', + 'isp', + ], + }, + internetWebpage: { + catalogOperation: 'EXTRACT_WEBPAGE_CONTENT', + path: 'internet.webpage', + version: 'v1', + endpoint: 'webpage', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/webpage', + params: [{ name: 'url', required: true, premium: false, enforced: true }], + responseFields: [ + 'url', + 'domain', + 'url_path', + 'url_parameters', + 'page_title', + 'page_description', + 'meta_tags', + 'favicon', + ], + }, + internetScrape: { + catalogOperation: 'SCRAPE_WEBSITE', + path: 'internet.scrape', + version: 'v1', + endpoint: 'webscraper', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/webscraper', + params: [ + { name: 'url', required: true, premium: false, enforced: true }, + { name: 'text_only', required: false, premium: false, enforced: null }, + { name: 'user_agent', required: false, premium: false, enforced: null }, + ], + responseFields: ['data'], + }, + internetUserAgent: { + catalogOperation: 'GENERATE_USER_AGENT', + path: 'internet.userAgent', + version: 'v1', + endpoint: 'useragentgenerate', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/useragent', + params: [ + { name: 'brand', required: false, premium: false, enforced: null }, + { name: 'model', required: false, premium: false, enforced: null }, + { name: 'os', required: false, premium: false, enforced: null }, + { name: 'browser', required: false, premium: false, enforced: null }, + ], + responseFields: ['user_agent'], + }, + validationEmail: { + catalogOperation: 'VALIDATE_EMAIL', + path: 'validation.email', + version: 'v1', + endpoint: 'validateemail', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/validateemail', + params: [{ name: 'email', required: true, premium: false, enforced: true }], + responseFields: [ + 'is_valid', + 'domain', + 'email', + 'local_part', + 'is_disposable', + 'is_public', + 'main_category', + 'sub_category', + ], + }, + validationDisposableEmail: { + catalogOperation: 'DISPOSABLE_EMAIL_CHECKER', + path: 'validation.disposableEmail', + version: 'v1', + endpoint: 'disposableemailchecker', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/disposableemailchecker', + params: [{ name: 'email', required: true, premium: false, enforced: true }], + responseFields: ['domain', 'email', 'is_disposable'], + }, + validationPhone: { + catalogOperation: 'VALIDATE_PHONE', + path: 'validation.phone', + version: 'v1', + endpoint: 'validatephone', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/validatephone', + params: [ + { name: 'number', required: true, premium: false, enforced: true }, + { name: 'country', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'is_valid', + 'is_formatted_properly', + 'country', + 'location', + 'timezones', + 'format_national', + 'format_international', + 'format_e164', + 'country_code', + 'line_type', + 'is_mobile', + 'format_rfc3966', + 'is_possible', + ], + }, + validationRoutingNumber: { + catalogOperation: 'VALIDATE_ROUTING_NUMBER', + path: 'validation.routingNumber', + version: 'v1', + endpoint: 'routingnumber', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/routingnumber', + params: [ + { + name: 'routing_number', + required: true, + premium: false, + enforced: true, + }, + ], + responseFields: [], + }, + validationIban: { + catalogOperation: 'IBAN_LOOKUP', + path: 'validation.iban', + version: 'v1', + endpoint: 'iban', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/iban', + params: [{ name: 'iban', required: true, premium: false, enforced: true }], + responseFields: [ + 'iban', + 'bank_name', + 'bank_address', + 'swift_code', + 'account_number', + 'bank_code', + 'country', + 'checksum', + 'valid', + 'invalid_reason', + 'bban', + ], + }, + validationBin: { + catalogOperation: 'BIN_LOOKUP', + path: 'validation.bin', + version: 'v2', + endpoint: 'bin', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/bin', + params: [{ name: 'bin', required: true, premium: false, enforced: true }], + responseFields: [ + 'bin', + 'brand', + 'type', + 'categories', + 'issuer', + 'country_iso2', + 'country', + 'is_valid', + ], + }, + validationSwiftCode: { + catalogOperation: 'GET_SWIFT_CODE', + path: 'validation.swiftCode', + version: 'v1', + endpoint: 'swiftcode', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/swiftcode', + params: [ + { name: 'swift', required: false, premium: false, enforced: null }, + { name: 'bank', required: false, premium: true, enforced: null }, + { name: 'city', required: false, premium: false, enforced: null }, + { name: 'country', required: false, premium: false, enforced: null }, + { + name: 'routing_number', + required: false, + premium: false, + enforced: null, + }, + { name: 'offset', required: false, premium: true, enforced: null }, + ], + responseFields: [ + 'swift_code', + 'bank_name', + 'address', + 'city', + 'region', + 'postal_code', + 'country', + 'country_code', + ], + }, + marketsStockPrice: { + catalogOperation: 'GET_STOCK_PRICE', + path: 'markets.stockPrice', + version: 'v1', + endpoint: 'stockprice', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/stockprice', + params: [ + { name: 'ticker', required: true, premium: false, enforced: true }, + ], + responseFields: [ + 'ticker', + 'name', + 'price', + 'exchange', + 'updated', + 'currency', + 'volume', + ], + }, + marketsTicker: { + catalogOperation: 'GET_TICKER', + path: 'markets.ticker', + version: 'v1', + endpoint: 'ticker', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/ticker', + params: [ + { name: 'ticker', required: true, premium: false, enforced: true }, + ], + responseFields: [ + 'name', + 'ticker', + 'chief_executive_officer', + 'address', + 'latest_price', + 'latest_market_cap', + 'latest_dividend', + 'cik', + 'cusip', + 'isin', + 'exchange', + 'website', + 'phone_number', + 'ipo_date', + 'latest_earnings', + 'sector', + 'industry', + 'sic_code', + 'sic_description', + ], + }, + marketsTickerList: { + catalogOperation: 'LIST_STOCK_TICKERS', + path: 'markets.tickerList', + version: 'v1', + endpoint: 'stockpricelist', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/stockprice', + params: [ + { name: 'offset', required: false, premium: false, enforced: null }, + { name: 'limit', required: false, premium: false, enforced: null }, + ], + responseFields: ['ticker', 'name'], + }, + marketsStockExchanges: { + catalogOperation: 'GET_STOCK_EXCHANGE', + path: 'markets.stockExchanges', + version: 'v1', + endpoint: 'stockexchange', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/stockexchange', + params: [ + { name: 'mic', required: false, premium: false, enforced: null }, + { name: 'name', required: false, premium: false, enforced: null }, + { name: 'city', required: false, premium: false, enforced: null }, + { name: 'country', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'mic', + 'name', + 'city', + 'country', + 'iso2', + 'description', + 'address', + 'website', + 'founded', + 'num_listings', + 'market_cap_usd', + 'market_cap', + 'currency', + 'timezone', + 'market_open', + 'market_close', + 'is_market_open', + 'closed_reason', + ], + }, + marketsSp500: { + catalogOperation: 'GET_SP500_CONSTITUENTS', + path: 'markets.sp500', + version: 'v1', + endpoint: 'sp500', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: null, + params: [ + { name: 'ticker', required: false, premium: false, enforced: null }, + { name: 'name', required: false, premium: false, enforced: null }, + { name: 'sector', required: false, premium: false, enforced: null }, + { name: 'date_added', required: false, premium: false, enforced: null }, + ], + responseFields: [], + }, + marketsMarketCap: { + catalogOperation: 'MARKET_CAP', + path: 'markets.marketCap', + version: 'v1', + endpoint: 'marketcap', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/marketcap', + params: [ + { name: 'ticker', required: true, premium: false, enforced: true }, + ], + responseFields: ['ticker', 'name', 'market_cap', 'currency', 'updated'], + }, + marketsEarnings: { + catalogOperation: 'GET_EARNINGS', + path: 'markets.earnings', + version: 'v2', + endpoint: 'earnings', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/earnings', + params: [ + { name: 'ticker', required: false, premium: false, enforced: null }, + { name: 'cik', required: false, premium: false, enforced: null }, + { name: 'period', required: false, premium: false, enforced: null }, + { name: 'year', required: false, premium: false, enforced: null }, + { name: 'quarter', required: false, premium: false, enforced: null }, + { name: 'date', required: false, premium: false, enforced: null }, + { name: 'date_start', required: false, premium: false, enforced: null }, + { name: 'date_end', required: false, premium: false, enforced: null }, + { name: 'offset', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'company_info', + 'income_statement', + 'balance_sheet', + 'cash_flow', + 'filing_info', + ], + }, + marketsEarningsCalendar: { + catalogOperation: 'EARNINGS_CALENDAR', + path: 'markets.earningsCalendar', + version: 'v1', + endpoint: 'earningscalendar', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/earningscalendar', + params: [ + { name: 'ticker', required: false, premium: false, enforced: null }, + { name: 'date', required: false, premium: false, enforced: null }, + { name: 'date_start', required: false, premium: false, enforced: null }, + { name: 'date_end', required: false, premium: false, enforced: null }, + { name: 'show_upcoming', required: false, premium: true, enforced: null }, + { name: 'offset', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'date', + 'ticker', + 'fiscal_year', + 'fiscal_quarter', + 'earnings_timing', + 'earnings_call_timestamp', + 'actual_revenue', + 'estimated_revenue', + 'revenue_difference', + 'revenue_difference_pct', + 'actual_eps', + 'estimated_eps', + 'eps_difference', + 'eps_difference_pct', + 'report_date_status', + 'date_confirmed', + 'report_datetime', + 'sec_8k_url', + 'eps_beat_miss', + 'revenue_beat_miss', + 'eps_surprise_streak', + 'avg_eps_surprise_pct_4q', + 'eps_sue', + 'last_earnings_move_pct', + 'avg_earnings_move_pct', + 'days_to_next_earnings', + 'next_earnings_date', + 'has_transcript', + 'surprise_history', + ], + }, + marketsEarningsTranscript: { + catalogOperation: 'EARNINGS_CALL_TRANSCRIPT', + path: 'markets.earningsTranscript', + version: 'v1', + endpoint: 'earningstranscript', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/earningscalltranscript', + params: [ + { name: 'ticker', required: false, premium: false, enforced: null }, + { name: 'cik', required: false, premium: false, enforced: null }, + { name: 'year', required: false, premium: false, enforced: null }, + { name: 'quarter', required: false, premium: false, enforced: null }, + { name: 'qa_only', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'date', + 'timestamp', + 'ticker', + 'cik', + 'year', + 'quarter', + 'earnings_timing', + 'transcript', + 'participants', + 'summary', + 'guidance', + 'risk_factors', + 'overall_sentiment', + 'overall_sentiment_rationale', + 'transcript_split', + ], + }, + marketsInsiderTransactions: { + catalogOperation: 'GET_INSIDER_TRANSACTIONS', + path: 'markets.insiderTransactions', + version: 'v1', + endpoint: 'insidertransactions', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/insidertrading', + params: [ + { name: 'ticker', required: false, premium: false, enforced: null }, + { name: 'cik', required: false, premium: false, enforced: null }, + { name: 'name', required: false, premium: false, enforced: null }, + { name: 'form_type', required: false, premium: false, enforced: null }, + { + name: 'transaction_type', + required: false, + premium: false, + enforced: null, + }, + { + name: 'transaction_code', + required: false, + premium: false, + enforced: null, + }, + { + name: 'transaction_date', + required: false, + premium: false, + enforced: null, + }, + { + name: 'min_transaction_date', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_transaction_date', + required: false, + premium: false, + enforced: null, + }, + { name: 'insider_type', required: false, premium: false, enforced: null }, + { + name: 'min_transaction_value', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_transaction_value', + required: false, + premium: false, + enforced: null, + }, + { name: 'limit', required: false, premium: true, enforced: null }, + { name: 'offset', required: false, premium: true, enforced: null }, + ], + responseFields: [ + 'accession_number', + 'form', + 'filing_date', + 'sec_filing_url', + 'cik', + 'ticker', + 'company_name', + 'insider_name', + 'insider_position', + 'transaction_code', + 'transaction_name', + 'transaction_type', + 'transaction_price', + 'shares', + 'transaction_value', + 'pre_transaction_shares', + 'pre_transaction_shares_value', + 'remaining_shares', + 'remaining_shares_value', + ], + }, + marketsSecFilings: { + catalogOperation: 'GET_SEC_FILING', + path: 'markets.secFilings', + version: 'v1', + endpoint: 'sec', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/sec', + params: [ + { name: 'ticker', required: true, premium: false, enforced: true }, + { name: 'filing', required: true, premium: false, enforced: true }, + { name: 'start', required: false, premium: true, enforced: null }, + { name: 'end', required: false, premium: true, enforced: null }, + { name: 'limit', required: false, premium: true, enforced: null }, + ], + responseFields: ['ticker', 'filing_date', 'filing_url', 'form_type'], + }, + marketsEtf: { + catalogOperation: 'ETF_INFO', + path: 'markets.etf', + version: 'v1', + endpoint: 'etf', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/etf', + params: [ + { name: 'ticker', required: true, premium: false, enforced: true }, + ], + responseFields: [ + 'etf_ticker', + 'etf_name', + 'isin', + 'cusip', + 'country', + 'domicile', + 'price', + 'expense_ratio', + 'aum', + 'aum_currency', + 'aum_usd', + 'num_holdings', + 'holdings', + ], + }, + marketsMutualFund: { + catalogOperation: 'GET_MUTUAL_FUND', + path: 'markets.mutualFund', + version: 'v1', + endpoint: 'mutualfund', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/mutualfund', + params: [ + { name: 'ticker', required: true, premium: false, enforced: true }, + ], + responseFields: [ + 'fund_ticker', + 'fund_name', + 'isin', + 'cusip', + 'country', + 'expense_ratio', + 'price', + 'aum', + 'holdings', + 'num_holdings', + ], + }, + marketsCryptoPrice: { + catalogOperation: 'CRYPTO_PRICE', + path: 'markets.cryptoPrice', + version: 'v1', + endpoint: 'cryptoprice', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/cryptoprice', + params: [ + { name: 'symbol', required: true, premium: false, enforced: true }, + ], + responseFields: ['symbol', 'price', 'timestamp'], + }, + marketsBitcoin: { + catalogOperation: 'BITCOIN', + path: 'markets.bitcoin', + version: 'v1', + endpoint: 'bitcoin', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/bitcoin', + params: [], + responseFields: [ + 'price', + 'timestamp', + '24h_price_change', + '24h_price_change_percent', + '24h_high', + '24h_low', + '24h_volume', + ], + }, + marketsCommodityPrice: { + catalogOperation: 'COMMODITY_PRICE', + path: 'markets.commodityPrice', + version: 'v1', + endpoint: 'commodityprice', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/commodityprice', + params: [ + { name: 'name', required: false, premium: false, enforced: null }, + { name: 'names', required: false, premium: false, enforced: null }, + { name: 'currency', required: false, premium: false, enforced: null }, + { name: 'unit', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'exchange', + 'name', + 'price', + 'currency_unit', + 'unit', + 'previous_close', + 'change_24h', + 'change_24h_percent', + 'high_24h', + 'high_52w', + 'updated', + ], + }, + marketsConvertCurrency: { + catalogOperation: 'CONVERT_CURRENCY', + path: 'markets.convertCurrency', + version: 'v1', + endpoint: 'convertcurrency', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/convertcurrency', + params: [ + { name: 'have', required: true, premium: false, enforced: true }, + { name: 'want', required: true, premium: false, enforced: true }, + { name: 'amount', required: true, premium: false, enforced: true }, + ], + responseFields: [ + 'old_amount', + 'old_currency', + 'new_amount', + 'new_currency', + 'timestamp', + ], + }, + marketsExchangeRate: { + catalogOperation: 'GET_EXCHANGE_RATE', + path: 'markets.exchangeRate', + version: 'v1', + endpoint: 'exchangerate', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/exchangerate', + params: [{ name: 'pair', required: true, premium: false, enforced: true }], + responseFields: ['currency_pair', 'exchange_rate', 'timestamp'], + }, + economicsGdp: { + catalogOperation: 'GET_GDP', + path: 'economics.gdp', + version: 'v1', + endpoint: 'gdp', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/gdp', + params: [ + { name: 'country', required: false, premium: false, enforced: null }, + { name: 'year', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'country', + 'year', + 'gdp_growth', + 'gdp_nominal', + 'gdp_per_capita_nominal', + 'gdp_ppp', + 'gdp_per_capita_ppp', + 'gdp_ppp_share', + ], + }, + economicsInflation: { + catalogOperation: 'INFLATION', + path: 'economics.inflation', + version: 'v1', + endpoint: 'inflation', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/inflation', + params: [ + { name: 'type', required: false, premium: false, enforced: null }, + { name: 'country', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'country', + 'country_code', + 'type', + 'period', + 'monthly_rate_pct', + 'yearly_rate_pct', + ], + }, + economicsUnemployment: { + catalogOperation: 'GET_UNEMPLOYMENT', + path: 'economics.unemployment', + version: 'v1', + endpoint: 'unemployment', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/unemployment', + params: [ + { name: 'country', required: false, premium: false, enforced: null }, + { name: 'year', required: false, premium: false, enforced: null }, + ], + responseFields: ['country', 'year', 'unemployment_rate'], + }, + economicsPopulation: { + catalogOperation: 'GET_POPULATION', + path: 'economics.population', + version: 'v1', + endpoint: 'population', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/population', + params: [ + { name: 'country', required: false, premium: false, enforced: null }, + { + name: 'min_population', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_population', + required: false, + premium: false, + enforced: null, + }, + { name: 'offset', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'country_name', + 'historical_population', + 'population', + 'yearly_change_percentage', + 'yearly_change', + 'migrants', + 'median_age', + 'fertility_rate', + 'density', + 'urban_population_pct', + 'urban_population', + 'percentage_of_world_population', + 'rank', + 'population_forecast', + ], + }, + economicsInterestRate: { + catalogOperation: 'INTEREST_RATE', + path: 'economics.interestRate', + version: 'v2', + endpoint: 'interestrate', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/interestrate', + params: [{ name: 'rate', required: true, premium: false, enforced: true }], + responseFields: ['rate_name', 'rate_pct', 'last_updated'], + }, + economicsMortgageRate: { + catalogOperation: 'MORTGAGE_RATE', + path: 'economics.mortgageRate', + version: 'v2', + endpoint: 'mortgagerate', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/mortgagerate', + params: [ + { name: 'date', required: false, premium: true, enforced: null }, + { name: 'min_date', required: false, premium: true, enforced: null }, + { name: 'max_date', required: false, premium: true, enforced: null }, + ], + responseFields: ['date', 'frm_30', 'frm_15'], + }, + economicsMortgageCalculator: { + catalogOperation: 'CALCULATE_MORTGAGE', + path: 'economics.mortgageCalculator', + version: 'v1', + endpoint: 'mortgagecalculator', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/mortgagecalculator', + params: [ + { name: 'loan_amount', required: false, premium: false, enforced: null }, + { name: 'home_value', required: false, premium: false, enforced: null }, + { name: 'downpayment', required: false, premium: false, enforced: null }, + { name: 'interest_rate', required: true, premium: false, enforced: true }, + { + name: 'duration_years', + required: false, + premium: false, + enforced: null, + }, + { name: 'monthly_hoa', required: false, premium: false, enforced: null }, + { + name: 'annual_property_tax', + required: false, + premium: false, + enforced: null, + }, + { + name: 'annual_home_insurance', + required: false, + premium: false, + enforced: null, + }, + ], + responseFields: [ + 'monthly_payment', + 'annual_payment', + 'total_interest_paid', + ], + }, + economicsIncomeTax: { + catalogOperation: 'GET_INCOME_TAX', + path: 'economics.incomeTax', + version: 'v2', + endpoint: 'incometax', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/incometax', + params: [ + { name: 'country', required: true, premium: false, enforced: true }, + { name: 'year', required: true, premium: false, enforced: true }, + { name: 'regions', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'country', + 'year', + 'federal', + 'fica', + 'states', + 'provinces', + ], + }, + economicsIncomeTaxCalculator: { + catalogOperation: 'INCOME_TAX_CALCULATOR', + path: 'economics.incomeTaxCalculator', + version: 'v1', + endpoint: 'incometaxcalculator', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/incometaxcalculator', + params: [ + { name: 'country', required: true, premium: false, enforced: true }, + { name: 'region', required: true, premium: false, enforced: true }, + { name: 'income', required: true, premium: false, enforced: true }, + { name: 'tax_year', required: false, premium: false, enforced: null }, + { name: 'filing_status', required: true, premium: false, enforced: true }, + { name: 'deductions', required: false, premium: false, enforced: null }, + { name: 'credits', required: false, premium: false, enforced: null }, + { + name: 'self_employed', + required: false, + premium: false, + enforced: null, + }, + ], + responseFields: [ + 'country', + 'region', + 'income', + 'taxable_income', + 'deductions', + 'credits', + 'federal_effective_rate', + 'federal_taxes_owed', + 'fica_social_security', + 'fica_social_security_rate', + 'fica_social_security_cap', + 'fica_medicare', + 'fica_medicare_rate', + 'fica_total', + 'region_effective_rate', + 'region_taxes_owed', + 'total_taxes_owed', + 'income_after_tax', + 'total_effective_tax_rate', + ], + }, + economicsSalesTax: { + catalogOperation: 'GET_SALES_TAX', + path: 'economics.salesTax', + version: 'v1', + endpoint: 'salestax', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/salestax', + params: [ + { name: 'zip_code', required: false, premium: false, enforced: null }, + { + name: 'street_address', + required: false, + premium: false, + enforced: null, + }, + { name: 'city', required: false, premium: false, enforced: null }, + { name: 'state', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'street_address', + 'zip_code', + 'state_rate', + 'city_rate', + 'county_rate', + 'additional_rate', + 'total_rate', + ], + }, + economicsSalesTaxCalculator: { + catalogOperation: 'CALCULATE_SALES_TAX', + path: 'economics.salesTaxCalculator', + version: 'v1', + endpoint: 'salestaxcalculator', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/salestaxcalculator', + params: [ + { name: 'amount', required: true, premium: false, enforced: true }, + { name: 'zip_code', required: false, premium: false, enforced: null }, + { + name: 'street_address', + required: false, + premium: false, + enforced: null, + }, + { name: 'city', required: false, premium: false, enforced: null }, + { name: 'state', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'street_address', + 'zip_code', + 'state_rate', + 'city_rate', + 'county_rate', + 'additional_rate', + 'total_rate', + 'state_tax', + 'city_tax', + 'county_tax', + 'additional_tax', + 'total_tax', + 'total_price', + ], + }, + economicsPropertyTax: { + catalogOperation: 'GET_PROPERTY_TAX', + path: 'economics.propertyTax', + version: 'v1', + endpoint: 'propertytax', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/propertytax', + params: [ + { name: 'state', required: false, premium: false, enforced: null }, + { name: 'county', required: false, premium: false, enforced: null }, + { name: 'city', required: false, premium: false, enforced: null }, + { name: 'zip', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'state', + 'county', + 'city', + 'zip', + 'property_tax_25th_percentile', + 'property_tax_50th_percentile', + 'property_tax_75th_percentile', + ], + }, + economicsVatRates: { + catalogOperation: 'VALIDATE_EU_VAT', + path: 'economics.vatRates', + version: 'v1', + endpoint: 'vat', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/vat', + params: [ + { name: 'country', required: true, premium: false, enforced: true }, + { name: 'type', required: false, premium: false, enforced: null }, + { name: 'min_date', required: false, premium: false, enforced: null }, + { name: 'max_date', required: false, premium: false, enforced: null }, + { name: 'limit', required: false, premium: true, enforced: null }, + { name: 'offset', required: false, premium: true, enforced: null }, + ], + responseFields: ['country', 'type', 'rate', 'date', 'category'], + }, + textSentiment: { + catalogOperation: 'ANALYZE_SENTIMENT', + path: 'text.sentiment', + version: 'v1', + endpoint: 'sentiment', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/sentiment', + params: [{ name: 'text', required: true, premium: false, enforced: true }], + responseFields: ['score', 'text', 'sentiment'], + }, + textSimilarity: { + catalogOperation: 'COMPUTE_TEXT_SIMILARITY', + path: 'text.similarity', + version: 'v1', + endpoint: 'textsimilarity', + method: 'POST', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/textsimilarity', + params: [ + { name: 'text_1', required: true, premium: false, enforced: null }, + { name: 'text_2', required: true, premium: false, enforced: null }, + ], + responseFields: ['similarity'], + }, + textEmbeddings: { + catalogOperation: 'GENERATE_TEXT_EMBEDDINGS', + path: 'text.embeddings', + version: 'v1', + endpoint: 'embeddings', + method: 'POST', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/embeddings', + params: [{ name: 'text', required: true, premium: false, enforced: null }], + responseFields: ['embeddings'], + }, + textLanguage: { + catalogOperation: 'DETECT_TEXT_LANGUAGE', + path: 'text.language', + version: 'v1', + endpoint: 'textlanguage', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/textlanguage', + params: [{ name: 'text', required: true, premium: false, enforced: true }], + responseFields: ['iso', 'language'], + }, + textSpellCheck: { + catalogOperation: 'CHECK_SPELLING', + path: 'text.spellCheck', + version: 'v1', + endpoint: 'spellcheck', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/spellcheck', + params: [{ name: 'text', required: true, premium: false, enforced: true }], + responseFields: [ + 'original', + 'corrected', + 'corrections', + 'index', + 'correction', + 'candidates', + ], + }, + textProfanityFilter: { + catalogOperation: 'FILTER_PROFANITY', + path: 'text.profanityFilter', + version: 'v1', + endpoint: 'profanityfilter', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/profanityfilter', + params: [{ name: 'text', required: true, premium: false, enforced: true }], + responseFields: ['original', 'censored', 'has_profanity'], + }, + textDictionary: { + catalogOperation: 'GET_DICTIONARY_DEFINITION', + path: 'text.dictionary', + version: 'v1', + endpoint: 'dictionary', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/dictionary', + params: [{ name: 'word', required: true, premium: false, enforced: true }], + responseFields: ['word', 'definition', 'valid'], + }, + textThesaurus: { + catalogOperation: 'GET_THESAURUS', + path: 'text.thesaurus', + version: 'v1', + endpoint: 'thesaurus', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/thesaurus', + params: [{ name: 'word', required: true, premium: false, enforced: true }], + responseFields: ['word', 'synonyms', 'antonyms'], + }, + textRhymes: { + catalogOperation: 'GET_RHYMES', + path: 'text.rhymes', + version: 'v1', + endpoint: 'rhyme', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/rhyme', + params: [{ name: 'word', required: true, premium: false, enforced: true }], + responseFields: [], + }, + textRandomWord: { + catalogOperation: 'GET_RANDOM_WORD', + path: 'text.randomWord', + version: 'v2', + endpoint: 'randomword', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/randomword', + params: [ + { name: 'type', required: false, premium: true, enforced: null }, + { name: 'limit', required: false, premium: true, enforced: null }, + ], + responseFields: [], + }, + textLoremIpsum: { + catalogOperation: 'GENERATE_LOREM_IPSUM', + path: 'text.loremIpsum', + version: 'v1', + endpoint: 'loremipsum', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/loremipsum', + params: [ + { name: 'max_length', required: false, premium: false, enforced: null }, + { name: 'paragraphs', required: false, premium: false, enforced: null }, + { + name: 'start_with_lorem_ipsum', + required: false, + premium: false, + enforced: null, + }, + { name: 'random', required: false, premium: false, enforced: null }, + ], + responseFields: ['text'], + }, + utilityQrCode: { + catalogOperation: 'GENERATE_QR_CODE', + path: 'utility.qrCode', + version: 'v1', + endpoint: 'qrcode', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/qrcode', + params: [ + { name: 'data', required: true, premium: false, enforced: true }, + { name: 'format', required: true, premium: false, enforced: false }, + { name: 'size', required: false, premium: false, enforced: null }, + { name: 'fg_color', required: false, premium: false, enforced: null }, + { name: 'bg_color', required: false, premium: false, enforced: null }, + ], + responseFields: [], + }, + utilityBarcode: { + catalogOperation: 'BARCODE_GENERATE', + path: 'utility.barcode', + version: 'v1', + endpoint: 'barcodegenerate', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/barcode', + params: [ + { name: 'text', required: true, premium: false, enforced: true }, + { name: 'type', required: false, premium: false, enforced: null }, + { name: 'format', required: false, premium: false, enforced: null }, + { name: 'include_text', required: false, premium: false, enforced: null }, + ], + responseFields: [], + }, + utilityPassword: { + catalogOperation: 'GENERATE_PASSWORD', + path: 'utility.password', + version: 'v1', + endpoint: 'passwordgenerator', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/passwordgenerator', + params: [ + { name: 'length', required: false, premium: false, enforced: null }, + { + name: 'exclude_numbers', + required: false, + premium: false, + enforced: null, + }, + { + name: 'exclude_special_chars', + required: false, + premium: false, + enforced: null, + }, + ], + responseFields: ['random_password'], + }, + utilityRandomUser: { + catalogOperation: 'GENERATE_RANDOM_USER', + path: 'utility.randomUser', + version: 'v2', + endpoint: 'randomuser', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/randomuser', + params: [ + { name: 'count', required: false, premium: false, enforced: null }, + { name: 'gender', required: false, premium: false, enforced: null }, + { name: 'min_age', required: false, premium: false, enforced: null }, + { name: 'max_age', required: false, premium: false, enforced: null }, + { name: 'locale', required: false, premium: false, enforced: null }, + { name: 'fields', required: false, premium: false, enforced: null }, + { name: 'exclude', required: false, premium: false, enforced: null }, + { name: 'seed', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'id', + 'username', + 'password', + 'email', + 'name', + 'first_name', + 'last_name', + 'full_name', + 'prefix', + 'suffix', + 'phone', + 'cell', + 'address', + 'street_address', + 'city', + 'state', + 'postal_code', + 'country', + 'latitude', + 'longitude', + 'timezone', + 'dob', + 'age', + 'gender', + 'job', + 'company', + 'company_email', + 'ssn', + 'credit_card', + 'credit_card_provider', + 'iban', + 'ipv4', + 'ipv6', + 'mac_address', + 'user_agent', + 'url', + 'domain', + 'picture', + 'avatar', + 'uuid', + 'md5', + 'sha1', + 'sha256', + 'locale', + ], + }, + utilityCounter: { + catalogOperation: 'GET_COUNTER', + path: 'utility.counter', + version: 'v1', + endpoint: 'counter', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/counter', + params: [ + { name: 'id', required: true, premium: false, enforced: true }, + { name: 'hit', required: false, premium: false, enforced: null }, + { name: 'value', required: false, premium: false, enforced: null }, + ], + responseFields: ['id', 'value'], + }, + utilityConvertUnit: { + catalogOperation: 'CONVERT_UNIT', + path: 'utility.convertUnit', + version: 'v1', + endpoint: 'unitconversion', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/unitconversion', + params: [ + { name: 'amount', required: true, premium: false, enforced: true }, + { name: 'unit', required: true, premium: false, enforced: true }, + ], + responseFields: ['type', 'unit', 'amount', 'conversions'], + }, + utilityLogo: { + catalogOperation: 'GET_COMPANY_LOGO', + path: 'utility.logo', + version: 'v1', + endpoint: 'logo', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/logo', + params: [ + { name: 'name', required: false, premium: false, enforced: null }, + { name: 'ticker', required: false, premium: false, enforced: null }, + ], + responseFields: ['name', 'ticker', 'image'], + }, + utilityCountryFlag: { + catalogOperation: 'GET_COUNTRY_FLAG', + path: 'utility.countryFlag', + version: 'v1', + endpoint: 'countryflag', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/countryflag', + params: [ + { name: 'country', required: true, premium: false, enforced: true }, + ], + responseFields: ['country', 'square_image_url', 'rectangle_image_url'], + }, + utilityRandomImage: { + catalogOperation: 'GET_RANDOM_IMAGE', + path: 'utility.randomImage', + version: 'v1', + endpoint: 'randomimage', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/randomimage', + params: [ + { name: 'category', required: false, premium: false, enforced: null }, + { name: 'width', required: false, premium: false, enforced: null }, + { name: 'height', required: false, premium: false, enforced: null }, + ], + responseFields: [], + }, + utilityEmoji: { + catalogOperation: 'GET_EMOJI', + path: 'utility.emoji', + version: 'v1', + endpoint: 'emoji', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/emoji', + params: [ + { name: 'name', required: false, premium: false, enforced: null }, + { name: 'code', required: false, premium: false, enforced: null }, + { name: 'group', required: false, premium: false, enforced: null }, + { name: 'subgroup', required: false, premium: false, enforced: null }, + { name: 'offset', required: false, premium: false, enforced: null }, + ], + responseFields: ['code', 'character', 'image', 'name', 'group', 'subgroup'], + }, + transportAircraft: { + catalogOperation: 'GET_AIRCRAFT', + path: 'transport.aircraft', + version: 'v1', + endpoint: 'aircraft', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/aircraft', + params: [ + { name: 'manufacturer', required: false, premium: false, enforced: null }, + { name: 'model', required: false, premium: false, enforced: null }, + { name: 'engine_type', required: false, premium: false, enforced: null }, + { name: 'min_speed', required: false, premium: false, enforced: null }, + { name: 'max_speed', required: false, premium: false, enforced: null }, + { name: 'min_range', required: false, premium: false, enforced: null }, + { name: 'max_range', required: false, premium: false, enforced: null }, + { name: 'min_length', required: false, premium: false, enforced: null }, + { name: 'max_length', required: false, premium: false, enforced: null }, + { name: 'min_height', required: false, premium: false, enforced: null }, + { name: 'max_height', required: false, premium: false, enforced: null }, + { name: 'min_wingspan', required: false, premium: false, enforced: null }, + { name: 'max_wingspan', required: false, premium: false, enforced: null }, + { name: 'limit', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'manufacturer', + 'model', + 'engine_type', + 'engine_thrust_lb_ft', + 'max_speed_knots', + 'cruise_speed_knots', + 'ceiling_ft', + 'takeoff_ground_run_ft', + 'landing_ground_roll_ft', + 'gross_weight_lbs', + 'empty_weight_lbs', + 'length_ft', + 'height_ft', + 'wing_span_ft', + 'range_nautical_miles', + ], + }, + transportAirlines: { + catalogOperation: 'GET_AIRLINES', + path: 'transport.airlines', + version: 'v1', + endpoint: 'airlines', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: null, + params: [ + { name: 'name', required: false, premium: false, enforced: null }, + { name: 'iata', required: false, premium: false, enforced: null }, + { name: 'icao', required: false, premium: false, enforced: null }, + ], + responseFields: [], + }, + transportAirports: { + catalogOperation: 'GET_AIRPORTS', + path: 'transport.airports', + version: 'v1', + endpoint: 'airports', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/airports', + params: [ + { name: 'iata', required: false, premium: false, enforced: null }, + { name: 'icao', required: false, premium: false, enforced: null }, + { name: 'name', required: false, premium: true, enforced: null }, + { name: 'country', required: false, premium: false, enforced: null }, + { name: 'region', required: false, premium: true, enforced: null }, + { name: 'city', required: false, premium: true, enforced: null }, + { name: 'timezone', required: false, premium: false, enforced: null }, + { + name: 'min_elevation', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_elevation', + required: false, + premium: false, + enforced: null, + }, + { name: 'size', required: false, premium: false, enforced: null }, + { name: 'has_iata', required: false, premium: false, enforced: null }, + { + name: 'min_runway_length', + required: false, + premium: false, + enforced: null, + }, + { name: 'type', required: false, premium: false, enforced: null }, + { + name: 'scheduled_service', + required: false, + premium: false, + enforced: null, + }, + { name: 'continent', required: false, premium: false, enforced: null }, + { name: 'surface', required: false, premium: false, enforced: null }, + { name: 'has_lights', required: false, premium: false, enforced: null }, + { name: 'q', required: false, premium: false, enforced: null }, + { + name: 'include_closed', + required: false, + premium: false, + enforced: null, + }, + { name: 'limit', required: false, premium: false, enforced: null }, + { name: 'sort', required: false, premium: false, enforced: null }, + { name: 'order', required: false, premium: false, enforced: null }, + { name: 'offset', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'iata', + 'icao', + 'name', + 'city', + 'region', + 'country', + 'elevation_ft', + 'latitude', + 'longitude', + 'timezone', + 'size', + 'num_runways', + 'ident', + 'type', + 'region_code', + 'country_name', + 'continent', + 'elevation_m', + 'scheduled_service', + 'is_closed', + 'gps_code', + 'local_code', + 'home_link', + 'wikipedia_link', + 'keywords', + 'longest_runway_ft', + 'runways', + 'estimated_annual_passengers', + ], + }, + transportHelicopters: { + catalogOperation: 'GET_HELICOPTER', + path: 'transport.helicopters', + version: 'v1', + endpoint: 'helicopter', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/helicopter', + params: [ + { name: 'manufacturer', required: false, premium: false, enforced: null }, + { name: 'model', required: false, premium: false, enforced: null }, + { name: 'min_speed', required: false, premium: false, enforced: null }, + { name: 'max_speed', required: false, premium: false, enforced: null }, + { name: 'min_range', required: false, premium: false, enforced: null }, + { name: 'max_range', required: false, premium: false, enforced: null }, + { name: 'min_length', required: false, premium: false, enforced: null }, + { name: 'max_length', required: false, premium: false, enforced: null }, + { name: 'min_height', required: false, premium: false, enforced: null }, + { name: 'max_height', required: false, premium: false, enforced: null }, + { name: 'limit', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'manufacturer', + 'model', + 'max_speed_sl_knots', + 'cruise_speed_sl_knots', + 'range_nautical_miles', + 'cruise_time_min', + 'fuel_capacity_gallons', + 'gross_external_load_lbs', + 'external_load_limit_lbs', + 'main_rotor_diameter_ft', + 'num_blades', + 'blade_material', + 'rotor_type', + 'storage_width_ft', + 'length_ft', + 'height_ft', + ], + }, + transportCars: { + catalogOperation: 'GET_CARS', + path: 'transport.cars', + version: 'v1', + endpoint: 'cars', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/cars', + params: [ + { name: 'make', required: true, premium: false, enforced: false }, + { name: 'model', required: true, premium: false, enforced: true }, + { name: 'trim', required: true, premium: false, enforced: false }, + ], + responseFields: [], + }, + transportMotorcycles: { + catalogOperation: 'GET_MOTORCYCLES', + path: 'transport.motorcycles', + version: 'v1', + endpoint: 'motorcycles', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/motorcycles', + params: [ + { name: 'make', required: false, premium: false, enforced: null }, + { name: 'model', required: false, premium: false, enforced: null }, + { name: 'year', required: false, premium: false, enforced: null }, + { name: 'offset', required: false, premium: true, enforced: null }, + ], + responseFields: [], + }, + transportElectricVehicles: { + catalogOperation: 'GET_ELECTRIC_VEHICLE_INFO', + path: 'transport.electricVehicles', + version: 'v1', + endpoint: 'electricvehicle', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/electricvehicle', + params: [ + { name: 'make', required: false, premium: false, enforced: null }, + { name: 'model', required: false, premium: false, enforced: null }, + { name: 'min_year', required: false, premium: false, enforced: null }, + { name: 'max_year', required: false, premium: false, enforced: null }, + { name: 'min_range', required: false, premium: false, enforced: null }, + { name: 'max_range', required: false, premium: false, enforced: null }, + { name: 'limit', required: false, premium: true, enforced: null }, + { name: 'offset', required: false, premium: true, enforced: null }, + ], + responseFields: [ + 'make', + 'model', + 'year_start', + 'battery_capacity', + 'battery_useable_capacity', + 'charge_power', + 'charge_power_max', + 'top_speed', + 'electric_range', + 'total_power', + 'vehicle_consumption', + 'length, width, height', + 'seats', + ], + }, + transportVin: { + catalogOperation: 'LOOKUP_VIN', + path: 'transport.vin', + version: 'v1', + endpoint: 'vinlookup', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/vinlookup', + params: [{ name: 'vin', required: true, premium: false, enforced: true }], + responseFields: [], + }, + healthCaloriesBurned: { + catalogOperation: 'CALCULATE_CALORIES_BURNED', + path: 'health.caloriesBurned', + version: 'v1', + endpoint: 'caloriesburned', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/caloriesburned', + params: [ + { name: 'activity', required: true, premium: false, enforced: true }, + { name: 'weight', required: false, premium: false, enforced: null }, + { name: 'duration', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'name', + 'calories_per_hour', + 'duration_minutes', + 'total_calories', + ], + }, + healthNutrition: { + catalogOperation: 'NUTRITION', + path: 'health.nutrition', + version: 'v1', + endpoint: 'nutrition', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/nutrition', + params: [{ name: 'query', required: true, premium: false, enforced: true }], + responseFields: [ + 'calories', + 'serving_size_g', + 'fat_total_g', + 'fat_saturated_g', + 'fat_trans_g', + 'protein_g', + 'sodium_mg', + 'potassium_mg', + 'cholesterol_mg', + 'carbohydrates_total_g', + 'fiber_g', + 'sugar_g', + 'added_sugars_g', + 'net_carbs_g', + 'iron_mg', + 'calcium_mg', + 'magnesium_mg', + 'zinc_mg', + 'vitamin_a_mcg', + 'vitamin_c_mg', + 'vitamin_d_mcg', + ], + }, + healthExercises: { + catalogOperation: 'GET_EXERCISES', + path: 'health.exercises', + version: 'v1', + endpoint: 'exercises', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: true, + documented: 'https://api-ninjas.com/api/exercises', + params: [ + { name: 'name', required: false, premium: false, enforced: null }, + { name: 'type', required: false, premium: false, enforced: null }, + { name: 'muscle', required: false, premium: false, enforced: null }, + { name: 'difficulty', required: false, premium: false, enforced: null }, + { name: 'equipments', required: false, premium: false, enforced: null }, + { name: 'offset', required: false, premium: true, enforced: null }, + ], + responseFields: [ + 'name', + 'type', + 'muscle', + 'difficulty', + 'instructions', + 'equipments', + 'safety_info', + ], + }, + healthRecipes: { + catalogOperation: 'GET_RECIPE', + path: 'health.recipes', + version: 'v3', + endpoint: 'recipe', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/recipe', + params: [ + { name: 'title', required: false, premium: false, enforced: null }, + { name: 'ingredients', required: false, premium: false, enforced: null }, + { name: 'limit', required: false, premium: false, enforced: null }, + { name: 'offset', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'title', + 'ingredients', + 'servings', + 'instructions', + 'nutrition', + ], + }, + healthCocktails: { + catalogOperation: 'GET_COCKTAIL', + path: 'health.cocktails', + version: 'v1', + endpoint: 'cocktail', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/cocktail', + params: [ + { name: 'name', required: false, premium: false, enforced: null }, + { name: 'ingredients', required: false, premium: false, enforced: null }, + ], + responseFields: ['name', 'ingredients', 'instructions'], + }, + referenceAnimals: { + catalogOperation: 'GET_ANIMALS', + path: 'reference.animals', + version: 'v1', + endpoint: 'animals', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/animals', + params: [{ name: 'name', required: true, premium: false, enforced: true }], + responseFields: ['name', 'taxonomy', 'locations', 'characteristics'], + }, + referenceCats: { + catalogOperation: 'GET_CATS', + path: 'reference.cats', + version: 'v1', + endpoint: 'cats', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/cats', + params: [ + { name: 'name', required: false, premium: false, enforced: null }, + { name: 'min_weight', required: false, premium: false, enforced: null }, + { name: 'max_weight', required: false, premium: false, enforced: null }, + { + name: 'min_life_expectancy', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_life_expectancy', + required: false, + premium: false, + enforced: null, + }, + { name: 'shedding', required: false, premium: false, enforced: null }, + { + name: 'family_friendly', + required: false, + premium: false, + enforced: null, + }, + { name: 'playfulness', required: false, premium: false, enforced: null }, + { name: 'grooming', required: false, premium: false, enforced: null }, + { + name: 'other_pets_friendly', + required: false, + premium: false, + enforced: null, + }, + { + name: 'children_friendly', + required: false, + premium: false, + enforced: null, + }, + { name: 'offset', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'name', + 'image_link', + 'length', + 'origin', + 'family_friendly', + 'children_friendly', + 'other_pets_friendly', + 'shedding', + 'grooming', + 'general_health', + 'playfulness', + 'intelligence', + 'min_weight', + 'max_weight', + 'min_life_expectancy', + 'max_life_expectancy', + ], + }, + referenceDogs: { + catalogOperation: 'GET_DOGS', + path: 'reference.dogs', + version: 'v1', + endpoint: 'dogs', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/dogs', + params: [ + { name: 'name', required: false, premium: false, enforced: null }, + { name: 'min_height', required: false, premium: false, enforced: null }, + { name: 'max_height', required: false, premium: false, enforced: null }, + { name: 'min_weight', required: false, premium: false, enforced: null }, + { name: 'max_weight', required: false, premium: false, enforced: null }, + { + name: 'min_life_expectancy', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_life_expectancy', + required: false, + premium: false, + enforced: null, + }, + { name: 'shedding', required: false, premium: false, enforced: null }, + { name: 'barking', required: false, premium: false, enforced: null }, + { name: 'energy', required: false, premium: false, enforced: null }, + { + name: 'protectiveness', + required: false, + premium: false, + enforced: null, + }, + { name: 'trainability', required: false, premium: false, enforced: null }, + { name: 'offset', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'name', + 'image_link', + 'good_with_children', + 'good_with_other_dogs', + 'good_with_strangers', + 'shedding', + 'grooming', + 'drooling', + 'coat_length', + 'playfulness', + 'protectiveness', + 'trainability', + 'energy', + 'barking', + 'min_life_expectancy', + 'max_life_expectancy', + 'min_height_male', + 'max_height_male', + 'min_height_female', + 'max_height_female', + 'min_weight_male', + 'max_weight_male', + 'min_weight_female', + 'max_weight_female', + ], + }, + referencePlanets: { + catalogOperation: 'GET_PLANETS', + path: 'reference.planets', + version: 'v1', + endpoint: 'planets', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/planets', + params: [ + { name: 'name', required: false, premium: false, enforced: null }, + { name: 'min_mass', required: false, premium: false, enforced: null }, + { name: 'max_mass', required: false, premium: false, enforced: null }, + { name: 'min_radius', required: false, premium: false, enforced: null }, + { name: 'max_radius', required: false, premium: false, enforced: null }, + { name: 'min_period', required: false, premium: false, enforced: null }, + { name: 'max_period', required: false, premium: false, enforced: null }, + { + name: 'min_temperature', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_temperature', + required: false, + premium: false, + enforced: null, + }, + { + name: 'min_distance_light_year', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_distance_light_year', + required: false, + premium: false, + enforced: null, + }, + { + name: 'min_semi_major_axis', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_semi_major_axis', + required: false, + premium: false, + enforced: null, + }, + { name: 'offset', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'name', + 'mass', + 'radius', + 'period', + 'semi_major_axis', + 'temperature', + 'distance_light_year', + 'host_star_mass', + 'host_star_temperature', + ], + }, + referenceStars: { + catalogOperation: 'GET_STARS', + path: 'reference.stars', + version: 'v1', + endpoint: 'stars', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/stars', + params: [ + { name: 'name', required: false, premium: false, enforced: null }, + { + name: 'constellation', + required: false, + premium: false, + enforced: null, + }, + { + name: 'min_apparent_magnitude', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_apparent_magnitude', + required: false, + premium: false, + enforced: null, + }, + { + name: 'min_absolute_magnitude', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_absolute_magnitude', + required: false, + premium: false, + enforced: null, + }, + { + name: 'min_distance_light_year', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_distance_light_year', + required: false, + premium: false, + enforced: null, + }, + { name: 'offset', required: false, premium: false, enforced: null }, + ], + responseFields: [ + 'name', + 'constellation', + 'right_ascension', + 'declination', + 'apparent_magnitude', + 'absolute_magnitude', + 'distance_light_year', + 'spectral_class', + ], + }, + referenceHistoricalEvents: { + catalogOperation: 'GET_HISTORICAL_EVENTS', + path: 'reference.historicalEvents', + version: 'v1', + endpoint: 'historicalevents', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/historicalevents', + params: [ + { name: 'text', required: false, premium: false, enforced: null }, + { name: 'year', required: false, premium: false, enforced: null }, + { name: 'month', required: false, premium: false, enforced: null }, + { name: 'day', required: false, premium: false, enforced: null }, + { name: 'offset', required: false, premium: true, enforced: null }, + ], + responseFields: ['year', 'month', 'day', 'event'], + }, + referenceHistoricalFigures: { + catalogOperation: 'GET_HISTORICAL_FIGURES', + path: 'reference.historicalFigures', + version: 'v1', + endpoint: 'historicalfigures', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/historicalfigures', + params: [ + { name: 'name', required: true, premium: false, enforced: true }, + { name: 'offset', required: false, premium: false, enforced: null }, + ], + responseFields: ['name', 'title', 'info'], + }, + referenceDayInHistory: { + catalogOperation: 'GET_DAY_IN_HISTORY', + path: 'reference.dayInHistory', + version: 'v1', + endpoint: 'dayinhistory', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/dayinhistory', + params: [ + { name: 'month', required: false, premium: true, enforced: null }, + { name: 'day', required: false, premium: true, enforced: null }, + { name: 'offset', required: false, premium: true, enforced: null }, + { name: 'limit', required: false, premium: true, enforced: null }, + ], + responseFields: ['year', 'month', 'day', 'event'], + }, + referenceCelebrities: { + catalogOperation: 'GET_CELEBRITY', + path: 'reference.celebrities', + version: 'v1', + endpoint: 'celebrity', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/celebrity', + params: [ + { name: 'name', required: false, premium: false, enforced: null }, + { + name: 'min_net_worth', + required: false, + premium: false, + enforced: null, + }, + { + name: 'max_net_worth', + required: false, + premium: false, + enforced: null, + }, + { name: 'nationality', required: false, premium: false, enforced: null }, + { name: 'min_height', required: false, premium: false, enforced: null }, + { name: 'max_height', required: false, premium: false, enforced: null }, + { name: 'offset', required: false, premium: true, enforced: null }, + ], + responseFields: [ + 'name', + 'net_worth', + 'gender', + 'nationality', + 'occupation', + 'height', + 'birthday', + 'age', + 'is_alive', + ], + }, + referenceBabyNames: { + catalogOperation: 'GET_BABY_NAMES', + path: 'reference.babyNames', + version: 'v1', + endpoint: 'babynames', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/babynames', + params: [ + { name: 'gender', required: false, premium: false, enforced: null }, + { name: 'popular_only', required: false, premium: false, enforced: null }, + ], + responseFields: [], + }, + entertainmentJokes: { + catalogOperation: 'GET_JOKES', + path: 'entertainment.jokes', + version: 'v1', + endpoint: 'jokes', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/jokes', + params: [{ name: 'limit', required: false, premium: true, enforced: null }], + responseFields: ['joke'], + }, + entertainmentDadJokes: { + catalogOperation: 'GET_DAD_JOKE', + path: 'entertainment.dadJokes', + version: 'v1', + endpoint: 'dadjokes', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/dadjokes', + params: [{ name: 'limit', required: false, premium: true, enforced: null }], + responseFields: ['joke'], + }, + entertainmentChuckNorris: { + catalogOperation: 'GET_CHUCK_NORRIS_JOKE', + path: 'entertainment.chuckNorris', + version: 'v1', + endpoint: 'chucknorris', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/chucknorris', + params: [], + responseFields: ['joke'], + }, + entertainmentJokeOfTheDay: { + catalogOperation: 'GET_JOKE_OF_THE_DAY', + path: 'entertainment.jokeOfTheDay', + version: 'v1', + endpoint: 'jokeoftheday', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/jokes', + params: [], + responseFields: [], + }, + entertainmentFacts: { + catalogOperation: 'GET_FACTS', + path: 'entertainment.facts', + version: 'v1', + endpoint: 'facts', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/facts', + params: [{ name: 'limit', required: false, premium: true, enforced: null }], + responseFields: [], + }, + entertainmentFactOfTheDay: { + catalogOperation: 'GET_FACT_OF_THE_DAY', + path: 'entertainment.factOfTheDay', + version: 'v1', + endpoint: 'factoftheday', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/facts', + params: [], + responseFields: [], + }, + entertainmentQuotes: { + catalogOperation: 'GET_QUOTES', + path: 'entertainment.quotes', + version: 'v2', + endpoint: 'quotes', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/quotes', + params: [ + { name: 'categories', required: false, premium: false, enforced: null }, + { + name: 'exclude_categories', + required: false, + premium: false, + enforced: null, + }, + { name: 'author', required: false, premium: false, enforced: null }, + { name: 'work', required: false, premium: false, enforced: null }, + { name: 'limit', required: false, premium: true, enforced: null }, + { name: 'offset', required: false, premium: true, enforced: null }, + ], + responseFields: ['quote', 'author', 'work', 'categories'], + }, + entertainmentRandomQuotes: { + catalogOperation: 'GET_RANDOM_QUOTES', + path: 'entertainment.randomQuotes', + version: 'v2', + endpoint: 'randomquotes', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/quotes', + params: [ + { name: 'categories', required: false, premium: false, enforced: null }, + { + name: 'exclude_categories', + required: false, + premium: false, + enforced: null, + }, + { name: 'author', required: false, premium: false, enforced: null }, + { name: 'work', required: false, premium: false, enforced: null }, + { name: 'limit', required: false, premium: true, enforced: null }, + ], + responseFields: [], + }, + entertainmentQuoteOfTheDay: { + catalogOperation: 'GET_QUOTE_OF_THE_DAY', + path: 'entertainment.quoteOfTheDay', + version: 'v2', + endpoint: 'quoteoftheday', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/quotes', + params: [], + responseFields: [], + }, + entertainmentAdvice: { + catalogOperation: 'GET_ADVICE', + path: 'entertainment.advice', + version: 'v1', + endpoint: 'advice', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/advice', + params: [], + responseFields: ['advice'], + }, + entertainmentBucketList: { + catalogOperation: 'GET_BUCKETLIST', + path: 'entertainment.bucketList', + version: 'v1', + endpoint: 'bucketlist', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/bucketlist', + params: [], + responseFields: ['item'], + }, + entertainmentHobbies: { + catalogOperation: 'GET_HOBBIES', + path: 'entertainment.hobbies', + version: 'v1', + endpoint: 'hobbies', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/hobbies', + params: [ + { name: 'category', required: false, premium: false, enforced: null }, + ], + responseFields: ['hobby', 'link', 'category'], + }, + entertainmentHoroscope: { + catalogOperation: 'GET_HOROSCOPE', + path: 'entertainment.horoscope', + version: 'v1', + endpoint: 'horoscope', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/horoscope', + params: [ + { name: 'zodiac', required: true, premium: false, enforced: true }, + { name: 'date', required: false, premium: true, enforced: null }, + ], + responseFields: ['date', 'sign', 'horoscope'], + }, + entertainmentRiddles: { + catalogOperation: 'GET_RIDDLES', + path: 'entertainment.riddles', + version: 'v1', + endpoint: 'riddles', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/riddles', + params: [{ name: 'limit', required: false, premium: true, enforced: null }], + responseFields: ['title', 'question', 'answer'], + }, + entertainmentTrivia: { + catalogOperation: 'GET_TRIVIA', + path: 'entertainment.trivia', + version: 'v1', + endpoint: 'trivia', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/trivia', + params: [ + { name: 'category', required: false, premium: true, enforced: null }, + { name: 'limit', required: false, premium: true, enforced: null }, + ], + responseFields: ['category', 'question', 'answer'], + }, + entertainmentTriviaOfTheDay: { + catalogOperation: 'GET_TRIVIA_OF_THE_DAY', + path: 'entertainment.triviaOfTheDay', + version: 'v1', + endpoint: 'triviaoftheday', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/trivia', + params: [], + responseFields: [], + }, + entertainmentGenerateSudoku: { + catalogOperation: 'GENERATE_SUDOKU', + path: 'entertainment.generateSudoku', + version: 'v1', + endpoint: 'sudokugenerate', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/sudoku', + params: [ + { name: 'width', required: false, premium: false, enforced: null }, + { name: 'height', required: false, premium: false, enforced: null }, + { name: 'difficulty', required: false, premium: false, enforced: null }, + { name: 'seed', required: false, premium: false, enforced: null }, + ], + responseFields: ['puzzle', 'solution'], + }, + entertainmentSolveSudoku: { + catalogOperation: 'SOLVE_SUDOKU', + path: 'entertainment.solveSudoku', + version: 'v1', + endpoint: 'sudokusolve', + method: 'GET', + /** True when the documentation describes a choice of parameter combinations. */ + combination: false, + documented: 'https://api-ninjas.com/api/sudoku', + params: [ + { name: 'puzzle', required: true, premium: false, enforced: true }, + { name: 'width', required: true, premium: false, enforced: true }, + { name: 'height', required: true, premium: false, enforced: true }, + ], + responseFields: ['status', 'solution'], + }, +}; diff --git a/packages/apininjas/endpoints.test.ts b/packages/apininjas/endpoints.test.ts new file mode 100644 index 000000000..95a4729ff --- /dev/null +++ b/packages/apininjas/endpoints.test.ts @@ -0,0 +1,412 @@ +/** + * Registry invariants and error routing. + * + * The registry checks keep the four parallel structures - the nested endpoint + * tree, the schema map, the metadata map and the documented contract - in step + * with each other; drift between them is the failure mode that a plugin this + * wide invites. + * + * The error checks matter more here than on most providers, because API Ninjas + * answers almost everything with a 400 and expects the body to be read. + */ +import { ApiError } from 'corsair/http'; +import { DOCUMENTED_OPERATIONS } from './docs-contract'; +import { + ApiNinjasEndpointInputSchemas, + ApiNinjasEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { apiNinjasEndpointSchemas, apininjas } from './index'; + +const plugin = apininjas(); + +/** + * The endpoint tree, read structurally. The declared type is a deep literal of + * 129 typed callables, which is precise for callers and unusable for iteration. + */ +const endpointTree = plugin.endpoints as unknown as Record< + string, + Record +>; + +/** Flattens the nested endpoint tree into `group.leaf` paths. */ +function nestedPaths(): string[] { + const paths: string[] = []; + for (const [group, leaves] of Object.entries(endpointTree)) { + for (const leaf of Object.keys(leaves)) { + paths.push(`${group}.${leaf}`); + } + } + return paths.sort(); +} + +describe('registry', () => { + it('registers all 129 catalog operations', () => { + expect(nestedPaths()).toHaveLength(129); + }); + + it('keeps the endpoint tree, schemas and metadata in step', () => { + const paths = nestedPaths(); + + expect(Object.keys(apiNinjasEndpointSchemas).sort()).toEqual(paths); + expect( + Object.keys(plugin.endpointMeta as Record).sort(), + ).toEqual(paths); + expect(Object.keys(DOCUMENTED_OPERATIONS)).toHaveLength(paths.length); + }); + + it('names every operation key as its path camel-cased', () => { + // The schema maps are keyed by `groupLeaf` while the registry is keyed by + // `group.leaf`. A mismatch would make a lookup silently return undefined. + for (const path of nestedPaths()) { + const [group, leaf] = path.split('.'); + const key = `${group}${(leaf as string).charAt(0).toUpperCase()}${(leaf as string).slice(1)}`; + + expect(ApiNinjasEndpointInputSchemas).toHaveProperty(key); + expect(ApiNinjasEndpointOutputSchemas).toHaveProperty(key); + expect(DOCUMENTED_OPERATIONS[key]?.path).toBe(path); + } + }); + + it('points every operation at a documented endpoint and version', () => { + for (const [key, documented] of Object.entries(DOCUMENTED_OPERATIONS)) { + expect({ key, version: documented.version }).toEqual({ + key, + version: expect.stringMatching(/^v[123]$/), + }); + expect(documented.endpoint).toMatch(/^[a-z0-9]+$/); + expect(['GET', 'POST']).toContain(documented.method); + } + }); + + it('exposes every endpoint as a callable', () => { + for (const leaves of Object.values(endpointTree)) { + for (const endpoint of Object.values(leaves)) { + expect(typeof endpoint).toBe('function'); + } + } + }); +}); + +describe('risk levels', () => { + const meta = plugin.endpointMeta as Record< + string, + { riskLevel: string; description: string } + >; + + it('marks the counter as the only operation that changes anything', () => { + // Everything else on this API is a pure lookup. The counter endpoint + // increments a stored value when called with `hit` or `value`. + // + // This doubles as the retry-safety check. Corsair replays the whole + // endpoint call when a handler asks for a retry and this API offers no + // idempotency key, so the set of operations that must not be replayed is + // exactly the set that is not a read - and it is derived here rather than + // matched by name, so a new write cannot join it silently. + const writes = Object.entries(meta) + .filter(([, entry]) => entry.riskLevel !== 'read') + .map(([path]) => path); + + expect(writes).toEqual(['utility.counter']); + expect(meta['utility.counter']?.riskLevel).toBe('write'); + }); + + it('has no destructive operation, because the API deletes nothing', () => { + const destructive = Object.values(meta).filter( + (entry) => entry.riskLevel === 'destructive', + ); + + expect(destructive).toHaveLength(0); + }); + + it('describes every operation in plain ASCII', () => { + for (const [path, entry] of Object.entries(meta)) { + expect({ path, empty: entry.description.trim().length === 0 }).toEqual({ + path, + empty: false, + }); + // Printable ASCII only: these descriptions are rendered in the operation + // catalog and quoted in the pull request, and a stray byte from a + // scraped description would surface there as mojibake. + expect(entry.description).toMatch(/^[ -~]+$/); + } + }); + + it('flags the premium-gated and deprecated operations in their description', () => { + expect(meta['markets.earningsTranscript']?.description).toContain( + 'premium plan required', + ); + expect(meta['transport.cars']?.description).toContain('deprecated'); + }); +}); + +/** Builds an ApiError the way the transport does, with a status and a body. */ +function apiError(status: number, body: unknown, message = 'Error'): ApiError { + return new ApiError( + { method: 'GET', url: 'https://api.api-ninjas.com/v1/sentiment' }, + { + url: 'https://api.api-ninjas.com/v1/sentiment', + ok: false, + status, + statusText: 'Error', + body, + }, + message, + ); +} + +type ErrorContext = { + pluginId: string; + operation: string; + input: Record; + originalError: Error; +}; + +const context: ErrorContext = { + pluginId: 'apininjas', + operation: 'text.sentiment', + input: {}, + originalError: new Error('test'), +}; + +type Matcher = { match: (error: Error, context: ErrorContext) => boolean }; + +/** The first handler that matches, in declaration order - as the core does it. */ +function route(error: Error): string { + for (const [name, handler] of Object.entries( + errorHandlers as Record, + )) { + if (handler.match(error, context)) return name; + } + return 'NONE'; +} + +describe('error routing', () => { + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it.each([ + [ + 'an exhausted monthly quota', + apiError(400, { error: 'Monthly quota exceeded. Consider upgrading.' }), + 'RATE_LIMIT_ERROR', + ], + [ + 'a throttled request', + apiError(429, { error: 'Too Many Requests' }), + 'RATE_LIMIT_ERROR', + ], + [ + 'a missing key', + apiError(400, { error: 'Missing API Key.' }), + 'AUTH_ERROR', + ], + [ + 'an invalid key', + apiError(400, { error: 'Invalid API Key.' }), + 'AUTH_ERROR', + ], + [ + 'a premium-only endpoint', + apiError(400, { + error: 'This endpoint is available to premium subscribers only.', + }), + 'PERMISSION_ERROR', + ], + [ + 'a premium-only parameter', + apiError(400, { + error: 'year parameter is for premium subscribers only', + }), + 'PERMISSION_ERROR', + ], + [ + 'an endpoint disabled for free users', + apiError(400, { + error: 'This endpoint is currently down for free users.', + }), + 'PERMISSION_ERROR', + ], + [ + 'an unknown endpoint', + apiError(404, { + message: + 'Endpoint not found. Please check your spelling and try again.', + }), + 'NOT_FOUND_ERROR', + ], + [ + 'an ordinary bad parameter', + apiError(400, { error: 'Invalid text parameter.' }), + 'BAD_REQUEST_ERROR', + ], + [ + 'a server fault', + apiError(502, { message: 'Internal server error' }), + 'SERVER_ERROR', + ], + ['a dropped connection', new Error('fetch failed'), 'NETWORK_ERROR'], + ])('routes %s to %s', (_label, error, expected) => { + expect(route(error as Error)).toBe(expected); + }); + + it('never retries an exhausted quota', async () => { + // The monthly allowance does not return inside a retry window, so retrying + // only spends attempts against a limit that is already gone. + const error = apiError(400, { error: 'Monthly quota exceeded.' }); + + const strategy = await errorHandlers.RATE_LIMIT_ERROR.handler( + error, + context, + ); + + expect(strategy.maxRetries).toBe(0); + }); + + it('retries a genuine 429', async () => { + const strategy = await errorHandlers.RATE_LIMIT_ERROR.handler( + apiError(429, { error: 'Too Many Requests' }), + context, + ); + + expect(strategy.maxRetries).toBe(5); + }); + + it('does not retry a 502, which is also how a bad parameter is reported', async () => { + // A wrong parameter name and an unsolvable puzzle both come back as 502 + // here, and retrying either five times just spends quota. + const strategy = await errorHandlers.SERVER_ERROR.handler( + apiError(502, { message: 'Internal server error' }), + context, + ); + + expect(strategy.maxRetries).toBe(0); + }); + + it('retries a real server fault', async () => { + const strategy = await errorHandlers.SERVER_ERROR.handler( + apiError(503, { message: 'Service Unavailable' }), + context, + ); + + expect(strategy.maxRetries).toBe(2); + }); + + it('keeps DEFAULT last so the specific handlers stay reachable', () => { + const names = Object.keys(errorHandlers); + + expect(names[names.length - 1]).toBe('DEFAULT'); + }); +}); + +describe('plugin definition', () => { + it('declares a single API key and no webhooks', () => { + expect(plugin.id).toBe('apininjas'); + expect(plugin.authConfig).toEqual({ api_key: { account: ['one'] } }); + // API Ninjas is request/response only - nothing calls back. + expect(plugin.webhooks).toEqual({}); + expect(plugin.pluginWebhookMatcher?.({} as never)).toBe(false); + }); + + it('uses the key given in options ahead of the stored credential', async () => { + const configured = apininjas({ key: 'option-key' }); + const keyBuilder = configured.keyBuilder as ( + ctx: unknown, + source: string, + ) => Promise; + + await expect( + keyBuilder({ authType: 'api_key', keys: {} }, 'endpoint'), + ).resolves.toBe('option-key'); + }); + + it('raises rather than sending an empty key', async () => { + const keyBuilder = plugin.keyBuilder as ( + ctx: unknown, + source: string, + ) => Promise; + + // An empty key reaches the provider as "Missing API Key." - a confusing + // way to report a configuration gap. + await expect( + keyBuilder( + { authType: 'api_key', keys: { get_api_key: async () => undefined } }, + 'endpoint', + ), + ).rejects.toThrow(); + }); + + it('returns the stored credential when there is one', async () => { + const keyBuilder = plugin.keyBuilder as ( + ctx: unknown, + source: string, + ) => Promise; + + await expect( + keyBuilder( + { + authType: 'api_key', + keys: { get_api_key: async () => 'stored-key' }, + }, + 'endpoint', + ), + ).resolves.toBe('stored-key'); + }); + + it('raises for any source other than an endpoint call', async () => { + // There is no webhook or OAuth path on this plugin, so a request for a key + // from anywhere else is a bug rather than a case to serve. + const keyBuilder = plugin.keyBuilder as ( + ctx: unknown, + source: string, + ) => Promise; + + await expect( + keyBuilder( + { + authType: 'api_key', + keys: { get_api_key: async () => 'stored-key' }, + }, + 'webhook', + ), + ).rejects.toThrow(); + }); + + it('merges caller-supplied error handlers ahead of the built-in default', () => { + // DEFAULT matches everything, so a caller handler spread after it would be + // unreachable. The merge keeps DEFAULT last. + const custom = apininjas({ + errorHandlers: { + RATE_LIMIT_ERROR: { match: () => false, handler: async () => ({}) }, + }, + }); + const names = Object.keys(custom.errorHandlers ?? {}); + + expect(names[names.length - 1]).toBe('DEFAULT'); + expect( + custom.errorHandlers?.RATE_LIMIT_ERROR?.match(new Error('x'), { + pluginId: 'apininjas', + operation: 'text.sentiment', + input: {}, + originalError: new Error('x'), + }), + ).toBe(false); + }); + + it('lets a caller replace the default handler itself', () => { + const replacement = { + match: () => true, + handler: async () => ({ maxRetries: 9 }), + }; + const custom = apininjas({ errorHandlers: { DEFAULT: replacement } }); + + expect(custom.errorHandlers?.DEFAULT).toBe(replacement); + expect(Object.keys(custom.errorHandlers ?? {}).pop()).toBe('DEFAULT'); + }); +}); diff --git a/packages/apininjas/endpoints/calendar.ts b/packages/apininjas/endpoints/calendar.ts new file mode 100644 index 000000000..76d99ac32 --- /dev/null +++ b/packages/apininjas/endpoints/calendar.ts @@ -0,0 +1,223 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeApiNinjasRequest } from '../client'; +import type { ApiNinjasEndpoints } from '../index'; +import { auditPayload, withCount } from './logging'; +import type { ApiNinjasEndpointOutputs } from './types'; + +/** + * Timezones, world time, holidays and working days. + * + * Every operation here is a single documented endpoint under + * https://api.api-ninjas.com. Inputs map one-to-one onto the documented query + * parameters, so nothing is renamed on the way through. + */ + +/** + * Get timezone info by city/state/country or location coordinates + * (latitude/longitude). Returns the timezone name of the specified input + * location and the time offset in seconds. + */ +export const timezone: ApiNinjasEndpoints['calendarTimezone'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['calendarTimezone'] + >('timezone', ctx.key, { + version: 'v1', + query: { + timezone: input.timezone, + lat: input.lat, + lon: input.lon, + city: input.city, + state: input.state, + country: input.country, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.calendar.timezone', + withCount( + auditPayload(input, ['timezone', 'city', 'state', 'country']), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Get the current date and time by city/state/country, location + * coordinates (latitude/longitude), or timezone. + */ +export const worldTime: ApiNinjasEndpoints['calendarWorldTime'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['calendarWorldTime'] + >('worldtime', ctx.key, { + version: 'v1', + query: { + timezone: input.timezone, + lat: input.lat, + lon: input.lon, + city: input.city, + state: input.state, + country: input.country, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.calendar.worldTime', + withCount( + auditPayload(input, ['timezone', 'city', 'state', 'country']), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Returns a list of holiday entries for a given country and year. Each + * entry in the response contains the holiday name, date, day of the week, + * and the type of holiday. + */ +export const holidays: ApiNinjasEndpoints['calendarHolidays'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['calendarHolidays'] + >('holidays', ctx.key, { + version: 'v2', + query: { + country: input.country, + year: input.year, + type: input.type, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.calendar.holidays', + withCount(auditPayload(input, ['country', 'year', 'type']), result), + 'completed', + ); + return result; +}; + +/** Returns a list of public holidays for a given country and year. */ +export const publicHolidays: ApiNinjasEndpoints['calendarPublicHolidays'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['calendarPublicHolidays'] + >('publicholidays', ctx.key, { + version: 'v1', + query: { + country: input.country, + year: input.year, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.calendar.publicHolidays', + withCount(auditPayload(input, ['country', 'year']), result), + 'completed', + ); + return result; + }; + +/** Returns whether a given date is a public holiday for a given country. */ +export const isPublicHoliday: ApiNinjasEndpoints['calendarIsPublicHoliday'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['calendarIsPublicHoliday'] + >('ispublicholiday', ctx.key, { + version: 'v1', + query: { + country: input.country, + date: input.date, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.calendar.isPublicHoliday', + withCount(auditPayload(input, ['country', 'date']), result), + 'completed', + ); + return result; + }; + +/** Returns whether a given date is a working day for a given country. */ +export const isWorkingDay: ApiNinjasEndpoints['calendarIsWorkingDay'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['calendarIsWorkingDay'] + >('isworkingday', ctx.key, { + version: 'v1', + query: { + country: input.country, + date: input.date, + weekend: input.weekend, + public_holidays: input.public_holidays, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.calendar.isWorkingDay', + withCount( + auditPayload(input, ['country', 'date', 'weekend', 'public_holidays']), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Returns a list of working days and non-working days for a given country + * and year/month. + */ +export const workingDays: ApiNinjasEndpoints['calendarWorkingDays'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['calendarWorkingDays'] + >('workingdays', ctx.key, { + version: 'v1', + query: { + country: input.country, + year: input.year, + month: input.month, + weekend: input.weekend, + public_holidays: input.public_holidays, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.calendar.workingDays', + withCount( + auditPayload(input, [ + 'country', + 'year', + 'month', + 'weekend', + 'public_holidays', + ]), + result, + ), + 'completed', + ); + return result; +}; diff --git a/packages/apininjas/endpoints/economics.ts b/packages/apininjas/endpoints/economics.ts new file mode 100644 index 000000000..9b1089d8d --- /dev/null +++ b/packages/apininjas/endpoints/economics.ts @@ -0,0 +1,404 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeApiNinjasRequest } from '../client'; +import type { ApiNinjasEndpoints } from '../index'; +import { auditPayload, withCount } from './logging'; +import type { ApiNinjasEndpointOutputs } from './types'; + +/** + * National statistics, interest rates and tax. + * + * Every operation here is a single documented endpoint under + * https://api.api-ninjas.com. Inputs map one-to-one onto the documented query + * parameters, so nothing is renamed on the way through. + */ + +/** + * Get GDP data from given parameters. Returns GDP statistics that satisfy + * the parameters. + */ +export const gdp: ApiNinjasEndpoints['economicsGdp'] = async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['economicsGdp'] + >('gdp', ctx.key, { + version: 'v1', + query: { + country: input.country, + year: input.year, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.economics.gdp', + withCount(auditPayload(input, ['country', 'year']), result), + 'completed', + ); + return result; +}; + +/** Returns current monthly and annual inflation percentages. */ +export const inflation: ApiNinjasEndpoints['economicsInflation'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['economicsInflation'] + >('inflation', ctx.key, { + version: 'v1', + query: { + type: input.type, + country: input.country, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.economics.inflation', + withCount(auditPayload(input, ['type', 'country']), result), + 'completed', + ); + return result; +}; + +/** + * Get unemployment data for a given country. Returns historical, current + * and forecast unemployment statistics. + */ +export const unemployment: ApiNinjasEndpoints['economicsUnemployment'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['economicsUnemployment'] + >('unemployment', ctx.key, { + version: 'v1', + query: { + country: input.country, + year: input.year, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.economics.unemployment', + withCount(auditPayload(input, ['country', 'year']), result), + 'completed', + ); + return result; +}; + +/** + * Get population data from given parameters. Returns a list of up to 5 + * country population statistics that satisfy the parameters. For more + * results use the offset parameter. + */ +export const population: ApiNinjasEndpoints['economicsPopulation'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['economicsPopulation'] + >('population', ctx.key, { + version: 'v1', + query: { + country: input.country, + min_population: input.min_population, + max_population: input.max_population, + offset: input.offset, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.economics.population', + withCount( + auditPayload(input, [ + 'country', + 'min_population', + 'max_population', + 'offset', + ]), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Get a specific interest rate by name. Returns the rate value, name, and + * last updated timestamp. + */ +export const interestRate: ApiNinjasEndpoints['economicsInterestRate'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['economicsInterestRate'] + >('interestrate', ctx.key, { + version: 'v2', + query: { + rate: input.rate, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.economics.interestRate', + withCount(auditPayload(input, ['rate']), result), + 'completed', + ); + return result; +}; + +/** + * Returns the daily 30-year and 15-year fixed-rate mortgage (FRM) data. If + * no parameters are set, the mortgage rate data for the most recent day is + * returned. + */ +export const mortgageRate: ApiNinjasEndpoints['economicsMortgageRate'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['economicsMortgageRate'] + >('mortgagerate', ctx.key, { + version: 'v2', + query: { + date: input.date, + min_date: input.min_date, + max_date: input.max_date, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.economics.mortgageRate', + withCount(auditPayload(input, ['date', 'min_date', 'max_date']), result), + 'completed', + ); + return result; +}; + +/** + * Returns monthly payment, annual payment, and interest rate information + * based on given mortgage parameters. + */ +export const mortgageCalculator: ApiNinjasEndpoints['economicsMortgageCalculator'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['economicsMortgageCalculator'] + >('mortgagecalculator', ctx.key, { + version: 'v1', + query: { + loan_amount: input.loan_amount, + home_value: input.home_value, + downpayment: input.downpayment, + interest_rate: input.interest_rate, + duration_years: input.duration_years, + monthly_hoa: input.monthly_hoa, + annual_property_tax: input.annual_property_tax, + annual_home_insurance: input.annual_home_insurance, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.economics.mortgageCalculator', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; + }; + +/** + * Returns comprehensive income tax information including tax brackets and + * rates at both federal and state/provincial levels (where applicable). + */ +export const incomeTax: ApiNinjasEndpoints['economicsIncomeTax'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['economicsIncomeTax'] + >('incometax', ctx.key, { + version: 'v2', + query: { + country: input.country, + year: input.year, + regions: input.regions, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.economics.incomeTax', + withCount(auditPayload(input, ['country', 'year', 'regions']), result), + 'completed', + ); + return result; +}; + +/** + * Returns comprehensive annual tax calculations including federal, + * state/provincial, and FICA taxes where applicable. + */ +export const incomeTaxCalculator: ApiNinjasEndpoints['economicsIncomeTaxCalculator'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['economicsIncomeTaxCalculator'] + >('incometaxcalculator', ctx.key, { + version: 'v1', + query: { + country: input.country, + region: input.region, + income: input.income, + tax_year: input.tax_year, + filing_status: input.filing_status, + deductions: input.deductions, + credits: input.credits, + self_employed: input.self_employed, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.economics.incomeTaxCalculator', + withCount(auditPayload(input, ['country', 'region', 'tax_year']), result), + 'completed', + ); + return result; + }; + +/** + * Returns one or more sales tax breakdowns by ZIP code according to the + * specified parameters. Each breakdown includes the state sales tax (if + * any), county sales tax (if any), city sales tax (if any), and any + * additional special sales taxes. All tax values are presented in decimals + * (e.g. 0.1 means 10% tax). + */ +export const salesTax: ApiNinjasEndpoints['economicsSalesTax'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['economicsSalesTax'] + >('salestax', ctx.key, { + version: 'v1', + query: { + zip_code: input.zip_code, + street_address: input.street_address, + city: input.city, + state: input.state, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.economics.salesTax', + withCount(auditPayload(input, ['city', 'state']), result), + 'completed', + ); + return result; +}; + +/** + * Calculates sales tax for a given amount and location. Returns a detailed + * breakdown including state, county, city, and special district taxes, + * along with the calculated tax amount and total amount after tax. + */ +export const salesTaxCalculator: ApiNinjasEndpoints['economicsSalesTaxCalculator'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['economicsSalesTaxCalculator'] + >('salestaxcalculator', ctx.key, { + version: 'v1', + query: { + amount: input.amount, + zip_code: input.zip_code, + street_address: input.street_address, + city: input.city, + state: input.state, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.economics.salesTaxCalculator', + withCount(auditPayload(input, ['city', 'state']), result), + 'completed', + ); + return result; + }; + +/** + * Returns a list of regions and corresponding 25th, 50th (median), and + * 75th percentile effective property tax rates. The region is mostly + * zipcode-based, but sometimes a single zipcode can contain multiple + * regions due to local tax laws. + */ +export const propertyTax: ApiNinjasEndpoints['economicsPropertyTax'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['economicsPropertyTax'] + >('propertytax', ctx.key, { + version: 'v1', + query: { + state: input.state, + county: input.county, + city: input.city, + zip: input.zip, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.economics.propertyTax', + withCount(auditPayload(input, ['state', 'county', 'city']), result), + 'completed', + ); + return result; +}; + +/** + * Returns VAT rates for a specified EU country. Results include standard + * rate, reduced rates, super-reduced rates, and any special categories. + */ +export const vatRates: ApiNinjasEndpoints['economicsVatRates'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['economicsVatRates'] + >('vat', ctx.key, { + version: 'v1', + query: { + country: input.country, + type: input.type, + min_date: input.min_date, + max_date: input.max_date, + limit: input.limit, + offset: input.offset, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.economics.vatRates', + withCount( + auditPayload(input, [ + 'country', + 'type', + 'min_date', + 'max_date', + 'limit', + 'offset', + ]), + result, + ), + 'completed', + ); + return result; +}; diff --git a/packages/apininjas/endpoints/entertainment.ts b/packages/apininjas/endpoints/entertainment.ts new file mode 100644 index 000000000..4348d9b7c --- /dev/null +++ b/packages/apininjas/endpoints/entertainment.ts @@ -0,0 +1,475 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeApiNinjasRequest } from '../client'; +import type { ApiNinjasEndpoints } from '../index'; +import { auditPayload, withCount } from './logging'; +import type { ApiNinjasEndpointOutputs } from './types'; + +/** + * Jokes, facts, quotes, trivia and puzzles. + * + * Every operation here is a single documented endpoint under + * https://api.api-ninjas.com. Inputs map one-to-one onto the documented query + * parameters, so nothing is renamed on the way through. + */ + +/** + * Returns one (or more) random funny jokes. Free users have access to 100 + * jokes - premium users have access to over 20,000 jokes. + */ +export const jokes: ApiNinjasEndpoints['entertainmentJokes'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['entertainmentJokes'] + >('jokes', ctx.key, { + version: 'v1', + query: { + limit: input.limit, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.entertainment.jokes', + withCount(auditPayload(input, ['limit']), result), + 'completed', + ); + return result; +}; + +/** + * Returns one (or more) random dad jokes. Free users have access to 100 + * jokes - premium users have access to over 15,000 dad jokes. + */ +export const dadJokes: ApiNinjasEndpoints['entertainmentDadJokes'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['entertainmentDadJokes'] + >('dadjokes', ctx.key, { + version: 'v1', + query: { + limit: input.limit, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.entertainment.dadJokes', + withCount(auditPayload(input, ['limit']), result), + 'completed', + ); + return result; +}; + +/** Returns a Chuck Norris joke. */ +export const chuckNorris: ApiNinjasEndpoints['entertainmentChuckNorris'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['entertainmentChuckNorris'] + >('chucknorris', ctx.key, { + version: 'v1', + }); + + await logEventFromContext( + ctx, + 'apininjas.entertainment.chuckNorris', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; + }; + +/** + * Returns a single joke for the current day. The same joke is returned for + * all requests on the same day, and changes each day. Perfect for + * displaying on your website or app. No parameters are available for this + * endpoint to ensure everyone sees the same joke of the day. + */ +export const jokeOfTheDay: ApiNinjasEndpoints['entertainmentJokeOfTheDay'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['entertainmentJokeOfTheDay'] + >('jokeoftheday', ctx.key, { + version: 'v1', + }); + + await logEventFromContext( + ctx, + 'apininjas.entertainment.jokeOfTheDay', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; + }; + +/** + * Returns one (or more) random facts. Free users have access to 100 facts + * - premium users have access to over 500,000 facts. + */ +export const facts: ApiNinjasEndpoints['entertainmentFacts'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['entertainmentFacts'] + >('facts', ctx.key, { + version: 'v1', + query: { + limit: input.limit, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.entertainment.facts', + withCount(auditPayload(input, ['limit']), result), + 'completed', + ); + return result; +}; + +/** + * Returns a single fact for the current day. The same fact is returned for + * all requests on the same day, and changes each day. Perfect for + * displaying on your website or app. No parameters are available for this + * endpoint to ensure everyone sees the same fact of the day. + */ +export const factOfTheDay: ApiNinjasEndpoints['entertainmentFactOfTheDay'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['entertainmentFactOfTheDay'] + >('factoftheday', ctx.key, { + version: 'v1', + }); + + await logEventFromContext( + ctx, + 'apininjas.entertainment.factOfTheDay', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; + }; + +/** + * Returns high-quality quotes with advanced filtering by categories + * (include/exclude), author, work, and pagination support. Returns quotes + * in deterministic order. For random quotes, use /v2/randomquotes or + * /v2/quoteoftheday. + */ +export const quotes: ApiNinjasEndpoints['entertainmentQuotes'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['entertainmentQuotes'] + >('quotes', ctx.key, { + version: 'v2', + query: { + categories: input.categories, + exclude_categories: input.exclude_categories, + author: input.author, + work: input.work, + limit: input.limit, + offset: input.offset, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.entertainment.quotes', + withCount( + auditPayload(input, [ + 'categories', + 'exclude_categories', + 'author', + 'work', + 'limit', + 'offset', + ]), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Returns random high-quality quotes with advanced filtering by categories + * (include/exclude), author, and work. Each request returns different + * random quotes. + */ +export const randomQuotes: ApiNinjasEndpoints['entertainmentRandomQuotes'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['entertainmentRandomQuotes'] + >('randomquotes', ctx.key, { + version: 'v2', + query: { + categories: input.categories, + exclude_categories: input.exclude_categories, + author: input.author, + work: input.work, + limit: input.limit, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.entertainment.randomQuotes', + withCount( + auditPayload(input, [ + 'categories', + 'exclude_categories', + 'author', + 'work', + 'limit', + ]), + result, + ), + 'completed', + ); + return result; + }; + +/** + * Returns a single aphoristic quote for the current day. The same + * pre-vetted, high-quality quote is returned for all requests on the same + * day, and changes each day. Perfect for displaying on your website or + * app. No filtering parameters are available for this endpoint to ensure + * everyone sees the same quote of the day. + */ +export const quoteOfTheDay: ApiNinjasEndpoints['entertainmentQuoteOfTheDay'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['entertainmentQuoteOfTheDay'] + >('quoteoftheday', ctx.key, { + version: 'v2', + }); + + await logEventFromContext( + ctx, + 'apininjas.entertainment.quoteOfTheDay', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; + }; + +/** Returns a random piece of life advice. */ +export const advice: ApiNinjasEndpoints['entertainmentAdvice'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['entertainmentAdvice'] + >('advice', ctx.key, { + version: 'v1', + }); + + await logEventFromContext( + ctx, + 'apininjas.entertainment.advice', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** Returns a random bucket list idea. */ +export const bucketList: ApiNinjasEndpoints['entertainmentBucketList'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['entertainmentBucketList'] + >('bucketlist', ctx.key, { + version: 'v1', + }); + + await logEventFromContext( + ctx, + 'apininjas.entertainment.bucketList', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** Returns a random hobby and a Wikipedia link detailing the hobby. */ +export const hobbies: ApiNinjasEndpoints['entertainmentHobbies'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['entertainmentHobbies'] + >('hobbies', ctx.key, { + version: 'v1', + query: { + category: input.category, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.entertainment.hobbies', + withCount(auditPayload(input, ['category']), result), + 'completed', + ); + return result; +}; + +/** + * Returns the daily horoscope for a specific zodiac sign. Optionally, you + * can provide a date parameter to get historical horoscopes. + */ +export const horoscope: ApiNinjasEndpoints['entertainmentHoroscope'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['entertainmentHoroscope'] + >('horoscope', ctx.key, { + version: 'v1', + query: { + zodiac: input.zodiac, + date: input.date, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.entertainment.horoscope', + withCount(auditPayload(input, ['zodiac', 'date']), result), + 'completed', + ); + return result; +}; + +/** Returns one or more random riddles. */ +export const riddles: ApiNinjasEndpoints['entertainmentRiddles'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['entertainmentRiddles'] + >('riddles', ctx.key, { + version: 'v1', + query: { + limit: input.limit, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.entertainment.riddles', + withCount(auditPayload(input, ['limit']), result), + 'completed', + ); + return result; +}; + +/** + * Returns a random trivia question and answer. Free users have access to + * 100 trivia questions - premium users have access to over 100,000 trivia + * questions. + */ +export const trivia: ApiNinjasEndpoints['entertainmentTrivia'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['entertainmentTrivia'] + >('trivia', ctx.key, { + version: 'v1', + query: { + category: input.category, + limit: input.limit, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.entertainment.trivia', + withCount(auditPayload(input, ['category', 'limit']), result), + 'completed', + ); + return result; +}; + +/** + * Returns a single trivia question and answer for the current day. The + * same question is returned for all requests on the same day, and changes + * each day. Perfect for displaying on your website or app. No filtering + * parameters are available for this endpoint to ensure everyone sees the + * same trivia of the day. + */ +export const triviaOfTheDay: ApiNinjasEndpoints['entertainmentTriviaOfTheDay'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['entertainmentTriviaOfTheDay'] + >('triviaoftheday', ctx.key, { + version: 'v1', + }); + + await logEventFromContext( + ctx, + 'apininjas.entertainment.triviaOfTheDay', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; + }; + +/** Generate a new Sudoku puzzle with specified parameters. */ +export const generateSudoku: ApiNinjasEndpoints['entertainmentGenerateSudoku'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['entertainmentGenerateSudoku'] + >('sudokugenerate', ctx.key, { + version: 'v1', + query: { + width: input.width, + height: input.height, + difficulty: input.difficulty, + seed: input.seed, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.entertainment.generateSudoku', + withCount( + auditPayload(input, ['width', 'height', 'difficulty', 'seed']), + result, + ), + 'completed', + ); + return result; + }; + +/** Solve an existing Sudoku puzzle. */ +export const solveSudoku: ApiNinjasEndpoints['entertainmentSolveSudoku'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['entertainmentSolveSudoku'] + >('sudokusolve', ctx.key, { + version: 'v1', + query: { + puzzle: input.puzzle, + width: input.width, + height: input.height, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.entertainment.solveSudoku', + withCount(auditPayload(input, ['width', 'height']), result), + 'completed', + ); + return result; + }; diff --git a/packages/apininjas/endpoints/health.ts b/packages/apininjas/endpoints/health.ts new file mode 100644 index 000000000..ddc1dba18 --- /dev/null +++ b/packages/apininjas/endpoints/health.ts @@ -0,0 +1,162 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeApiNinjasRequest } from '../client'; +import type { ApiNinjasEndpoints } from '../index'; +import { auditPayload, withCount } from './logging'; +import type { ApiNinjasEndpointOutputs } from './types'; + +/** + * Fitness, nutrition and food. + * + * Every operation here is a single documented endpoint under + * https://api.api-ninjas.com. Inputs map one-to-one onto the documented query + * parameters, so nothing is renamed on the way through. + */ + +/** + * Returns the calories burned per hour and total calories burned according + * to given parameters for given activities (up to 10). + */ +export const caloriesBurned: ApiNinjasEndpoints['healthCaloriesBurned'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['healthCaloriesBurned'] + >('caloriesburned', ctx.key, { + version: 'v1', + query: { + activity: input.activity, + weight: input.weight, + duration: input.duration, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.health.caloriesBurned', + withCount(auditPayload(input, ['activity', 'duration']), result), + 'completed', + ); + return result; + }; + +/** + * This endpoint uses AI to automatically read any text and extract every + * food item it contains, along with the right portion for each. It can + * process multiple food items at once - simply copy and paste any text, + * such as a recipe or your food journal, directly, and it will return the + * nutrition data for every food item found. Items without a specified + * amount default to a 100g serving. + */ +export const nutrition: ApiNinjasEndpoints['healthNutrition'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['healthNutrition'] + >('nutrition', ctx.key, { + version: 'v1', + query: { + query: input.query, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.health.nutrition', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** Returns up to 5 exercises that satisfy the given parameters. */ +export const exercises: ApiNinjasEndpoints['healthExercises'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['healthExercises'] + >('exercises', ctx.key, { + version: 'v1', + query: { + name: input.name, + type: input.type, + muscle: input.muscle, + difficulty: input.difficulty, + equipments: input.equipments, + offset: input.offset, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.health.exercises', + withCount( + auditPayload(input, [ + 'name', + 'type', + 'muscle', + 'difficulty', + 'equipments', + 'offset', + ]), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Get a list of recipes for a given recipe name or ingredient(s). Returns + * a list of recipes. To access more results, use the limit parameter to + * limit the number of results and the offset parameter to offset results + * for pagination in multiple API calls. + */ +export const recipes: ApiNinjasEndpoints['healthRecipes'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['healthRecipes'] + >('recipe', ctx.key, { + version: 'v3', + query: { + title: input.title, + ingredients: input.ingredients, + limit: input.limit, + offset: input.offset, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.health.recipes', + withCount(auditPayload(input, ['limit', 'offset']), result), + 'completed', + ); + return result; +}; + +/** Returns up to 10 cocktail recipes matching the search parameters. */ +export const cocktails: ApiNinjasEndpoints['healthCocktails'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['healthCocktails'] + >('cocktail', ctx.key, { + version: 'v1', + query: { + name: input.name, + ingredients: input.ingredients, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.health.cocktails', + withCount(auditPayload(input, ['name']), result), + 'completed', + ); + return result; +}; diff --git a/packages/apininjas/endpoints/index.ts b/packages/apininjas/endpoints/index.ts new file mode 100644 index 000000000..1e4975d2a --- /dev/null +++ b/packages/apininjas/endpoints/index.ts @@ -0,0 +1,320 @@ +import { + holidays as calendarHolidays, + isPublicHoliday as calendarIsPublicHoliday, + isWorkingDay as calendarIsWorkingDay, + publicHolidays as calendarPublicHolidays, + timezone as calendarTimezone, + workingDays as calendarWorkingDays, + worldTime as calendarWorldTime, +} from './calendar'; +import { + gdp as economicsGdp, + incomeTax as economicsIncomeTax, + incomeTaxCalculator as economicsIncomeTaxCalculator, + inflation as economicsInflation, + interestRate as economicsInterestRate, + mortgageCalculator as economicsMortgageCalculator, + mortgageRate as economicsMortgageRate, + population as economicsPopulation, + propertyTax as economicsPropertyTax, + salesTax as economicsSalesTax, + salesTaxCalculator as economicsSalesTaxCalculator, + unemployment as economicsUnemployment, + vatRates as economicsVatRates, +} from './economics'; +import { + advice as entertainmentAdvice, + bucketList as entertainmentBucketList, + chuckNorris as entertainmentChuckNorris, + dadJokes as entertainmentDadJokes, + factOfTheDay as entertainmentFactOfTheDay, + facts as entertainmentFacts, + generateSudoku as entertainmentGenerateSudoku, + hobbies as entertainmentHobbies, + horoscope as entertainmentHoroscope, + jokeOfTheDay as entertainmentJokeOfTheDay, + jokes as entertainmentJokes, + quoteOfTheDay as entertainmentQuoteOfTheDay, + quotes as entertainmentQuotes, + randomQuotes as entertainmentRandomQuotes, + riddles as entertainmentRiddles, + solveSudoku as entertainmentSolveSudoku, + trivia as entertainmentTrivia, + triviaOfTheDay as entertainmentTriviaOfTheDay, +} from './entertainment'; +import { + caloriesBurned as healthCaloriesBurned, + cocktails as healthCocktails, + exercises as healthExercises, + nutrition as healthNutrition, + recipes as healthRecipes, +} from './health'; +import { + dnsRecords as internetDnsRecords, + domain as internetDomain, + ipLookup as internetIpLookup, + mxRecords as internetMxRecords, + scrape as internetScrape, + urlLookup as internetUrlLookup, + userAgent as internetUserAgent, + webpage as internetWebpage, + whois as internetWhois, +} from './internet'; +import { + airQuality as locationAirQuality, + cities as locationCities, + country as locationCountry, + county as locationCounty, + evChargers as locationEvChargers, + geocode as locationGeocode, + hospitals as locationHospitals, + postalCode as locationPostalCode, + reverseGeocode as locationReverseGeocode, + universities as locationUniversities, + weather as locationWeather, + weatherForecast as locationWeatherForecast, + zipCode as locationZipCode, +} from './location'; +import { + bitcoin as marketsBitcoin, + commodityPrice as marketsCommodityPrice, + convertCurrency as marketsConvertCurrency, + cryptoPrice as marketsCryptoPrice, + earnings as marketsEarnings, + earningsCalendar as marketsEarningsCalendar, + earningsTranscript as marketsEarningsTranscript, + etf as marketsEtf, + exchangeRate as marketsExchangeRate, + insiderTransactions as marketsInsiderTransactions, + marketCap as marketsMarketCap, + mutualFund as marketsMutualFund, + secFilings as marketsSecFilings, + sp500 as marketsSp500, + stockExchanges as marketsStockExchanges, + stockPrice as marketsStockPrice, + ticker as marketsTicker, + tickerList as marketsTickerList, +} from './markets'; +import { + animals as referenceAnimals, + babyNames as referenceBabyNames, + cats as referenceCats, + celebrities as referenceCelebrities, + dayInHistory as referenceDayInHistory, + dogs as referenceDogs, + historicalEvents as referenceHistoricalEvents, + historicalFigures as referenceHistoricalFigures, + planets as referencePlanets, + stars as referenceStars, +} from './reference'; +import { + dictionary as textDictionary, + embeddings as textEmbeddings, + language as textLanguage, + loremIpsum as textLoremIpsum, + profanityFilter as textProfanityFilter, + randomWord as textRandomWord, + rhymes as textRhymes, + sentiment as textSentiment, + similarity as textSimilarity, + spellCheck as textSpellCheck, + thesaurus as textThesaurus, +} from './text'; +import { + aircraft as transportAircraft, + airlines as transportAirlines, + airports as transportAirports, + cars as transportCars, + electricVehicles as transportElectricVehicles, + helicopters as transportHelicopters, + motorcycles as transportMotorcycles, + vin as transportVin, +} from './transport'; +import { + barcode as utilityBarcode, + convertUnit as utilityConvertUnit, + counter as utilityCounter, + countryFlag as utilityCountryFlag, + emoji as utilityEmoji, + logo as utilityLogo, + password as utilityPassword, + qrCode as utilityQrCode, + randomImage as utilityRandomImage, + randomUser as utilityRandomUser, +} from './utility'; +import { + bin as validationBin, + disposableEmail as validationDisposableEmail, + email as validationEmail, + iban as validationIban, + phone as validationPhone, + routingNumber as validationRoutingNumber, + swiftCode as validationSwiftCode, +} from './validation'; + +export const Location = { + geocode: locationGeocode, + reverseGeocode: locationReverseGeocode, + cities: locationCities, + country: locationCountry, + county: locationCounty, + zipCode: locationZipCode, + postalCode: locationPostalCode, + universities: locationUniversities, + hospitals: locationHospitals, + evChargers: locationEvChargers, + weather: locationWeather, + weatherForecast: locationWeatherForecast, + airQuality: locationAirQuality, +}; + +export const Calendar = { + timezone: calendarTimezone, + worldTime: calendarWorldTime, + holidays: calendarHolidays, + publicHolidays: calendarPublicHolidays, + isPublicHoliday: calendarIsPublicHoliday, + isWorkingDay: calendarIsWorkingDay, + workingDays: calendarWorkingDays, +}; + +export const Internet = { + domain: internetDomain, + dnsRecords: internetDnsRecords, + mxRecords: internetMxRecords, + whois: internetWhois, + ipLookup: internetIpLookup, + urlLookup: internetUrlLookup, + webpage: internetWebpage, + scrape: internetScrape, + userAgent: internetUserAgent, +}; + +export const Validation = { + email: validationEmail, + disposableEmail: validationDisposableEmail, + phone: validationPhone, + routingNumber: validationRoutingNumber, + iban: validationIban, + bin: validationBin, + swiftCode: validationSwiftCode, +}; + +export const Markets = { + stockPrice: marketsStockPrice, + ticker: marketsTicker, + tickerList: marketsTickerList, + stockExchanges: marketsStockExchanges, + sp500: marketsSp500, + marketCap: marketsMarketCap, + earnings: marketsEarnings, + earningsCalendar: marketsEarningsCalendar, + earningsTranscript: marketsEarningsTranscript, + insiderTransactions: marketsInsiderTransactions, + secFilings: marketsSecFilings, + etf: marketsEtf, + mutualFund: marketsMutualFund, + cryptoPrice: marketsCryptoPrice, + bitcoin: marketsBitcoin, + commodityPrice: marketsCommodityPrice, + convertCurrency: marketsConvertCurrency, + exchangeRate: marketsExchangeRate, +}; + +export const Economics = { + gdp: economicsGdp, + inflation: economicsInflation, + unemployment: economicsUnemployment, + population: economicsPopulation, + interestRate: economicsInterestRate, + mortgageRate: economicsMortgageRate, + mortgageCalculator: economicsMortgageCalculator, + incomeTax: economicsIncomeTax, + incomeTaxCalculator: economicsIncomeTaxCalculator, + salesTax: economicsSalesTax, + salesTaxCalculator: economicsSalesTaxCalculator, + propertyTax: economicsPropertyTax, + vatRates: economicsVatRates, +}; + +export const Text = { + sentiment: textSentiment, + similarity: textSimilarity, + embeddings: textEmbeddings, + language: textLanguage, + spellCheck: textSpellCheck, + profanityFilter: textProfanityFilter, + dictionary: textDictionary, + thesaurus: textThesaurus, + rhymes: textRhymes, + randomWord: textRandomWord, + loremIpsum: textLoremIpsum, +}; + +export const Utility = { + qrCode: utilityQrCode, + barcode: utilityBarcode, + password: utilityPassword, + randomUser: utilityRandomUser, + counter: utilityCounter, + convertUnit: utilityConvertUnit, + logo: utilityLogo, + countryFlag: utilityCountryFlag, + randomImage: utilityRandomImage, + emoji: utilityEmoji, +}; + +export const Transport = { + aircraft: transportAircraft, + airlines: transportAirlines, + airports: transportAirports, + helicopters: transportHelicopters, + cars: transportCars, + motorcycles: transportMotorcycles, + electricVehicles: transportElectricVehicles, + vin: transportVin, +}; + +export const Health = { + caloriesBurned: healthCaloriesBurned, + nutrition: healthNutrition, + exercises: healthExercises, + recipes: healthRecipes, + cocktails: healthCocktails, +}; + +export const Reference = { + animals: referenceAnimals, + cats: referenceCats, + dogs: referenceDogs, + planets: referencePlanets, + stars: referenceStars, + historicalEvents: referenceHistoricalEvents, + historicalFigures: referenceHistoricalFigures, + dayInHistory: referenceDayInHistory, + celebrities: referenceCelebrities, + babyNames: referenceBabyNames, +}; + +export const Entertainment = { + jokes: entertainmentJokes, + dadJokes: entertainmentDadJokes, + chuckNorris: entertainmentChuckNorris, + jokeOfTheDay: entertainmentJokeOfTheDay, + facts: entertainmentFacts, + factOfTheDay: entertainmentFactOfTheDay, + quotes: entertainmentQuotes, + randomQuotes: entertainmentRandomQuotes, + quoteOfTheDay: entertainmentQuoteOfTheDay, + advice: entertainmentAdvice, + bucketList: entertainmentBucketList, + hobbies: entertainmentHobbies, + horoscope: entertainmentHoroscope, + riddles: entertainmentRiddles, + trivia: entertainmentTrivia, + triviaOfTheDay: entertainmentTriviaOfTheDay, + generateSudoku: entertainmentGenerateSudoku, + solveSudoku: entertainmentSolveSudoku, +}; + +export * from './types'; diff --git a/packages/apininjas/endpoints/internet.ts b/packages/apininjas/endpoints/internet.ts new file mode 100644 index 000000000..27db3befd --- /dev/null +++ b/packages/apininjas/endpoints/internet.ts @@ -0,0 +1,246 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeApiNinjasRequest } from '../client'; +import type { ApiNinjasEndpoints } from '../index'; +import { auditPayload, withCount } from './logging'; +import type { ApiNinjasEndpointOutputs } from './types'; + +/** + * Domains, DNS, IP and URL intelligence, page extraction and user agents. + * + * Every operation here is a single documented endpoint under + * https://api.api-ninjas.com. Inputs map one-to-one onto the documented query + * parameters, so nothing is renamed on the way through. + */ + +/** + * Returns availability, registration lifecycle, and email/hosting + * intelligence for a given domain name. + */ +export const domain: ApiNinjasEndpoints['internetDomain'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['internetDomain'] + >('domain', ctx.key, { + version: 'v1', + query: { + domain: input.domain, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.internet.domain', + withCount(auditPayload(input, ['domain']), result), + 'completed', + ); + return result; +}; + +/** Returns a list of DNS records associated with a particular domain. */ +export const dnsRecords: ApiNinjasEndpoints['internetDnsRecords'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['internetDnsRecords'] + >('dnslookup', ctx.key, { + version: 'v1', + query: { + domain: input.domain, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.internet.dnsRecords', + withCount(auditPayload(input, ['domain']), result), + 'completed', + ); + return result; +}; + +/** + * Returns a list of MX records associated with a particular domain. Free + * users receive only data from the first MX record, while premium users + * get access to all MX records. + */ +export const mxRecords: ApiNinjasEndpoints['internetMxRecords'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['internetMxRecords'] + >('mxlookup', ctx.key, { + version: 'v1', + query: { + domain: input.domain, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.internet.mxRecords', + withCount(auditPayload(input, ['domain']), result), + 'completed', + ); + return result; +}; + +/** + * Returns domain registration details (e.g. registrar, contact + * information, expiration date, name servers) for a given domain name. + */ +export const whois: ApiNinjasEndpoints['internetWhois'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['internetWhois'] + >('whois', ctx.key, { + version: 'v1', + query: { + domain: input.domain, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.internet.whois', + withCount(auditPayload(input, ['domain']), result), + 'completed', + ); + return result; +}; + +/** + * Returns the location of the IP address specified. The response contains + * both the geographical coordinates (latitude/longitude) as well as the + * city and country. + */ +export const ipLookup: ApiNinjasEndpoints['internetIpLookup'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['internetIpLookup'] + >('iplookup', ctx.key, { + version: 'v1', + query: { + address: input.address, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.internet.ipLookup', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** + * Returns the location of the IP address hosting the URL domain. The + * response contains both the geographical coordinates (latitude/longitude) + * as well as the city and country. + */ +export const urlLookup: ApiNinjasEndpoints['internetUrlLookup'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['internetUrlLookup'] + >('urllookup', ctx.key, { + version: 'v1', + query: { + url: input.url, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.internet.urlLookup', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** Returns the URL information and web page metadata from a given URL. */ +export const webpage: ApiNinjasEndpoints['internetWebpage'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['internetWebpage'] + >('webpage', ctx.key, { + version: 'v1', + query: { + url: input.url, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.internet.webpage', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** + * Returns the HTML or plaintext data scraped from a given URL. Maximum + * size of data returned is 2MB. + */ +export const scrape: ApiNinjasEndpoints['internetScrape'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['internetScrape'] + >('webscraper', ctx.key, { + version: 'v1', + query: { + url: input.url, + text_only: input.text_only, + user_agent: input.user_agent, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.internet.scrape', + withCount(auditPayload(input, ['text_only']), result), + 'completed', + ); + return result; +}; + +/** Generates a realistic user agent string based on optional parameters. */ +export const userAgent: ApiNinjasEndpoints['internetUserAgent'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['internetUserAgent'] + >('useragentgenerate', ctx.key, { + version: 'v1', + query: { + brand: input.brand, + model: input.model, + os: input.os, + browser: input.browser, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.internet.userAgent', + withCount(auditPayload(input, ['brand', 'model']), result), + 'completed', + ); + return result; +}; diff --git a/packages/apininjas/endpoints/location.ts b/packages/apininjas/endpoints/location.ts new file mode 100644 index 000000000..d5bdde221 --- /dev/null +++ b/packages/apininjas/endpoints/location.ts @@ -0,0 +1,484 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeApiNinjasRequest } from '../client'; +import type { ApiNinjasEndpoints } from '../index'; +import { auditPayload, withCount } from './logging'; +import { cacheCities, cacheCountries, cacheUniversities } from './persist'; +import { asArray } from './shared'; +import type { ApiNinjasEndpointOutputs } from './types'; + +/** + * Geocoding, administrative geography, points of interest and weather. + * + * Every operation here is a single documented endpoint under + * https://api.api-ninjas.com. Inputs map one-to-one onto the documented query + * parameters, so nothing is renamed on the way through. + */ + +/** Get current city coordinates by city and country name. */ +export const geocode: ApiNinjasEndpoints['locationGeocode'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['locationGeocode'] + >('geocoding', ctx.key, { + version: 'v1', + query: { + city: input.city, + state: input.state, + country: input.country, + zipcode: input.zipcode, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.location.geocode', + withCount(auditPayload(input, ['city', 'state', 'country']), result), + 'completed', + ); + return result; +}; + +/** Returns a list of cities that contain a given latitude and longitude. */ +export const reverseGeocode: ApiNinjasEndpoints['locationReverseGeocode'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['locationReverseGeocode'] + >('reversegeocoding', ctx.key, { + version: 'v1', + query: { + lat: input.lat, + lon: input.lon, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.location.reverseGeocode', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; + }; + +/** + * Get city data from either a name or population range. Returns a list of + * cities that satisfies the parameters. + */ +export const cities: ApiNinjasEndpoints['locationCities'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['locationCities'] + >('city', ctx.key, { + version: 'v1', + query: { + name: input.name, + country: input.country, + min_lat: input.min_lat, + max_lat: input.max_lat, + min_lon: input.min_lon, + max_lon: input.max_lon, + min_population: input.min_population, + max_population: input.max_population, + limit: input.limit, + offset: input.offset, + }, + }); + + await cacheCities(ctx.db.cities, asArray(result), new Date()); + + await logEventFromContext( + ctx, + 'apininjas.location.cities', + withCount( + auditPayload(input, [ + 'name', + 'country', + 'min_population', + 'max_population', + 'limit', + 'offset', + ]), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Get country data from given parameters. Returns a list of country + * statistics that satisfy the parameters. + */ +export const country: ApiNinjasEndpoints['locationCountry'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['locationCountry'] + >('country', ctx.key, { + version: 'v1', + query: { + name: input.name, + currency: input.currency, + min_gdp: input.min_gdp, + max_gdp: input.max_gdp, + min_population: input.min_population, + max_population: input.max_population, + min_area: input.min_area, + max_area: input.max_area, + min_unemployment: input.min_unemployment, + max_unemployment: input.max_unemployment, + min_gdp_growth: input.min_gdp_growth, + max_gdp_growth: input.max_gdp_growth, + min_infant_mortality: input.min_infant_mortality, + max_infant_mortality: input.max_infant_mortality, + min_fertility: input.min_fertility, + max_fertility: input.max_fertility, + min_urban_pop_rate: input.min_urban_pop_rate, + max_urban_pop_rate: input.max_urban_pop_rate, + limit: input.limit, + }, + }); + + await cacheCountries(ctx.db.countries, asArray(result), new Date()); + + await logEventFromContext( + ctx, + 'apininjas.location.country', + withCount( + auditPayload(input, [ + 'name', + 'currency', + 'min_gdp', + 'max_gdp', + 'min_population', + 'max_population', + 'min_area', + 'max_area', + 'min_unemployment', + 'max_unemployment', + 'min_gdp_growth', + 'max_gdp_growth', + 'min_infant_mortality', + 'max_infant_mortality', + 'min_fertility', + 'max_fertility', + 'min_urban_pop_rate', + 'max_urban_pop_rate', + 'limit', + ]), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Returns details for one or more counties matching the input parameters. + * For premium users, you can also specify the limit and offset parameters + * to paginate through results. + */ +export const county: ApiNinjasEndpoints['locationCounty'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['locationCounty'] + >('county', ctx.key, { + version: 'v1', + query: { + county: input.county, + zipcode: input.zipcode, + state: input.state, + limit: input.limit, + offset: input.offset, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.location.county', + withCount( + auditPayload(input, ['county', 'state', 'limit', 'offset']), + result, + ), + 'completed', + ); + return result; +}; + +/** Returns a list of ZIP Code details matching the input parameters. */ +export const zipCode: ApiNinjasEndpoints['locationZipCode'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['locationZipCode'] + >('zipcode', ctx.key, { + version: 'v1', + query: { + zip: input.zip, + city: input.city, + state: input.state, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.location.zipCode', + withCount(auditPayload(input, ['city', 'state']), result), + 'completed', + ); + return result; +}; + +/** Returns a list of postal code details matching the input parameters. */ +export const postalCode: ApiNinjasEndpoints['locationPostalCode'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['locationPostalCode'] + >('postalcode', ctx.key, { + version: 'v1', + query: { + postal_code: input.postal_code, + city: input.city, + province: input.province, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.location.postalCode', + withCount(auditPayload(input, ['city', 'province']), result), + 'completed', + ); + return result; +}; + +/** + * Returns information about universities matching the provided filters. At + * least one filter parameter is required. Free users can use name or + * country - all other filters are premium-only. + */ +export const universities: ApiNinjasEndpoints['locationUniversities'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['locationUniversities'] + >('university', ctx.key, { + version: 'v1', + query: { + name: input.name, + country: input.country, + city: input.city, + state: input.state, + min_faculty_ratio: input.min_faculty_ratio, + max_faculty_ratio: input.max_faculty_ratio, + min_enrolled: input.min_enrolled, + max_enrolled: input.max_enrolled, + min_tuition: input.min_tuition, + max_tuition: input.max_tuition, + offset: input.offset, + limit: input.limit, + }, + }); + + await cacheUniversities(ctx.db.universities, asArray(result), new Date()); + + await logEventFromContext( + ctx, + 'apininjas.location.universities', + withCount( + auditPayload(input, [ + 'name', + 'country', + 'city', + 'state', + 'min_faculty_ratio', + 'max_faculty_ratio', + 'min_enrolled', + 'max_enrolled', + 'min_tuition', + 'max_tuition', + 'offset', + 'limit', + ]), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Get hospital data based on given parameters. Returns a list of hospitals + * that match the specified criteria. + */ +export const hospitals: ApiNinjasEndpoints['locationHospitals'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['locationHospitals'] + >('hospitals', ctx.key, { + version: 'v1', + query: { + name: input.name, + city: input.city, + state: input.state, + zipcode: input.zipcode, + county: input.county, + min_latitude: input.min_latitude, + max_latitude: input.max_latitude, + min_longitude: input.min_longitude, + max_longitude: input.max_longitude, + limit: input.limit, + offset: input.offset, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.location.hospitals', + withCount( + auditPayload(input, [ + 'name', + 'city', + 'state', + 'county', + 'limit', + 'offset', + ]), + result, + ), + 'completed', + ); + return result; +}; + +/** FIND_EV_CHARGING_STATIONS */ +export const evChargers: ApiNinjasEndpoints['locationEvChargers'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['locationEvChargers'] + >('evcharger', ctx.key, { + version: 'v1', + query: { + lat: input.lat, + lon: input.lon, + distance: input.distance, + level: input.level, + limit: input.limit, + offset: input.offset, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.location.evChargers', + withCount( + auditPayload(input, ['distance', 'level', 'limit', 'offset']), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Get current weather, wind speed and direction, humidity, and temperature + * data by city, ZIP code, or geolocation coordinates (latitude/longitude). + */ +export const weather: ApiNinjasEndpoints['locationWeather'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['locationWeather'] + >('weather', ctx.key, { + version: 'v1', + query: { + lat: input.lat, + lon: input.lon, + zip: input.zip, + city: input.city, + state: input.state, + country: input.country, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.location.weather', + withCount(auditPayload(input, ['city', 'state', 'country']), result), + 'completed', + ); + return result; +}; + +/** Returns a 5-day weather forecast in 3-hour intervals for a given city. */ +export const weatherForecast: ApiNinjasEndpoints['locationWeatherForecast'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['locationWeatherForecast'] + >('weatherforecast', ctx.key, { + version: 'v1', + query: { + lat: input.lat, + lon: input.lon, + zip: input.zip, + city: input.city, + state: input.state, + country: input.country, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.location.weatherForecast', + withCount(auditPayload(input, ['city', 'state', 'country']), result), + 'completed', + ); + return result; + }; + +/** + * Get air quality by city or location coordinates (latitude/longitude). + * Returns the air quality index (AQI) and concentrations of major + * pollutants. + */ +export const airQuality: ApiNinjasEndpoints['locationAirQuality'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['locationAirQuality'] + >('airquality', ctx.key, { + version: 'v1', + query: { + lat: input.lat, + lon: input.lon, + city: input.city, + state: input.state, + country: input.country, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.location.airQuality', + withCount(auditPayload(input, ['city', 'state', 'country']), result), + 'completed', + ); + return result; +}; diff --git a/packages/apininjas/endpoints/logging.ts b/packages/apininjas/endpoints/logging.ts new file mode 100644 index 000000000..d8d60c465 --- /dev/null +++ b/packages/apininjas/endpoints/logging.ts @@ -0,0 +1,297 @@ +/** + * Builds the payload recorded in `corsair_events`. + * + * `logEventFromContext` persists whatever it is handed, and those rows inherit + * the event log's retention, so anything put here is readable by everyone with + * access to the log, for as long as the log is kept. + * + * The rule is therefore deny by default: a parameter's **value** is recorded + * only if it appears in {@link LOGGABLE_KEYS} below. Everything else is + * recorded by name, with a length where the value has one, so an operator can + * still see what a call supplied without seeing what it contained. + * + * The earlier version of this file worked the other way round - it listed the + * fields to hide - which meant a parameter nobody had thought about was logged + * in full. Across 129 operations and 235 distinct parameters that is not a + * list anyone can keep correct, and it put income, deductions, street + * addresses, IBANs and routing numbers into the event log. Inverting it makes + * the failure mode a missing field in an audit row rather than a caller's bank + * details in permanent storage. + * + * The test in `logging.test.ts` walks the documented parameters of all 129 + * operations and asserts that nothing outside this list can reach a payload. + */ + +/** + * Parameters whose values may be recorded. + * + * The test each entry has to pass: it identifies a public thing or shapes the + * query, and it says nothing about the caller - not their identity, their + * location to street level, their money, their credentials or their documents. + */ +const LOGGABLE_KEYS = new Set([ + // Identifiers of public entities: a company, an airport, a security, a + // commodity, a currency. Note `have` and `want` are ISO currency codes on + // the conversion endpoint - the amount being converted is not logged. + 'bank', + 'brand', + 'cik', + 'code', + 'currency', + 'domain', + 'have', + 'iata', + 'icao', + 'make', + 'manufacturer', + 'mic', + 'model', + 'name', + 'names', + 'pair', + 'symbol', + 'ticker', + 'trim', + 'want', + + // Coarse geography. Deliberately excludes coordinates, postal codes and + // street addresses, which locate a caller rather than name a place. + 'city', + 'continent', + 'country', + 'county', + 'locale', + 'province', + 'region', + 'regions', + 'state', + 'timezone', + + // Taxonomy and filters: what kind of thing was asked for. + 'activity', + 'author', + 'barking', + 'categories', + 'category', + 'children_friendly', + 'constellation', + 'difficulty', + 'energy', + 'engine_type', + 'equipments', + 'exclude', + 'exclude_categories', + 'family_friendly', + 'filing', + 'form_type', + 'gender', + 'grooming', + 'group', + 'has_iata', + 'has_lights', + 'insider_type', + 'level', + 'muscle', + 'nationality', + 'other_pets_friendly', + 'playfulness', + 'protectiveness', + 'scheduled_service', + 'sector', + 'shedding', + 'subgroup', + 'surface', + 'trainability', + 'transaction_type', + 'type', + 'unit', + 'work', + 'zodiac', + + // Dates and periods. + 'date', + 'date_added', + 'date_end', + 'date_start', + 'day', + 'end', + 'max_date', + 'max_transaction_date', + 'max_year', + 'min_date', + 'min_transaction_date', + 'min_year', + 'month', + 'period', + 'quarter', + 'start', + 'tax_year', + 'transaction_date', + 'year', + + // How the answer should be shaped or paged. + 'bg_color', + 'count', + 'exclude_numbers', + 'exclude_special_chars', + 'fg_color', + 'fields', + 'format', + 'height', + 'include_closed', + 'include_text', + 'length', + 'limit', + 'offset', + 'order', + 'paragraphs', + 'popular_only', + 'public_holidays', + 'qa_only', + 'quarter', + 'random', + 'seed', + 'show_upcoming', + 'size', + 'sort', + 'start_with_lorem_ipsum', + 'text_only', + 'weekend', + 'width', + + // Numeric search bounds over public reference data - a star's magnitude, a + // country's GDP, an aircraft's range. None of these describe the caller. + 'max_absolute_magnitude', + 'max_age', + 'max_apparent_magnitude', + 'max_area', + 'max_distance_light_year', + 'max_elevation', + 'max_enrolled', + 'max_faculty_ratio', + 'max_fertility', + 'max_gdp', + 'max_gdp_growth', + 'max_height', + 'max_infant_mortality', + 'max_length', + 'max_life_expectancy', + 'max_mass', + 'max_net_worth', + 'max_period', + 'max_population', + 'max_radius', + 'max_range', + 'max_semi_major_axis', + 'max_speed', + 'max_temperature', + 'max_transaction_value', + 'max_tuition', + 'max_unemployment', + 'max_urban_pop_rate', + 'max_weight', + 'max_wingspan', + 'min_absolute_magnitude', + 'min_age', + 'min_apparent_magnitude', + 'min_area', + 'min_distance_light_year', + 'min_elevation', + 'min_enrolled', + 'min_faculty_ratio', + 'min_fertility', + 'min_gdp', + 'min_gdp_growth', + 'min_height', + 'min_infant_mortality', + 'min_length', + 'min_life_expectancy', + 'min_mass', + 'min_net_worth', + 'min_period', + 'min_population', + 'min_radius', + 'min_range', + 'min_runway_length', + 'min_semi_major_axis', + 'min_speed', + 'min_temperature', + 'min_transaction_value', + 'min_tuition', + 'min_unemployment', + 'min_urban_pop_rate', + 'min_weight', + 'min_wingspan', + 'distance', + 'duration', + 'rate', +]); + +/** True when a parameter's value may be written to the event log. */ +export function isLoggableKey(key: string): boolean { + return LOGGABLE_KEYS.has(key); +} + +/** The keys whose values may be logged, for the tests to assert against. */ +export function loggableKeys(): string[] { + return [...LOGGABLE_KEYS].sort(); +} + +/** Size of a value, for a field whose content must not be logged. */ +function sizeOf(value: unknown): number | undefined { + if (typeof value === 'string') return value.length; + if (Array.isArray(value)) return value.length; + return undefined; +} + +/** + * Builds an audit payload from an endpoint's input. + * + * `identifierKeys` names the fields an operation considers worth recording. + * It is a hint, not an authority: a key is recorded by value only if it is + * also loggable, so naming a field here can never widen what is stored. + */ +export function auditPayload>( + input: T, + identifierKeys: readonly (keyof T & string)[], +): Record { + const payload: Record = {}; + + for (const key of identifierKeys) { + const value = input[key]; + if (value === undefined) continue; + if (!isLoggableKey(key)) continue; + payload[key] = value; + } + + const supplied = Object.keys(input).filter((key) => input[key] !== undefined); + if (supplied.length > 0) { + // Namespaced deliberately: `fields` is itself a parameter on the random + // user endpoint, so a plain `fields` key here would overwrite the caller's + // argument and leave the row ambiguous about which of the two it meant. + payload.supplied_fields = supplied; + } + + // Everything not recorded by value is recorded by size instead, so a call + // is still traceable without its contents being readable. + for (const key of supplied) { + if (isLoggableKey(key)) continue; + const size = sizeOf(input[key]); + if (size !== undefined) { + payload[`${key}_length`] = size; + } + } + + return payload; +} + +/** Records how many rows an operation returned, without recording the rows. */ +export function withCount( + payload: Record, + result: unknown, +): Record { + if (Array.isArray(result)) { + return { ...payload, result_count: result.length }; + } + return payload; +} diff --git a/packages/apininjas/endpoints/markets.ts b/packages/apininjas/endpoints/markets.ts new file mode 100644 index 000000000..90cd1262e --- /dev/null +++ b/packages/apininjas/endpoints/markets.ts @@ -0,0 +1,580 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeApiNinjasRequest } from '../client'; +import type { ApiNinjasEndpoints } from '../index'; +import { auditPayload, withCount } from './logging'; +import { cacheSp500, cacheStockExchanges } from './persist'; +import { asArray } from './shared'; +import type { ApiNinjasEndpointOutputs } from './types'; + +/** + * Equities, funds, crypto, commodities and foreign exchange. + * + * Every operation here is a single documented endpoint under + * https://api.api-ninjas.com. Inputs map one-to-one onto the documented query + * parameters, so nothing is renamed on the way through. + */ + +/** + * Returns price information for any given ticker symbol. Premium members + * have access to live prices, while free users only have access to + * 15-minute delayed data. + */ +export const stockPrice: ApiNinjasEndpoints['marketsStockPrice'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['marketsStockPrice'] + >('stockprice', ctx.key, { + version: 'v1', + query: { + ticker: input.ticker, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.markets.stockPrice', + withCount(auditPayload(input, ['ticker']), result), + 'completed', + ); + return result; +}; + +/** + * Returns comprehensive company profile information including company + * name, CEO, address, financial data, exchange information, identifiers + * (CIK, CUSIP, ISIN), and latest earnings information when available. + * Premium members have access to live prices, while free users only have + * access to 15-minute delayed data. + */ +export const ticker: ApiNinjasEndpoints['marketsTicker'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['marketsTicker'] + >('ticker', ctx.key, { + version: 'v1', + query: { + ticker: input.ticker, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.markets.ticker', + withCount(auditPayload(input, ['ticker']), result), + 'completed', + ); + return result; +}; + +/** + * Returns a list of all available companies and their ticker symbols. + * Supports pagination to retrieve results in batches. + */ +export const tickerList: ApiNinjasEndpoints['marketsTickerList'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['marketsTickerList'] + >('stockpricelist', ctx.key, { + version: 'v1', + query: { + offset: input.offset, + limit: input.limit, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.markets.tickerList', + withCount(auditPayload(input, ['offset', 'limit']), result), + 'completed', + ); + return result; +}; + +/** + * Returns detailed information about stock exchanges matching the + * specified criteria. At least one parameter is required. + */ +export const stockExchanges: ApiNinjasEndpoints['marketsStockExchanges'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['marketsStockExchanges'] + >('stockexchange', ctx.key, { + version: 'v1', + query: { + mic: input.mic, + name: input.name, + city: input.city, + country: input.country, + }, + }); + + await cacheStockExchanges( + ctx.db.stockExchanges, + asArray(result), + new Date(), + ); + + await logEventFromContext( + ctx, + 'apininjas.markets.stockExchanges', + withCount( + auditPayload(input, ['mic', 'name', 'city', 'country']), + result, + ), + 'completed', + ); + return result; + }; + +/** + * Returns S&P 500 index constituents, filterable by ticker, company name, + * sector or the date the company joined the index. + */ +export const sp500: ApiNinjasEndpoints['marketsSp500'] = async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['marketsSp500'] + >('sp500', ctx.key, { + version: 'v1', + query: { + ticker: input.ticker, + name: input.name, + sector: input.sector, + date_added: input.date_added, + }, + }); + + await cacheSp500(ctx.db.sp500, asArray(result), new Date()); + + await logEventFromContext( + ctx, + 'apininjas.markets.sp500', + withCount( + auditPayload(input, ['ticker', 'name', 'sector', 'date_added']), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Returns the current market cap data for any given company ticker. + * Premium members have access to live prices, while free users only have + * access to 15-minute delayed data. + */ +export const marketCap: ApiNinjasEndpoints['marketsMarketCap'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['marketsMarketCap'] + >('marketcap', ctx.key, { + version: 'v1', + query: { + ticker: input.ticker, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.markets.marketCap', + withCount(auditPayload(input, ['ticker']), result), + 'completed', + ); + return result; +}; + +/** + * Returns a JSON array of detailed earnings reports, each with + * comprehensive financial statements and key performance metrics. Query a + * single company by ticker or cik, or query every company that filed + * within a date range using date or date_start/date_end. Results are + * paginated 50 per page via offset. + */ +export const earnings: ApiNinjasEndpoints['marketsEarnings'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['marketsEarnings'] + >('earnings', ctx.key, { + version: 'v2', + query: { + ticker: input.ticker, + cik: input.cik, + period: input.period, + year: input.year, + quarter: input.quarter, + date: input.date, + date_start: input.date_start, + date_end: input.date_end, + offset: input.offset, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.markets.earnings', + withCount( + auditPayload(input, [ + 'ticker', + 'cik', + 'period', + 'year', + 'quarter', + 'date', + 'date_start', + 'date_end', + 'offset', + ]), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Returns a list of past earnings results and upcoming earnings dates. You + * can query by ticker symbol to get earnings for a specific company, by a + * single date, or by a date range. Up to 50 earnings results are returned + * per request. + */ +export const earningsCalendar: ApiNinjasEndpoints['marketsEarningsCalendar'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['marketsEarningsCalendar'] + >('earningscalendar', ctx.key, { + version: 'v1', + query: { + ticker: input.ticker, + date: input.date, + date_start: input.date_start, + date_end: input.date_end, + show_upcoming: input.show_upcoming, + offset: input.offset, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.markets.earningsCalendar', + withCount( + auditPayload(input, [ + 'ticker', + 'date', + 'date_start', + 'date_end', + 'show_upcoming', + 'offset', + ]), + result, + ), + 'completed', + ); + return result; + }; + +/** Returns the earnings transcript for a given company earning quarter. */ +export const earningsTranscript: ApiNinjasEndpoints['marketsEarningsTranscript'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['marketsEarningsTranscript'] + >('earningstranscript', ctx.key, { + version: 'v1', + query: { + ticker: input.ticker, + cik: input.cik, + year: input.year, + quarter: input.quarter, + qa_only: input.qa_only, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.markets.earningsTranscript', + withCount( + auditPayload(input, ['ticker', 'cik', 'year', 'quarter', 'qa_only']), + result, + ), + 'completed', + ); + return result; + }; + +/** + * Returns a list of insider trading transactions that match the specified + * filters. All parameters are optional and can be combined for advanced + * filtering. + */ +export const insiderTransactions: ApiNinjasEndpoints['marketsInsiderTransactions'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['marketsInsiderTransactions'] + >('insidertransactions', ctx.key, { + version: 'v1', + query: { + ticker: input.ticker, + cik: input.cik, + name: input.name, + form_type: input.form_type, + transaction_type: input.transaction_type, + transaction_code: input.transaction_code, + transaction_date: input.transaction_date, + min_transaction_date: input.min_transaction_date, + max_transaction_date: input.max_transaction_date, + insider_type: input.insider_type, + min_transaction_value: input.min_transaction_value, + max_transaction_value: input.max_transaction_value, + limit: input.limit, + offset: input.offset, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.markets.insiderTransactions', + withCount( + auditPayload(input, [ + 'ticker', + 'cik', + 'name', + 'form_type', + 'transaction_type', + 'transaction_date', + 'min_transaction_date', + 'max_transaction_date', + 'insider_type', + 'min_transaction_value', + 'max_transaction_value', + 'limit', + 'offset', + ]), + result, + ), + 'completed', + ); + return result; + }; + +/** + * Returns a list of SEC filing information (including the submission URL) + * corresponding to the given search parameters. + */ +export const secFilings: ApiNinjasEndpoints['marketsSecFilings'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['marketsSecFilings'] + >('sec', ctx.key, { + version: 'v1', + query: { + ticker: input.ticker, + filing: input.filing, + start: input.start, + end: input.end, + limit: input.limit, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.markets.secFilings', + withCount( + auditPayload(input, ['ticker', 'filing', 'start', 'end', 'limit']), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Returns comprehensive information about any ETF by its ticker. Premium + * members have access to live prices, while free users only have access to + * 15-minute delayed data. + */ +export const etf: ApiNinjasEndpoints['marketsEtf'] = async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['marketsEtf'] + >('etf', ctx.key, { + version: 'v1', + query: { + ticker: input.ticker, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.markets.etf', + withCount(auditPayload(input, ['ticker']), result), + 'completed', + ); + return result; +}; + +/** Returns comprehensive information about any Mutual Fund by its ticker. */ +export const mutualFund: ApiNinjasEndpoints['marketsMutualFund'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['marketsMutualFund'] + >('mutualfund', ctx.key, { + version: 'v1', + query: { + ticker: input.ticker, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.markets.mutualFund', + withCount(auditPayload(input, ['ticker']), result), + 'completed', + ); + return result; +}; + +/** + * Returns the current price and current time (in UNIX timestamp in + * seconds) for any cryptocurrency symbol. Premium members have access to + * live prices, while free users only have access to 15-minute delayed + * data. For historical price data, see /v1/cryptopricehistorical. + */ +export const cryptoPrice: ApiNinjasEndpoints['marketsCryptoPrice'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['marketsCryptoPrice'] + >('cryptoprice', ctx.key, { + version: 'v1', + query: { + symbol: input.symbol, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.markets.cryptoPrice', + withCount(auditPayload(input, ['symbol']), result), + 'completed', + ); + return result; +}; + +/** + * Returns the latest Bitcoin price in USD and 24-hour market data. Premium + * members have access to live prices, while free users only have access to + * 15-minute delayed data. For historical price data, see + * /v1/bitcoinhistorical. + */ +export const bitcoin: ApiNinjasEndpoints['marketsBitcoin'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['marketsBitcoin'] + >('bitcoin', ctx.key, { + version: 'v1', + }); + + await logEventFromContext( + ctx, + 'apininjas.markets.bitcoin', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** + * Returns the current price information for one or more commodities. + * Prices are based on rolling futures contracts and are quoted in the + * commodity's native unit and currency convention - see the unit and + * currency_unit fields below. Use the optional currency and unit + * parameters to convert into any supported currency or compatible + * mass/volume/energy unit. Premium members have access to live prices, + * while free users only have access to 15-minute delayed data. + */ +export const commodityPrice: ApiNinjasEndpoints['marketsCommodityPrice'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['marketsCommodityPrice'] + >('commodityprice', ctx.key, { + version: 'v1', + query: { + name: input.name, + names: input.names, + currency: input.currency, + unit: input.unit, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.markets.commodityPrice', + withCount( + auditPayload(input, ['name', 'names', 'currency', 'unit']), + result, + ), + 'completed', + ); + return result; + }; + +/** Converts an existing currency and amount into a new currency. */ +export const convertCurrency: ApiNinjasEndpoints['marketsConvertCurrency'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['marketsConvertCurrency'] + >('convertcurrency', ctx.key, { + version: 'v1', + query: { + have: input.have, + want: input.want, + amount: input.amount, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.markets.convertCurrency', + withCount(auditPayload(input, ['have', 'want']), result), + 'completed', + ); + return result; + }; + +/** Returns the exchange rate for a given currency pair. */ +export const exchangeRate: ApiNinjasEndpoints['marketsExchangeRate'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['marketsExchangeRate'] + >('exchangerate', ctx.key, { + version: 'v1', + query: { + pair: input.pair, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.markets.exchangeRate', + withCount(auditPayload(input, ['pair']), result), + 'completed', + ); + return result; +}; diff --git a/packages/apininjas/endpoints/persist.ts b/packages/apininjas/endpoints/persist.ts new file mode 100644 index 000000000..0606b9f59 --- /dev/null +++ b/packages/apininjas/endpoints/persist.ts @@ -0,0 +1,672 @@ +import type { + ApiNinjasAircraftEntity, + ApiNinjasAirlineEntity, + ApiNinjasAirportEntity, + ApiNinjasAnimalEntity, + ApiNinjasCityEntity, + ApiNinjasCountryEntity, + ApiNinjasEmojiEntity, + ApiNinjasPlanetEntity, + ApiNinjasSp500Entity, + ApiNinjasStarEntity, + ApiNinjasStockExchangeEntity, + ApiNinjasUniversityEntity, + ApiNinjasVehicleEntity, +} from '../schema/database'; +import { asNumber, entityId, isMaskedValue, keyed, unmasked } from './shared'; +import type { ApiNinjasEndpointOutputs } from './types'; + +/** + * Mirrors official reference fields into the local cache. + * + * Writes are best-effort. Masked free-tier prose is dropped. Field names + * match the official JSON keys — see `schema/database.ts`. + */ + +type EntityStore = { + upsertByEntityId: (entityId: string, data: T) => Promise; +}; + +async function safely(operation: () => Promise, what: string) { + try { + await operation(); + } catch (error) { + console.warn(`[APININJAS] failed to cache ${what}:`, error); + } +} + +function text(value: unknown): string | undefined { + if (typeof value !== 'string' || isMaskedValue(value)) return undefined; + return value; +} + +function clean(value: unknown): unknown { + if (value === undefined || value === null || isMaskedValue(value)) { + return undefined; + } + if (Array.isArray(value)) { + const items = value + .map(clean) + .filter((item) => item !== undefined && item !== null); + return items.length ? items : undefined; + } + if (typeof value === 'object') { + const out: Record = {}; + for (const [key, item] of Object.entries(value)) { + const next = clean(item); + if (next !== undefined && next !== null) out[key] = next; + } + return Object.keys(out).length ? out : undefined; + } + return value; +} + +function nested(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const cleaned = clean(value); + if (!cleaned || typeof cleaned !== 'object' || Array.isArray(cleaned)) { + return undefined; + } + return cleaned as Record; +} + +function strings(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const cleaned = clean(value); + if (!Array.isArray(cleaned)) return undefined; + const items = cleaned.filter( + (item): item is string => typeof item === 'string', + ); + return items.length ? items : undefined; +} + +const OBJECT_KEYS = new Set([ + 'fleet', + 'currency', + 'taxonomy', + 'characteristics', + 'runways', +]); + +function copy( + row: Record, + keys: readonly string[], +): Record { + const out: Record = {}; + for (const key of keys) { + const value = unmasked(row[key]); + if (value === undefined || value === null) continue; + if (OBJECT_KEYS.has(key) || typeof value === 'object') { + if (Array.isArray(value)) { + const items = strings(value) ?? clean(value); + if (Array.isArray(items) && items.length) out[key] = items; + continue; + } + const object = nested(value); + if (object) out[key] = object; + continue; + } + out[key] = value; + } + return out; +} + +type AirportRow = ApiNinjasEndpointOutputs['transportAirports'][number]; +type AirlineRow = ApiNinjasEndpointOutputs['transportAirlines'][number]; +type AircraftRow = ApiNinjasEndpointOutputs['transportAircraft'][number]; +type CountryRow = ApiNinjasEndpointOutputs['locationCountry'][number]; +type CityRow = ApiNinjasEndpointOutputs['locationCities'][number]; +type UniversityRow = ApiNinjasEndpointOutputs['locationUniversities'][number]; +type StockExchangeRow = + ApiNinjasEndpointOutputs['marketsStockExchanges'][number]; +type Sp500Row = ApiNinjasEndpointOutputs['marketsSp500'][number]; +type EmojiRow = ApiNinjasEndpointOutputs['utilityEmoji'][number]; +type AnimalRow = ApiNinjasEndpointOutputs['referenceAnimals'][number]; +type PlanetRow = ApiNinjasEndpointOutputs['referencePlanets'][number]; +type StarRow = ApiNinjasEndpointOutputs['referenceStars'][number]; +type CarRow = ApiNinjasEndpointOutputs['transportCars'][number]; +type MotorcycleRow = ApiNinjasEndpointOutputs['transportMotorcycles'][number]; +type ElectricVehicleRow = + ApiNinjasEndpointOutputs['transportElectricVehicles'][number]; + +const AIRPORT_FIELDS = [ + 'iata', + 'icao', + 'ident', + 'name', + 'city', + 'region', + 'region_code', + 'country', + 'country_name', + 'continent', + 'elevation_ft', + 'elevation_m', + 'timezone', + 'type', + 'size', + 'scheduled_service', + 'is_closed', + 'gps_code', + 'local_code', + 'home_link', + 'wikipedia_link', + 'keywords', + 'num_runways', + 'longest_runway_ft', + 'runways', + 'estimated_annual_passengers', +] as const; + +const AIRLINE_FIELDS = [ + 'name', + 'iata', + 'icao', + 'country', + 'year_created', + 'base', + 'fleet', + 'logo_url', + 'brandmark_url', + 'tail_logo_url', +] as const; + +const AIRCRAFT_FIELDS = [ + 'manufacturer', + 'model', + 'engine_type', + 'engine_thrust_lb_ft', + 'max_speed_knots', + 'cruise_speed_knots', + 'ceiling_ft', + 'takeoff_ground_run_ft', + 'landing_ground_roll_ft', + 'gross_weight_lbs', + 'empty_weight_lbs', + 'length_ft', + 'height_ft', + 'wing_span_ft', + 'range_nautical_miles', +] as const; + +const COUNTRY_FIELDS = [ + 'name', + 'iso2', + 'capital', + 'region', + 'currency', + 'gdp', + 'gdp_per_capita', + 'gdp_growth', + 'population', + 'pop_density', + 'pop_growth', + 'surface_area', + 'urban_population', + 'urban_population_growth', + 'unemployment', + 'fertility', + 'infant_mortality', + 'life_expectancy_male', + 'life_expectancy_female', + 'sex_ratio', + 'employment_services', + 'employment_industry', + 'employment_agriculture', + 'imports', + 'exports', + 'co2_emissions', + 'forested_area', + 'tourists', + 'homicide_rate', + 'threatened_species', + 'internet_users', + 'refugees', + 'primary_school_enrollment_female', + 'primary_school_enrollment_male', + 'secondary_school_enrollment_female', + 'secondary_school_enrollment_male', + 'post_secondary_enrollment_female', + 'post_secondary_enrollment_male', + 'telephone_country_codes', +] as const; + +const UNIVERSITY_FIELDS = [ + 'name', + 'degree_types', + 'address', + 'city', + 'state', + 'postal_code', + 'country', + 'county', + 'timezone', + 'latitude', + 'longitude', + 'phone', + 'email', + 'website', + 'institution_type', + 'years', + 'enrollment', + 'student_faculty_ratio', + 'tuition', +] as const; + +const EXCHANGE_FIELDS = [ + 'mic', + 'name', + 'city', + 'country', + 'iso2', + 'description', + 'address', + 'website', + 'founded', + 'num_listings', + 'market_cap_usd', + 'market_cap', + 'currency', + 'timezone', + 'market_open', + 'market_close', + 'is_market_open', + 'closed_reason', +] as const; + +const SP500_FIELDS = [ + 'ticker', + 'company_name', + 'sector', + 'sub_industry', + 'headquarters', + 'date_added', + 'cik', +] as const; + +const EMOJI_FIELDS = [ + 'code', + 'character', + 'image', + 'name', + 'group', + 'subgroup', +] as const; + +const PLANET_FIELDS = [ + 'name', + 'mass', + 'radius', + 'period', + 'semi_major_axis', + 'temperature', + 'distance_light_year', + 'host_star_mass', + 'host_star_temperature', +] as const; + +const STAR_FIELDS = [ + 'name', + 'constellation', + 'right_ascension', + 'declination', + 'apparent_magnitude', + 'absolute_magnitude', + 'distance_light_year', + 'spectral_class', +] as const; + +const CAR_FIELDS = [ + 'make', + 'model', + 'year', + 'class', + 'fuel_type', + 'city_mpg', + 'combination_mpg', + 'highway_mpg', + 'cylinders', + 'displacement', + 'drive', + 'transmission', +] as const; + +export async function cacheAirports( + store: EntityStore | undefined, + rows: AirportRow[], + capturedAt: Date, +) { + if (!store) return; + for (const row of rows) { + const id = entityId(row.ident ?? row.icao ?? row.iata ?? row.name); + if (!id) continue; + await safely( + () => + store.upsertByEntityId(id, { + id, + ...copy(row, AIRPORT_FIELDS), + latitude: asNumber(row.latitude), + longitude: asNumber(row.longitude), + captured_at: capturedAt, + } as ApiNinjasAirportEntity), + `airport ${id}`, + ); + } +} + +export async function cacheAirlines( + store: EntityStore | undefined, + rows: AirlineRow[], + capturedAt: Date, +) { + if (!store) return; + for (const row of rows) { + const id = entityId(row.iata ?? row.icao ?? row.name); + if (!id) continue; + await safely( + () => + store.upsertByEntityId(id, { + id, + ...copy(row, AIRLINE_FIELDS), + captured_at: capturedAt, + } as ApiNinjasAirlineEntity), + `airline ${id}`, + ); + } +} + +export async function cacheAircraft( + store: EntityStore | undefined, + rows: AircraftRow[], + capturedAt: Date, +) { + if (!store) return; + for (const row of rows) { + if (!keyed(row.manufacturer, row.model)) continue; + const id = entityId(row.manufacturer, row.model); + await safely( + () => + store.upsertByEntityId(id, { + id, + ...copy(row, AIRCRAFT_FIELDS), + captured_at: capturedAt, + } as ApiNinjasAircraftEntity), + `aircraft ${id}`, + ); + } +} + +export async function cacheCars( + store: EntityStore | undefined, + rows: CarRow[], + capturedAt: Date, +) { + if (!store) return; + for (const row of rows) { + if (!keyed(row.make, row.model, row.year)) continue; + const id = entityId('car', row.make, row.model, row.year); + await safely( + () => + store.upsertByEntityId(id, { + id, + kind: 'car', + ...copy(row, CAR_FIELDS), + captured_at: capturedAt, + } as ApiNinjasVehicleEntity), + `car ${id}`, + ); + } +} + +export async function cacheMotorcycles( + store: EntityStore | undefined, + rows: MotorcycleRow[], + capturedAt: Date, +) { + if (!store) return; + for (const row of rows) { + if (!keyed(row.make, row.model, row.year)) continue; + const id = entityId('motorcycle', row.make, row.model, row.year); + await safely( + () => + store.upsertByEntityId(id, { + id, + kind: 'motorcycle', + make: text(row.make), + model: text(row.model), + year: unmasked(row.year) ?? undefined, + type: text(row.type), + displacement: unmasked(row.displacement) ?? undefined, + transmission: text(row.transmission), + captured_at: capturedAt, + }), + `motorcycle ${id}`, + ); + } +} + +export async function cacheElectricVehicles( + store: EntityStore | undefined, + rows: ElectricVehicleRow[], + capturedAt: Date, +) { + if (!store) return; + for (const row of rows) { + if (!keyed(row.make, row.model, row.year_start)) continue; + const id = entityId('electric', row.make, row.model, row.year_start); + await safely( + () => + store.upsertByEntityId(id, { + id, + kind: 'electric', + make: text(row.make), + model: text(row.model), + year_start: unmasked(row.year_start) ?? undefined, + drive: text(row.drive), + battery_capacity: text(row.battery_capacity), + electric_range: unmasked(row.electric_range) ?? undefined, + captured_at: capturedAt, + }), + `electric vehicle ${id}`, + ); + } +} + +export async function cacheCountries( + store: EntityStore | undefined, + rows: CountryRow[], + capturedAt: Date, +) { + if (!store) return; + for (const row of rows) { + const id = entityId(row.iso2 ?? row.name); + if (!id) continue; + await safely( + () => + store.upsertByEntityId(id, { + id, + ...copy(row, COUNTRY_FIELDS), + captured_at: capturedAt, + } as ApiNinjasCountryEntity), + `country ${id}`, + ); + } +} + +export async function cacheCities( + store: EntityStore | undefined, + rows: CityRow[], + capturedAt: Date, +) { + if (!store) return; + for (const row of rows) { + if (!keyed(row.name, row.country)) continue; + const id = entityId(row.name, row.country); + await safely( + () => + store.upsertByEntityId(id, { + id, + name: text(row.name), + country: text(row.country), + latitude: asNumber(row.latitude), + longitude: asNumber(row.longitude), + population: unmasked(row.population) ?? undefined, + is_capital: unmasked(row.is_capital) ?? undefined, + captured_at: capturedAt, + }), + `city ${id}`, + ); + } +} + +export async function cacheUniversities( + store: EntityStore | undefined, + rows: UniversityRow[], + capturedAt: Date, +) { + if (!store) return; + for (const row of rows) { + if (!keyed(row.name, row.country)) continue; + const id = entityId(row.name, row.country); + await safely( + () => + store.upsertByEntityId(id, { + id, + ...copy(row, UNIVERSITY_FIELDS), + captured_at: capturedAt, + } as ApiNinjasUniversityEntity), + `university ${id}`, + ); + } +} + +export async function cacheStockExchanges( + store: EntityStore | undefined, + rows: StockExchangeRow[], + capturedAt: Date, +) { + if (!store) return; + for (const row of rows) { + const id = entityId(row.mic ?? row.name); + if (!id) continue; + await safely( + () => + store.upsertByEntityId(id, { + id, + ...copy(row as Record, EXCHANGE_FIELDS), + captured_at: capturedAt, + } as ApiNinjasStockExchangeEntity), + `stock exchange ${id}`, + ); + } +} + +export async function cacheSp500( + store: EntityStore | undefined, + rows: Sp500Row[], + capturedAt: Date, +) { + if (!store) return; + for (const row of rows) { + const id = entityId(row.ticker); + if (!id) continue; + await safely( + () => + store.upsertByEntityId(id, { + id, + ...copy(row, SP500_FIELDS), + captured_at: capturedAt, + } as ApiNinjasSp500Entity), + `S&P 500 constituent ${id}`, + ); + } +} + +export async function cacheEmoji( + store: EntityStore | undefined, + rows: EmojiRow[], + capturedAt: Date, +) { + if (!store) return; + for (const row of rows) { + const id = entityId(row.code ?? row.name); + if (!id) continue; + await safely( + () => + store.upsertByEntityId(id, { + id, + ...copy(row, EMOJI_FIELDS), + captured_at: capturedAt, + } as ApiNinjasEmojiEntity), + `emoji ${id}`, + ); + } +} + +export async function cacheAnimals( + store: EntityStore | undefined, + rows: AnimalRow[], + capturedAt: Date, +) { + if (!store) return; + for (const row of rows) { + const id = entityId(row.name); + if (!id) continue; + await safely( + () => + store.upsertByEntityId(id, { + id, + name: text(row.name), + taxonomy: nested(row.taxonomy), + characteristics: nested(row.characteristics), + locations: strings(row.locations), + captured_at: capturedAt, + }), + `animal ${id}`, + ); + } +} + +export async function cachePlanets( + store: EntityStore | undefined, + rows: PlanetRow[], + capturedAt: Date, +) { + if (!store) return; + for (const row of rows) { + const id = entityId(row.name); + if (!id) continue; + await safely( + () => + store.upsertByEntityId(id, { + id, + ...copy(row, PLANET_FIELDS), + captured_at: capturedAt, + } as ApiNinjasPlanetEntity), + `planet ${id}`, + ); + } +} + +export async function cacheStars( + store: EntityStore | undefined, + rows: StarRow[], + capturedAt: Date, +) { + if (!store) return; + for (const row of rows) { + const id = entityId(row.name); + if (!id) continue; + await safely( + () => + store.upsertByEntityId(id, { + id, + ...copy(row, STAR_FIELDS), + captured_at: capturedAt, + } as ApiNinjasStarEntity), + `star ${id}`, + ); + } +} diff --git a/packages/apininjas/endpoints/reference.ts b/packages/apininjas/endpoints/reference.ts new file mode 100644 index 000000000..5455dd6e6 --- /dev/null +++ b/packages/apininjas/endpoints/reference.ts @@ -0,0 +1,403 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeApiNinjasRequest } from '../client'; +import type { ApiNinjasEndpoints } from '../index'; +import { auditPayload, withCount } from './logging'; +import { cacheAnimals, cachePlanets, cacheStars } from './persist'; +import { asArray } from './shared'; +import type { ApiNinjasEndpointOutputs } from './types'; + +/** + * Species, astronomy, history and people. + * + * Every operation here is a single documented endpoint under + * https://api.api-ninjas.com. Inputs map one-to-one onto the documented query + * parameters, so nothing is renamed on the way through. + */ + +/** Returns up to 10 results matching the input name parameter. */ +export const animals: ApiNinjasEndpoints['referenceAnimals'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['referenceAnimals'] + >('animals', ctx.key, { + version: 'v1', + query: { + name: input.name, + }, + }); + + await cacheAnimals(ctx.db.animals, asArray(result), new Date()); + + await logEventFromContext( + ctx, + 'apininjas.reference.animals', + withCount(auditPayload(input, ['name']), result), + 'completed', + ); + return result; +}; + +/** + * Get a list of cat breeds matching specified parameters. Returns at most + * 20 results. To access more than 20 results, use the offset parameter to + * offset results in multiple API calls. + */ +export const cats: ApiNinjasEndpoints['referenceCats'] = async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['referenceCats'] + >('cats', ctx.key, { + version: 'v1', + query: { + name: input.name, + min_weight: input.min_weight, + max_weight: input.max_weight, + min_life_expectancy: input.min_life_expectancy, + max_life_expectancy: input.max_life_expectancy, + shedding: input.shedding, + family_friendly: input.family_friendly, + playfulness: input.playfulness, + grooming: input.grooming, + other_pets_friendly: input.other_pets_friendly, + children_friendly: input.children_friendly, + offset: input.offset, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.reference.cats', + withCount( + auditPayload(input, [ + 'name', + 'min_weight', + 'max_weight', + 'min_life_expectancy', + 'max_life_expectancy', + 'shedding', + 'family_friendly', + 'playfulness', + 'grooming', + 'other_pets_friendly', + 'children_friendly', + 'offset', + ]), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Get a list of dog breeds matching specified parameters. Returns at most + * 20 results. To access more than 20 results, use the offset parameter to + * offset results in multiple API calls. + */ +export const dogs: ApiNinjasEndpoints['referenceDogs'] = async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['referenceDogs'] + >('dogs', ctx.key, { + version: 'v1', + query: { + name: input.name, + min_height: input.min_height, + max_height: input.max_height, + min_weight: input.min_weight, + max_weight: input.max_weight, + min_life_expectancy: input.min_life_expectancy, + max_life_expectancy: input.max_life_expectancy, + shedding: input.shedding, + barking: input.barking, + energy: input.energy, + protectiveness: input.protectiveness, + trainability: input.trainability, + offset: input.offset, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.reference.dogs', + withCount( + auditPayload(input, [ + 'name', + 'min_height', + 'max_height', + 'min_weight', + 'max_weight', + 'min_life_expectancy', + 'max_life_expectancy', + 'shedding', + 'barking', + 'energy', + 'protectiveness', + 'trainability', + 'offset', + ]), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Get a list of planets matching specified parameters. Returns at most 30 + * results. To access more than 30 results, use the offset parameter to + * offset results in multiple API calls. + */ +export const planets: ApiNinjasEndpoints['referencePlanets'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['referencePlanets'] + >('planets', ctx.key, { + version: 'v1', + query: { + name: input.name, + min_mass: input.min_mass, + max_mass: input.max_mass, + min_radius: input.min_radius, + max_radius: input.max_radius, + min_period: input.min_period, + max_period: input.max_period, + min_temperature: input.min_temperature, + max_temperature: input.max_temperature, + min_distance_light_year: input.min_distance_light_year, + max_distance_light_year: input.max_distance_light_year, + min_semi_major_axis: input.min_semi_major_axis, + max_semi_major_axis: input.max_semi_major_axis, + offset: input.offset, + }, + }); + + await cachePlanets(ctx.db.planets, asArray(result), new Date()); + + await logEventFromContext( + ctx, + 'apininjas.reference.planets', + withCount( + auditPayload(input, [ + 'name', + 'min_mass', + 'max_mass', + 'min_radius', + 'max_radius', + 'min_period', + 'max_period', + 'min_temperature', + 'max_temperature', + 'min_distance_light_year', + 'max_distance_light_year', + 'min_semi_major_axis', + 'max_semi_major_axis', + 'offset', + ]), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Get a list of stars matching specified parameters. Returns at most 30 + * results. To access more than 30 results, use the offset parameter to + * offset results in multiple API calls. + */ +export const stars: ApiNinjasEndpoints['referenceStars'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['referenceStars'] + >('stars', ctx.key, { + version: 'v1', + query: { + name: input.name, + constellation: input.constellation, + min_apparent_magnitude: input.min_apparent_magnitude, + max_apparent_magnitude: input.max_apparent_magnitude, + min_absolute_magnitude: input.min_absolute_magnitude, + max_absolute_magnitude: input.max_absolute_magnitude, + min_distance_light_year: input.min_distance_light_year, + max_distance_light_year: input.max_distance_light_year, + offset: input.offset, + }, + }); + + await cacheStars(ctx.db.stars, asArray(result), new Date()); + + await logEventFromContext( + ctx, + 'apininjas.reference.stars', + withCount( + auditPayload(input, [ + 'name', + 'constellation', + 'min_apparent_magnitude', + 'max_apparent_magnitude', + 'min_absolute_magnitude', + 'max_absolute_magnitude', + 'min_distance_light_year', + 'max_distance_light_year', + 'offset', + ]), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Returns a list of up to 10 events that match the search parameters. Use + * the offset parameter to paginate through more results. + */ +export const historicalEvents: ApiNinjasEndpoints['referenceHistoricalEvents'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['referenceHistoricalEvents'] + >('historicalevents', ctx.key, { + version: 'v1', + query: { + text: input.text, + year: input.year, + month: input.month, + day: input.day, + offset: input.offset, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.reference.historicalEvents', + withCount( + auditPayload(input, ['year', 'month', 'day', 'offset']), + result, + ), + 'completed', + ); + return result; + }; + +/** Returns a list of up to 10 people that match the search parameters. */ +export const historicalFigures: ApiNinjasEndpoints['referenceHistoricalFigures'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['referenceHistoricalFigures'] + >('historicalfigures', ctx.key, { + version: 'v1', + query: { + name: input.name, + offset: input.offset, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.reference.historicalFigures', + withCount(auditPayload(input, ['name', 'offset']), result), + 'completed', + ); + return result; + }; + +/** + * Returns historical events that occurred on a specific date. If no date + * parameters are provided, returns events for today's date. + */ +export const dayInHistory: ApiNinjasEndpoints['referenceDayInHistory'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['referenceDayInHistory'] + >('dayinhistory', ctx.key, { + version: 'v1', + query: { + month: input.month, + day: input.day, + offset: input.offset, + limit: input.limit, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.reference.dayInHistory', + withCount(auditPayload(input, ['month', 'day', 'offset', 'limit']), result), + 'completed', + ); + return result; +}; + +/** + * Returns a list of up to 30 celebrities that match the search parameters. + * To get more than 30 results, use the offset parameter. + */ +export const celebrities: ApiNinjasEndpoints['referenceCelebrities'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['referenceCelebrities'] + >('celebrity', ctx.key, { + version: 'v1', + query: { + name: input.name, + min_net_worth: input.min_net_worth, + max_net_worth: input.max_net_worth, + nationality: input.nationality, + min_height: input.min_height, + max_height: input.max_height, + offset: input.offset, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.reference.celebrities', + withCount( + auditPayload(input, [ + 'name', + 'min_net_worth', + 'max_net_worth', + 'nationality', + 'min_height', + 'max_height', + 'offset', + ]), + result, + ), + 'completed', + ); + return result; +}; + +/** Returns 10 baby name results. */ +export const babyNames: ApiNinjasEndpoints['referenceBabyNames'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['referenceBabyNames'] + >('babynames', ctx.key, { + version: 'v1', + query: { + gender: input.gender, + popular_only: input.popular_only, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.reference.babyNames', + withCount(auditPayload(input, ['gender', 'popular_only']), result), + 'completed', + ); + return result; +}; diff --git a/packages/apininjas/endpoints/shared.ts b/packages/apininjas/endpoints/shared.ts new file mode 100644 index 000000000..f9f70556f --- /dev/null +++ b/packages/apininjas/endpoints/shared.ts @@ -0,0 +1,138 @@ +/** + * Helpers shared by the endpoint modules. + * + * There is no pagination envelope to share here: API Ninjas has no cursor, no + * limit/offset wrapper and no total count. Collections come back as bare JSON + * arrays capped server-side, and where a `limit` parameter exists at all it is + * premium-gated. What is shared instead is the handling of the free tier's two + * quirks - masked values and missing rows. + */ + +/** + * Matches the prose the free tier returns in place of a value. + * + * The wording is not consistent between endpoints. Across the responses + * captured from every operation there are 38 distinct variants - "This field is + * for premium subscribers only.", "Only available for premium subscribers.", + * "sector is reserved for premium subscribers only.", "premium subscription + * required.", a lowercase form, and the electric-vehicle endpoint's bare "No + * Data" - so this cannot match a phrase. + * + * It deliberately does not match the bare word "premium" either. A field value + * like "Premium Economy" or a fund named "... Premium Fund" is real data, and + * treating it as withheld would silently drop it from the mirror. Every one of + * the 38 observed variants names the subscription, so that is what is matched. + */ +const PREMIUM_PLACEHOLDER = + /premium subscriber|premium subscription|premium users only|^no data$/i; + +/** + * True when a field holds the provider's placeholder prose rather than data. + * + * Useful before mirroring a value into the cache: storing "This field is for + * premium subscribers only." as an airline's fleet size would be worse than + * storing nothing. + */ +export function isMaskedValue(value: unknown): boolean { + return typeof value === 'string' && PREMIUM_PLACEHOLDER.test(value); +} + +/** Returns the value unless the provider masked it, in which case undefined. */ +export function unmasked(value: T): T | undefined { + return isMaskedValue(value) ? undefined : value; +} + +/** Coerces a value the provider may send as a number or as a numeric string. */ +export function asNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && !isMaskedValue(value)) { + const parsed = Number.parseFloat(value.replace(/,/g, '')); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +/** + * Builds a stable cache key from the fields that identify a row. + * + * Most of these endpoints return no identifier of their own, so the key is + * composed from the natural key instead - an airport's ICAO code, a city's name + * and country. Parts are lowercased and blanks are kept as empty segments, so + * the same row always produces the same key. + */ +export function entityId( + ...parts: (string | number | null | undefined)[] +): string { + return parts + .map((part) => (part === null || part === undefined ? '' : String(part))) + .map((part) => part.trim().toLowerCase()) + .join('|'); +} + +/** + * True when at least one part of a natural key carries a value. + * + * A row with nothing to key on has to be skipped rather than stored, or every + * such row collides on the same blank key and overwrites the last. Checking the + * parts rather than the joined string keeps that independent of how many parts + * a key has - comparing the result against `'|'` only catches it for a key of + * exactly two, and silently misses a three-part one. + */ +export function keyed( + ...parts: (string | number | null | undefined)[] +): boolean { + return parts.some( + (part) => + part !== null && part !== undefined && String(part).trim().length > 0, + ); +} + +/** + * Content types the image endpoints answer with, keyed by the `format` + * parameter the provider documents. + */ +const IMAGE_CONTENT_TYPES: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + svg: 'image/svg+xml', + eps: 'application/postscript', +}; + +/** + * Maps a requested image format onto its content type, defaulting to PNG - the + * provider's own default when `format` is omitted. + */ +export function imageContentType(format: string | undefined): string { + if (!format) return 'image/png'; + return ( + IMAGE_CONTENT_TYPES[format.toLowerCase()] ?? 'application/octet-stream' + ); +} + +/** Formats whose payload is text, and therefore survives the transport exactly. */ +const TEXT_IMAGE_FORMATS = new Set(['svg', 'eps']); + +/** + * Whether the payload for a format is exactly what the provider sent. + * + * The shared transport decodes any non-JSON response with `response.text()`. + * SVG and EPS are text and come back byte-for-byte; raster bytes do not + * survive that decode and cannot be written back out as an image. The + * operations report this rather than leaving a caller to infer it from + * `content_type`, which describes what was asked for, not what arrived. + */ +export function imageEncoding( + format: string | undefined, +): 'text' | 'lossy-text' { + return TEXT_IMAGE_FORMATS.has((format ?? '').toLowerCase()) + ? 'text' + : 'lossy-text'; +} + +/** Normalises a collection response that may arrive as a bare object. */ +export function asArray(result: T[] | T | null | undefined): T[] { + if (Array.isArray(result)) return result; + if (result === null || result === undefined) return []; + return [result]; +} diff --git a/packages/apininjas/endpoints/text.ts b/packages/apininjas/endpoints/text.ts new file mode 100644 index 000000000..1015966a0 --- /dev/null +++ b/packages/apininjas/endpoints/text.ts @@ -0,0 +1,291 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeApiNinjasRequest } from '../client'; +import type { ApiNinjasEndpoints } from '../index'; +import { auditPayload, withCount } from './logging'; +import type { ApiNinjasEndpointOutputs } from './types'; + +/** + * Natural language: sentiment, similarity, embeddings and lexical lookups. + * + * Every operation here is a single documented endpoint under + * https://api.api-ninjas.com. Inputs map one-to-one onto the documented query + * parameters, so nothing is renamed on the way through. + */ + +/** + * Returns sentiment analysis score and overall sentiment for a given block + * of text. + */ +export const sentiment: ApiNinjasEndpoints['textSentiment'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['textSentiment'] + >('sentiment', ctx.key, { + version: 'v1', + query: { + text: input.text, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.text.sentiment', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** + * Returns a similarity score between 0 and 1 (1 is similar and 0 is + * dissimilar) of two given texts. + */ +export const similarity: ApiNinjasEndpoints['textSimilarity'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['textSimilarity'] + >('textsimilarity', ctx.key, { + version: 'v1', + method: 'POST', + body: { + text_1: input.text_1, + text_2: input.text_2, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.text.similarity', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** + * Returns a 768-dimensional vector as an array that encodes the meaning of + * any given input text. + */ +export const embeddings: ApiNinjasEndpoints['textEmbeddings'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['textEmbeddings'] + >('embeddings', ctx.key, { + version: 'v1', + method: 'POST', + body: { + text: input.text, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.text.embeddings', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** + * Returns the language name and 2-letter ISO language code for a given + * block of text string. + */ +export const language: ApiNinjasEndpoints['textLanguage'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['textLanguage'] + >('textlanguage', ctx.key, { + version: 'v1', + query: { + text: input.text, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.text.language', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** Returns spelling corrections and suggestions for any given text. */ +export const spellCheck: ApiNinjasEndpoints['textSpellCheck'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['textSpellCheck'] + >('spellcheck', ctx.key, { + version: 'v1', + query: { + text: input.text, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.text.spellCheck', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** + * Returns the censored version (bad words replaced with asterisks) of any + * given text and whether the text contains profanity. + */ +export const profanityFilter: ApiNinjasEndpoints['textProfanityFilter'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['textProfanityFilter'] + >('profanityfilter', ctx.key, { + version: 'v1', + query: { + text: input.text, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.text.profanityFilter', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; + }; + +/** Returns a string containing definitions for a given word. */ +export const dictionary: ApiNinjasEndpoints['textDictionary'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['textDictionary'] + >('dictionary', ctx.key, { + version: 'v1', + query: { + word: input.word, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.text.dictionary', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** Returns a list of synonyms and a list of antonyms for a given word. */ +export const thesaurus: ApiNinjasEndpoints['textThesaurus'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['textThesaurus'] + >('thesaurus', ctx.key, { + version: 'v1', + query: { + word: input.word, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.text.thesaurus', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** Returns a list of rhyming words for any given word. */ +export const rhymes: ApiNinjasEndpoints['textRhymes'] = async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['textRhymes'] + >('rhyme', ctx.key, { + version: 'v1', + query: { + word: input.word, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.text.rhymes', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** Returns a random word. */ +export const randomWord: ApiNinjasEndpoints['textRandomWord'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['textRandomWord'] + >('randomword', ctx.key, { + version: 'v2', + query: { + type: input.type, + limit: input.limit, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.text.randomWord', + withCount(auditPayload(input, ['type', 'limit']), result), + 'completed', + ); + return result; +}; + +/** Returns one or more paragraphs of lorem ipsum placeholder text. */ +export const loremIpsum: ApiNinjasEndpoints['textLoremIpsum'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['textLoremIpsum'] + >('loremipsum', ctx.key, { + version: 'v1', + query: { + max_length: input.max_length, + paragraphs: input.paragraphs, + start_with_lorem_ipsum: input.start_with_lorem_ipsum, + random: input.random, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.text.loremIpsum', + withCount( + auditPayload(input, [ + 'max_length', + 'paragraphs', + 'start_with_lorem_ipsum', + 'random', + ]), + result, + ), + 'completed', + ); + return result; +}; diff --git a/packages/apininjas/endpoints/transport.ts b/packages/apininjas/endpoints/transport.ts new file mode 100644 index 000000000..4c0851c23 --- /dev/null +++ b/packages/apininjas/endpoints/transport.ts @@ -0,0 +1,363 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeApiNinjasRequest } from '../client'; +import type { ApiNinjasEndpoints } from '../index'; +import { auditPayload, withCount } from './logging'; +import { + cacheAircraft, + cacheAirlines, + cacheAirports, + cacheCars, + cacheElectricVehicles, + cacheMotorcycles, +} from './persist'; +import { asArray } from './shared'; +import type { ApiNinjasEndpointOutputs } from './types'; + +/** + * Aircraft, airlines, airports and road vehicles. + * + * Every operation here is a single documented endpoint under + * https://api.api-ninjas.com. Inputs map one-to-one onto the documented query + * parameters, so nothing is renamed on the way through. + */ + +/** + * Returns a list of aircrafts that match the given parameters. This API + * only supports airplanes - for helicopter specs please use our Helicopter + * API. + */ +export const aircraft: ApiNinjasEndpoints['transportAircraft'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['transportAircraft'] + >('aircraft', ctx.key, { + version: 'v1', + query: { + manufacturer: input.manufacturer, + model: input.model, + engine_type: input.engine_type, + min_speed: input.min_speed, + max_speed: input.max_speed, + min_range: input.min_range, + max_range: input.max_range, + min_length: input.min_length, + max_length: input.max_length, + min_height: input.min_height, + max_height: input.max_height, + min_wingspan: input.min_wingspan, + max_wingspan: input.max_wingspan, + limit: input.limit, + }, + }); + + await cacheAircraft(ctx.db.aircraft, asArray(result), new Date()); + + await logEventFromContext( + ctx, + 'apininjas.transport.aircraft', + withCount( + auditPayload(input, [ + 'manufacturer', + 'model', + 'engine_type', + 'min_speed', + 'max_speed', + 'min_range', + 'max_range', + 'min_length', + 'max_length', + 'min_height', + 'max_height', + 'min_wingspan', + 'max_wingspan', + 'limit', + ]), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Returns airline details including fleet composition, base airport and + * branding assets, by name, IATA code or ICAO code. + */ +export const airlines: ApiNinjasEndpoints['transportAirlines'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['transportAirlines'] + >('airlines', ctx.key, { + version: 'v1', + query: { + name: input.name, + iata: input.iata, + icao: input.icao, + }, + }); + + await cacheAirlines(ctx.db.airlines, asArray(result), new Date()); + + await logEventFromContext( + ctx, + 'apininjas.transport.airlines', + withCount(auditPayload(input, ['name', 'iata', 'icao']), result), + 'completed', + ); + return result; +}; + +/** + * Returns a list of up to 10 airport results. Use the offset parameter to + * access more results if available. + */ +export const airports: ApiNinjasEndpoints['transportAirports'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['transportAirports'] + >('airports', ctx.key, { + version: 'v1', + query: { + iata: input.iata, + icao: input.icao, + name: input.name, + country: input.country, + region: input.region, + city: input.city, + timezone: input.timezone, + min_elevation: input.min_elevation, + max_elevation: input.max_elevation, + size: input.size, + has_iata: input.has_iata, + min_runway_length: input.min_runway_length, + type: input.type, + scheduled_service: input.scheduled_service, + continent: input.continent, + surface: input.surface, + has_lights: input.has_lights, + q: input.q, + include_closed: input.include_closed, + limit: input.limit, + sort: input.sort, + order: input.order, + offset: input.offset, + }, + }); + + await cacheAirports(ctx.db.airports, asArray(result), new Date()); + + await logEventFromContext( + ctx, + 'apininjas.transport.airports', + withCount( + auditPayload(input, [ + 'iata', + 'icao', + 'name', + 'country', + 'region', + 'city', + 'timezone', + 'min_elevation', + 'max_elevation', + 'size', + 'has_iata', + 'min_runway_length', + 'type', + 'scheduled_service', + 'continent', + 'surface', + 'has_lights', + 'include_closed', + 'limit', + 'sort', + 'order', + 'offset', + ]), + result, + ), + 'completed', + ); + return result; +}; + +/** Get helicopter technical specifications that match the given parameters. */ +export const helicopters: ApiNinjasEndpoints['transportHelicopters'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['transportHelicopters'] + >('helicopter', ctx.key, { + version: 'v1', + query: { + manufacturer: input.manufacturer, + model: input.model, + min_speed: input.min_speed, + max_speed: input.max_speed, + min_range: input.min_range, + max_range: input.max_range, + min_length: input.min_length, + max_length: input.max_length, + min_height: input.min_height, + max_height: input.max_height, + limit: input.limit, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.transport.helicopters', + withCount( + auditPayload(input, [ + 'manufacturer', + 'model', + 'min_speed', + 'max_speed', + 'min_range', + 'max_range', + 'min_length', + 'max_length', + 'min_height', + 'max_height', + 'limit', + ]), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Get car data from given parameters. Returns a list of car models (and + * their information) that satisfy the parameters. + */ +export const cars: ApiNinjasEndpoints['transportCars'] = async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['transportCars'] + >('cars', ctx.key, { + version: 'v1', + query: { + make: input.make, + model: input.model, + trim: input.trim, + }, + }); + + await cacheCars(ctx.db.vehicles, asArray(result), new Date()); + + await logEventFromContext( + ctx, + 'apininjas.transport.cars', + withCount(auditPayload(input, ['make', 'model', 'trim']), result), + 'completed', + ); + return result; +}; + +/** + * Returns up to 30 motorcycle results matching the input name parameters. + * For searches that yield more than 30 results, please use the offset + * parameter. + */ +export const motorcycles: ApiNinjasEndpoints['transportMotorcycles'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['transportMotorcycles'] + >('motorcycles', ctx.key, { + version: 'v1', + query: { + make: input.make, + model: input.model, + year: input.year, + offset: input.offset, + }, + }); + + await cacheMotorcycles(ctx.db.vehicles, asArray(result), new Date()); + + await logEventFromContext( + ctx, + 'apininjas.transport.motorcycles', + withCount(auditPayload(input, ['make', 'model', 'year', 'offset']), result), + 'completed', + ); + return result; +}; + +/** + * Get electric vehicle data from given parameters. Returns a list of + * electric vehicles that satisfy the parameters. + */ +export const electricVehicles: ApiNinjasEndpoints['transportElectricVehicles'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['transportElectricVehicles'] + >('electricvehicle', ctx.key, { + version: 'v1', + query: { + make: input.make, + model: input.model, + min_year: input.min_year, + max_year: input.max_year, + min_range: input.min_range, + max_range: input.max_range, + limit: input.limit, + offset: input.offset, + }, + }); + + await cacheElectricVehicles(ctx.db.vehicles, asArray(result), new Date()); + + await logEventFromContext( + ctx, + 'apininjas.transport.electricVehicles', + withCount( + auditPayload(input, [ + 'make', + 'model', + 'min_year', + 'max_year', + 'min_range', + 'max_range', + 'limit', + 'offset', + ]), + result, + ), + 'completed', + ); + return result; + }; + +/** + * Returns key vehicle information including manufacturer, country of + * origin, and model year for a given VIN. + */ +export const vin: ApiNinjasEndpoints['transportVin'] = async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['transportVin'] + >('vinlookup', ctx.key, { + version: 'v1', + query: { + vin: input.vin, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.transport.vin', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; diff --git a/packages/apininjas/endpoints/types.ts b/packages/apininjas/endpoints/types.ts new file mode 100644 index 000000000..60b526381 --- /dev/null +++ b/packages/apininjas/endpoints/types.ts @@ -0,0 +1,5305 @@ +import { z } from 'zod'; + +/** + * Request and response shapes for the API Ninjas endpoints. + * + * Input schemas come from the documented parameter table of each endpoint; a + * response never says which parameters a call accepts. Output schemas are built + * from responses captured against a live account on 2026-08-15 and checked + * against the documented response fields. + * + * Two provider behaviours drive the shape of everything below: + * + * 1. The free tier answers 200 and replaces individual field values with prose + * ("This field is for premium subscribers only."). A field the documentation + * describes as a number can therefore arrive as a string, so those fields + * accept both. A schema that insisted on the documented type would reject + * the whole row, and a rejected row is a lost row. + * 2. Fields come and go by plan and by record. Every field is optional and + * nullable and every object is loose, so an unmodelled field is preserved + * rather than stripped. + * + * @see https://api-ninjas.com/api + */ + +/* -------------------------------------------------------------------------- */ +/* location */ +/* -------------------------------------------------------------------------- */ + +/** + * Get current city coordinates by city and country name. + * + * GET v1/geocoding + */ +const LocationGeocodeInputSchema = z.object({ + /** City name. */ + city: z.string(), + /** US state (for United States cities only). */ + state: z.string().optional(), + /** Country name, 2-letter ISO country code, or 3-letter ISO country code. */ + country: z.string().optional(), + /** 5-digit zipcode (for United States cities only). */ + zipcode: z.string().optional(), +}); + +const LocationGeocodeOutputSchema = z.array( + z + .object({ + name: z.string().nullable().optional(), + latitude: z.number().nullable().optional(), + longitude: z.number().nullable().optional(), + country: z.string().nullable().optional(), + state: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns a list of cities that contain a given latitude and longitude. + * + * GET v1/reversegeocoding + */ +const LocationReverseGeocodeInputSchema = z.object({ + /** Latitude coordinate. */ + lat: z.number(), + /** Longitude coordinate. */ + lon: z.number(), +}); + +const LocationReverseGeocodeOutputSchema = z.array( + z + .object({ + name: z.string().nullable().optional(), + country: z.string().nullable().optional(), + state: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Get city data from either a name or population range. Returns a list of cities that satisfies the parameters. + * + * GET v1/city + */ +const LocationCitiesInputSchema = z.object({ + /** Name of city. */ + name: z.string().optional(), + /** Country filter. Must be an ISO-3166 alpha-2 country code (e.g. US). */ + country: z.string().optional(), + /** Minimum latitude coordinate. */ + min_lat: z.number().optional(), + /** Maximum latitude coordinate. */ + max_lat: z.number().optional(), + /** Minimum longitude coordinate. */ + min_lon: z.number().optional(), + /** Maximum longitude coordinate. */ + max_lon: z.number().optional(), + /** Minimum city population. */ + min_population: z.number().optional(), + /** Maximum city population. */ + max_population: z.number().optional(), + /** How many results to return. Must be between 1 and 30. Default is 1. To get more than 30 results, use the offset parameter. [premium] */ + limit: z.number().optional(), + /** Number of results to offset for pagination. [premium] */ + offset: z.number().optional(), +}); + +const LocationCitiesOutputSchema = z.array( + z + .object({ + name: z.string().nullable().optional(), + latitude: z.number().nullable().optional(), + longitude: z.number().nullable().optional(), + country: z.string().nullable().optional(), + population: z.number().nullable().optional(), + is_capital: z.boolean().nullable().optional(), + }) + .loose(), +); + +/** + * Get country data from given parameters. Returns a list of country statistics that satisfy the parameters. + * + * GET v1/country + */ +const LocationCountryInputSchema = z.object({ + /** Plain English name, 2-letter ISO-3166 alpha-2, or 3-letter ISO-3166 alpha-3 code of country. */ + name: z.string().optional(), + /** 3-letter currency code of country (e.g. USD). */ + currency: z.string().optional(), + /** Minimum gross domestic product (GDP) of country, in US Dollars. */ + min_gdp: z.number().optional(), + /** Maximum gross domestic product (GDP) of country, in US Dollars. */ + max_gdp: z.number().optional(), + /** Minimum population of country (in thousands). */ + min_population: z.number().optional(), + /** Maximum population of country (in thousands). */ + max_population: z.number().optional(), + /** Minimum surface area of country in km2. */ + min_area: z.number().optional(), + /** Maximum surface area of country in km2. */ + max_area: z.number().optional(), + /** Minimum unemployment rate in %. */ + min_unemployment: z.number().optional(), + /** Maximum unemployment rate in %. */ + max_unemployment: z.number().optional(), + /** Minimum GDP growth rate in %. */ + min_gdp_growth: z.number().optional(), + /** Maximum GDP growth rate in %. */ + max_gdp_growth: z.number().optional(), + /** Minimum infant mortality rate per 1,000 live births. */ + min_infant_mortality: z.number().optional(), + /** Maximum infant mortality rate per 1,000 live births. */ + max_infant_mortality: z.number().optional(), + /** Minimum fertility rate (average number of children per woman). */ + min_fertility: z.number().optional(), + /** Maximum fertility rate (average number of children per woman). */ + max_fertility: z.number().optional(), + /** Minimum urban population rate in %. */ + min_urban_pop_rate: z.number().optional(), + /** Maximum urban population rate in %. */ + max_urban_pop_rate: z.number().optional(), + /** How many results to return. Must be between 1 and 30. Default is 5. */ + limit: z.number().optional(), +}); + +const LocationCountryOutputSchema = z.array( + z + .object({ + gdp: z.number().nullable().optional(), + sex_ratio: z.number().nullable().optional(), + surface_area: z.number().nullable().optional(), + life_expectancy_male: z.number().nullable().optional(), + unemployment: z.number().nullable().optional(), + imports: z.number().nullable().optional(), + homicide_rate: z.number().nullable().optional(), + currency: z + .object({ + code: z.string().nullable().optional(), + name: z.string().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + iso2: z.string().nullable().optional(), + employment_services: z.number().nullable().optional(), + employment_industry: z.number().nullable().optional(), + urban_population_growth: z.number().nullable().optional(), + secondary_school_enrollment_female: z.number().nullable().optional(), + employment_agriculture: z.number().nullable().optional(), + capital: z.string().nullable().optional(), + co2_emissions: z.number().nullable().optional(), + forested_area: z.number().nullable().optional(), + tourists: z.number().nullable().optional(), + exports: z.number().nullable().optional(), + life_expectancy_female: z.number().nullable().optional(), + post_secondary_enrollment_female: z.number().nullable().optional(), + post_secondary_enrollment_male: z.number().nullable().optional(), + primary_school_enrollment_female: z.number().nullable().optional(), + infant_mortality: z.number().nullable().optional(), + gdp_growth: z.number().nullable().optional(), + threatened_species: z.number().nullable().optional(), + population: z.number().nullable().optional(), + urban_population: z.number().nullable().optional(), + secondary_school_enrollment_male: z.number().nullable().optional(), + name: z.string().nullable().optional(), + pop_growth: z.number().nullable().optional(), + region: z.string().nullable().optional(), + pop_density: z.number().nullable().optional(), + internet_users: z.number().nullable().optional(), + gdp_per_capita: z.number().nullable().optional(), + fertility: z.number().nullable().optional(), + refugees: z.number().nullable().optional(), + primary_school_enrollment_male: z.number().nullable().optional(), + telephone_country_codes: z.array(z.string()).nullable().optional(), + }) + .loose(), +); + +/** + * Returns details for one or more counties matching the input parameters. For premium users, you can also specify the limit and offset parameters to paginate through results. + * + * GET v1/county + */ +const LocationCountyInputSchema = z.object({ + /** Full name of the county to search. */ + county: z.string().optional(), + /** 5-digit ZIP code to search. */ + zipcode: z.string().optional(), + /** 2-letter state code (case-insensitive). */ + state: z.string().optional(), + /** Number of results to return. Must be between 1 and 30. Default is 1. [premium] */ + limit: z.number().optional(), + /** Number of results to offset for pagination. Default is 0. [premium] */ + offset: z.number().optional(), +}); + +const LocationCountyOutputSchema = z.array( + z + .object({ + county_name: z.string().nullable().optional(), + county_fips: z.string().nullable().optional(), + state_code: z.string().nullable().optional(), + state_name: z.string().nullable().optional(), + latitude: z.union([z.number(), z.string()]).nullable().optional(), + longitude: z.union([z.number(), z.string()]).nullable().optional(), + zip_codes: z + .union([z.array(z.string()), z.string()]) + .nullable() + .optional(), + timezone: z.string().nullable().optional(), + population: z.number().nullable().optional(), + median_age: z.number().nullable().optional(), + }) + .loose(), +); + +/** + * Returns a list of ZIP Code details matching the input parameters. + * + * GET v1/zipcode + */ +const LocationZipCodeInputSchema = z.object({ + /** The ZIP Code to look up. */ + zip: z.string().optional(), + /** Full name of the city to search (case-sensitive). [premium] */ + city: z.string().optional(), + /** 2-letter abbreviation of the state (case-insensitive). [premium] */ + state: z.string().optional(), +}); + +const LocationZipCodeOutputSchema = z.array( + z + .object({ + zip_code: z.string().nullable().optional(), + valid: z.union([z.boolean(), z.string()]).nullable().optional(), + city: z.string().nullable().optional(), + state: z.string().nullable().optional(), + county: z.string().nullable().optional(), + timezone: z.string().nullable().optional(), + area_codes: z + .union([z.array(z.string()), z.string()]) + .nullable() + .optional(), + country: z.string().nullable().optional(), + lat: z.string().nullable().optional(), + lon: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns a list of postal code details matching the input parameters. + * + * GET v1/postalcode + */ +const LocationPostalCodeInputSchema = z.object({ + /** The postal code to look up. Accepts Canadian postal codes in 6 characters (A1A1A1) or 7 characters with a space (A1A 1A1). The space will be automatically normalized if not provided. */ + postal_code: z.string().optional(), + /** Full name of the city to search (case-sensitive). [premium] */ + city: z.string().optional(), + /** 2-letter abbreviation of the province (e.g., ON, BC, QC). [premium] */ + province: z.string().optional(), +}); + +const LocationPostalCodeOutputSchema = z.array( + z + .object({ + city: z.string().nullable().optional(), + province: z.string().nullable().optional(), + postal_code: z.string().nullable().optional(), + area_code: z.string().nullable().optional(), + timezone: z.string().nullable().optional(), + lat: z.string().nullable().optional(), + lon: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns information about universities matching the provided filters. At least one filter parameter is required. Free users can use name or country - all other filters are premium-only. + * + * GET v1/university + */ +const LocationUniversitiesInputSchema = z.object({ + /** The name of the university to search for. Can be a partial match (e.g., "Harvard" will match "Harvard University"). At least one filter parameter (excluding offset/limit) must be provided. */ + name: z.string().optional(), + /** The country to filter by. Must be USA or Canada (case-insensitive). At least one filter parameter (excluding offset/limit) must be provided. */ + country: z.string().optional(), + /** The city where the university is located. [premium] */ + city: z.string().optional(), + /** The state or province where the university is located. [premium] */ + state: z.string().optional(), + /** Minimum student-to-faculty ratio as a number (e.g., 15 for 15:1 ratio). [premium] */ + min_faculty_ratio: z.number().optional(), + /** Maximum student-to-faculty ratio as a number (e.g., 20 for 20:1 ratio). [premium] */ + max_faculty_ratio: z.number().optional(), + /** Minimum number of enrolled students. [premium] */ + min_enrolled: z.number().optional(), + /** Maximum number of enrolled students. [premium] */ + max_enrolled: z.number().optional(), + /** Minimum annual tuition cost (in USD). [premium] */ + min_tuition: z.number().optional(), + /** Maximum annual tuition cost (in USD). [premium] */ + max_tuition: z.number().optional(), + /** The number of results to skip. Must be zero or a positive integer. Default is 0. [premium] */ + offset: z.number().optional(), + /** The maximum number of results to return. Must be between 1 and 30. Default is 10 for premium users, fixed at 5 for free users. [premium] */ + limit: z.number().optional(), +}); + +const LocationUniversitiesOutputSchema = z.array( + z + .object({ + name: z.string().nullable().optional(), + degree_types: z.array(z.string()).nullable().optional(), + address: z.string().nullable().optional(), + city: z.string().nullable().optional(), + state: z.string().nullable().optional(), + postal_code: z.string().nullable().optional(), + country: z.string().nullable().optional(), + county: z.string().nullable().optional(), + timezone: z.string().nullable().optional(), + latitude: z.string().nullable().optional(), + longitude: z.string().nullable().optional(), + phone: z.string().nullable().optional(), + website: z.string().nullable().optional(), + institution_type: z.string().nullable().optional(), + years: z.string().nullable().optional(), + enrollment: z.string().nullable().optional(), + student_faculty_ratio: z.string().nullable().optional(), + /** The contact email address of the university. Only returned for some records. */ + email: z.string().nullable().optional(), + /** The annual tuition cost in USD. Only returned for records where tuition data is available; frequently omitted. */ + tuition: z.union([z.number(), z.string()]).nullable().optional(), + }) + .loose(), +); + +/** + * Get hospital data based on given parameters. Returns a list of hospitals that match the specified criteria. + * + * GET v1/hospitals + */ +const LocationHospitalsInputSchema = z.object({ + /** Name of the hospital to search for. Supports partial matching. */ + name: z.string().optional(), + /** City where the hospital is located. */ + city: z.string().optional(), + /** State where the hospital is located. */ + state: z.string().optional(), + /** ZIP code of the hospital location. */ + zipcode: z.string().optional(), + /** County where the hospital is located. */ + county: z.string().optional(), + /** Minimum latitude coordinate. */ + min_latitude: z.number().optional(), + /** Maximum latitude coordinate. */ + max_latitude: z.number().optional(), + /** Minimum longitude coordinate. */ + min_longitude: z.number().optional(), + /** Maximum longitude coordinate. */ + max_longitude: z.number().optional(), + /** Number of results to return. Default is 5. Maximum is 100. [premium] */ + limit: z.number().optional(), + /** Number of results to skip. Default is 0. [premium] */ + offset: z.number().optional(), +}); + +const LocationHospitalsOutputSchema = z.array( + z + .object({ + name: z.string().nullable().optional(), + care_type: z.string().nullable().optional(), + address: z.string().nullable().optional(), + city: z.string().nullable().optional(), + state: z.string().nullable().optional(), + zipcode: z.string().nullable().optional(), + county: z.string().nullable().optional(), + location_area_code: z.string().nullable().optional(), + fips_code: z.string().nullable().optional(), + timezone: z.string().nullable().optional(), + latitude: z.string().nullable().optional(), + longitude: z.string().nullable().optional(), + phone_number: z.string().nullable().optional(), + website: z.string().nullable().optional(), + ownership: z.string().nullable().optional(), + bedcount: z.number().nullable().optional(), + /** Mailing address fields of the hospital. */ + 'address, city, state, zipcode': z.string().nullable().optional(), + /** Geographic coordinates of the hospital. */ + 'latitude, longitude': z.string().nullable().optional(), + }) + .loose(), +); + +/** + * FIND_EV_CHARGING_STATIONS + * + * GET v1/evcharger + */ +const LocationEvChargersInputSchema = z.object({ + /** Latitude coordinate (e.g. 37.4277). */ + lat: z.number(), + /** Longitude coordinate (e.g. -122.1701). */ + lon: z.number(), + /** Search distance in kilometers. The search area is a box from specified lat - distance to lat + distance and lon - distance to lon + distance. Default is 3 kilometers. Max value is 50 kilometers. */ + distance: z.number().optional(), + /** Charging level (1, 2, or 3). By default, all levels are returned. */ + level: z.string().optional(), + /** How many results to return. Must be between 1 and 30. Default is 3. [premium] */ + limit: z.number().optional(), + /** Number of results to skip. Used for pagination. Default is 0. [premium] */ + offset: z.number().optional(), +}); + +const LocationEvChargersOutputSchema = z.array( + z + .object({ + is_active: z.boolean().nullable().optional(), + name: z.string().nullable().optional(), + address: z.string().nullable().optional(), + city: z.string().nullable().optional(), + region: z.string().nullable().optional(), + country: z.string().nullable().optional(), + latitude: z.number().nullable().optional(), + longitude: z.number().nullable().optional(), + connections: z + .array( + z + .object({ + type_name: z.string().nullable().optional(), + type_official: z.string().nullable().optional(), + level: z.number().nullable().optional(), + num_connectors: z.number().nullable().optional(), + }) + .loose(), + ) + .nullable() + .optional(), + /** Geographic coordinates of the charging station. */ + 'latitude, longitude': z.string().nullable().optional(), + /** Official specification name (e.g., SAE J1772-2009). */ + type_official: z.string().nullable().optional(), + /** Charging level (1, 2, or 3). */ + level: z.string().nullable().optional(), + /** Number of connectors of this type at the station. */ + num_connectors: z.union([z.number(), z.string()]).nullable().optional(), + }) + .loose(), +); + +/** + * Get current weather, wind speed and direction, humidity, and temperature data by city, ZIP code, or geolocation coordinates (latitude/longitude). + * + * GET v1/weather + */ +/** + * One of the following parameter combinations must be provided: + * + * Documented as a parameter combination, so every field is optional here and + * the provider validates the combination. + */ +const LocationWeatherInputSchema = z.object({ + /** Latitude of desired location. */ + lat: z.number().optional(), + /** Longitude of desired location. */ + lon: z.number().optional(), + /** 5 digit Zip code (United States only) [premium] */ + zip: z.string().optional(), + /** City name. [premium] */ + city: z.string().optional(), + /** US state (for United States cities only). [premium] */ + state: z.string().optional(), + /** Country name. [premium] */ + country: z.string().optional(), +}); + +const LocationWeatherOutputSchema = z + .object({ + cloud_pct: z.number().nullable().optional(), + temp: z.number().nullable().optional(), + feels_like: z.number().nullable().optional(), + humidity: z.number().nullable().optional(), + min_temp: z.number().nullable().optional(), + max_temp: z.number().nullable().optional(), + wind_speed: z.number().nullable().optional(), + wind_degrees: z.number().nullable().optional(), + sunrise: z.number().nullable().optional(), + sunset: z.number().nullable().optional(), + }) + .loose(); + +/** + * Returns a 5-day weather forecast in 3-hour intervals for a given city. + * + * GET v1/weatherforecast + */ +/** + * One of the following parameter combinations must be provided: + * + * Documented as a parameter combination, so every field is optional here and + * the provider validates the combination. + */ +const LocationWeatherForecastInputSchema = z.object({ + /** Latitude of desired location. */ + lat: z.number().optional(), + /** Longitude of desired location. */ + lon: z.number().optional(), + /** 5 digit Zip code (United States only) [premium] */ + zip: z.string().optional(), + /** City name. [premium] */ + city: z.string().optional(), + /** US state (for United States cities only). [premium] */ + state: z.string().optional(), + /** Country name. [premium] */ + country: z.string().optional(), +}); + +const LocationWeatherForecastOutputSchema = z.array( + z + .object({ + timestamp: z.number().nullable().optional(), + temp: z.number().nullable().optional(), + feels_like: z.number().nullable().optional(), + humidity: z.number().nullable().optional(), + min_temp: z.number().nullable().optional(), + max_temp: z.number().nullable().optional(), + weather: z.string().nullable().optional(), + cloud_pct: z.number().nullable().optional(), + wind_speed: z.number().nullable().optional(), + wind_degrees: z.number().nullable().optional(), + }) + .loose(), +); + +/** + * Get air quality by city or location coordinates (latitude/longitude). Returns the air quality index (AQI) and concentrations of major pollutants. + * + * GET v1/airquality + */ +const LocationAirQualityInputSchema = z.object({ + /** Latitude of desired location. */ + lat: z.number().optional(), + /** Longitude of desired location. */ + lon: z.number().optional(), + /** City name. */ + city: z.string().optional(), + /** US state (for United States cities only). */ + state: z.string().optional(), + /** Country name. */ + country: z.string().optional(), +}); + +const LocationAirQualityOutputSchema = z + .object({ + CO: z + .object({ + concentration: z.number().nullable().optional(), + aqi: z.number().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + NO2: z + .object({ + concentration: z.number().nullable().optional(), + aqi: z.number().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + O3: z + .object({ + concentration: z.number().nullable().optional(), + aqi: z.number().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + SO2: z + .object({ + concentration: z.number().nullable().optional(), + aqi: z.number().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + 'PM2.5': z + .object({ + concentration: z.number().nullable().optional(), + aqi: z.number().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + PM10: z + .object({ + concentration: z.number().nullable().optional(), + aqi: z.number().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + overall_aqi: z.number().nullable().optional(), + }) + .loose(); + +/* -------------------------------------------------------------------------- */ +/* calendar */ +/* -------------------------------------------------------------------------- */ + +/** + * Get timezone info by city/state/country or location coordinates (latitude/longitude). Returns the timezone name of the specified input location and the time offset in seconds. + * + * GET v1/timezone + */ +const CalendarTimezoneInputSchema = z.object({ + /** Timezone name. */ + timezone: z.string().optional(), + /** Latitude of desired location. [premium] */ + lat: z.number().optional(), + /** Longitude of desired location. [premium] */ + lon: z.number().optional(), + /** City name. [premium] */ + city: z.string().optional(), + /** US state (for United States cities only). [premium] */ + state: z.string().optional(), + /** Country name. [premium] */ + country: z.string().optional(), +}); + +const CalendarTimezoneOutputSchema = z + .object({ + timezone: z.string().nullable().optional(), + utc_offset: z.number().nullable().optional(), + local_time: z.string().nullable().optional(), + /** City name. Only available for lat/lon or city/state/country inputs. */ + city: z.string().nullable().optional(), + }) + .loose(); + +/** + * Get the current date and time by city/state/country, location coordinates (latitude/longitude), or timezone. + * + * GET v1/worldtime + */ +const CalendarWorldTimeInputSchema = z.object({ + /** Timezone name (e.g. Europe/London). */ + timezone: z.string().optional(), + /** Latitude of desired location. [premium] */ + lat: z.number().optional(), + /** Longitude of desired location. [premium] */ + lon: z.number().optional(), + /** City name. [premium] */ + city: z.string().optional(), + /** US state (for United States cities only). [premium] */ + state: z.string().optional(), + /** Country name. [premium] */ + country: z.string().optional(), +}); + +/** Declared from the documentation: this endpoint is premium-gated, so no free-tier response could be captured. */ +const CalendarWorldTimeOutputSchema = z + .object({ + /** IANA timezone identifier (for example Europe/London). */ + timezone: z.string().nullable().optional(), + /** Local date and time string (YYYY-MM-DD HH:MM:SS). */ + datetime: z.string().nullable().optional(), + /** Current date in YYYY-MM-DD format. */ + date: z.string().nullable().optional(), + /** Current year as a 4-digit string. */ + year: z.string().nullable().optional(), + /** Current month as a 2-digit string between 01 and 12 (inclusive). */ + month: z.string().nullable().optional(), + /** Current day of the month as a 2-digit string between 01 and 31 (inclusive). */ + day: z.string().nullable().optional(), + /** Current hour in 24-hour format as a 2-digit string between 00 and 23 (inclusive). */ + hour: z.string().nullable().optional(), + /** Current minute as a 2-digit string between 00 and 59 (inclusive). */ + minute: z.string().nullable().optional(), + /** Current second as a 2-digit string between 00 and 59 (inclusive). */ + second: z.string().nullable().optional(), + /** Name of the day of the week (for example Sunday). */ + day_of_week: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns a list of holiday entries for a given country and year. Each entry in the response contains the holiday name, date, day of the week, and the type of holiday. + * + * GET v2/holidays + */ +const CalendarHolidaysInputSchema = z.object({ + /** Country name or ISO 3166-2 country code (preferred). */ + country: z.string(), + /** Calendar year between 2005 and 2039 (inclusive). Default is the current year. Note: not all countries are guaranteed to contain data going back to 2005. */ + year: z.number().optional(), + /** Holiday type filter. Possible values are: */ + type: z.string().optional(), +}); + +const CalendarHolidaysOutputSchema = z.array( + z + .object({ + country: z.string().nullable().optional(), + iso: z.string().nullable().optional(), + year: z.number().nullable().optional(), + date: z.string().nullable().optional(), + day: z.string().nullable().optional(), + name: z.string().nullable().optional(), + type: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns a list of public holidays for a given country and year. + * + * GET v1/publicholidays + */ +const CalendarPublicHolidaysInputSchema = z.object({ + /** 2-letter ISO country code or full country name. */ + country: z.string(), + /** Calendar year between 1980 and 2050 (inclusive). Defaults to current year. [premium] */ + year: z.number().optional(), +}); + +const CalendarPublicHolidaysOutputSchema = z.array( + z + .object({ + name: z.string().nullable().optional(), + local_name: z.string().nullable().optional(), + date: z.string().nullable().optional(), + country: z.string().nullable().optional(), + year: z.number().nullable().optional(), + regions: z.array(z.string()).nullable().optional(), + federal: z.boolean().nullable().optional(), + }) + .loose(), +); + +/** + * Returns whether a given date is a public holiday for a given country. + * + * GET v1/ispublicholiday + */ +const CalendarIsPublicHolidayInputSchema = z.object({ + /** 2-letter ISO country code. */ + country: z.string(), + /** Date in YYYY-MM-DD format. Must be between 1980-01-01 and 2050-12-31 (inclusive). */ + date: z.string(), +}); + +const CalendarIsPublicHolidayOutputSchema = z + .object({ + date: z.string().nullable().optional(), + country: z.string().nullable().optional(), + is_public_holiday: z.boolean().nullable().optional(), + public_holiday_name: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns whether a given date is a working day for a given country. + * + * GET v1/isworkingday + */ +const CalendarIsWorkingDayInputSchema = z.object({ + /** 2-letter ISO country code. */ + country: z.string(), + /** Date in YYYY-MM-DD format. Must be between 1980-01-01 and 2050-12-31 (inclusive). */ + date: z.string(), + /** Comma-separated list of weekend days (mon,tue,wed,thu,fri,sat,sun). This parameter is optional: if not provided, the default weekend days will be determined based on the country. If specified, your values will override the country defaults. */ + weekend: z.string().optional(), + /** Whether to include public holidays as non-working days (true/false). Defaults to true. */ + public_holidays: z.boolean().optional(), +}); + +const CalendarIsWorkingDayOutputSchema = z + .object({ + date: z.string().nullable().optional(), + country: z.string().nullable().optional(), + day_of_week: z.string().nullable().optional(), + is_workday: z.boolean().nullable().optional(), + public_holiday_name: z.string().nullable().optional(), + non_working_reason: z.array(z.string()).nullable().optional(), + }) + .loose(); + +/** + * Returns a list of working days and non-working days for a given country and year/month. + * + * GET v1/workingdays + */ +const CalendarWorkingDaysInputSchema = z.object({ + /** 2-letter ISO country code. */ + country: z.string(), + /** Calendar year between 1980 and 2050 (inclusive). By default, the current year is used. [premium] */ + year: z.number().optional(), + /** Month number (1-12). If provided, returns data for just that month. */ + month: z.number().optional(), + /** Comma-separated list of weekend days (mon, tue, wed, thu, fri, sat, sun). This parameter is optional: if not provided, the default weekend days will be determined based on the country. If specified, your values will override the country defaults. */ + weekend: z.string().optional(), + /** Whether to include public holidays as non-working days (true/false). Defaults to true. */ + public_holidays: z.boolean().optional(), +}); + +const CalendarWorkingDaysOutputSchema = z + .object({ + num_working_days: z.number().nullable().optional(), + num_non_working_days: z.number().nullable().optional(), + working_days: z.array(z.string()).nullable().optional(), + non_working_days: z + .array( + z + .object({ + date: z.string().nullable().optional(), + reasons: z.array(z.string()).nullable().optional(), + holiday_name: z.string().nullable().optional(), + }) + .loose(), + ) + .nullable() + .optional(), + year: z.number().nullable().optional(), + }) + .loose(); + +/* -------------------------------------------------------------------------- */ +/* internet */ +/* -------------------------------------------------------------------------- */ + +/** + * Returns availability, registration lifecycle, and email/hosting intelligence for a given domain name. + * + * GET v1/domain + */ +const InternetDomainInputSchema = z.object({ + /** Valid domain to check (e.g. github.com). For top-level domains other than .com, a premium subscription is required. */ + domain: z.string(), +}); + +const InternetDomainOutputSchema = z + .object({ + domain: z.string().nullable().optional(), + available: z.boolean().nullable().optional(), + creation_date: z.number().nullable().optional(), + expiration_date: z.number().nullable().optional(), + registrar: z.string().nullable().optional(), + age_days: z.number().nullable().optional(), + /** Unix timestamp of when the domain record was last updated. */ + updated_date: z.union([z.number(), z.string()]).nullable().optional(), + /** Array of EPP status codes translated to snake_case strings (e.g. client_transfer_prohibited, pending_delete, redemption_period). Indicates registrar/registry locks and the domain's lifecycle state. */ + domain_status: z + .union([z.array(z.string()), z.string()]) + .nullable() + .optional(), + /** Whether the domain has any MX (mail exchange) records, i.e. whether it is configured to receive email. */ + has_mx: z.union([z.boolean(), z.string()]).nullable().optional(), + /** Whether the domain is a known free or webmail provider (e.g. gmail.com, outlook.com). */ + is_free_email_provider: z + .union([z.boolean(), z.string()]) + .nullable() + .optional(), + /** Whether the domain's top-level domain is one disproportionately associated with spam or abuse. */ + risky_tld: z.union([z.boolean(), z.string()]).nullable().optional(), + /** Whether the domain is a known disposable / temporary email provider. See our Disposable Email Checker API. */ + is_disposable_email_domain: z + .union([z.boolean(), z.string()]) + .nullable() + .optional(), + /** Whether the domain looks like a custom/business email domain: it accepts mail and is not a free, webmail, or disposable provider. */ + is_custom_domain: z.union([z.boolean(), z.string()]).nullable().optional(), + /** The email provider serving the domain, inferred from its MX records (e.g. Google Workspace, Microsoft 365), or null if not recognized. */ + mx_provider: z.string().nullable().optional(), + /** Whether the domain appears to be parked or listed for sale, inferred from its nameservers. */ + is_parked: z.union([z.boolean(), z.string()]).nullable().optional(), + /** The IPv4 address the domain currently resolves to. */ + ip: z.string().nullable().optional(), + /** The network / hosting provider (autonomous system) the resolved IP belongs to. */ + hosting_provider: z.string().nullable().optional(), + /** Two-letter country code where the resolved IP is registered. */ + country: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns a list of DNS records associated with a particular domain. + * + * GET v1/dnslookup + */ +const InternetDnsRecordsInputSchema = z.object({ + /** Valid domain to check (e.g. example.com). For top-level domains other than .com, a premium subscription is required. */ + domain: z.string(), +}); + +const InternetDnsRecordsOutputSchema = z.array( + z + .object({ + record_type: z.string().nullable().optional(), + value: z.string().nullable().optional(), + mname: z.string().nullable().optional(), + rname: z.string().nullable().optional(), + serial: z.number().nullable().optional(), + refresh: z.number().nullable().optional(), + retry: z.number().nullable().optional(), + expire: z.number().nullable().optional(), + ttl: z.number().nullable().optional(), + /** */ + AAAA: z.string().nullable().optional(), + /** */ + CNAME: z.string().nullable().optional(), + /** */ + MX: z.string().nullable().optional(), + /** */ + NS: z.string().nullable().optional(), + /** */ + PTR: z.string().nullable().optional(), + /** */ + SRV: z.string().nullable().optional(), + /** */ + SOA: z.string().nullable().optional(), + /** */ + TXT: z.string().nullable().optional(), + /** */ + CAA: z.string().nullable().optional(), + /** Priority value (for MX records). */ + priority: z.string().nullable().optional(), + /** Additional fields for SOA records only. */ + 'mname, rname, serial, refresh, retry, expire, ttl': z + .string() + .nullable() + .optional(), + }) + .loose(), +); + +/** + * Returns a list of MX records associated with a particular domain. Free users receive only data from the first MX record, while premium users get access to all MX records. + * + * GET v1/mxlookup + */ +const InternetMxRecordsInputSchema = z.object({ + /** Valid domain to check (e.g. x.com). All top-level domains are supported. */ + domain: z.string(), +}); + +const InternetMxRecordsOutputSchema = z.array( + z + .object({ + priority: z.number().nullable().optional(), + value: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns domain registration details (e.g. registrar, contact information, expiration date, name servers) for a given domain name. + * + * GET v1/whois + */ +const InternetWhoisInputSchema = z.object({ + /** Valid domain to check (e.g. example.com). For top-level domains other than .com, a premium subscription is required. */ + domain: z.string(), +}); + +/** Declared from the documentation: this endpoint is premium-gated, so no free-tier response could be captured. */ +const InternetWhoisOutputSchema = z + .object({ + /** The domain name that was queried. */ + domain_name: z.string().nullable().optional(), + /** Name of the domain registrar. */ + registrar: z.string().nullable().optional(), + /** URL of the domain registrar. Not returned for all TLDs. */ + registrar_url: z.string().nullable().optional(), + /** WHOIS server used for the query. Not returned for all TLDs. */ + whois_server: z.string().nullable().optional(), + /** Unix timestamp of when the domain was last updated. */ + updated_date: z.union([z.number(), z.string()]).nullable().optional(), + /** Unix timestamp of when the domain was created. */ + creation_date: z.union([z.number(), z.string()]).nullable().optional(), + /** Unix timestamp of when the domain expires. */ + expiration_date: z.union([z.number(), z.string()]).nullable().optional(), + /** Array of name server hostnames. */ + name_servers: z + .union([z.array(z.string()), z.string()]) + .nullable() + .optional(), + /** DNSSEC status (e.g., signeddelegation). */ + dnssec: z.string().nullable().optional(), + /** Contact email(s) from the WHOIS record. Returned only for some TLDs (e.g. .org); not present for .com. */ + emails: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns the location of the IP address specified. The response contains both the geographical coordinates (latitude/longitude) as well as the city and country. + * + * GET v1/iplookup + */ +const InternetIpLookupInputSchema = z.object({ + /** IP Address to query. Must be in IPv4 format A.B.C.D(e.g. 73.9.149.180) or IPv6 format X:X:X:X:X:X:X:X(e.g. 2001:0db8:85a3:0000:0000:8a2e:0370:7334). */ + address: z.string(), +}); + +const InternetIpLookupOutputSchema = z + .object({ + is_valid: z.boolean().nullable().optional(), + country: z.string().nullable().optional(), + country_code: z.string().nullable().optional(), + region_code: z.string().nullable().optional(), + region: z.string().nullable().optional(), + city: z.string().nullable().optional(), + zip: z.string().nullable().optional(), + lat: z.number().nullable().optional(), + lon: z.number().nullable().optional(), + timezone: z.string().nullable().optional(), + isp: z.string().nullable().optional(), + address: z.string().nullable().optional(), + /** Whether the IP belongs to a known cloud or datacenter provider (e.g. AWS, GCP, Azure, Oracle, DigitalOcean). */ + is_datacenter: z.union([z.boolean(), z.string()]).nullable().optional(), + /** Whether the IP's network (ASN) is a known hosting/datacenter provider. */ + is_hosting: z.union([z.boolean(), z.string()]).nullable().optional(), + /** Whether the IP is a known Tor exit node. */ + is_tor: z.union([z.boolean(), z.string()]).nullable().optional(), + /** Whether the IP is associated with a known commercial VPN provider. */ + is_vpn: z.union([z.boolean(), z.string()]).nullable().optional(), + /** Whether the IP is an Apple iCloud Private Relay egress node. */ + is_icloud_relay: z.union([z.boolean(), z.string()]).nullable().optional(), + /** Whether the IP is a bogon (unallocated or reserved address that should not appear on the public internet). */ + is_bogon: z.union([z.boolean(), z.string()]).nullable().optional(), + /** Whether the IP appears on multiple public abuse/threat blocklists. */ + is_abuser: z.union([z.boolean(), z.string()]).nullable().optional(), + /** Overall risk level derived from blocklist activity: one of low, medium, or high. */ + threat_level: z.string().nullable().optional(), + /** The Autonomous System Number that announces the IP (e.g. AS15169). */ + asn: z.string().nullable().optional(), + /** The name of the organization that operates the ASN. */ + asn_name: z.string().nullable().optional(), + /** The network route (CIDR prefix) the IP belongs to. */ + route: z.string().nullable().optional(), + /** The registered abuse-contact email for the IP's network, when available. */ + abuse_email: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns the location of the IP address hosting the URL domain. The response contains both the geographical coordinates (latitude/longitude) as well as the city and country. + * + * GET v1/urllookup + */ +const InternetUrlLookupInputSchema = z.object({ + /** Valid URL to check. It supports schemes (e.g. http://example.com) as well as schemeless (e.g. example.com) formats. For top-level domains other than .com, a premium subscription is required. */ + url: z.string(), +}); + +const InternetUrlLookupOutputSchema = z + .object({ + is_valid: z.boolean().nullable().optional(), + country: z.string().nullable().optional(), + country_code: z.string().nullable().optional(), + region_code: z.string().nullable().optional(), + region: z.string().nullable().optional(), + city: z.string().nullable().optional(), + zip: z.string().nullable().optional(), + lat: z.number().nullable().optional(), + lon: z.number().nullable().optional(), + timezone: z.string().nullable().optional(), + isp: z.string().nullable().optional(), + url: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns the URL information and web page metadata from a given URL. + * + * GET v1/webpage + */ +const InternetWebpageInputSchema = z.object({ + /** URL to retrieve information from. */ + url: z.string(), +}); + +const InternetWebpageOutputSchema = z + .object({ + url: z.string().nullable().optional(), + domain: z.string().nullable().optional(), + url_path: z.string().nullable().optional(), + url_parameters: z.record(z.string(), z.unknown()).nullable().optional(), + page_title: z.string().nullable().optional(), + page_description: z.string().nullable().optional(), + meta_tags: z + .object({ + viewport: z.string().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + favicon: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns the HTML or plaintext data scraped from a given URL. Maximum size of data returned is 2MB. + * + * GET v1/webscraper + */ +const InternetScrapeInputSchema = z.object({ + /** URL to scrape. */ + url: z.string(), + /** Whether to only extract visible text (ignores HTML tags and metadata). Must be either true or false. Default is false. */ + text_only: z.boolean().optional(), + /** User-Agent string to use in the request header. */ + user_agent: z.string().optional(), +}); + +const InternetScrapeOutputSchema = z + .object({ + data: z.string().nullable().optional(), + }) + .loose(); + +/** + * Generates a realistic user agent string based on optional parameters. + * + * GET v1/useragentgenerate + */ +const InternetUserAgentInputSchema = z.object({ + /** Device brand (e.g. Apple, Samsung) */ + brand: z.string().optional(), + /** Device model (e.g. iPhone, Galaxy) */ + model: z.string().optional(), + /** Operating system (e.g. Windows, iOS, Android) */ + os: z.string().optional(), + /** Browser name (e.g. Chrome, Firefox, Safari) */ + browser: z.string().optional(), +}); + +const InternetUserAgentOutputSchema = z + .object({ + user_agent: z.string().nullable().optional(), + }) + .loose(); + +/* -------------------------------------------------------------------------- */ +/* validation */ +/* -------------------------------------------------------------------------- */ + +/** + * Returns metadata (including whether it is valid) for a given email address. This API will check the formatting of the email and the existence of DNS records for the domain to make sure it is a valid email address. + * + * GET v1/validateemail + */ +const ValidationEmailInputSchema = z.object({ + /** Email address to validate. */ + email: z.string(), +}); + +const ValidationEmailOutputSchema = z + .object({ + is_valid: z.boolean().nullable().optional(), + email: z.string().nullable().optional(), + is_disposable: z.boolean().nullable().optional(), + is_public: z.boolean().nullable().optional(), + main_category: z.string().nullable().optional(), + sub_category: z.string().nullable().optional(), + /** Domain of the email address. */ + domain: z.string().nullable().optional(), + /** The local part of the email address (the portion before the @). */ + local_part: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns metadata for a given email address, including whether it is from a disposable email provider. We maintain a large database of hundreds of thousands of disposable domains and check against it for every email address. + * + * GET v1/disposableemailchecker + */ +const ValidationDisposableEmailInputSchema = z.object({ + /** Email address to check. */ + email: z.string(), +}); + +const ValidationDisposableEmailOutputSchema = z + .object({ + email: z.string().nullable().optional(), + domain: z.string().nullable().optional(), + is_disposable: z.boolean().nullable().optional(), + }) + .loose(); + +/** + * Returns metadata (including whether it is valid) for a given phone number. + * + * GET v1/validatephone + */ +const ValidationPhoneInputSchema = z.object({ + /** Phone number to check. The leading + is optional. If country is not set, include the country code (e.g. 12065550100 or +12065550100). */ + number: z.string(), + /** 2-letter ISO-3166 country code the phone number belongs to. */ + country: z.string().optional(), +}); + +const ValidationPhoneOutputSchema = z + .object({ + is_valid: z.boolean().nullable().optional(), + is_formatted_properly: z.boolean().nullable().optional(), + country: z.string().nullable().optional(), + location: z.string().nullable().optional(), + timezones: z.array(z.string()).nullable().optional(), + format_national: z.string().nullable().optional(), + format_international: z.string().nullable().optional(), + format_e164: z.string().nullable().optional(), + country_code: z.number().nullable().optional(), + /** Line type of the number, such as mobile, landline, voip, or toll_free. For carrier and VOIP details, see the Phone Lookup API. */ + line_type: z.string().nullable().optional(), + /** Whether the number is a mobile number. */ + is_mobile: z.union([z.boolean(), z.string()]).nullable().optional(), + /** The phone number as an RFC3966 tel: URI. */ + format_rfc3966: z.string().nullable().optional(), + /** Whether the number is a possible number (valid length and pattern), even if not confirmed valid. */ + is_possible: z.union([z.boolean(), z.string()]).nullable().optional(), + }) + .loose(); + +/** + * Returns detailed information about a bank based on its routing number. + * + * GET v1/routingnumber + */ +const ValidationRoutingNumberInputSchema = z.object({ + /** The 9-digit routing number of the bank to look up. */ + routing_number: z.number(), +}); + +const ValidationRoutingNumberOutputSchema = z.array( + z + .object({ + bank_name: z.string().nullable().optional(), + routing_number: z.string().nullable().optional(), + street_address: z.string().nullable().optional(), + city: z.string().nullable().optional(), + state: z.string().nullable().optional(), + zip_code: z.string().nullable().optional(), + country: z.string().nullable().optional(), + county: z.string().nullable().optional(), + timezone: z.string().nullable().optional(), + latitude: z.string().nullable().optional(), + longitude: z.string().nullable().optional(), + phone_number: z.union([z.number(), z.string()]).nullable().optional(), + ach_supported: z.boolean().nullable().optional(), + fedwire_supported: z.boolean().nullable().optional(), + checksum_valid: z.boolean().nullable().optional(), + }) + .loose(), +); + +/** + * Returns detailed information on a given IBAN. + * + * GET v1/iban + */ +const ValidationIbanInputSchema = z.object({ + /** The IBAN to look up. */ + iban: z.string(), +}); + +const ValidationIbanOutputSchema = z + .object({ + iban: z.string().nullable().optional(), + bank_name: z.string().nullable().optional(), + bank_address: z.string().nullable().optional(), + account_number: z.string().nullable().optional(), + bank_code: z.string().nullable().optional(), + country: z.string().nullable().optional(), + checksum: z.string().nullable().optional(), + valid: z.union([z.boolean(), z.string()]).nullable().optional(), + invalid_reason: z.string().nullable().optional(), + bban: z.string().nullable().optional(), + swift_code: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns detailed information about a bank based on the BIN number provided. + * + * GET v2/bin + */ +const ValidationBinInputSchema = z.object({ + /** The Bank Identification Number (BIN) to look up. This is typically the first 6 or 8 digits of a credit card number. */ + bin: z.string(), +}); + +const ValidationBinOutputSchema = z.array( + z + .object({ + bin: z.string().nullable().optional(), + country_iso2: z.string().nullable().optional(), + country: z.string().nullable().optional(), + brand: z.string().nullable().optional(), + type: z.string().nullable().optional(), + categories: z + .union([z.array(z.string()), z.string()]) + .nullable() + .optional(), + issuer: z.string().nullable().optional(), + is_valid: z.union([z.boolean(), z.string()]).nullable().optional(), + }) + .loose(), +); + +/** + * Returns a list of bank information (including SWIFT/BIC Code) that match the input parameter. Returns at most 100 results. For more results, use the offset parameter. + * + * GET v1/swiftcode + */ +const ValidationSwiftCodeInputSchema = z.object({ + /** The SWIFT Code of the bank to look up. */ + swift: z.string().optional(), + /** The name of the bank to look up. This parameter supports partial matching (e.g., Silicon Valley will match Silicon Valley Bank). [premium] */ + bank: z.string().optional(), + /** Name of the city in which the bank is located. */ + city: z.string().optional(), + /** ISO 3166 2-letter country code of the bank's country. */ + country: z.string().optional(), + /** 9-digit US ABA routing number (e.g. 121000248). Returns the SWIFT/BIC codes of the US bank identified by the routing number - useful for finding the SWIFT code needed to receive an international wire into a US account. See our Routing Number API for the reverse lookup. */ + routing_number: z.string().optional(), + /** The number of results to offset for pagination. Default is 0. [premium] */ + offset: z.number().optional(), +}); + +const ValidationSwiftCodeOutputSchema = z.array( + z + .object({ + swift_code: z.string().nullable().optional(), + bank_name: z.string().nullable().optional(), + address: z.string().nullable().optional(), + city: z.string().nullable().optional(), + region: z.string().nullable().optional(), + postal_code: z.string().nullable().optional(), + country: z.string().nullable().optional(), + country_code: z.string().nullable().optional(), + }) + .loose(), +); + +/* -------------------------------------------------------------------------- */ +/* markets */ +/* -------------------------------------------------------------------------- */ + +/** + * Returns price information for any given ticker symbol. Premium members have access to live prices, while free users only have access to 15-minute delayed data. + * + * GET v1/stockprice + */ +const MarketsStockPriceInputSchema = z.object({ + /** Stock or index ticker symbol (e.g., AAPL or ^DJI). */ + ticker: z.string(), +}); + +const MarketsStockPriceOutputSchema = z + .object({ + ticker: z.string().nullable().optional(), + name: z.string().nullable().optional(), + price: z.number().nullable().optional(), + exchange: z.string().nullable().optional(), + updated: z.number().nullable().optional(), + currency: z.string().nullable().optional(), + volume: z.number().nullable().optional(), + }) + .loose(); + +/** + * Returns comprehensive company profile information including company name, CEO, address, financial data, exchange information, identifiers (CIK, CUSIP, ISIN), and latest earnings information when available. Premium members have access to live prices, while free users only have access to 15-minute delayed data. + * + * GET v1/ticker + */ +const MarketsTickerInputSchema = z.object({ + /** Stock ticker symbol (e.g., AAPL). */ + ticker: z.string(), +}); + +const MarketsTickerOutputSchema = z + .object({ + name: z.string().nullable().optional(), + ticker: z.string().nullable().optional(), + chief_executive_officer: z.string().nullable().optional(), + address: z + .object({ + address: z.string().nullable().optional(), + city: z.string().nullable().optional(), + state: z.string().nullable().optional(), + zip: z.string().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + latest_price: z.union([z.number(), z.string()]).nullable().optional(), + latest_market_cap: z.union([z.number(), z.string()]).nullable().optional(), + latest_dividend: z.string().nullable().optional(), + cik: z.string().nullable().optional(), + cusip: z.string().nullable().optional(), + isin: z.string().nullable().optional(), + exchange: z.string().nullable().optional(), + website: z.string().nullable().optional(), + phone_number: z.string().nullable().optional(), + ipo_date: z.string().nullable().optional(), + latest_earnings: z + .object({ + year: z.number().nullable().optional(), + quarter: z.number().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + sector: z.string().nullable().optional(), + industry: z.string().nullable().optional(), + sic_code: z.string().nullable().optional(), + sic_description: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns a list of all available companies and their ticker symbols. Supports pagination to retrieve results in batches. + * + * GET v1/stockpricelist + */ +const MarketsTickerListInputSchema = z.object({ + /** Number of results to offset for pagination. Default is 0. */ + offset: z.number().optional(), + /** Number of results to return. Must be between 1 and 1000. Default is 100. */ + limit: z.number().optional(), +}); + +/** Declared from the documentation: this endpoint is premium-gated, so no free-tier response could be captured. */ +const MarketsTickerListOutputSchema = z.array( + z + .object({ + /** The stock ticker symbol. */ + ticker: z.string().nullable().optional(), + /** The full company name. */ + name: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns detailed information about stock exchanges matching the specified criteria. At least one parameter is required. + * + * GET v1/stockexchange + */ +const MarketsStockExchangesInputSchema = z.object({ + /** Market Identifier Code (e.g., XNYS). */ + mic: z.string().optional(), + /** Stock exchange name (supports partial matching). */ + name: z.string().optional(), + /** City where the exchange is located. */ + city: z.string().optional(), + /** 2-letter country code (ISO-3166-1 alpha-2) (e.g., US). */ + country: z.string().optional(), +}); + +const MarketsStockExchangesOutputSchema = z.array( + z + .object({ + mic: z.string().nullable().optional(), + name: z.string().nullable().optional(), + city: z.string().nullable().optional(), + country: z.string().nullable().optional(), + iso2: z.string().nullable().optional(), + description: z.string().nullable().optional(), + address: z.string().nullable().optional(), + website: z.string().nullable().optional(), + founded: z.string().nullable().optional(), + num_listings: z.number().nullable().optional(), + market_cap_usd: z.number().nullable().optional(), + /** Market cap in local currency when market_cap_usd is absent. */ + market_cap: z.union([z.number(), z.string()]).nullable().optional(), + currency: z.string().nullable().optional(), + timezone: z.string().nullable().optional(), + /** Opening time. Business/Professional tier. */ + market_open: z.string().nullable().optional(), + /** Closing time. Business/Professional tier. */ + market_close: z.string().nullable().optional(), + /** Whether the exchange is currently open. Business/Professional tier. */ + is_market_open: z.union([z.boolean(), z.string()]).nullable().optional(), + /** Reason the exchange is closed, or null if open. */ + closed_reason: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns S&P 500 index constituents, filterable by ticker, company name, sector or the date the company joined the index. + * + * GET v1/sp500 + */ +const MarketsSp500InputSchema = z.object({ + /** Stock ticker symbol of a constituent. */ + ticker: z.string().optional(), + /** Company name of a constituent. */ + name: z.string().optional(), + /** GICS sector, for example Health Care. */ + sector: z.string().optional(), + /** Date the company was added to the index, as YYYY-MM-DD. */ + date_added: z.string().optional(), +}); + +const MarketsSp500OutputSchema = z.array( + z + .object({ + ticker: z.string().nullable().optional(), + company_name: z.string().nullable().optional(), + sector: z.string().nullable().optional(), + date_added: z.string().nullable().optional(), + cik: z.string().nullable().optional(), + sub_industry: z.string().nullable().optional(), + headquarters: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns the current market cap data for any given company ticker. Premium members have access to live prices, while free users only have access to 15-minute delayed data. + * + * GET v1/marketcap + */ +const MarketsMarketCapInputSchema = z.object({ + /** Stock ticker symbol (e.g., NVDA). */ + ticker: z.string(), +}); + +const MarketsMarketCapOutputSchema = z + .object({ + ticker: z.string().nullable().optional(), + name: z.string().nullable().optional(), + market_cap: z.number().nullable().optional(), + currency: z.string().nullable().optional(), + updated: z.number().nullable().optional(), + }) + .loose(); + +/** + * Returns a JSON array of detailed earnings reports, each with comprehensive financial statements and key performance metrics. Query a single company by ticker or cik, or query every company that filed within a date range using date or date_start/date_end. Results are paginated 50 per page via offset. + * + * GET v2/earnings + */ +const MarketsEarningsInputSchema = z.object({ + /** Company ticker symbol (e.g., ADBE). Identifies the company to query - use this or cik, or omit both and query by date. */ + ticker: z.string().optional(), + /** Company Central Index Key (e.g., 796343). Alternative to ticker for identifying a company. */ + cik: z.string().optional(), + /** Fiscal period. Must be one of: q1, q2, q3, q4, or fy (full year). Requires a ticker/cik and year. */ + period: z.string().optional(), + /** Fiscal year. E.g. 2026. Historical coverage goes back to 2010. For data before 2026, you must have a premium subscription. Combine with a ticker to return every period that year, or add period/quarter for a single filing. */ + year: z.number().optional(), + /** Fiscal quarter 1-4 (an alternative to period). Requires a ticker/cik and year. */ + quarter: z.number().optional(), + /** Return every filing whose SEC filing date equals this date (YYYY-MM-DD). Works without a ticker - returns all companies that filed that day. */ + date: z.string().optional(), + /** Start of a filing-date range (YYYY-MM-DD): all companies that filed on or after this date. Cannot be combined with date. */ + date_start: z.string().optional(), + /** End of a filing-date range (YYYY-MM-DD). Combine with date_start and page through results with offset. */ + date_end: z.string().optional(), + /** Number of results to skip for pagination. Results are returned 50 per page. Default is 0. */ + offset: z.number().optional(), +}); + +const MarketsEarningsOutputSchema = z.array( + z + .object({ + company_info: z + .object({ + ticker: z.string().nullable().optional(), + cik: z.string().nullable().optional(), + company_name: z.string().nullable().optional(), + fiscal_year: z.number().nullable().optional(), + fiscal_quarter: z.number().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + income_statement: z + .object({ + weighted_average_shares_basic: z.number().nullable().optional(), + weighted_average_shares_diluted: z.number().nullable().optional(), + earnings_per_share_basic: z.number().nullable().optional(), + earnings_per_share_diluted: z.number().nullable().optional(), + total_revenue: z.number().nullable().optional(), + cost_of_revenue: z.number().nullable().optional(), + gross_profit: z.number().nullable().optional(), + research_and_development: z.number().nullable().optional(), + general_and_administrative: z.number().nullable().optional(), + sales_and_marketing: z.number().nullable().optional(), + operating_income: z.number().nullable().optional(), + interest_expense: z.string().nullable().optional(), + tax_provision: z.number().nullable().optional(), + net_income: z.number().nullable().optional(), + net_income_available_to_common: z.string().nullable().optional(), + depreciation_and_amortization: z.number().nullable().optional(), + stock_based_compensation: z.number().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + balance_sheet: z + .object({ + cash_and_equivalents: z.number().nullable().optional(), + accounts_receivable: z.number().nullable().optional(), + inventory: z.number().nullable().optional(), + current_assets: z.number().nullable().optional(), + property_plant_equipment: z.number().nullable().optional(), + goodwill: z.string().nullable().optional(), + intangible_assets: z.number().nullable().optional(), + total_assets: z.number().nullable().optional(), + accounts_payable: z.number().nullable().optional(), + current_liabilities: z.number().nullable().optional(), + long_term_debt: z.number().nullable().optional(), + total_debt: z.number().nullable().optional(), + total_liabilities: z.number().nullable().optional(), + stockholders_equity: z.number().nullable().optional(), + retained_earnings: z.number().nullable().optional(), + working_capital: z.number().nullable().optional(), + temporary_equity: z.string().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + cash_flow: z + .object({ + operating_cash_flow: z.number().nullable().optional(), + capital_expenditures: z.number().nullable().optional(), + free_cash_flow: z.number().nullable().optional(), + dividends_paid: z.number().nullable().optional(), + share_repurchases: z.number().nullable().optional(), + net_cash_investing: z.number().nullable().optional(), + net_cash_financing: z.number().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + filing_info: z + .object({ + filing_type: z.string().nullable().optional(), + filing_date: z.string().nullable().optional(), + period_end_date: z.string().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + }) + .loose(), +); + +/** + * Returns a list of past earnings results and upcoming earnings dates. You can query by ticker symbol to get earnings for a specific company, by a single date, or by a date range. Up to 50 earnings results are returned per request. + * + * GET v1/earningscalendar + */ +const MarketsEarningsCalendarInputSchema = z.object({ + /** Company ticker symbol (e.g., MSFT). If provided, returns earnings data for that specific company. */ + ticker: z.string().optional(), + /** Date in YYYY-MM-DD format (e.g., 2024-01-15). If provided, returns all earnings data for that specific date. */ + date: z.string().optional(), + /** Start date of a range in YYYY-MM-DD format (e.g., 2024-01-15). Inclusive. If only date_start is provided, date_end defaults to 7 days later. Cannot be combined with date. */ + date_start: z.string().optional(), + /** End date of a range in YYYY-MM-DD format (e.g., 2024-01-22). Inclusive. Must be on or after date_start. If only date_end is provided, date_start defaults to 7 days earlier. Cannot be combined with date. */ + date_end: z.string().optional(), + /** Whether to show upcoming earnings dates. Must be either true or false. If unset, the default value is false. [premium] */ + show_upcoming: z.boolean().optional(), + /** Number of results to skip for pagination. Must be a non-negative integer. Each request returns up to 50 results; use offset to page through larger result sets (e.g. offset=50 for the next 50 results). */ + offset: z.number().optional(), +}); + +const MarketsEarningsCalendarOutputSchema = z.array( + z + .object({ + date: z.string().nullable().optional(), + ticker: z.string().nullable().optional(), + earnings_timing: z.string().nullable().optional(), + earnings_call_timestamp: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + actual_revenue: z.number().nullable().optional(), + estimated_revenue: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + revenue_difference: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + revenue_difference_pct: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + actual_eps: z.number().nullable().optional(), + estimated_eps: z.union([z.number(), z.string()]).nullable().optional(), + eps_difference: z.union([z.number(), z.string()]).nullable().optional(), + eps_difference_pct: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + report_date_status: z.string().nullable().optional(), + date_confirmed: z.string().nullable().optional(), + report_datetime: z.string().nullable().optional(), + sec_8k_url: z.string().nullable().optional(), + eps_beat_miss: z.string().nullable().optional(), + revenue_beat_miss: z.string().nullable().optional(), + eps_surprise_streak: z.string().nullable().optional(), + avg_eps_surprise_pct_4q: z.string().nullable().optional(), + eps_sue: z.string().nullable().optional(), + last_earnings_move_pct: z.string().nullable().optional(), + avg_earnings_move_pct: z.string().nullable().optional(), + days_to_next_earnings: z.string().nullable().optional(), + next_earnings_date: z.string().nullable().optional(), + has_transcript: z.string().nullable().optional(), + surprise_history: z.string().nullable().optional(), + fiscal_year: z.number().nullable().optional(), + fiscal_quarter: z.number().nullable().optional(), + }) + .loose(), +); + +/** + * Returns the earnings transcript for a given company earning quarter. + * + * GET v1/earningstranscript + */ +const MarketsEarningsTranscriptInputSchema = z.object({ + /** Company ticker symbol (e.g., AAPL). */ + ticker: z.string().optional(), + /** Company Central Index Key (e.g., 320193). */ + cik: z.string().optional(), + /** Earnings year (e.g., 2026). Must be a valid year between 2000 and the current year. If provided, quarter must also be provided. */ + year: z.number().optional(), + /** Earnings quarter from Q1 to Q4. Must be one of the following values: 1, 2, 3, 4. If provided, year must also be provided. */ + quarter: z.number().optional(), + /** If set to true, restricts transcript_split (and the rebuilt transcript string) to analyst Q&A turns only, omitting prepared remarks. */ + qa_only: z.boolean().optional(), +}); + +/** Declared from the documentation: this endpoint is premium-gated, so no free-tier response could be captured. */ +const MarketsEarningsTranscriptOutputSchema = z + .object({ + /** The date of the earnings call. */ + date: z.string().nullable().optional(), + /** The UNIX timestamp (in seconds) of the earnings call to the nearest minute. */ + timestamp: z.union([z.number(), z.string()]).nullable().optional(), + /** The ticker symbol of the company. */ + ticker: z.string().nullable().optional(), + /** The CIK of the company. */ + cik: z.string().nullable().optional(), + /** The year of the earnings call. */ + year: z.union([z.number(), z.string()]).nullable().optional(), + /** The quarter of the earnings call. */ + quarter: z.string().nullable().optional(), + /** Timing of the earnings call. Possible values are: */ + earnings_timing: z.string().nullable().optional(), + /** The transcript of the earnings call as a single string. */ + transcript: z.string().nullable().optional(), + /** The list of participants of the earnings call. Each participant is an object with name, role, and company properties. */ + participants: z + .union([z.array(z.string()), z.string()]) + .nullable() + .optional(), + /** A concise summary of the earnings call, covering the main points discussed including financial performance, key metrics, and strategic initiatives. */ + summary: z.string().nullable().optional(), + /** Any forward-looking guidance issued by the company during the call, including revenue projections, earnings estimates, margin expectations, or other forecasts. Empty string if no guidance was provided. */ + guidance: z.string().nullable().optional(), + /** Any risk factors, challenges, headwinds, or concerns mentioned during the call that could negatively impact the company's future performance. Empty string if no risk factors were mentioned. */ + risk_factors: z.string().nullable().optional(), + /** The overall sentiment of the entire transcript on a scale from -1 (very negative) to 1 (very positive), where 0 is neutral. */ + overall_sentiment: z.string().nullable().optional(), + /** A brief explanation of the overall sentiment score, including key positive signals, key negative signals, and the reasoning behind the score. */ + overall_sentiment_rationale: z.string().nullable().optional(), + /** The transcript of the earnings call split into sections by speaker. Each section includes: */ + transcript_split: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns a list of insider trading transactions that match the specified filters. All parameters are optional and can be combined for advanced filtering. + * + * GET v1/insidertransactions + */ +const MarketsInsiderTransactionsInputSchema = z.object({ + /** Company ticker symbol (e.g., AAPL, MSFT). */ + ticker: z.string().optional(), + /** Central Index Key (CIK) of the company (e.g., 789019). */ + cik: z.string().optional(), + /** Name of the insider (exact match). Use the /v1/insiderslist endpoint to look up insider names. */ + name: z.string().optional(), + /** SEC form type: 3, 4, or 5. */ + form_type: z.string().optional(), + /** Type of transaction (e.g., Purchase, Sale, Award). */ + transaction_type: z.string().optional(), + /** Transaction code (e.g., P for Purchase, S for Sale, A for Award). */ + transaction_code: z.string().optional(), + /** Transaction date in YYYY-MM-DD format (e.g., 2024-01-15). */ + transaction_date: z.string().optional(), + /** Minimum transaction date in YYYY-MM-DD format. */ + min_transaction_date: z.number().optional(), + /** Maximum transaction date in YYYY-MM-DD format. */ + max_transaction_date: z.number().optional(), + /** Type of insider: director (matches director or chairman), 10_percent_owner (matches 10% Owner), or officer (excludes director, 10% owner, and chairman). */ + insider_type: z.string().optional(), + /** Minimum transaction value in USD (e.g., 10000). */ + min_transaction_value: z.number().optional(), + /** Maximum transaction value in USD (e.g., 1000000). */ + max_transaction_value: z.number().optional(), + /** Maximum number of results to return. Max value is 100. Default value is 10. [premium] */ + limit: z.number().optional(), + /** Number of results to skip for pagination (default: 0). [premium] */ + offset: z.number().optional(), +}); + +const MarketsInsiderTransactionsOutputSchema = z.array( + z + .object({ + accession_number: z.string().nullable().optional(), + form: z.string().nullable().optional(), + filing_date: z.string().nullable().optional(), + sec_filing_url: z.string().nullable().optional(), + cik: z.string().nullable().optional(), + ticker: z.string().nullable().optional(), + company_name: z.string().nullable().optional(), + insider_name: z.string().nullable().optional(), + insider_position: z.string().nullable().optional(), + transaction_code: z.string().nullable().optional(), + transaction_name: z.string().nullable().optional(), + transaction_type: z.string().nullable().optional(), + transaction_price: z.number().nullable().optional(), + shares: z.number().nullable().optional(), + transaction_value: z.number().nullable().optional(), + pre_transaction_shares: z.number().nullable().optional(), + pre_transaction_shares_value: z.number().nullable().optional(), + remaining_shares: z.number().nullable().optional(), + remaining_shares_value: z.number().nullable().optional(), + }) + .loose(), +); + +/** + * Returns a list of SEC filing information (including the submission URL) corresponding to the given search parameters. + * + * GET v1/sec + */ +const MarketsSecFilingsInputSchema = z.object({ + /** Ticker symbol of the company to search (e.g. AAPL for Apple). */ + ticker: z.string(), + /** SEC filing form type. The following values are supported: */ + filing: z.string(), + /** Start date to search. Must be in YYYY-MM-DD format (e.g. 2023-04-01). [premium] */ + start: z.string().optional(), + /** End date to search. Must be in YYYY-MM-DD format (e.g. 2023-04-01). [premium] */ + end: z.string().optional(), + /** Number of results to return from 1 to 100. By default, up to 2 results are returned. [premium] */ + limit: z.number().optional(), +}); + +const MarketsSecFilingsOutputSchema = z.array( + z + .object({ + ticker: z.string().nullable().optional(), + filing_date: z.string().nullable().optional(), + filing_url: z.string().nullable().optional(), + form_type: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns comprehensive information about any ETF by its ticker. Premium members have access to live prices, while free users only have access to 15-minute delayed data. + * + * GET v1/etf + */ +const MarketsEtfInputSchema = z.object({ + /** ETF ticker symbol (e.g., QQQ, SPY, VTI). You must pass the complete ticker symbol, including any exchange suffix (dot notation) when the listing requires it. For example, EUNL.DE, not EUNL. */ + ticker: z.string(), +}); + +const MarketsEtfOutputSchema = z + .object({ + etf_ticker: z.string().nullable().optional(), + price: z.union([z.number(), z.string()]).nullable().optional(), + etf_name: z.string().nullable().optional(), + isin: z.string().nullable().optional(), + cusip: z.string().nullable().optional(), + country: z.string().nullable().optional(), + domicile: z.string().nullable().optional(), + expense_ratio: z.union([z.number(), z.string()]).nullable().optional(), + aum: z.string().nullable().optional(), + aum_currency: z.string().nullable().optional(), + aum_usd: z.union([z.number(), z.string()]).nullable().optional(), + holdings: z + .union([z.array(z.string()), z.string()]) + .nullable() + .optional(), + num_holdings: z.union([z.number(), z.string()]).nullable().optional(), + }) + .loose(); + +/** + * Returns comprehensive information about any Mutual Fund by its ticker. + * + * GET v1/mutualfund + */ +const MarketsMutualFundInputSchema = z.object({ + /** Mutual Fund ticker symbol (e.g., VFIAX, FXAIX, FZROX). */ + ticker: z.string(), +}); + +const MarketsMutualFundOutputSchema = z + .object({ + fund_ticker: z.string().nullable().optional(), + fund_name: z.string().nullable().optional(), + isin: z.string().nullable().optional(), + cusip: z.string().nullable().optional(), + country: z.string().nullable().optional(), + expense_ratio: z.string().nullable().optional(), + aum: z.union([z.number(), z.string()]).nullable().optional(), + price: z.union([z.number(), z.string()]).nullable().optional(), + holdings: z + .union([z.array(z.string()), z.string()]) + .nullable() + .optional(), + num_holdings: z.union([z.number(), z.string()]).nullable().optional(), + }) + .loose(); + +/** + * Returns the current price and current time (in UNIX timestamp in seconds) for any cryptocurrency symbol. Premium members have access to live prices, while free users only have access to 15-minute delayed data. For historical price data, see /v1/cryptopricehistorical. + * + * GET v1/cryptoprice + */ +const MarketsCryptoPriceInputSchema = z.object({ + /** Cryptocurrency symbol (e.g. LTCBTC). To get the full list of available crypto-quoted symbols, use the Crypto Symbols API. Premium subscribers can also quote any cryptocurrency in a supported fiat currency (e.g. BTCEUR, ETHJPY). */ + symbol: z.string(), +}); + +const MarketsCryptoPriceOutputSchema = z + .object({ + symbol: z.string().nullable().optional(), + price: z.string().nullable().optional(), + timestamp: z.number().nullable().optional(), + }) + .loose(); + +/** + * Returns the latest Bitcoin price in USD and 24-hour market data. Premium members have access to live prices, while free users only have access to 15-minute delayed data. For historical price data, see /v1/bitcoinhistorical. + * + * GET v1/bitcoin + */ +const MarketsBitcoinInputSchema = z.object({}); + +const MarketsBitcoinOutputSchema = z + .object({ + price: z.string().nullable().optional(), + timestamp: z.number().nullable().optional(), + '24h_price_change': z.string().nullable().optional(), + '24h_price_change_percent': z.string().nullable().optional(), + '24h_high': z.string().nullable().optional(), + '24h_low': z.string().nullable().optional(), + '24h_volume': z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns the current price information for one or more commodities. Prices are based on rolling futures contracts and are quoted in the commodity's native unit and currency convention - see the unit and currency_unit fields below. Use the optional currency and unit parameters to convert into any supported currency or compatible mass/volume/energy unit. Premium members have access to live prices, while free users only have access to 15-minute delayed data. + * + * GET v1/commodityprice + */ +const MarketsCommodityPriceInputSchema = z.object({ + /** Name of a single commodity. Either name or names is required (not both). Free tier users have access to 7 commodities per week. These commodities rotate weekly on a deterministic schedule. Premium users have access to all commodities. The supported values are: */ + name: z.string().optional(), + /** Comma-separated list of commodity values for a batch request (e.g., gold,silver,platinum). Maximum 30 per call. When provided, the response is a JSON array instead of a single object. Mutually exclusive with name. Available to Business, Professional, and Enterprise subscribers, or any annual plan; other tiers should use the name parameter for single-commodity lookups. */ + names: z.string().optional(), + /** ISO 4217 currency code to convert the price into (e.g., EUR, GBP, INR, JPY). When provided, USX prices are first normalized to USD before conversion, so the response is always in major currency units. Defaults to the commodity's native USD/USX quote. */ + currency: z.string().optional(), + /** Target unit for the price. Supported: mass (troy_ounce, lb, kg, g, oz, metric_ton, short_ton, hundredweight), volume (barrel, gallon, liter, cubic_meter), and energy (MMBtu, MWh, GJ, therm). Bushel and board_feet are commodity-specific and cannot be cross-converted - requests to convert them will return an error. */ + unit: z.string().optional(), +}); + +const MarketsCommodityPriceOutputSchema = z + .object({ + exchange: z.string().nullable().optional(), + name: z.string().nullable().optional(), + value: z.string().nullable().optional(), + unit: z.string().nullable().optional(), + currency_unit: z.string().nullable().optional(), + price: z.number().nullable().optional(), + change_24h_percent: z.number().nullable().optional(), + change_24h: z.number().nullable().optional(), + low_24h: z.number().nullable().optional(), + high_24h: z.number().nullable().optional(), + previous_close: z.number().nullable().optional(), + updated: z.number().nullable().optional(), + /** 52-week high and low prices. */ + high_52w: z.string().nullable().optional(), + }) + .loose(); + +/** + * Converts an existing currency and amount into a new currency. + * + * GET v1/convertcurrency + */ +const MarketsConvertCurrencyInputSchema = z.object({ + /** Currency you currently hold. Must be 3-character currency code (e.g. USD). */ + have: z.string(), + /** Currency you want to convert to. Must be 3-character currency code (e.g. USD). */ + want: z.string(), + /** Amount of currency to convert. */ + amount: z.number(), +}); + +/** Declared from the documentation: this endpoint is premium-gated, so no free-tier response could be captured. */ +const MarketsConvertCurrencyOutputSchema = z + .object({ + /** The original amount to convert (e.g. 5000). */ + old_amount: z.union([z.number(), z.string()]).nullable().optional(), + /** The original currency code (e.g. GBP). */ + old_currency: z.string().nullable().optional(), + /** The converted amount in the new currency (e.g. 9559.32). */ + new_amount: z.union([z.number(), z.string()]).nullable().optional(), + /** The new currency code (e.g. AUD). */ + new_currency: z.string().nullable().optional(), + /** Unix timestamp (in seconds) indicating the time at which the exchange rate used for the conversion was applied. */ + timestamp: z.union([z.number(), z.string()]).nullable().optional(), + }) + .loose(); + +/** + * Returns the exchange rate for a given currency pair. + * + * GET v1/exchangerate + */ +const MarketsExchangeRateInputSchema = z.object({ + /** Currency pair to query. Must be in the form of currency1_currency2 (e.g. USD_EUR). */ + pair: z.string(), +}); + +/** Declared from the documentation: this endpoint is premium-gated, so no free-tier response could be captured. */ +const MarketsExchangeRateOutputSchema = z + .object({ + /** The requested currency pair. */ + currency_pair: z.string().nullable().optional(), + /** The exchange rate for the given currency pair. */ + exchange_rate: z.union([z.number(), z.string()]).nullable().optional(), + /** Unix timestamp (in seconds) indicating the time at which the exchange rate was applied. */ + timestamp: z.union([z.number(), z.string()]).nullable().optional(), + }) + .loose(); + +/* -------------------------------------------------------------------------- */ +/* economics */ +/* -------------------------------------------------------------------------- */ + +/** + * Get GDP data from given parameters. Returns GDP statistics that satisfy the parameters. + * + * GET v1/gdp + */ +const EconomicsGdpInputSchema = z.object({ + /** Country name (case-insensitive) or 2-letter ISO-3166 alpha-2 code of the country. E.g. Canada or CA. */ + country: z.string().optional(), + /** Year for which to retrieve GDP data. */ + year: z.number().optional(), +}); + +const EconomicsGdpOutputSchema = z.array( + z + .object({ + country: z.string().nullable().optional(), + year: z.number().nullable().optional(), + gdp_growth: z.number().nullable().optional(), + gdp_nominal: z.number().nullable().optional(), + gdp_per_capita_nominal: z.number().nullable().optional(), + gdp_ppp: z.number().nullable().optional(), + gdp_per_capita_ppp: z.number().nullable().optional(), + gdp_ppp_share: z.number().nullable().optional(), + }) + .loose(), +); + +/** + * Returns current monthly and annual inflation percentages. + * + * GET v1/inflation + */ +const EconomicsInflationInputSchema = z.object({ + /** Inflation indicator type. Can be either CPI (Consumer Price Index) or HICP (Harmonized Index of Consumer Prices). If not provided, the CPI will be used by default. */ + type: z.string().optional(), + /** 2-letter country code (ISO-3166-1 alpha-2) or name of country (case-insensitive). */ + country: z.string().optional(), +}); + +/** Declared from the documentation: this endpoint is premium-gated, so no free-tier response could be captured. */ +const EconomicsInflationOutputSchema = z.array( + z + .object({ + /** The name of the country. */ + country: z.string().nullable().optional(), + /** The 2-letter country code (ISO-3166-1 alpha-2). */ + country_code: z.string().nullable().optional(), + /** The type of inflation indicator (CPI or HICP). */ + type: z.string().nullable().optional(), + /** The period for the inflation data. */ + period: z.union([z.number(), z.string()]).nullable().optional(), + /** The monthly inflation rate as a percentage. */ + monthly_rate_pct: z.union([z.number(), z.string()]).nullable().optional(), + /** The yearly inflation rate as a percentage. */ + yearly_rate_pct: z.union([z.number(), z.string()]).nullable().optional(), + }) + .loose(), +); + +/** + * Get unemployment data for a given country. Returns historical, current and forecast unemployment statistics. + * + * GET v1/unemployment + */ +const EconomicsUnemploymentInputSchema = z.object({ + /** Country name (case-insensitive) or 2-letter ISO-3166 alpha-2 code of the country. E.g. Canada or CA. */ + country: z.string().optional(), + /** Year for which to retrieve unemployment data. */ + year: z.number().optional(), +}); + +const EconomicsUnemploymentOutputSchema = z.array( + z + .object({ + country: z.string().nullable().optional(), + year: z.number().nullable().optional(), + unemployment_rate: z.number().nullable().optional(), + }) + .loose(), +); + +/** + * Get population data from given parameters. Returns a list of up to 5 country population statistics that satisfy the parameters. For more results use the offset parameter. + * + * GET v1/population + */ +const EconomicsPopulationInputSchema = z.object({ + /** Country name (case-insensitive) or 2-letter ISO-3166 alpha-2 code of the country. E.g. Japan or JP. */ + country: z.string().optional(), + /** Minimum population of country. */ + min_population: z.number().optional(), + /** Maximum population of country. */ + max_population: z.number().optional(), + /** Offset results for pagination. */ + offset: z.number().optional(), +}); + +const EconomicsPopulationOutputSchema = z + .object({ + historical_population: z + .array( + z + .object({ + year: z.number().nullable().optional(), + population: z.number().nullable().optional(), + yearly_change_percentage: z.number().nullable().optional(), + yearly_change: z.number().nullable().optional(), + migrants: z.number().nullable().optional(), + median_age: z.number().nullable().optional(), + fertility_rate: z.number().nullable().optional(), + density: z.number().nullable().optional(), + urban_population_pct: z.number().nullable().optional(), + urban_population: z.number().nullable().optional(), + percentage_of_world_population: z.number().nullable().optional(), + rank: z.number().nullable().optional(), + }) + .loose(), + ) + .nullable() + .optional(), + population_forecast: z + .array( + z + .object({ + year: z.number().nullable().optional(), + population: z.number().nullable().optional(), + yearly_change_percentage: z.number().nullable().optional(), + yearly_change: z.number().nullable().optional(), + migrants: z.union([z.number(), z.string()]).nullable().optional(), + median_age: z.number().nullable().optional(), + fertility_rate: z.number().nullable().optional(), + density: z.number().nullable().optional(), + urban_population_pct: z.number().nullable().optional(), + urban_population: z.number().nullable().optional(), + percentage_of_world_population: z.number().nullable().optional(), + rank: z.number().nullable().optional(), + }) + .loose(), + ) + .nullable() + .optional(), + country_name: z.string().nullable().optional(), + /** Total population count for the country in the given year. */ + population: z.union([z.number(), z.string()]).nullable().optional(), + /** Percentage change in population from the previous year (can be positive or negative). */ + yearly_change_percentage: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + /** Absolute change in population from the previous year (can be positive or negative). */ + yearly_change: z.string().nullable().optional(), + /** Net number of migrants (immigrants minus emigrants) for the year. */ + migrants: z.union([z.number(), z.string()]).nullable().optional(), + /** Median age of the population in years. */ + median_age: z.string().nullable().optional(), + /** Average number of children born per woman in the population. */ + fertility_rate: z.union([z.number(), z.string()]).nullable().optional(), + /** Population density per square kilometer. */ + density: z.string().nullable().optional(), + /** Percentage of the total population living in urban areas. */ + urban_population_pct: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + /** Total number of people living in urban areas. */ + urban_population: z.union([z.number(), z.string()]).nullable().optional(), + /** Percentage of the world's total population represented by this country. */ + percentage_of_world_population: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + /** World ranking by population size (1 being the most populous country). */ + rank: z.union([z.number(), z.string()]).nullable().optional(), + }) + .loose(); + +/** + * Get a specific interest rate by name. Returns the rate value, name, and last updated timestamp. + * + * GET v2/interestrate + */ +const EconomicsInterestRateInputSchema = z.object({ + rate: z.string(), +}); + +/** Declared from the documentation: this endpoint is premium-gated, so no free-tier response could be captured. */ +const EconomicsInterestRateOutputSchema = z + .object({ + /** The name of the interest rate. */ + rate_name: z.string().nullable().optional(), + /** The interest rate value as a percentage. */ + rate_pct: z.union([z.number(), z.string()]).nullable().optional(), + /** Date when the rate was last updated (MM-DD-YYYY format). */ + last_updated: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns the daily 30-year and 15-year fixed-rate mortgage (FRM) data. If no parameters are set, the mortgage rate data for the most recent day is returned. + * + * GET v2/mortgagerate + */ +const EconomicsMortgageRateInputSchema = z.object({ + /** Individual date to query in YYYY-MM-DD format. [premium] */ + date: z.string().optional(), + /** Minimum date range to query in YYYY-MM-DD format. Must be used with max_date. [premium] */ + min_date: z.number().optional(), + /** Maximum date range to query in YYYY-MM-DD format. Must be used with min_date. [premium] */ + max_date: z.number().optional(), +}); + +const EconomicsMortgageRateOutputSchema = z.array( + z + .object({ + date: z.string().nullable().optional(), + frm_30: z.string().nullable().optional(), + frm_15: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns monthly payment, annual payment, and interest rate information based on given mortgage parameters. + * + * GET v1/mortgagecalculator + */ +const EconomicsMortgageCalculatorInputSchema = z.object({ + /** Principal loan amount. */ + loan_amount: z.number().optional(), + /** Total value of the home or asset. Must be greater than downpayment. */ + home_value: z.string().optional(), + /** Downpayment on the home or asset. Cannot exceed home_value. */ + downpayment: z.number().optional(), + /** Annual interest rate (in %). For example, a 3.5% interest rate would be 3.5. Cannot exceed 10000. */ + interest_rate: z.number(), + /** Duration of the loan in years. Must be between 1 and 10000. If not set, the default value is 30 years. */ + duration_years: z.number().optional(), + /** Monthly homeowner association fees. */ + monthly_hoa: z.number().optional(), + /** Annual property tax owed. */ + annual_property_tax: z.number().optional(), + /** Annual homeowner's insurance bill. */ + annual_home_insurance: z.number().optional(), +}); + +const EconomicsMortgageCalculatorOutputSchema = z + .object({ + monthly_payment: z + .object({ + total: z.number().nullable().optional(), + mortgage: z.number().nullable().optional(), + property_tax: z.number().nullable().optional(), + hoa: z.number().nullable().optional(), + annual_home_ins: z.number().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + annual_payment: z + .object({ + total: z.number().nullable().optional(), + mortgage: z.number().nullable().optional(), + property_tax: z.number().nullable().optional(), + hoa: z.number().nullable().optional(), + home_insurance: z.number().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + total_interest_paid: z.number().nullable().optional(), + }) + .loose(); + +/** + * Returns comprehensive income tax information including tax brackets and rates at both federal and state/provincial levels (where applicable). + * + * GET v2/incometax + */ +const EconomicsIncomeTaxInputSchema = z.object({ + /** 2-letter country code (e.g., US, CA) */ + country: z.string(), + /** The tax year for which to retrieve data */ + year: z.number(), + /** Comma-separated list of regions to filter the response. For United States, specify 2-letter state codes (e.g., AL, CA, NY) or federal for federal tax information only. For Canada, specify 2-letter provincial codes (e.g., ON, BC, QC) or federal for federal tax information only. Multiple regions can be specified (e.g., federal,AL,CA,NY). When specified, filters the response to only include tax information for those regions. If unset, the response will include all regions (federal and all states/provinces). */ + regions: z.string().optional(), +}); + +const EconomicsIncomeTaxOutputSchema = z + .object({ + country: z.string().nullable().optional(), + year: z.number().nullable().optional(), + fica: z.string().nullable().optional(), + states: z.string().nullable().optional(), + federal: z + .object({ + married: z + .object({ + brackets: z + .array( + z + .object({ + rate: z.number().nullable().optional(), + min: z.number().nullable().optional(), + max: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + }) + .loose(), + ) + .nullable() + .optional(), + }) + .loose() + .nullable() + .optional(), + married_separate: z + .object({ + brackets: z + .array( + z + .object({ + rate: z.number().nullable().optional(), + min: z.number().nullable().optional(), + max: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + }) + .loose(), + ) + .nullable() + .optional(), + }) + .loose() + .nullable() + .optional(), + single: z + .object({ + brackets: z + .array( + z + .object({ + rate: z.number().nullable().optional(), + min: z.number().nullable().optional(), + max: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + }) + .loose(), + ) + .nullable() + .optional(), + }) + .loose() + .nullable() + .optional(), + head_of_household: z + .object({ + brackets: z + .array( + z + .object({ + rate: z.number().nullable().optional(), + min: z.number().nullable().optional(), + max: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + }) + .loose(), + ) + .nullable() + .optional(), + }) + .loose() + .nullable() + .optional(), + }) + .loose() + .nullable() + .optional(), + /** The provincial tax rates for the given country and year (Canada only). */ + provinces: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns comprehensive annual tax calculations including federal, state/provincial, and FICA taxes where applicable. + * + * GET v1/incometaxcalculator + */ +const EconomicsIncomeTaxCalculatorInputSchema = z.object({ + /** 2-letter country code (e.g., US, CA) */ + country: z.string(), + /** State/province code (e.g., CA, NY, ON) */ + region: z.string(), + /** Annual income amount */ + income: z.number(), + /** Tax year in YYYY format (e.g., 2026). If not specified, the latest year will be used. */ + tax_year: z.string().optional(), + /** Tax filing status. Possible values: single, married (married filing jointly), married_separate (married filing separately), or head_of_household */ + filing_status: z.string(), + /** Total tax deductions amount */ + deductions: z.string().optional(), + /** Total tax credits amount */ + credits: z.string().optional(), + /** Set to true for self-employed tax calculations (US only) */ + self_employed: z.boolean().optional(), +}); + +const EconomicsIncomeTaxCalculatorOutputSchema = z + .object({ + country: z.string().nullable().optional(), + region: z.string().nullable().optional(), + income: z.number().nullable().optional(), + taxable_income: z.number().nullable().optional(), + deductions: z.number().nullable().optional(), + credits: z.number().nullable().optional(), + tax_year: z.string().nullable().optional(), + federal_effective_rate: z.number().nullable().optional(), + federal_taxes_owed: z.number().nullable().optional(), + fica_social_security: z.string().nullable().optional(), + fica_social_security_rate: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + fica_social_security_cap: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + fica_medicare: z.string().nullable().optional(), + fica_medicare_rate: z.union([z.number(), z.string()]).nullable().optional(), + fica_total: z.union([z.number(), z.string()]).nullable().optional(), + region_effective_rate: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + region_taxes_owed: z.string().nullable().optional(), + total_taxes_owed: z.string().nullable().optional(), + income_after_tax: z.string().nullable().optional(), + total_effective_tax_rate: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + }) + .loose(); + +/** + * Returns one or more sales tax breakdowns by ZIP code according to the specified parameters. Each breakdown includes the state sales tax (if any), county sales tax (if any), city sales tax (if any), and any additional special sales taxes. All tax values are presented in decimals (e.g. 0.1 means 10% tax). + * + * GET v1/salestax + */ +const EconomicsSalesTaxInputSchema = z.object({ + /** Valid US ZIP code. */ + zip_code: z.string().optional(), + /** Street address (e.g. 9641 Sunset Blvd). Used together with city and state for the most accurate lookup. */ + street_address: z.string().optional(), + /** City name. */ + city: z.string().optional(), + /** State name. */ + state: z.string().optional(), +}); + +const EconomicsSalesTaxOutputSchema = z.array( + z + .object({ + zip_code: z.string().nullable().optional(), + state_rate: z.string().nullable().optional(), + city_rate: z.union([z.number(), z.string()]).nullable().optional(), + county_rate: z.union([z.number(), z.string()]).nullable().optional(), + additional_rate: z.union([z.number(), z.string()]).nullable().optional(), + total_rate: z.union([z.number(), z.string()]).nullable().optional(), + /** The street address from the request. Only returned when the street_address parameter is provided. */ + street_address: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Calculates sales tax for a given amount and location. Returns a detailed breakdown including state, county, city, and special district taxes, along with the calculated tax amount and total amount after tax. + * + * GET v1/salestaxcalculator + */ +const EconomicsSalesTaxCalculatorInputSchema = z.object({ + /** Purchase amount to calculate tax on. */ + amount: z.number(), + /** Valid US ZIP code. */ + zip_code: z.string().optional(), + /** Street address (e.g. 9641 Sunset Blvd). Used together with city and state for the most accurate lookup. */ + street_address: z.string().optional(), + /** City name. */ + city: z.string().optional(), + /** State name. */ + state: z.string().optional(), +}); + +const EconomicsSalesTaxCalculatorOutputSchema = z.array( + z + .object({ + zip_code: z.string().nullable().optional(), + pre_tax_amount: z.string().nullable().optional(), + state_rate: z.number().nullable().optional(), + total_rate: z.union([z.number(), z.string()]).nullable().optional(), + city_rate: z.union([z.number(), z.string()]).nullable().optional(), + county_rate: z.union([z.number(), z.string()]).nullable().optional(), + additional_rate: z.union([z.number(), z.string()]).nullable().optional(), + state_tax: z.number().nullable().optional(), + city_tax: z.string().nullable().optional(), + county_tax: z.string().nullable().optional(), + additional_tax: z.string().nullable().optional(), + total_tax: z.string().nullable().optional(), + total_price: z.union([z.number(), z.string()]).nullable().optional(), + /** The street address from the request. Only returned when the street_address parameter is provided. */ + street_address: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns a list of regions and corresponding 25th, 50th (median), and 75th percentile effective property tax rates. The region is mostly zipcode-based, but sometimes a single zipcode can contain multiple regions due to local tax laws. + * + * GET v1/propertytax + */ +const EconomicsPropertyTaxInputSchema = z.object({ + /** 2-letter abbreviation of the state (case-insensitive). */ + state: z.string().optional(), + /** The name of the county for which property tax data is being requested. */ + county: z.string().optional(), + /** Full name of the city to search (case-sensitive). */ + city: z.string().optional(), + /** The ZIP Code to look up property tax rates. */ + zip: z.string().optional(), +}); + +const EconomicsPropertyTaxOutputSchema = z.array( + z + .object({ + state: z.string().nullable().optional(), + county: z.string().nullable().optional(), + city: z.string().nullable().optional(), + zip: z.string().nullable().optional(), + property_tax_25th_percentile: z.number().nullable().optional(), + property_tax_50th_percentile: z.number().nullable().optional(), + property_tax_75th_percentile: z.number().nullable().optional(), + }) + .loose(), +); + +/** + * Returns VAT rates for a specified EU country. Results include standard rate, reduced rates, super-reduced rates, and any special categories. + * + * GET v1/vat + */ +const EconomicsVatRatesInputSchema = z.object({ + /** Two-letter country code (ISO 3166-1 alpha-2). */ + country: z.string(), + /** VAT rate type. Possible values: standard, reduced, super_reduced, exempted, parking. Numeric rate values for types other than standard require a premium subscription. */ + type: z.number().optional(), + /** Filter results after this date (YYYY-MM-DD format). */ + min_date: z.number().optional(), + /** Filter results before this date (YYYY-MM-DD format). */ + max_date: z.number().optional(), + /** Number of results to return (1-100). Default is 5. [premium] */ + limit: z.number().optional(), + /** Number of results to offset for pagination. Default is 0. [premium] */ + offset: z.number().optional(), +}); + +const EconomicsVatRatesOutputSchema = z.array( + z + .object({ + country: z.string().nullable().optional(), + type: z.string().nullable().optional(), + rate: z.union([z.number(), z.string()]).nullable().optional(), + date: z.string().nullable().optional(), + category: z.string().nullable().optional(), + }) + .loose(), +); + +/* -------------------------------------------------------------------------- */ +/* text */ +/* -------------------------------------------------------------------------- */ + +/** + * Returns sentiment analysis score and overall sentiment for a given block of text. + * + * GET v1/sentiment + */ +const TextSentimentInputSchema = z.object({ + /** Query text for sentiment analysis. Maximum 2000 characters. */ + text: z.string(), +}); + +const TextSentimentOutputSchema = z + .object({ + score: z.number().nullable().optional(), + text: z.string().nullable().optional(), + sentiment: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns a similarity score between 0 and 1 (1 is similar and 0 is dissimilar) of two given texts. + * + * POST v1/textsimilarity + */ +const TextSimilarityInputSchema = z.object({ + /** First input text. Maximum 5000 characters. */ + text_1: z.string(), + /** Second input text. Maximum 5000 characters. */ + text_2: z.string(), +}); + +const TextSimilarityOutputSchema = z + .object({ + similarity: z.number().nullable().optional(), + }) + .loose(); + +/** + * Returns a 768-dimensional vector as an array that encodes the meaning of any given input text. + * + * POST v1/embeddings + */ +const TextEmbeddingsInputSchema = z.object({ + /** Query text to embed. Maximum 5000 characters. */ + text: z.string(), +}); + +const TextEmbeddingsOutputSchema = z + .object({ + embeddings: z.array(z.number()).nullable().optional(), + }) + .loose(); + +/** + * Returns the language name and 2-letter ISO language code for a given block of text string. + * + * GET v1/textlanguage + */ +const TextLanguageInputSchema = z.object({ + /** Input text (10 words or more recommended). Maximum 1000 characters. */ + text: z.string(), +}); + +const TextLanguageOutputSchema = z + .object({ + iso: z.string().nullable().optional(), + language: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns spelling corrections and suggestions for any given text. + * + * GET v1/spellcheck + */ +const TextSpellCheckInputSchema = z.object({ + /** Input text. Maximum 50 characters for free tier, 500 characters for premium subscribers. */ + text: z.string(), +}); + +const TextSpellCheckOutputSchema = z + .object({ + original: z.string().nullable().optional(), + corrected: z.string().nullable().optional(), + corrections: z + .array( + z + .object({ + word: z.string().nullable().optional(), + index: z.number().nullable().optional(), + correction: z.string().nullable().optional(), + candidates: z.array(z.string()).nullable().optional(), + }) + .loose(), + ) + .nullable() + .optional(), + /** The index of the word in the original text. */ + index: z.string().nullable().optional(), + /** The corrected word. */ + correction: z.string().nullable().optional(), + /** An array of possible corrections for the word. */ + candidates: z + .union([z.array(z.string()), z.string()]) + .nullable() + .optional(), + }) + .loose(); + +/** + * Returns the censored version (bad words replaced with asterisks) of any given text and whether the text contains profanity. + * + * GET v1/profanityfilter + */ +const TextProfanityFilterInputSchema = z.object({ + /** Input text. Maximum 1000 characters. */ + text: z.string(), +}); + +const TextProfanityFilterOutputSchema = z + .object({ + original: z.string().nullable().optional(), + censored: z.string().nullable().optional(), + has_profanity: z.boolean().nullable().optional(), + }) + .loose(); + +/** + * Returns a string containing definitions for a given word. + * + * GET v1/dictionary + */ +const TextDictionaryInputSchema = z.object({ + /** Word to look up. */ + word: z.string(), +}); + +const TextDictionaryOutputSchema = z + .object({ + definition: z.string().nullable().optional(), + word: z.string().nullable().optional(), + valid: z.boolean().nullable().optional(), + }) + .loose(); + +/** + * Returns a list of synonyms and a list of antonyms for a given word. + * + * GET v1/thesaurus + */ +const TextThesaurusInputSchema = z.object({ + /** Word to look up. */ + word: z.string(), +}); + +const TextThesaurusOutputSchema = z + .object({ + word: z.string().nullable().optional(), + synonyms: z.array(z.string()).nullable().optional(), + antonyms: z.array(z.string()).nullable().optional(), + }) + .loose(); + +/** + * Returns a list of rhyming words for any given word. + * + * GET v1/rhyme + */ +const TextRhymesInputSchema = z.object({ + /** Word to look up. */ + word: z.string(), +}); + +const TextRhymesOutputSchema = z.array(z.string()); + +/** + * Returns a random word. + * + * GET v2/randomword + */ +const TextRandomWordInputSchema = z.object({ + /** Type of word. Possible values are: noun, verb, adjective, adverb. [premium] */ + type: z.string().optional(), + /** How many results to return. Must be between 1 and 30. Default is 1. [premium] */ + limit: z.number().optional(), +}); + +const TextRandomWordOutputSchema = z.array(z.string()); + +/** + * Returns one or more paragraphs of lorem ipsum placeholder text. + * + * GET v1/loremipsum + */ +const TextLoremIpsumInputSchema = z.object({ + /** Maximum character length. */ + max_length: z.number().optional(), + /** Number of paragraphs to generate. If unset, a default value of 1 will be used. */ + paragraphs: z.number().optional(), + /** Whether to begin the text with the words "Lorem ipsum". Must be either true or false. If unset, a default value of true will be used. */ + start_with_lorem_ipsum: z.boolean().optional(), + /** Whether to randomly generate paragraphs. Must be either true or false. If unset, a default value of true will be used. */ + random: z.boolean().optional(), +}); + +const TextLoremIpsumOutputSchema = z + .object({ + text: z.string().nullable().optional(), + }) + .loose(); + +/* -------------------------------------------------------------------------- */ +/* utility */ +/* -------------------------------------------------------------------------- */ + +/** + * Returns a QRCode image binary specified by input parameters. + * + * GET v1/qrcode + */ +const UtilityQrCodeInputSchema = z.object({ + /** Data to encode in the QR code. */ + data: z.string(), + /** Image format to return. Must be one of the following: png, jpg, jpeg, eps, svg. */ + format: z.string().optional(), + /** Size of the QR code image to generate (e.g. 200). The output will be a square image with (size x size) dimensions. The default size is 250. */ + size: z.number().optional(), + /** Foreground color of the QR code. Must be a 6-digit hex color (e.g. 00ff00 for green). Default is 000000 (black). */ + fg_color: z.string().optional(), + /** Background color of the QR code. Must be a 6-digit hex color (e.g. 00ff00 for green). Default is ffffff (white). */ + bg_color: z.string().optional(), +}); + +/** + * Returns the generated QR code image in the format specified by the format parameter (for example PNG, JPG, SVG). The response body is binary image data (or text for SVG/EPS). Returns an error if the request is unsuccessful. + * + * `encoding` is `text` when the payload is exactly what the provider sent + * (SVG, EPS) and `lossy-text` when it is a raster format that was decoded as + * text on the way through and can no longer be written back out as an image. + */ +const UtilityQrCodeOutputSchema = z.object({ + content_type: z.string(), + encoding: z.enum(['text', 'lossy-text']), + data: z.string(), +}); + +/** + * Returns a barcode image binary specified by input parameters. + * + * GET v1/barcodegenerate + */ +const UtilityBarcodeInputSchema = z.object({ + /** Text to encode in the barcode. */ + text: z.string(), + /** Type of barcode to generate. Must be one of: code39, code128, ean, ean13, ean8, gs1, gtin, isbn, isbn10, isbn13, issn, jan, pzn, upc, upca. Default is upc. */ + type: z.string().optional(), + /** Image format to return. Must be one of: png, svg. Default is png. */ + format: z.string().optional(), + /** Whether to include the text below the barcode. Must be true or false. Default is true. */ + include_text: z.boolean().optional(), +}); + +/** + * Returns the barcode image as binary data in the requested format (PNG or SVG), or an error if the request is unsuccessful. + * + * `encoding` is `text` when the payload is exactly what the provider sent + * (SVG, EPS) and `lossy-text` when it is a raster format that was decoded as + * text on the way through and can no longer be written back out as an image. + */ +const UtilityBarcodeOutputSchema = z.object({ + content_type: z.string(), + encoding: z.enum(['text', 'lossy-text']), + data: z.string(), +}); + +/** + * Returns a random password string adhering to the specified parameters. + * + * GET v1/passwordgenerator + */ +const UtilityPasswordInputSchema = z.object({ + /** Length of password in characters. If not set, a default value of 16 is used. */ + length: z.number().optional(), + /** Whether to exclude numbers from the password. Must be either true or false. If not set, a default value of false will be used. */ + exclude_numbers: z.boolean().optional(), + /** Whether to exclude special characters(!@#$%^&*()) from the password. Must be either true or false. If not set, a default value of false will be used. */ + exclude_special_chars: z.boolean().optional(), +}); + +const UtilityPasswordOutputSchema = z + .object({ + random_password: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns fake random user profiles. Supports customizable fields, filtering, and localization. + * + * GET v2/randomuser + */ +const UtilityRandomUserInputSchema = z.object({ + /** Number of users to generate (1-30). Default: 10 */ + count: z.number().optional(), + /** Filter by gender: "male", "female", "nonbinary", or "any". Default: "any" */ + gender: z.string().optional(), + /** Minimum age (0-1000). Default: 0 */ + min_age: z.number().optional(), + /** Maximum age (0-1000). Default: 100 */ + max_age: z.number().optional(), + /** Locale for generating localized data (e.g., "en_US", "de_DE", "fr_FR"). Default: "en_US" */ + locale: z.string().optional(), + /** Comma-separated list of fields to include (e.g., "name,email,phone"). If not specified, all available fields are returned. */ + fields: z.string().optional(), + /** Comma-separated list of fields to exclude from the response. */ + exclude: z.string().optional(), + /** Seed value for reproducible random data generation. */ + seed: z.string().optional(), +}); + +const UtilityRandomUserOutputSchema = z.array( + z + .object({ + id: z.string().nullable().optional(), + username: z.string().nullable().optional(), + password: z.string().nullable().optional(), + email: z.string().nullable().optional(), + name: z.string().nullable().optional(), + first_name: z.string().nullable().optional(), + last_name: z.string().nullable().optional(), + full_name: z.string().nullable().optional(), + prefix: z.string().nullable().optional(), + suffix: z.string().nullable().optional(), + phone: z.string().nullable().optional(), + cell: z.string().nullable().optional(), + address: z.string().nullable().optional(), + street_address: z.string().nullable().optional(), + city: z.string().nullable().optional(), + state: z.string().nullable().optional(), + postal_code: z.string().nullable().optional(), + country: z.string().nullable().optional(), + latitude: z.number().nullable().optional(), + longitude: z.number().nullable().optional(), + timezone: z.string().nullable().optional(), + dob: z.string().nullable().optional(), + age: z.number().nullable().optional(), + gender: z.string().nullable().optional(), + job: z.string().nullable().optional(), + company: z.string().nullable().optional(), + company_email: z.string().nullable().optional(), + ssn: z.string().nullable().optional(), + credit_card: z.string().nullable().optional(), + credit_card_provider: z.string().nullable().optional(), + iban: z.string().nullable().optional(), + ipv4: z.string().nullable().optional(), + ipv6: z.string().nullable().optional(), + mac_address: z.string().nullable().optional(), + user_agent: z.string().nullable().optional(), + url: z.string().nullable().optional(), + domain: z.string().nullable().optional(), + picture: z.string().nullable().optional(), + avatar: z.string().nullable().optional(), + uuid: z.string().nullable().optional(), + md5: z.string().nullable().optional(), + sha1: z.string().nullable().optional(), + sha256: z.string().nullable().optional(), + locale: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Fetch and possibly update a counter. + * + * GET v1/counter + */ +const UtilityCounterInputSchema = z.object({ + /** ID to specify the counter. Use a new id to create a new counter. */ + id: z.string(), + /** Whether to increase the count by 1. If used, must be set to true. */ + hit: z.boolean().optional(), + /** Set the count to a specific integer value. Setting the value to 0 resets the counter. */ + value: z.number().optional(), +}); + +const UtilityCounterOutputSchema = z + .object({ + id: z.string().nullable().optional(), + value: z.number().nullable().optional(), + }) + .loose(); + +/** + * Returns conversions between different units of the same measurement type. + * + * GET v1/unitconversion + */ +const UtilityConvertUnitInputSchema = z.object({ + /** The numerical value to convert. */ + amount: z.number(), + /** The source unit to convert from. Spaces should be replaced with underscores. See Supported Measurement Types for a list of available units. */ + unit: z.string(), +}); + +const UtilityConvertUnitOutputSchema = z + .object({ + type: z.string().nullable().optional(), + unit: z.string().nullable().optional(), + amount: z.number().nullable().optional(), + conversions: z + .object({ + meter: z.number().nullable().optional(), + kilometer: z.number().nullable().optional(), + centimeter: z.number().nullable().optional(), + millimeter: z.number().nullable().optional(), + micrometer: z.number().nullable().optional(), + nanometer: z.number().nullable().optional(), + mile: z.number().nullable().optional(), + yard: z.number().nullable().optional(), + foot: z.number().nullable().optional(), + inch: z.number().nullable().optional(), + nautical_mile: z.number().nullable().optional(), + furlong: z.number().nullable().optional(), + light_year: z.number().nullable().optional(), + astronomical_unit: z.number().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + }) + .loose(); + +/** + * Get a list of company names, ticker symbols, and logo image URLs matching the input parameters. Returns at most 10 results. + * + * GET v1/logo + */ +const UtilityLogoInputSchema = z.object({ + /** Company name. Supports partial matching (e.g. Micro will match Microsoft). Case-insensitive. */ + name: z.string().optional(), + /** Company ticker symbol (for publicly traded companies only). */ + ticker: z.string().optional(), +}); + +const UtilityLogoOutputSchema = z.array( + z + .object({ + name: z.string().nullable().optional(), + image: z.string().nullable().optional(), + ticker: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Get a country's flag as SVG image URLs. Both 1:1 and 4:3 aspect ratios are supported and returned in the response. + * + * GET v1/countryflag + */ +const UtilityCountryFlagInputSchema = z.object({ + /** 2-letter ISO-3166 alpha-2 country code (e.g. US, CA, FR). For countries in the United Kingdom, use GB for Great Britain, GB-ENG for England, GB-SCT for Scotland, GB-WLS for Wales, GB-NIR for Northern Ireland. */ + country: z.string(), +}); + +const UtilityCountryFlagOutputSchema = z + .object({ + country: z.string().nullable().optional(), + square_image_url: z.string().nullable().optional(), + rectangle_image_url: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns a random image in JPEG format. + * + * GET v1/randomimage + */ +const UtilityRandomImageInputSchema = z.object({ + /** Image category. If set, must be one of the following: nature, city, technology, food, still_life, abstract, wildlife. */ + category: z.string().optional(), + /** Width of the image to generate. Must be between 1 and 5000. Default value is 640. */ + width: z.number().optional(), + /** Height of the image to generate. Must be between 1 and 5000. Default value is 480. */ + height: z.number().optional(), +}); + +/** + * Returns a random image in JPG format. The response body is binary image data. Returns an error if the request is unsuccessful. + * + * `encoding` is `text` when the payload is exactly what the provider sent + * (SVG, EPS) and `lossy-text` when it is a raster format that was decoded as + * text on the way through and can no longer be written back out as an image. + */ +const UtilityRandomImageOutputSchema = z.object({ + content_type: z.string(), + encoding: z.enum(['text', 'lossy-text']), + data: z.string(), +}); + +/** + * Returns a list of emojis according to input parameters. Returns at most 30 results. To access more than 30 results, use the offset parameter to offset results in multiple API calls. + * + * GET v1/emoji + */ +const UtilityEmojiInputSchema = z.object({ + /** Descriptive name of emoji. */ + name: z.string().optional(), + /** Unicode character code for the emoji. */ + code: z.string().optional(), + /** Main category the emoji belongs to. Possible values are: */ + group: z.string().optional(), + /** Sub-category the emoji belongs to. Possible values are: */ + subgroup: z.string().optional(), + /** Number of results to offset for pagination. */ + offset: z.number().optional(), +}); + +const UtilityEmojiOutputSchema = z.array( + z + .object({ + code: z.string().nullable().optional(), + character: z.string().nullable().optional(), + image: z.string().nullable().optional(), + name: z.string().nullable().optional(), + group: z.string().nullable().optional(), + subgroup: z.string().nullable().optional(), + }) + .loose(), +); + +/* -------------------------------------------------------------------------- */ +/* transport */ +/* -------------------------------------------------------------------------- */ + +/** + * Returns a list of aircrafts that match the given parameters. This API only supports airplanes - for helicopter specs please use our Helicopter API. + * + * GET v1/aircraft + */ +const TransportAircraftInputSchema = z.object({ + /** Company that designed and built the aircraft. */ + manufacturer: z.string().optional(), + /** Aircraft model name. */ + model: z.string().optional(), + /** Type of engine. Must be one of: piston, propjet, jet. */ + engine_type: z.string().optional(), + /** Minimum max. air speed in knots. */ + min_speed: z.number().optional(), + /** Maximum max. air speed in knots. */ + max_speed: z.number().optional(), + /** Minimum range of the aircraft in nautical miles. */ + min_range: z.number().optional(), + /** Maximum range of the aircraft in nautical miles. */ + max_range: z.number().optional(), + /** Minimum length of the aircraft in feet. */ + min_length: z.number().optional(), + /** Maximum length of the aircraft in feet. */ + max_length: z.number().optional(), + /** Minimum height of the aircraft in feet. */ + min_height: z.number().optional(), + /** Maximum height of the aircraft in feet. */ + max_height: z.number().optional(), + /** Minimum wingspan of the aircraft in feet. */ + min_wingspan: z.number().optional(), + /** Maximum wingspan of the aircraft in feet. */ + max_wingspan: z.number().optional(), + /** How many results to return. Must be between 1 and 30. Default is 1. */ + limit: z.number().optional(), +}); + +const TransportAircraftOutputSchema = z.array( + z + .object({ + manufacturer: z.string().nullable().optional(), + model: z.string().nullable().optional(), + engine_type: z.string().nullable().optional(), + max_speed_knots: z.string().nullable().optional(), + ceiling_ft: z.string().nullable().optional(), + gross_weight_lbs: z.string().nullable().optional(), + length_ft: z.string().nullable().optional(), + height_ft: z.string().nullable().optional(), + wing_span_ft: z.string().nullable().optional(), + range_nautical_miles: z.string().nullable().optional(), + /** Engine thrust in pounds-force. */ + engine_thrust_lb_ft: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + /** Cruise speed in knots. */ + cruise_speed_knots: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + /** Takeoff ground run distance in feet. */ + takeoff_ground_run_ft: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + /** Landing ground roll distance in feet. */ + landing_ground_roll_ft: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + /** Empty weight in pounds. */ + empty_weight_lbs: z.union([z.number(), z.string()]).nullable().optional(), + }) + .loose(), +); + +/** + * Returns airline details including fleet composition, base airport and branding assets, by name, IATA code or ICAO code. + * + * GET v1/airlines + */ +const TransportAirlinesInputSchema = z.object({ + /** Airline name. */ + name: z.string().optional(), + /** Two-character IATA airline code. */ + iata: z.string().optional(), + /** Three-character ICAO airline code. */ + icao: z.string().optional(), +}); + +const TransportAirlinesOutputSchema = z.array( + z + .object({ + name: z.string().nullable().optional(), + country: z.string().nullable().optional(), + year_created: z.string().nullable().optional(), + base: z.string().nullable().optional(), + iata: z.string().nullable().optional(), + icao: z.string().nullable().optional(), + fleet: z + .object({ + A359: z.number().nullable().optional(), + A388: z.number().nullable().optional(), + B38M: z.number().nullable().optional(), + B738: z.number().nullable().optional(), + B744: z.number().nullable().optional(), + B772: z.number().nullable().optional(), + B773: z.number().nullable().optional(), + B77W: z.number().nullable().optional(), + B78X: z.number().nullable().optional(), + total: z.number().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + logo_url: z.string().nullable().optional(), + brandmark_url: z.string().nullable().optional(), + tail_logo_url: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns a list of up to 10 airport results. Use the offset parameter to access more results if available. + * + * GET v1/airports + */ +const TransportAirportsInputSchema = z.object({ + /** International Air Transport Association (IATA) airport code (typically 3 characters). Supports partial, case-insensitive matching (e.g. LH matches LHR). */ + iata: z.string().optional(), + /** International Civil Aviation Organization (ICAO) 4-character airport code. Supports partial, case-insensitive matching (e.g. EGL matches EGLL). */ + icao: z.string().optional(), + /** Airport name. Supports partial matching (e.g. Heathrow matches London Heathrow Airport). [premium] */ + name: z.string().optional(), + /** Airport country. Must be 2-character ISO-2 country code (e.g. GB). */ + country: z.string().optional(), + /** Administrative region such as state or province within a country (e.g. California). Supports partial, case-insensitive matching. [premium] */ + region: z.string().optional(), + /** Airport city (e.g. London). Supports partial, case-insensitive matching (e.g. York may match New York). [premium] */ + city: z.string().optional(), + /** Airport timezone (e.g. Europe/London). */ + timezone: z.string().optional(), + /** Minimum airport elevation in feet. */ + min_elevation: z.number().optional(), + /** Maximum airport elevation in feet. */ + max_elevation: z.number().optional(), + /** Airport size. Must be one of: large, medium, small. */ + size: z.string().optional(), + /** Filter by whether the airport has an IATA code. true returns only IATA-coded airports; false returns only those without. */ + has_iata: z.boolean().optional(), + /** Minimum length (in feet) of at least one runway at the airport. */ + min_runway_length: z.number().optional(), + /** Facility type (more granular than size). Must be one of: large_airport, medium_airport, small_airport, heliport, seaplane_base, balloonport, closed. */ + type: z.string().optional(), + /** Filter by whether the airport has scheduled airline service. true or false. */ + scheduled_service: z.boolean().optional(), + /** Continent code. Must be one of: AF, AN, AS, EU, NA, OC, SA. */ + continent: z.string().optional(), + /** Filter to airports having at least one runway of the given surface category. Must be one of: paved, unpaved, water, unknown. */ + surface: z.string().optional(), + /** Filter by whether the airport has at least one lighted runway. true or false. */ + has_lights: z.boolean().optional(), + /** Free-text search across airport name, city, codes, and alternate-name keywords. */ + q: z.string().optional(), + /** Set to true to include permanently closed airports. Closed airports are excluded by default. */ + include_closed: z.boolean().optional(), + /** Maximum number of results to return. Must be between 1 and 100. Default is 10. */ + limit: z.number().optional(), + /** Sort order for results. Must be one of: passengers (default), name, elevation, runway_length, longest_runway, num_runways. */ + sort: z.string().optional(), + /** Sort direction. Must be asc or desc. Default is desc. */ + order: z.string().optional(), + /** Number of results to offset for pagination. */ + offset: z.number().optional(), +}); + +const TransportAirportsOutputSchema = z.array( + z + .object({ + icao: z.string().nullable().optional(), + ident: z.string().nullable().optional(), + iata: z.string().nullable().optional(), + name: z.string().nullable().optional(), + city: z.string().nullable().optional(), + region: z.string().nullable().optional(), + region_code: z.string().nullable().optional(), + country: z.string().nullable().optional(), + country_name: z.string().nullable().optional(), + continent: z.string().nullable().optional(), + elevation_ft: z.number().nullable().optional(), + elevation_m: z.number().nullable().optional(), + latitude: z.number().nullable().optional(), + longitude: z.number().nullable().optional(), + timezone: z.string().nullable().optional(), + type: z.string().nullable().optional(), + size: z.string().nullable().optional(), + scheduled_service: z.boolean().nullable().optional(), + is_closed: z.boolean().nullable().optional(), + gps_code: z.string().nullable().optional(), + local_code: z.string().nullable().optional(), + home_link: z.string().nullable().optional(), + wikipedia_link: z.string().nullable().optional(), + keywords: z.array(z.string()).nullable().optional(), + num_runways: z.number().nullable().optional(), + longest_runway_ft: z.number().nullable().optional(), + runways: z + .array( + z + .object({ + length: z.number().nullable().optional(), + width: z.number().nullable().optional(), + has_lights: z.boolean().nullable().optional(), + surface: z.string().nullable().optional(), + surface_category: z.string().nullable().optional(), + closed: z.boolean().nullable().optional(), + le_ident: z.string().nullable().optional(), + he_ident: z.string().nullable().optional(), + le_heading_deg: z.number().nullable().optional(), + he_heading_deg: z.number().nullable().optional(), + }) + .loose(), + ) + .nullable() + .optional(), + estimated_annual_passengers: z.number().nullable().optional(), + }) + .loose(), +); + +/** + * Get helicopter technical specifications that match the given parameters. + * + * GET v1/helicopter + */ +const TransportHelicoptersInputSchema = z.object({ + /** Company that designed and built the helicopter. */ + manufacturer: z.string().optional(), + /** Helicopter model name. */ + model: z.string().optional(), + /** Minimum max. air speed in knots. */ + min_speed: z.number().optional(), + /** Maximum max. air speed in knots. */ + max_speed: z.number().optional(), + /** Minimum range of the helicopter in nautical miles. */ + min_range: z.number().optional(), + /** Maximum range of the helicopter in nautical miles. */ + max_range: z.number().optional(), + /** Minimum length of the helicopter in feet. */ + min_length: z.number().optional(), + /** Maximum length of the helicopter in feet. */ + max_length: z.number().optional(), + /** Minimum height of the helicopter in feet. */ + min_height: z.number().optional(), + /** Maximum height of the helicopter in feet. */ + max_height: z.number().optional(), + /** How many results to return. Must be between 1 and 30. Default is 1. */ + limit: z.number().optional(), +}); + +const TransportHelicoptersOutputSchema = z.array( + z + .object({ + manufacturer: z.string().nullable().optional(), + model: z.string().nullable().optional(), + max_speed_sl_knots: z.string().nullable().optional(), + cruise_speed_sl_knots: z.string().nullable().optional(), + vne_speed_knots: z.string().nullable().optional(), + range_nautical_miles: z.string().nullable().optional(), + fuel_consumption_gallons_pr_hr: z.string().nullable().optional(), + fuel_capacity_gallons: z.string().nullable().optional(), + fuel_opt_gallons: z.string().nullable().optional(), + gross_external_load_lbs: z.string().nullable().optional(), + external_load_limit_lbs: z.string().nullable().optional(), + main_rotor_diameter_ft: z.string().nullable().optional(), + num_blades: z.string().nullable().optional(), + blade_material: z.string().nullable().optional(), + storage_width_ft: z.string().nullable().optional(), + length_ft: z.string().nullable().optional(), + height_ft: z.string().nullable().optional(), + /** Cruise time in minutes. */ + cruise_time_min: z.union([z.number(), z.string()]).nullable().optional(), + /** Type of rotor system. */ + rotor_type: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Get car data from given parameters. Returns a list of car models (and their information) that satisfy the parameters. + * + * GET v1/cars + */ +const TransportCarsInputSchema = z.object({ + /** Vehicle manufacturer (e.g. audi). */ + make: z.string().optional(), + /** Vehicle model (e.g. a4). You can find the list of models by calling the /v1/carmodels endpoint. */ + model: z.string(), + /** Vehicle trim (e.g. 1.6 AT (101 hp)). You can find the list of trims by calling the /v1/cartrims endpoint. */ + trim: z.string().optional(), +}); + +const TransportCarsOutputSchema = z.array( + z + .object({ + city_mpg: z.union([z.number(), z.string()]).nullable().optional(), + class: z.string().nullable().optional(), + combination_mpg: z.union([z.number(), z.string()]).nullable().optional(), + cylinders: z.number().nullable().optional(), + displacement: z.number().nullable().optional(), + drive: z.string().nullable().optional(), + fuel_type: z.string().nullable().optional(), + highway_mpg: z.union([z.number(), z.string()]).nullable().optional(), + make: z.string().nullable().optional(), + model: z.string().nullable().optional(), + transmission: z.string().nullable().optional(), + year: z.number().nullable().optional(), + }) + .loose(), +); + +/** + * Returns up to 30 motorcycle results matching the input name parameters. For searches that yield more than 30 results, please use the offset parameter. + * + * GET v1/motorcycles + */ +const TransportMotorcyclesInputSchema = z.object({ + /** Name of manufacturer/brand. Supports partial matching (e.g. Harley will match Harley-Davidson). */ + make: z.string().optional(), + /** Name of motorcycle model. Supports partial matching (e.g. Ninja will match Ninja 650). */ + model: z.string().optional(), + /** Release year of motorcycle model. Must be in the form of YYYY (e.g. 2022). */ + year: z.number().optional(), + /** Number of results to offset for pagination. Default is 0. [premium] */ + offset: z.number().optional(), +}); + +const TransportMotorcyclesOutputSchema = z.array( + z + .object({ + make: z.string().nullable().optional(), + model: z.string().nullable().optional(), + year: z.string().nullable().optional(), + type: z.string().nullable().optional(), + displacement: z.string().nullable().optional(), + engine: z.string().nullable().optional(), + compression: z.string().nullable().optional(), + bore_stroke: z.string().nullable().optional(), + valves_per_cylinder: z.string().nullable().optional(), + fuel_system: z.string().nullable().optional(), + fuel_control: z.string().nullable().optional(), + lubrication: z.string().nullable().optional(), + cooling: z.string().nullable().optional(), + gearbox: z.string().nullable().optional(), + transmission: z.string().nullable().optional(), + clutch: z.string().nullable().optional(), + frame: z.string().nullable().optional(), + front_suspension: z.string().nullable().optional(), + front_wheel_travel: z.string().nullable().optional(), + rear_suspension: z.string().nullable().optional(), + rear_wheel_travel: z.string().nullable().optional(), + front_tire: z.string().nullable().optional(), + rear_tire: z.string().nullable().optional(), + front_brakes: z.string().nullable().optional(), + rear_brakes: z.string().nullable().optional(), + seat_height: z.string().nullable().optional(), + ground_clearance: z.string().nullable().optional(), + wheelbase: z.string().nullable().optional(), + fuel_capacity: z.string().nullable().optional(), + starter: z.string().nullable().optional(), + power: z.string().nullable().optional(), + torque: z.string().nullable().optional(), + top_speed: z.string().nullable().optional(), + fuel_consumption: z.string().nullable().optional(), + emission: z.string().nullable().optional(), + total_weight: z.string().nullable().optional(), + total_height: z.string().nullable().optional(), + total_length: z.string().nullable().optional(), + total_width: z.string().nullable().optional(), + ignition: z.string().nullable().optional(), + dry_weight: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Get electric vehicle data from given parameters. Returns a list of electric vehicles that satisfy the parameters. + * + * GET v1/electricvehicle + */ +const TransportElectricVehiclesInputSchema = z.object({ + /** Vehicle manufacturer (e.g. tesla or nissan). */ + make: z.string().optional(), + /** Vehicle model. Supports partial matching (e.g. Model matches Model 3, Model Y, etc.). */ + model: z.string().optional(), + /** Minimum vehicle model year (e.g. 2020). */ + min_year: z.number().optional(), + /** Maximum vehicle model year (e.g. 2023). */ + max_year: z.number().optional(), + /** Minimum range in kilometers (e.g. 250). */ + min_range: z.number().optional(), + /** Maximum range in kilometers (e.g. 400). */ + max_range: z.number().optional(), + /** How many results to return. Must be between 1 and 10. Default is 1. [premium] */ + limit: z.number().optional(), + /** Number of results to skip. Used for pagination. Default is 0. [premium] */ + offset: z.number().optional(), +}); + +const TransportElectricVehiclesOutputSchema = z.array( + z + .object({ + make: z.string().nullable().optional(), + model: z.string().nullable().optional(), + year_start: z.string().nullable().optional(), + battery_capacity: z.string().nullable().optional(), + battery_type: z.string().nullable().optional(), + battery_number_of_cells: z.string().nullable().optional(), + battery_architecture: z.string().nullable().optional(), + battery_useable_capacity: z.string().nullable().optional(), + battery_cathode_material: z.string().nullable().optional(), + battery_pack_configuration: z.string().nullable().optional(), + battery_voltage: z.string().nullable().optional(), + battery_form_factor: z.string().nullable().optional(), + battery_name: z.string().nullable().optional(), + charge_port: z.string().nullable().optional(), + charge_port_location: z.string().nullable().optional(), + charge_power: z.string().nullable().optional(), + charge_speed: z.string().nullable().optional(), + charge_power_max: z.string().nullable().optional(), + charge_power_10p_80p: z.string().nullable().optional(), + autocharge_supported: z.string().nullable().optional(), + plug_charge_supported: z.string().nullable().optional(), + supported_charging_protocol: z.string().nullable().optional(), + preconditioning_possible: z.string().nullable().optional(), + acceleration_0_100_kmh: z.string().nullable().optional(), + top_speed: z.string().nullable().optional(), + electric_range: z.union([z.number(), z.string()]).nullable().optional(), + total_power: z.string().nullable().optional(), + total_torque: z.string().nullable().optional(), + drive: z.string().nullable().optional(), + vehicle_consumption: z.string().nullable().optional(), + co2_emissions: z.string().nullable().optional(), + vehicle_fuel_equivalent: z.string().nullable().optional(), + rated_consumption: z.string().nullable().optional(), + rated_fuel_equivalent: z.string().nullable().optional(), + length: z.string().nullable().optional(), + width: z.string().nullable().optional(), + width_with_mirrors: z.string().nullable().optional(), + height: z.string().nullable().optional(), + wheelbase: z.string().nullable().optional(), + gross_vehicle_weight: z.string().nullable().optional(), + max_payload: z.string().nullable().optional(), + cargo_volume: z.string().nullable().optional(), + cargo_volume_frunk: z.string().nullable().optional(), + seats: z.string().nullable().optional(), + turning_circle: z.string().nullable().optional(), + platform: z.string().nullable().optional(), + car_body: z.string().nullable().optional(), + segment: z.string().nullable().optional(), + /** Exterior dimensions in millimeters. */ + 'length, width, height': z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns key vehicle information including manufacturer, country of origin, and model year for a given VIN. + * + * GET v1/vinlookup + */ +const TransportVinInputSchema = z.object({ + /** Valid VIN to check. Must be a 17-character string. */ + vin: z.string(), +}); + +const TransportVinOutputSchema = z + .object({ + vin: z.string().nullable().optional(), + country: z.string().nullable().optional(), + manufacturer: z.string().nullable().optional(), + model: z.string().nullable().optional(), + class: z.string().nullable().optional(), + region: z.string().nullable().optional(), + wmi: z.string().nullable().optional(), + vds: z.string().nullable().optional(), + vis: z.string().nullable().optional(), + year: z.number().nullable().optional(), + }) + .loose(); + +/* -------------------------------------------------------------------------- */ +/* health */ +/* -------------------------------------------------------------------------- */ + +/** + * Returns the calories burned per hour and total calories burned according to given parameters for given activities (up to 10). + * + * GET v1/caloriesburned + */ +const HealthCaloriesBurnedInputSchema = z.object({ + /** Name of the given activity. This value can be partial (e.g. ski will match water skiing and downhill skiing). */ + activity: z.string(), + /** Weight of the user performing the activity in pounds. Must be between 50 and 500. Default value is 160. */ + weight: z.number().optional(), + /** How long the activity was performed in minutes. Must be 1 or greater. Default value is 60 (1 hour). */ + duration: z.number().optional(), +}); + +const HealthCaloriesBurnedOutputSchema = z.array( + z + .object({ + name: z.string().nullable().optional(), + calories_per_hour: z.number().nullable().optional(), + duration_minutes: z.number().nullable().optional(), + total_calories: z.number().nullable().optional(), + }) + .loose(), +); + +/** + * This endpoint uses AI to automatically read any text and extract every food item it contains, along with the right portion for each. It can process multiple food items at once - simply copy and paste any text, such as a recipe or your food journal, directly, and it will return the nutrition data for every food item found. Items without a specified amount default to a 100g serving. + * + * GET v1/nutrition + */ +const HealthNutritionInputSchema = z.object({ + /** Query text to extract nutrition information. If your query contains commas, make sure they are URL encoded properly (e.g., %2C) - otherwise the server will fail to receive the correct input. */ + query: z.string(), +}); + +/** Declared from the documentation: this endpoint is premium-gated, so no free-tier response could be captured. */ +const HealthNutritionOutputSchema = z.array( + z + .object({ + /** Nutritional energy in calories. */ + calories: z.union([z.number(), z.string()]).nullable().optional(), + /** Serving size in grams. */ + serving_size_g: z.union([z.number(), z.string()]).nullable().optional(), + /** Total combined fat (including saturated and trans fats) in grams. */ + fat_total_g: z.union([z.number(), z.string()]).nullable().optional(), + /** Saturated fat in grams. */ + fat_saturated_g: z.union([z.number(), z.string()]).nullable().optional(), + /** Trans fat in grams. */ + fat_trans_g: z.union([z.number(), z.string()]).nullable().optional(), + /** Protein in grams. */ + protein_g: z.union([z.number(), z.string()]).nullable().optional(), + /** Sodium in milligrams. */ + sodium_mg: z.union([z.number(), z.string()]).nullable().optional(), + /** Potassium in milligrams. */ + potassium_mg: z.union([z.number(), z.string()]).nullable().optional(), + /** Cholesterol in milligrams. */ + cholesterol_mg: z.union([z.number(), z.string()]).nullable().optional(), + /** Total carbohydrates (including fiber and sugar) in grams. */ + carbohydrates_total_g: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + /** Fiber in grams. */ + fiber_g: z.union([z.number(), z.string()]).nullable().optional(), + /** Sugar in grams. */ + sugar_g: z.union([z.number(), z.string()]).nullable().optional(), + /** Added sugars in grams. */ + added_sugars_g: z.union([z.number(), z.string()]).nullable().optional(), + /** Net carbohydrates (total carbohydrates minus fiber) in grams. */ + net_carbs_g: z.union([z.number(), z.string()]).nullable().optional(), + /** Iron in milligrams. */ + iron_mg: z.union([z.number(), z.string()]).nullable().optional(), + /** Calcium in milligrams. */ + calcium_mg: z.union([z.number(), z.string()]).nullable().optional(), + /** Magnesium in milligrams. */ + magnesium_mg: z.union([z.number(), z.string()]).nullable().optional(), + /** Zinc in milligrams. */ + zinc_mg: z.union([z.number(), z.string()]).nullable().optional(), + /** Vitamin A in micrograms (RAE). */ + vitamin_a_mcg: z.string().nullable().optional(), + /** Vitamin C in milligrams. */ + vitamin_c_mg: z.union([z.number(), z.string()]).nullable().optional(), + /** Vitamin D in micrograms. */ + vitamin_d_mcg: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns up to 5 exercises that satisfy the given parameters. + * + * GET v1/exercises + */ +/** + * Show Available Values + * + * Documented as a parameter combination, so every field is optional here and + * the provider validates the combination. + */ +const HealthExercisesInputSchema = z.object({ + /** Name of exercise. This value can be partial (e.g. press will match Dumbbell Bench Press). */ + name: z.string().optional(), + /** Exercise type. Possible values are: cardio, olympic_weightlifting, plyometrics, powerlifting, strength, stretching, strongman. */ + type: z.string().optional(), + /** Muscle group targeted by the exercise. Possible values are: */ + muscle: z.string().optional(), + /** Difficulty level of the exercise. Possible values are: beginner, intermediate, expert. */ + difficulty: z.string().optional(), + /** Equipment required for the exercise. Multiple equipments can be specified using comma separation (e.g. dumbbell,flat bench). This value can be partial (e.g. dumbbell will match exercises using dumbbells). */ + equipments: z.string().optional(), + /** Number of results to offset for pagination. Default is 0. [premium] */ + offset: z.number().optional(), +}); + +const HealthExercisesOutputSchema = z.array( + z + .object({ + name: z.string().nullable().optional(), + type: z.string().nullable().optional(), + muscle: z.string().nullable().optional(), + difficulty: z.string().nullable().optional(), + instructions: z.string().nullable().optional(), + equipments: z.array(z.string()).nullable().optional(), + safety_info: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Get a list of recipes for a given recipe name or ingredient(s). Returns a list of recipes. To access more results, use the limit parameter to limit the number of results and the offset parameter to offset results for pagination in multiple API calls. + * + * GET v3/recipe + */ +const HealthRecipesInputSchema = z.object({ + /** Recipe title to search for. */ + title: z.string().optional(), + /** Comma-separated list of ingredients to search for. */ + ingredients: z.string().optional(), + /** Number of results to return. Must be between 1 and 10. If not set, a default value of 1 will be used. */ + limit: z.number().optional(), + /** Number of results to offset for pagination. */ + offset: z.number().optional(), +}); + +const HealthRecipesOutputSchema = z.array( + z + .object({ + title: z.string().nullable().optional(), + ingredients: z + .array( + z + .object({ + name: z.string().nullable().optional(), + quantity: z.number().nullable().optional(), + unit: z.string().nullable().optional(), + }) + .loose(), + ) + .nullable() + .optional(), + servings: z.string().nullable().optional(), + instructions: z.array(z.string()).nullable().optional(), + nutrition: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns up to 10 cocktail recipes matching the search parameters. + * + * GET v1/cocktail + */ +const HealthCocktailsInputSchema = z.object({ + /** Name of cocktail. This parameter supports partial matches (e.g. bloody will match bloody mary and bloody margarita). */ + name: z.string().optional(), + /** Comma-separated string of ingredients to search. Only cocktails containing all listed ingredients will be returned. For example, to search cocktails containing Vodka and lemon juice, use vodka,lemon juice. */ + ingredients: z.string().optional(), +}); + +const HealthCocktailsOutputSchema = z.array( + z + .object({ + ingredients: z.array(z.string()).nullable().optional(), + instructions: z.string().nullable().optional(), + name: z.string().nullable().optional(), + }) + .loose(), +); + +/* -------------------------------------------------------------------------- */ +/* reference */ +/* -------------------------------------------------------------------------- */ + +/** + * Returns up to 10 results matching the input name parameter. + * + * GET v1/animals + */ +const ReferenceAnimalsInputSchema = z.object({ + /** Common name of animal to search. This parameter supports partial matches (e.g. fox will match gray fox and red fox). */ + name: z.string(), +}); + +const ReferenceAnimalsOutputSchema = z.array( + z + .object({ + name: z.string().nullable().optional(), + taxonomy: z + .object({ + kingdom: z.string().nullable().optional(), + phylum: z.string().nullable().optional(), + class: z.string().nullable().optional(), + order: z.string().nullable().optional(), + family: z.string().nullable().optional(), + genus: z.string().nullable().optional(), + scientific_name: z.string().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + locations: z.array(z.string()).nullable().optional(), + characteristics: z + .object({ + prey: z.string().nullable().optional(), + name_of_young: z.string().nullable().optional(), + group_behavior: z.string().nullable().optional(), + estimated_population_size: z.string().nullable().optional(), + biggest_threat: z.string().nullable().optional(), + most_distinctive_feature: z.string().nullable().optional(), + gestation_period: z.string().nullable().optional(), + habitat: z.string().nullable().optional(), + diet: z.string().nullable().optional(), + average_litter_size: z.string().nullable().optional(), + lifestyle: z.string().nullable().optional(), + common_name: z.string().nullable().optional(), + number_of_species: z.string().nullable().optional(), + location: z.string().nullable().optional(), + slogan: z.string().nullable().optional(), + group: z.string().nullable().optional(), + color: z.string().nullable().optional(), + skin_type: z.string().nullable().optional(), + top_speed: z.string().nullable().optional(), + lifespan: z.string().nullable().optional(), + weight: z.string().nullable().optional(), + height: z.string().nullable().optional(), + age_of_sexual_maturity: z.string().nullable().optional(), + age_of_weaning: z.string().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + }) + .loose(), +); + +/** + * Get a list of cat breeds matching specified parameters. Returns at most 20 results. To access more than 20 results, use the offset parameter to offset results in multiple API calls. + * + * GET v1/cats + */ +const ReferenceCatsInputSchema = z.object({ + /** The name of cat breed. */ + name: z.string().optional(), + /** Minimum weight in pounds. */ + min_weight: z.number().optional(), + /** Maximum weight in pounds. */ + max_weight: z.number().optional(), + /** Minimum life expectancy in years. */ + min_life_expectancy: z.number().optional(), + /** Maximum life expectancy in years. */ + max_life_expectancy: z.number().optional(), + /** How much hair the cat sheds. Possible values: 1, 2, 3, 4, 5, where 1 indicates no shedding and 5 indicates maximum shedding. */ + shedding: z.string().optional(), + /** How affectionate the cat is to family. Possible values: 1, 2, 3, 4, 5, where 1 indicates minimal affection and 5 indicates maximum affection. */ + family_friendly: z.string().optional(), + /** How playful the cat is. Possible values: 1, 2, 3, 4, 5, where 1 indicates serious and stern and 5 indicates maximum playfulness. */ + playfulness: z.string().optional(), + /** How much work is required to properly groom the cat. Possible values: 1, 2, 3, 4, 5, where 1 indicates maximum grooming effort and 5 indicates minimum grooming effort. */ + grooming: z.string().optional(), + /** How well the cat gets along with other pets in the household (for example, dogs). Possible values: 1, 2, 3, 4, 5, where 1 indicates the cat isn't very friendly to other pets and 5 indicates the cat gets along very well with other pets. */ + other_pets_friendly: z.string().optional(), + /** How well the cat gets along with children. Possible values: 1, 2, 3, 4, 5, where 1 indicates the cat does not get along well with kids and 5 indicates the cat is very kid-friendly. */ + children_friendly: z.string().optional(), + /** Number of results to offset for pagination. */ + offset: z.number().optional(), +}); + +const ReferenceCatsOutputSchema = z.array( + z + .object({ + length: z.string().nullable().optional(), + origin: z.string().nullable().optional(), + image_link: z.string().nullable().optional(), + family_friendly: z.number().nullable().optional(), + shedding: z.number().nullable().optional(), + general_health: z.number().nullable().optional(), + playfulness: z.number().nullable().optional(), + meowing: z.number().nullable().optional(), + children_friendly: z.number().nullable().optional(), + stranger_friendly: z.number().nullable().optional(), + grooming: z.number().nullable().optional(), + intelligence: z.number().nullable().optional(), + other_pets_friendly: z.number().nullable().optional(), + min_weight: z.number().nullable().optional(), + max_weight: z.number().nullable().optional(), + min_life_expectancy: z.number().nullable().optional(), + max_life_expectancy: z.number().nullable().optional(), + name: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Get a list of dog breeds matching specified parameters. Returns at most 20 results. To access more than 20 results, use the offset parameter to offset results in multiple API calls. + * + * GET v1/dogs + */ +const ReferenceDogsInputSchema = z.object({ + /** The name of breed. */ + name: z.string().optional(), + /** Minimum height in inches. */ + min_height: z.number().optional(), + /** Maximum height in inches. */ + max_height: z.number().optional(), + /** Minimum weight in pounds. */ + min_weight: z.number().optional(), + /** Maximum weight in pounds. */ + max_weight: z.number().optional(), + /** Minimum life expectancy in years. */ + min_life_expectancy: z.number().optional(), + /** Maximum life expectancy in years. */ + max_life_expectancy: z.number().optional(), + /** How much hair the breed sheds. Possible values: 1, 2, 3, 4, 5, where 1 indicates no shedding and 5 indicates maximum shedding. */ + shedding: z.string().optional(), + /** How vocal the breed is. Possible values: 1, 2, 3, 4, 5, where 1 indicates minimal barking and 5 indicates maximum barking. */ + barking: z.string().optional(), + /** How much energy the breed has. Possible values: 1, 2, 3, 4, 5, where 1 indicates low energy and 5 indicates high energy. */ + energy: z.string().optional(), + /** How likely the breed is to alert strangers. Possible values: 1, 2, 3, 4, 5, where 1 indicates minimal alerting and 5 indicates maximum alerting. */ + protectiveness: z.string().optional(), + /** How easy it is to train the breed. Possible values: 1, 2, 3, 4, 5, where 1 indicates the breed is very difficult to train and 5 indicates the breed is very easy to train. */ + trainability: z.string().optional(), + /** Number of results to offset for pagination. */ + offset: z.number().optional(), +}); + +const ReferenceDogsOutputSchema = z.array( + z + .object({ + image_link: z.string().nullable().optional(), + good_with_children: z.number().nullable().optional(), + good_with_other_dogs: z.number().nullable().optional(), + shedding: z.number().nullable().optional(), + grooming: z.number().nullable().optional(), + drooling: z.number().nullable().optional(), + coat_length: z.number().nullable().optional(), + good_with_strangers: z.number().nullable().optional(), + playfulness: z.number().nullable().optional(), + protectiveness: z.number().nullable().optional(), + trainability: z.number().nullable().optional(), + energy: z.number().nullable().optional(), + barking: z.number().nullable().optional(), + min_life_expectancy: z.number().nullable().optional(), + max_life_expectancy: z.number().nullable().optional(), + max_height_male: z.number().nullable().optional(), + max_height_female: z.number().nullable().optional(), + max_weight_male: z.number().nullable().optional(), + max_weight_female: z.number().nullable().optional(), + min_height_male: z.number().nullable().optional(), + min_height_female: z.number().nullable().optional(), + min_weight_male: z.number().nullable().optional(), + min_weight_female: z.number().nullable().optional(), + name: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Get a list of planets matching specified parameters. Returns at most 30 results. To access more than 30 results, use the offset parameter to offset results in multiple API calls. + * + * GET v1/planets + */ +const ReferencePlanetsInputSchema = z.object({ + /** The name of the planet. */ + name: z.string().optional(), + /** Minimum mass of the planet in Jupiters (1 Jupiter = 1.898 1027 kg). */ + min_mass: z.number().optional(), + /** Maximum mass of the planet in Jupiters (1 Jupiter = 1.898 1027 kg). */ + max_mass: z.number().optional(), + /** Minimum average radius of the planet in Jupiters (1 Jupiter = 69911 km). */ + min_radius: z.number().optional(), + /** Maximum average radius of the planet in Jupiters (1 Jupiter = 69911 km). */ + max_radius: z.number().optional(), + /** Minimum orbital period of the planet in Earth days. */ + min_period: z.number().optional(), + /** Maximum orbital period of the planet in Earth days. */ + max_period: z.number().optional(), + /** Minimum average surface temperature of the planet in Kelvin. */ + min_temperature: z.number().optional(), + /** Maximum average surface temperature of the planet in Kelvin. */ + max_temperature: z.number().optional(), + /** Minimum distance the planet is from Earth in light years. */ + min_distance_light_year: z.number().optional(), + /** Maximum distance the planet is from Earth in light years. */ + max_distance_light_year: z.number().optional(), + /** Minimum semi major axis of planet in astronomical units (AU). */ + min_semi_major_axis: z.number().optional(), + /** Maximum semi major axis of planet in astronomical units (AU). */ + max_semi_major_axis: z.number().optional(), + /** Number of results to offset for pagination. */ + offset: z.number().optional(), +}); + +const ReferencePlanetsOutputSchema = z.array( + z + .object({ + name: z.string().nullable().optional(), + mass: z.number().nullable().optional(), + radius: z.number().nullable().optional(), + period: z.number().nullable().optional(), + semi_major_axis: z.number().nullable().optional(), + temperature: z.number().nullable().optional(), + distance_light_year: z.number().nullable().optional(), + host_star_mass: z.number().nullable().optional(), + host_star_temperature: z.number().nullable().optional(), + }) + .loose(), +); + +/** + * Get a list of stars matching specified parameters. Returns at most 30 results. To access more than 30 results, use the offset parameter to offset results in multiple API calls. + * + * GET v1/stars + */ +const ReferenceStarsInputSchema = z.object({ + /** The name of the star. Note that many of the star names contain Greek characters. */ + name: z.string().optional(), + /** The constellation that the star belongs to. */ + constellation: z.string().optional(), + /** Minimum apparent magnitude brightness of the star. */ + min_apparent_magnitude: z.number().optional(), + /** Maximum apparent magnitude brightness of the star. */ + max_apparent_magnitude: z.number().optional(), + /** Minimum absolute magnitude brightness of the star. */ + min_absolute_magnitude: z.number().optional(), + /** Maximum absolute magnitude brightness of the star. */ + max_absolute_magnitude: z.number().optional(), + /** Minimum distance the star is from Earth in light years. */ + min_distance_light_year: z.number().optional(), + /** Maximum distance the star is from Earth in light years. */ + max_distance_light_year: z.number().optional(), + /** Number of results to offset for pagination. */ + offset: z.number().optional(), +}); + +const ReferenceStarsOutputSchema = z.array( + z + .object({ + name: z.string().nullable().optional(), + constellation: z.string().nullable().optional(), + right_ascension: z.string().nullable().optional(), + declination: z.string().nullable().optional(), + apparent_magnitude: z.string().nullable().optional(), + absolute_magnitude: z.string().nullable().optional(), + distance_light_year: z.string().nullable().optional(), + spectral_class: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns a list of up to 10 events that match the search parameters. Use the offset parameter to paginate through more results. + * + * GET v1/historicalevents + */ +const ReferenceHistoricalEventsInputSchema = z.object({ + /** Query text to search events by. Use keywords or short phrases for best match results. */ + text: z.string().optional(), + /** 4-digit year (e.g. 1776). For BC/BCE years, use a negative integer (e.g. -351 for 351 BC). */ + year: z.number().optional(), + /** Integer month (e.g. 3 for March). */ + month: z.number().optional(), + /** Calendar day of the month. */ + day: z.number().optional(), + /** Number of results to offset pagination. [premium] */ + offset: z.number().optional(), +}); + +const ReferenceHistoricalEventsOutputSchema = z.array( + z + .object({ + year: z.string().nullable().optional(), + month: z.string().nullable().optional(), + day: z.string().nullable().optional(), + event: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns a list of up to 10 people that match the search parameters. + * + * GET v1/historicalfigures + */ +const ReferenceHistoricalFiguresInputSchema = z.object({ + /** Name of the person to search. Includes partial results (e.g. julius will match Julius Caesar). */ + name: z.string(), + /** Number of results to offset pagination. */ + offset: z.number().optional(), +}); + +const ReferenceHistoricalFiguresOutputSchema = z.array( + z + .object({ + name: z.string().nullable().optional(), + title: z.string().nullable().optional(), + info: z + .object({ + born: z.string().nullable().optional(), + died: z.string().nullable().optional(), + rank: z + .union([z.array(z.unknown()), z.string()]) + .nullable() + .optional(), + unit: z.array(z.string()).nullable().optional(), + house: z.string().nullable().optional(), + issue: z.string().nullable().optional(), + reign: z.string().nullable().optional(), + burial: z.string().nullable().optional(), + father: z.string().nullable().optional(), + mother: z.string().nullable().optional(), + spouse: z + .union([z.array(z.unknown()), z.string()]) + .nullable() + .optional(), + religion: z.string().nullable().optional(), + successor: z.string().nullable().optional(), + allegiance: z.string().nullable().optional(), + preceded_by: z.string().nullable().optional(), + predecessor: z.string().nullable().optional(), + 'battles/wars': z.array(z.string()).nullable().optional(), + succeeded_by: z.string().nullable().optional(), + prime_minister: z.string().nullable().optional(), + 'service/branch': z + .union([z.array(z.unknown()), z.string()]) + .nullable() + .optional(), + vice_president: z.string().nullable().optional(), + years_of_service: z.string().nullable().optional(), + in_office: z.string().nullable().optional(), + coronation: z.string().nullable().optional(), + issuedetail: z.string().nullable().optional(), + regent: z.string().nullable().optional(), + genre: z.string().nullable().optional(), + period: z.string().nullable().optional(), + children: z.string().nullable().optional(), + occupation: z.string().nullable().optional(), + citizenship: z.string().nullable().optional(), + notable_works: z.string().nullable().optional(), + commands_held: z.string().nullable().optional(), + genres: z.string().nullable().optional(), + labels: z.string().nullable().optional(), + birth_name: z.string().nullable().optional(), + instruments: z.string().nullable().optional(), + years_active: z.string().nullable().optional(), + also_known_as: z.string().nullable().optional(), + }) + .loose() + .nullable() + .optional(), + }) + .loose(), +); + +/** + * Returns historical events that occurred on a specific date. If no date parameters are provided, returns events for today's date. + * + * GET v1/dayinhistory + */ +const ReferenceDayInHistoryInputSchema = z.object({ + /** The month of the historical events to retrieve. Must be between 1 and 12. If specified, day must also be provided. If both are omitted, today's date is used. [premium] */ + month: z.number().optional(), + /** The day of the month for the historical events to retrieve. Must be between 1 and 31. If specified, month must also be provided. If both are omitted, today's date is used. [premium] */ + day: z.number().optional(), + /** The number of results to skip. Must be zero or a positive integer. Default is 0. [premium] */ + offset: z.number().optional(), + /** The maximum number of results to return. Must be between 1 and 30. Default is 1. [premium] */ + limit: z.number().optional(), +}); + +const ReferenceDayInHistoryOutputSchema = z.array( + z + .object({ + year: z.number().nullable().optional(), + month: z.number().nullable().optional(), + day: z.number().nullable().optional(), + event: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns a list of up to 30 celebrities that match the search parameters. To get more than 30 results, use the offset parameter. + * + * GET v1/celebrity + */ +const ReferenceCelebritiesInputSchema = z.object({ + /** Name of the celebrity you wish to search. This field is case-insensitive. */ + name: z.string().optional(), + /** Minimum net worth of celebrities. */ + min_net_worth: z.number().optional(), + /** Maximum net worth of celebrities. */ + max_net_worth: z.number().optional(), + /** Nationality of celebrities. Must be an ISO 3166 Alpha-2 country code (e.g. US). */ + nationality: z.string().optional(), + /** Minimum height of celebrities in meters (e.g. 1.65). */ + min_height: z.number().optional(), + /** Maximum height of celebrities in meters (e.g. 1.80). */ + max_height: z.number().optional(), + /** Number of results to offset for pagination. [premium] */ + offset: z.number().optional(), +}); + +const ReferenceCelebritiesOutputSchema = z.array( + z + .object({ + name: z.string().nullable().optional(), + net_worth: z.number().nullable().optional(), + gender: z.string().nullable().optional(), + nationality: z.string().nullable().optional(), + occupation: z.array(z.string()).nullable().optional(), + height: z.number().nullable().optional(), + birthday: z.string().nullable().optional(), + age: z.number().nullable().optional(), + is_alive: z.boolean().nullable().optional(), + }) + .loose(), +); + +/** + * Returns 10 baby name results. + * + * GET v1/babynames + */ +const ReferenceBabyNamesInputSchema = z.object({ + /** Baby name gender. Must be one of the following: boy, girl, neutral */ + gender: z.string().optional(), + /** Whether to only return popular (top 10%) of names. Must be either true or false. If unset, default is true. */ + popular_only: z.boolean().optional(), +}); + +const ReferenceBabyNamesOutputSchema = z.array(z.string()); + +/* -------------------------------------------------------------------------- */ +/* entertainment */ +/* -------------------------------------------------------------------------- */ + +/** + * Returns one (or more) random funny jokes. Free users have access to 100 jokes - premium users have access to over 20,000 jokes. + * + * GET v1/jokes + */ +const EntertainmentJokesInputSchema = z.object({ + /** How many jokes to return. Must be between 1 and 100. Default is 1. [premium] */ + limit: z.number().optional(), +}); + +const EntertainmentJokesOutputSchema = z.array( + z + .object({ + joke: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns one (or more) random dad jokes. Free users have access to 100 jokes - premium users have access to over 15,000 dad jokes. + * + * GET v1/dadjokes + */ +const EntertainmentDadJokesInputSchema = z.object({ + /** How many jokes to return. Must be between 1 and 100. Default is 1. [premium] */ + limit: z.number().optional(), +}); + +const EntertainmentDadJokesOutputSchema = z.array( + z + .object({ + joke: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns a Chuck Norris joke. + * + * GET v1/chucknorris + */ +const EntertainmentChuckNorrisInputSchema = z.object({}); + +const EntertainmentChuckNorrisOutputSchema = z + .object({ + joke: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns a single joke for the current day. The same joke is returned for all requests on the same day, and changes each day. Perfect for displaying on your website or app. No parameters are available for this endpoint to ensure everyone sees the same joke of the day. + * + * GET v1/jokeoftheday + */ +const EntertainmentJokeOfTheDayInputSchema = z.object({}); + +const EntertainmentJokeOfTheDayOutputSchema = z.array( + z + .object({ + joke: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns one (or more) random facts. Free users have access to 100 facts - premium users have access to over 500,000 facts. + * + * GET v1/facts + */ +const EntertainmentFactsInputSchema = z.object({ + /** How many results to return. Must be between 1 and 100. Default is 1. [premium] */ + limit: z.number().optional(), +}); + +const EntertainmentFactsOutputSchema = z.array( + z + .object({ + fact: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns a single fact for the current day. The same fact is returned for all requests on the same day, and changes each day. Perfect for displaying on your website or app. No parameters are available for this endpoint to ensure everyone sees the same fact of the day. + * + * GET v1/factoftheday + */ +const EntertainmentFactOfTheDayInputSchema = z.object({}); + +const EntertainmentFactOfTheDayOutputSchema = z.array( + z + .object({ + fact: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns high-quality quotes with advanced filtering by categories (include/exclude), author, work, and pagination support. Returns quotes in deterministic order. For random quotes, use /v2/randomquotes or /v2/quoteoftheday. + * + * GET v2/quotes + */ +const EntertainmentQuotesInputSchema = z.object({ + /** Comma-separated list of categories to include in results (results will match all of the categories). Example: categories=wisdom,success */ + categories: z.string().optional(), + /** Comma-separated list of categories to exclude from results (results will not match any of the categories). Example: exclude_categories=love,philosophy */ + exclude_categories: z.string().optional(), + /** Filter quotes by author name (partial match supported). Example: author=Einstein */ + author: z.string().optional(), + /** Filter quotes by work title (partial match supported). Example: work=War */ + work: z.string().optional(), + /** Number of results to return. Must be between 1 and 100. Default is 1. [premium] */ + limit: z.number().optional(), + /** Number of results to skip for pagination. Default is 0. [premium] */ + offset: z.number().optional(), +}); + +const EntertainmentQuotesOutputSchema = z.array( + z + .object({ + quote: z.string().nullable().optional(), + author: z.string().nullable().optional(), + work: z.string().nullable().optional(), + categories: z.array(z.string()).nullable().optional(), + }) + .loose(), +); + +/** + * Returns random high-quality quotes with advanced filtering by categories (include/exclude), author, and work. Each request returns different random quotes. + * + * GET v2/randomquotes + */ +const EntertainmentRandomQuotesInputSchema = z.object({ + /** Comma-separated list of categories to include in results (results will match all of the categories). Example: categories=wisdom,success */ + categories: z.string().optional(), + /** Comma-separated list of categories to exclude from results (results will not match any of the categories). Example: exclude_categories=love,philosophy */ + exclude_categories: z.string().optional(), + /** Filter quotes by author name (partial match supported). Example: author=Einstein */ + author: z.string().optional(), + /** Filter quotes by work title (partial match supported). Example: work=War */ + work: z.string().optional(), + /** Number of random results to return. Must be between 1 and 100. Default is 1. [premium] */ + limit: z.number().optional(), +}); + +const EntertainmentRandomQuotesOutputSchema = z.array( + z + .object({ + quote: z.string().nullable().optional(), + author: z.string().nullable().optional(), + work: z.string().nullable().optional(), + categories: z.array(z.string()).nullable().optional(), + }) + .loose(), +); + +/** + * Returns a single aphoristic quote for the current day. The same pre-vetted, high-quality quote is returned for all requests on the same day, and changes each day. Perfect for displaying on your website or app. No filtering parameters are available for this endpoint to ensure everyone sees the same quote of the day. + * + * GET v2/quoteoftheday + */ +const EntertainmentQuoteOfTheDayInputSchema = z.object({}); + +const EntertainmentQuoteOfTheDayOutputSchema = z.array( + z + .object({ + quote: z.string().nullable().optional(), + author: z.string().nullable().optional(), + work: z.string().nullable().optional(), + categories: z.array(z.string()).nullable().optional(), + }) + .loose(), +); + +/** + * Returns a random piece of life advice. + * + * GET v1/advice + */ +const EntertainmentAdviceInputSchema = z.object({}); + +const EntertainmentAdviceOutputSchema = z + .object({ + advice: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns a random bucket list idea. + * + * GET v1/bucketlist + */ +const EntertainmentBucketListInputSchema = z.object({}); + +const EntertainmentBucketListOutputSchema = z + .object({ + item: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns a random hobby and a Wikipedia link detailing the hobby. + * + * GET v1/hobbies + */ +const EntertainmentHobbiesInputSchema = z.object({ + /** Possible values are: general, sports_and_outdoors, education, collection, competition, observation. */ + category: z.string().optional(), +}); + +const EntertainmentHobbiesOutputSchema = z + .object({ + hobby: z.string().nullable().optional(), + link: z.string().nullable().optional(), + category: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns the daily horoscope for a specific zodiac sign. Optionally, you can provide a date parameter to get historical horoscopes. + * + * GET v1/horoscope + */ +const EntertainmentHoroscopeInputSchema = z.object({ + /** The zodiac sign to get a horoscope for. Valid values are: aries, taurus, gemini, cancer, leo, virgo, libra, scorpio, sagittarius, capricorn, aquarius, pisces. */ + zodiac: z.string(), + /** The date for the horoscope in YYYY-MM-DD format. The date must be either current or in the past. It cannot be in the future. If not provided, returns the horoscope for today's date. [premium] */ + date: z.string().optional(), +}); + +const EntertainmentHoroscopeOutputSchema = z + .object({ + date: z.string().nullable().optional(), + sign: z.string().nullable().optional(), + horoscope: z.string().nullable().optional(), + }) + .loose(); + +/** + * Returns one or more random riddles. + * + * GET v1/riddles + */ +const EntertainmentRiddlesInputSchema = z.object({ + /** Number of results to return. Must be between 1 and 20. Default is 1. [premium] */ + limit: z.number().optional(), +}); + +const EntertainmentRiddlesOutputSchema = z.array( + z + .object({ + title: z.string().nullable().optional(), + question: z.string().nullable().optional(), + answer: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns a random trivia question and answer. Free users have access to 100 trivia questions - premium users have access to over 100,000 trivia questions. + * + * GET v1/trivia + */ +const EntertainmentTriviaInputSchema = z.object({ + /** Category of trivia. The possible values are: [premium] */ + category: z.string().optional(), + /** How many results to return. Must be between 1 and 30. Default is 1. [premium] */ + limit: z.number().optional(), +}); + +const EntertainmentTriviaOutputSchema = z.array( + z + .object({ + category: z.string().nullable().optional(), + question: z.string().nullable().optional(), + answer: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Returns a single trivia question and answer for the current day. The same question is returned for all requests on the same day, and changes each day. Perfect for displaying on your website or app. No filtering parameters are available for this endpoint to ensure everyone sees the same trivia of the day. + * + * GET v1/triviaoftheday + */ +const EntertainmentTriviaOfTheDayInputSchema = z.object({}); + +const EntertainmentTriviaOfTheDayOutputSchema = z.array( + z + .object({ + category: z.string().nullable().optional(), + question: z.string().nullable().optional(), + answer: z.string().nullable().optional(), + }) + .loose(), +); + +/** + * Generate a new Sudoku puzzle with specified parameters. + * + * GET v1/sudokugenerate + */ +const EntertainmentGenerateSudokuInputSchema = z.object({ + /** Width of each box in the Sudoku grid. Default is 3. Must be between 2 and 4. */ + width: z.number().optional(), + /** Height of each box in the Sudoku grid. Default is 3. Must be between 2 and 4. */ + height: z.number().optional(), + /** Difficulty level of the puzzle. Possible values: easy, medium, hard. Default is medium. */ + difficulty: z.string().optional(), + /** Seed value for reproducible puzzle generation. */ + seed: z.string().optional(), +}); + +const EntertainmentGenerateSudokuOutputSchema = z + .object({ + puzzle: z.array(z.array(z.number().nullable())).nullable().optional(), + solution: z.array(z.array(z.number().nullable())).nullable().optional(), + }) + .loose(); + +/** + * Solve an existing Sudoku puzzle. + * + * GET v1/sudokusolve + */ +const EntertainmentSolveSudokuInputSchema = z.object({ + /** 2D JSON array representing the Sudoku puzzle. Use 0 for empty cells. */ + puzzle: z.array(z.array(z.number())), + /** Width of each box in the Sudoku grid. Must be between 2 and 4. */ + width: z.number(), + /** Height of each box in the Sudoku grid. Must be between 2 and 4. */ + height: z.number(), +}); + +const EntertainmentSolveSudokuOutputSchema = z + .object({ + status: z.string().nullable().optional(), + solution: z.array(z.array(z.number().nullable())).nullable().optional(), + }) + .loose(); + +/* -------------------------------------------------------------------------- */ +/* registry */ +/* -------------------------------------------------------------------------- */ + +export const ApiNinjasEndpointInputSchemas = { + locationGeocode: LocationGeocodeInputSchema, + locationReverseGeocode: LocationReverseGeocodeInputSchema, + locationCities: LocationCitiesInputSchema, + locationCountry: LocationCountryInputSchema, + locationCounty: LocationCountyInputSchema, + locationZipCode: LocationZipCodeInputSchema, + locationPostalCode: LocationPostalCodeInputSchema, + locationUniversities: LocationUniversitiesInputSchema, + locationHospitals: LocationHospitalsInputSchema, + locationEvChargers: LocationEvChargersInputSchema, + locationWeather: LocationWeatherInputSchema, + locationWeatherForecast: LocationWeatherForecastInputSchema, + locationAirQuality: LocationAirQualityInputSchema, + calendarTimezone: CalendarTimezoneInputSchema, + calendarWorldTime: CalendarWorldTimeInputSchema, + calendarHolidays: CalendarHolidaysInputSchema, + calendarPublicHolidays: CalendarPublicHolidaysInputSchema, + calendarIsPublicHoliday: CalendarIsPublicHolidayInputSchema, + calendarIsWorkingDay: CalendarIsWorkingDayInputSchema, + calendarWorkingDays: CalendarWorkingDaysInputSchema, + internetDomain: InternetDomainInputSchema, + internetDnsRecords: InternetDnsRecordsInputSchema, + internetMxRecords: InternetMxRecordsInputSchema, + internetWhois: InternetWhoisInputSchema, + internetIpLookup: InternetIpLookupInputSchema, + internetUrlLookup: InternetUrlLookupInputSchema, + internetWebpage: InternetWebpageInputSchema, + internetScrape: InternetScrapeInputSchema, + internetUserAgent: InternetUserAgentInputSchema, + validationEmail: ValidationEmailInputSchema, + validationDisposableEmail: ValidationDisposableEmailInputSchema, + validationPhone: ValidationPhoneInputSchema, + validationRoutingNumber: ValidationRoutingNumberInputSchema, + validationIban: ValidationIbanInputSchema, + validationBin: ValidationBinInputSchema, + validationSwiftCode: ValidationSwiftCodeInputSchema, + marketsStockPrice: MarketsStockPriceInputSchema, + marketsTicker: MarketsTickerInputSchema, + marketsTickerList: MarketsTickerListInputSchema, + marketsStockExchanges: MarketsStockExchangesInputSchema, + marketsSp500: MarketsSp500InputSchema, + marketsMarketCap: MarketsMarketCapInputSchema, + marketsEarnings: MarketsEarningsInputSchema, + marketsEarningsCalendar: MarketsEarningsCalendarInputSchema, + marketsEarningsTranscript: MarketsEarningsTranscriptInputSchema, + marketsInsiderTransactions: MarketsInsiderTransactionsInputSchema, + marketsSecFilings: MarketsSecFilingsInputSchema, + marketsEtf: MarketsEtfInputSchema, + marketsMutualFund: MarketsMutualFundInputSchema, + marketsCryptoPrice: MarketsCryptoPriceInputSchema, + marketsBitcoin: MarketsBitcoinInputSchema, + marketsCommodityPrice: MarketsCommodityPriceInputSchema, + marketsConvertCurrency: MarketsConvertCurrencyInputSchema, + marketsExchangeRate: MarketsExchangeRateInputSchema, + economicsGdp: EconomicsGdpInputSchema, + economicsInflation: EconomicsInflationInputSchema, + economicsUnemployment: EconomicsUnemploymentInputSchema, + economicsPopulation: EconomicsPopulationInputSchema, + economicsInterestRate: EconomicsInterestRateInputSchema, + economicsMortgageRate: EconomicsMortgageRateInputSchema, + economicsMortgageCalculator: EconomicsMortgageCalculatorInputSchema, + economicsIncomeTax: EconomicsIncomeTaxInputSchema, + economicsIncomeTaxCalculator: EconomicsIncomeTaxCalculatorInputSchema, + economicsSalesTax: EconomicsSalesTaxInputSchema, + economicsSalesTaxCalculator: EconomicsSalesTaxCalculatorInputSchema, + economicsPropertyTax: EconomicsPropertyTaxInputSchema, + economicsVatRates: EconomicsVatRatesInputSchema, + textSentiment: TextSentimentInputSchema, + textSimilarity: TextSimilarityInputSchema, + textEmbeddings: TextEmbeddingsInputSchema, + textLanguage: TextLanguageInputSchema, + textSpellCheck: TextSpellCheckInputSchema, + textProfanityFilter: TextProfanityFilterInputSchema, + textDictionary: TextDictionaryInputSchema, + textThesaurus: TextThesaurusInputSchema, + textRhymes: TextRhymesInputSchema, + textRandomWord: TextRandomWordInputSchema, + textLoremIpsum: TextLoremIpsumInputSchema, + utilityQrCode: UtilityQrCodeInputSchema, + utilityBarcode: UtilityBarcodeInputSchema, + utilityPassword: UtilityPasswordInputSchema, + utilityRandomUser: UtilityRandomUserInputSchema, + utilityCounter: UtilityCounterInputSchema, + utilityConvertUnit: UtilityConvertUnitInputSchema, + utilityLogo: UtilityLogoInputSchema, + utilityCountryFlag: UtilityCountryFlagInputSchema, + utilityRandomImage: UtilityRandomImageInputSchema, + utilityEmoji: UtilityEmojiInputSchema, + transportAircraft: TransportAircraftInputSchema, + transportAirlines: TransportAirlinesInputSchema, + transportAirports: TransportAirportsInputSchema, + transportHelicopters: TransportHelicoptersInputSchema, + transportCars: TransportCarsInputSchema, + transportMotorcycles: TransportMotorcyclesInputSchema, + transportElectricVehicles: TransportElectricVehiclesInputSchema, + transportVin: TransportVinInputSchema, + healthCaloriesBurned: HealthCaloriesBurnedInputSchema, + healthNutrition: HealthNutritionInputSchema, + healthExercises: HealthExercisesInputSchema, + healthRecipes: HealthRecipesInputSchema, + healthCocktails: HealthCocktailsInputSchema, + referenceAnimals: ReferenceAnimalsInputSchema, + referenceCats: ReferenceCatsInputSchema, + referenceDogs: ReferenceDogsInputSchema, + referencePlanets: ReferencePlanetsInputSchema, + referenceStars: ReferenceStarsInputSchema, + referenceHistoricalEvents: ReferenceHistoricalEventsInputSchema, + referenceHistoricalFigures: ReferenceHistoricalFiguresInputSchema, + referenceDayInHistory: ReferenceDayInHistoryInputSchema, + referenceCelebrities: ReferenceCelebritiesInputSchema, + referenceBabyNames: ReferenceBabyNamesInputSchema, + entertainmentJokes: EntertainmentJokesInputSchema, + entertainmentDadJokes: EntertainmentDadJokesInputSchema, + entertainmentChuckNorris: EntertainmentChuckNorrisInputSchema, + entertainmentJokeOfTheDay: EntertainmentJokeOfTheDayInputSchema, + entertainmentFacts: EntertainmentFactsInputSchema, + entertainmentFactOfTheDay: EntertainmentFactOfTheDayInputSchema, + entertainmentQuotes: EntertainmentQuotesInputSchema, + entertainmentRandomQuotes: EntertainmentRandomQuotesInputSchema, + entertainmentQuoteOfTheDay: EntertainmentQuoteOfTheDayInputSchema, + entertainmentAdvice: EntertainmentAdviceInputSchema, + entertainmentBucketList: EntertainmentBucketListInputSchema, + entertainmentHobbies: EntertainmentHobbiesInputSchema, + entertainmentHoroscope: EntertainmentHoroscopeInputSchema, + entertainmentRiddles: EntertainmentRiddlesInputSchema, + entertainmentTrivia: EntertainmentTriviaInputSchema, + entertainmentTriviaOfTheDay: EntertainmentTriviaOfTheDayInputSchema, + entertainmentGenerateSudoku: EntertainmentGenerateSudokuInputSchema, + entertainmentSolveSudoku: EntertainmentSolveSudokuInputSchema, +} as const; + +export const ApiNinjasEndpointOutputSchemas = { + locationGeocode: LocationGeocodeOutputSchema, + locationReverseGeocode: LocationReverseGeocodeOutputSchema, + locationCities: LocationCitiesOutputSchema, + locationCountry: LocationCountryOutputSchema, + locationCounty: LocationCountyOutputSchema, + locationZipCode: LocationZipCodeOutputSchema, + locationPostalCode: LocationPostalCodeOutputSchema, + locationUniversities: LocationUniversitiesOutputSchema, + locationHospitals: LocationHospitalsOutputSchema, + locationEvChargers: LocationEvChargersOutputSchema, + locationWeather: LocationWeatherOutputSchema, + locationWeatherForecast: LocationWeatherForecastOutputSchema, + locationAirQuality: LocationAirQualityOutputSchema, + calendarTimezone: CalendarTimezoneOutputSchema, + calendarWorldTime: CalendarWorldTimeOutputSchema, + calendarHolidays: CalendarHolidaysOutputSchema, + calendarPublicHolidays: CalendarPublicHolidaysOutputSchema, + calendarIsPublicHoliday: CalendarIsPublicHolidayOutputSchema, + calendarIsWorkingDay: CalendarIsWorkingDayOutputSchema, + calendarWorkingDays: CalendarWorkingDaysOutputSchema, + internetDomain: InternetDomainOutputSchema, + internetDnsRecords: InternetDnsRecordsOutputSchema, + internetMxRecords: InternetMxRecordsOutputSchema, + internetWhois: InternetWhoisOutputSchema, + internetIpLookup: InternetIpLookupOutputSchema, + internetUrlLookup: InternetUrlLookupOutputSchema, + internetWebpage: InternetWebpageOutputSchema, + internetScrape: InternetScrapeOutputSchema, + internetUserAgent: InternetUserAgentOutputSchema, + validationEmail: ValidationEmailOutputSchema, + validationDisposableEmail: ValidationDisposableEmailOutputSchema, + validationPhone: ValidationPhoneOutputSchema, + validationRoutingNumber: ValidationRoutingNumberOutputSchema, + validationIban: ValidationIbanOutputSchema, + validationBin: ValidationBinOutputSchema, + validationSwiftCode: ValidationSwiftCodeOutputSchema, + marketsStockPrice: MarketsStockPriceOutputSchema, + marketsTicker: MarketsTickerOutputSchema, + marketsTickerList: MarketsTickerListOutputSchema, + marketsStockExchanges: MarketsStockExchangesOutputSchema, + marketsSp500: MarketsSp500OutputSchema, + marketsMarketCap: MarketsMarketCapOutputSchema, + marketsEarnings: MarketsEarningsOutputSchema, + marketsEarningsCalendar: MarketsEarningsCalendarOutputSchema, + marketsEarningsTranscript: MarketsEarningsTranscriptOutputSchema, + marketsInsiderTransactions: MarketsInsiderTransactionsOutputSchema, + marketsSecFilings: MarketsSecFilingsOutputSchema, + marketsEtf: MarketsEtfOutputSchema, + marketsMutualFund: MarketsMutualFundOutputSchema, + marketsCryptoPrice: MarketsCryptoPriceOutputSchema, + marketsBitcoin: MarketsBitcoinOutputSchema, + marketsCommodityPrice: MarketsCommodityPriceOutputSchema, + marketsConvertCurrency: MarketsConvertCurrencyOutputSchema, + marketsExchangeRate: MarketsExchangeRateOutputSchema, + economicsGdp: EconomicsGdpOutputSchema, + economicsInflation: EconomicsInflationOutputSchema, + economicsUnemployment: EconomicsUnemploymentOutputSchema, + economicsPopulation: EconomicsPopulationOutputSchema, + economicsInterestRate: EconomicsInterestRateOutputSchema, + economicsMortgageRate: EconomicsMortgageRateOutputSchema, + economicsMortgageCalculator: EconomicsMortgageCalculatorOutputSchema, + economicsIncomeTax: EconomicsIncomeTaxOutputSchema, + economicsIncomeTaxCalculator: EconomicsIncomeTaxCalculatorOutputSchema, + economicsSalesTax: EconomicsSalesTaxOutputSchema, + economicsSalesTaxCalculator: EconomicsSalesTaxCalculatorOutputSchema, + economicsPropertyTax: EconomicsPropertyTaxOutputSchema, + economicsVatRates: EconomicsVatRatesOutputSchema, + textSentiment: TextSentimentOutputSchema, + textSimilarity: TextSimilarityOutputSchema, + textEmbeddings: TextEmbeddingsOutputSchema, + textLanguage: TextLanguageOutputSchema, + textSpellCheck: TextSpellCheckOutputSchema, + textProfanityFilter: TextProfanityFilterOutputSchema, + textDictionary: TextDictionaryOutputSchema, + textThesaurus: TextThesaurusOutputSchema, + textRhymes: TextRhymesOutputSchema, + textRandomWord: TextRandomWordOutputSchema, + textLoremIpsum: TextLoremIpsumOutputSchema, + utilityQrCode: UtilityQrCodeOutputSchema, + utilityBarcode: UtilityBarcodeOutputSchema, + utilityPassword: UtilityPasswordOutputSchema, + utilityRandomUser: UtilityRandomUserOutputSchema, + utilityCounter: UtilityCounterOutputSchema, + utilityConvertUnit: UtilityConvertUnitOutputSchema, + utilityLogo: UtilityLogoOutputSchema, + utilityCountryFlag: UtilityCountryFlagOutputSchema, + utilityRandomImage: UtilityRandomImageOutputSchema, + utilityEmoji: UtilityEmojiOutputSchema, + transportAircraft: TransportAircraftOutputSchema, + transportAirlines: TransportAirlinesOutputSchema, + transportAirports: TransportAirportsOutputSchema, + transportHelicopters: TransportHelicoptersOutputSchema, + transportCars: TransportCarsOutputSchema, + transportMotorcycles: TransportMotorcyclesOutputSchema, + transportElectricVehicles: TransportElectricVehiclesOutputSchema, + transportVin: TransportVinOutputSchema, + healthCaloriesBurned: HealthCaloriesBurnedOutputSchema, + healthNutrition: HealthNutritionOutputSchema, + healthExercises: HealthExercisesOutputSchema, + healthRecipes: HealthRecipesOutputSchema, + healthCocktails: HealthCocktailsOutputSchema, + referenceAnimals: ReferenceAnimalsOutputSchema, + referenceCats: ReferenceCatsOutputSchema, + referenceDogs: ReferenceDogsOutputSchema, + referencePlanets: ReferencePlanetsOutputSchema, + referenceStars: ReferenceStarsOutputSchema, + referenceHistoricalEvents: ReferenceHistoricalEventsOutputSchema, + referenceHistoricalFigures: ReferenceHistoricalFiguresOutputSchema, + referenceDayInHistory: ReferenceDayInHistoryOutputSchema, + referenceCelebrities: ReferenceCelebritiesOutputSchema, + referenceBabyNames: ReferenceBabyNamesOutputSchema, + entertainmentJokes: EntertainmentJokesOutputSchema, + entertainmentDadJokes: EntertainmentDadJokesOutputSchema, + entertainmentChuckNorris: EntertainmentChuckNorrisOutputSchema, + entertainmentJokeOfTheDay: EntertainmentJokeOfTheDayOutputSchema, + entertainmentFacts: EntertainmentFactsOutputSchema, + entertainmentFactOfTheDay: EntertainmentFactOfTheDayOutputSchema, + entertainmentQuotes: EntertainmentQuotesOutputSchema, + entertainmentRandomQuotes: EntertainmentRandomQuotesOutputSchema, + entertainmentQuoteOfTheDay: EntertainmentQuoteOfTheDayOutputSchema, + entertainmentAdvice: EntertainmentAdviceOutputSchema, + entertainmentBucketList: EntertainmentBucketListOutputSchema, + entertainmentHobbies: EntertainmentHobbiesOutputSchema, + entertainmentHoroscope: EntertainmentHoroscopeOutputSchema, + entertainmentRiddles: EntertainmentRiddlesOutputSchema, + entertainmentTrivia: EntertainmentTriviaOutputSchema, + entertainmentTriviaOfTheDay: EntertainmentTriviaOfTheDayOutputSchema, + entertainmentGenerateSudoku: EntertainmentGenerateSudokuOutputSchema, + entertainmentSolveSudoku: EntertainmentSolveSudokuOutputSchema, +} as const; + +export type ApiNinjasEndpointInputs = { + locationGeocode: z.infer; + locationReverseGeocode: z.infer; + locationCities: z.infer; + locationCountry: z.infer; + locationCounty: z.infer; + locationZipCode: z.infer; + locationPostalCode: z.infer; + locationUniversities: z.infer; + locationHospitals: z.infer; + locationEvChargers: z.infer; + locationWeather: z.infer; + locationWeatherForecast: z.infer; + locationAirQuality: z.infer; + calendarTimezone: z.infer; + calendarWorldTime: z.infer; + calendarHolidays: z.infer; + calendarPublicHolidays: z.infer; + calendarIsPublicHoliday: z.infer; + calendarIsWorkingDay: z.infer; + calendarWorkingDays: z.infer; + internetDomain: z.infer; + internetDnsRecords: z.infer; + internetMxRecords: z.infer; + internetWhois: z.infer; + internetIpLookup: z.infer; + internetUrlLookup: z.infer; + internetWebpage: z.infer; + internetScrape: z.infer; + internetUserAgent: z.infer; + validationEmail: z.infer; + validationDisposableEmail: z.infer< + typeof ValidationDisposableEmailInputSchema + >; + validationPhone: z.infer; + validationRoutingNumber: z.infer; + validationIban: z.infer; + validationBin: z.infer; + validationSwiftCode: z.infer; + marketsStockPrice: z.infer; + marketsTicker: z.infer; + marketsTickerList: z.infer; + marketsStockExchanges: z.infer; + marketsSp500: z.infer; + marketsMarketCap: z.infer; + marketsEarnings: z.infer; + marketsEarningsCalendar: z.infer; + marketsEarningsTranscript: z.infer< + typeof MarketsEarningsTranscriptInputSchema + >; + marketsInsiderTransactions: z.infer< + typeof MarketsInsiderTransactionsInputSchema + >; + marketsSecFilings: z.infer; + marketsEtf: z.infer; + marketsMutualFund: z.infer; + marketsCryptoPrice: z.infer; + marketsBitcoin: z.infer; + marketsCommodityPrice: z.infer; + marketsConvertCurrency: z.infer; + marketsExchangeRate: z.infer; + economicsGdp: z.infer; + economicsInflation: z.infer; + economicsUnemployment: z.infer; + economicsPopulation: z.infer; + economicsInterestRate: z.infer; + economicsMortgageRate: z.infer; + economicsMortgageCalculator: z.infer< + typeof EconomicsMortgageCalculatorInputSchema + >; + economicsIncomeTax: z.infer; + economicsIncomeTaxCalculator: z.infer< + typeof EconomicsIncomeTaxCalculatorInputSchema + >; + economicsSalesTax: z.infer; + economicsSalesTaxCalculator: z.infer< + typeof EconomicsSalesTaxCalculatorInputSchema + >; + economicsPropertyTax: z.infer; + economicsVatRates: z.infer; + textSentiment: z.infer; + textSimilarity: z.infer; + textEmbeddings: z.infer; + textLanguage: z.infer; + textSpellCheck: z.infer; + textProfanityFilter: z.infer; + textDictionary: z.infer; + textThesaurus: z.infer; + textRhymes: z.infer; + textRandomWord: z.infer; + textLoremIpsum: z.infer; + utilityQrCode: z.infer; + utilityBarcode: z.infer; + utilityPassword: z.infer; + utilityRandomUser: z.infer; + utilityCounter: z.infer; + utilityConvertUnit: z.infer; + utilityLogo: z.infer; + utilityCountryFlag: z.infer; + utilityRandomImage: z.infer; + utilityEmoji: z.infer; + transportAircraft: z.infer; + transportAirlines: z.infer; + transportAirports: z.infer; + transportHelicopters: z.infer; + transportCars: z.infer; + transportMotorcycles: z.infer; + transportElectricVehicles: z.infer< + typeof TransportElectricVehiclesInputSchema + >; + transportVin: z.infer; + healthCaloriesBurned: z.infer; + healthNutrition: z.infer; + healthExercises: z.infer; + healthRecipes: z.infer; + healthCocktails: z.infer; + referenceAnimals: z.infer; + referenceCats: z.infer; + referenceDogs: z.infer; + referencePlanets: z.infer; + referenceStars: z.infer; + referenceHistoricalEvents: z.infer< + typeof ReferenceHistoricalEventsInputSchema + >; + referenceHistoricalFigures: z.infer< + typeof ReferenceHistoricalFiguresInputSchema + >; + referenceDayInHistory: z.infer; + referenceCelebrities: z.infer; + referenceBabyNames: z.infer; + entertainmentJokes: z.infer; + entertainmentDadJokes: z.infer; + entertainmentChuckNorris: z.infer; + entertainmentJokeOfTheDay: z.infer< + typeof EntertainmentJokeOfTheDayInputSchema + >; + entertainmentFacts: z.infer; + entertainmentFactOfTheDay: z.infer< + typeof EntertainmentFactOfTheDayInputSchema + >; + entertainmentQuotes: z.infer; + entertainmentRandomQuotes: z.infer< + typeof EntertainmentRandomQuotesInputSchema + >; + entertainmentQuoteOfTheDay: z.infer< + typeof EntertainmentQuoteOfTheDayInputSchema + >; + entertainmentAdvice: z.infer; + entertainmentBucketList: z.infer; + entertainmentHobbies: z.infer; + entertainmentHoroscope: z.infer; + entertainmentRiddles: z.infer; + entertainmentTrivia: z.infer; + entertainmentTriviaOfTheDay: z.infer< + typeof EntertainmentTriviaOfTheDayInputSchema + >; + entertainmentGenerateSudoku: z.infer< + typeof EntertainmentGenerateSudokuInputSchema + >; + entertainmentSolveSudoku: z.infer; +}; + +export type ApiNinjasEndpointOutputs = { + locationGeocode: z.infer; + locationReverseGeocode: z.infer; + locationCities: z.infer; + locationCountry: z.infer; + locationCounty: z.infer; + locationZipCode: z.infer; + locationPostalCode: z.infer; + locationUniversities: z.infer; + locationHospitals: z.infer; + locationEvChargers: z.infer; + locationWeather: z.infer; + locationWeatherForecast: z.infer; + locationAirQuality: z.infer; + calendarTimezone: z.infer; + calendarWorldTime: z.infer; + calendarHolidays: z.infer; + calendarPublicHolidays: z.infer; + calendarIsPublicHoliday: z.infer; + calendarIsWorkingDay: z.infer; + calendarWorkingDays: z.infer; + internetDomain: z.infer; + internetDnsRecords: z.infer; + internetMxRecords: z.infer; + internetWhois: z.infer; + internetIpLookup: z.infer; + internetUrlLookup: z.infer; + internetWebpage: z.infer; + internetScrape: z.infer; + internetUserAgent: z.infer; + validationEmail: z.infer; + validationDisposableEmail: z.infer< + typeof ValidationDisposableEmailOutputSchema + >; + validationPhone: z.infer; + validationRoutingNumber: z.infer; + validationIban: z.infer; + validationBin: z.infer; + validationSwiftCode: z.infer; + marketsStockPrice: z.infer; + marketsTicker: z.infer; + marketsTickerList: z.infer; + marketsStockExchanges: z.infer; + marketsSp500: z.infer; + marketsMarketCap: z.infer; + marketsEarnings: z.infer; + marketsEarningsCalendar: z.infer; + marketsEarningsTranscript: z.infer< + typeof MarketsEarningsTranscriptOutputSchema + >; + marketsInsiderTransactions: z.infer< + typeof MarketsInsiderTransactionsOutputSchema + >; + marketsSecFilings: z.infer; + marketsEtf: z.infer; + marketsMutualFund: z.infer; + marketsCryptoPrice: z.infer; + marketsBitcoin: z.infer; + marketsCommodityPrice: z.infer; + marketsConvertCurrency: z.infer; + marketsExchangeRate: z.infer; + economicsGdp: z.infer; + economicsInflation: z.infer; + economicsUnemployment: z.infer; + economicsPopulation: z.infer; + economicsInterestRate: z.infer; + economicsMortgageRate: z.infer; + economicsMortgageCalculator: z.infer< + typeof EconomicsMortgageCalculatorOutputSchema + >; + economicsIncomeTax: z.infer; + economicsIncomeTaxCalculator: z.infer< + typeof EconomicsIncomeTaxCalculatorOutputSchema + >; + economicsSalesTax: z.infer; + economicsSalesTaxCalculator: z.infer< + typeof EconomicsSalesTaxCalculatorOutputSchema + >; + economicsPropertyTax: z.infer; + economicsVatRates: z.infer; + textSentiment: z.infer; + textSimilarity: z.infer; + textEmbeddings: z.infer; + textLanguage: z.infer; + textSpellCheck: z.infer; + textProfanityFilter: z.infer; + textDictionary: z.infer; + textThesaurus: z.infer; + textRhymes: z.infer; + textRandomWord: z.infer; + textLoremIpsum: z.infer; + utilityQrCode: z.infer; + utilityBarcode: z.infer; + utilityPassword: z.infer; + utilityRandomUser: z.infer; + utilityCounter: z.infer; + utilityConvertUnit: z.infer; + utilityLogo: z.infer; + utilityCountryFlag: z.infer; + utilityRandomImage: z.infer; + utilityEmoji: z.infer; + transportAircraft: z.infer; + transportAirlines: z.infer; + transportAirports: z.infer; + transportHelicopters: z.infer; + transportCars: z.infer; + transportMotorcycles: z.infer; + transportElectricVehicles: z.infer< + typeof TransportElectricVehiclesOutputSchema + >; + transportVin: z.infer; + healthCaloriesBurned: z.infer; + healthNutrition: z.infer; + healthExercises: z.infer; + healthRecipes: z.infer; + healthCocktails: z.infer; + referenceAnimals: z.infer; + referenceCats: z.infer; + referenceDogs: z.infer; + referencePlanets: z.infer; + referenceStars: z.infer; + referenceHistoricalEvents: z.infer< + typeof ReferenceHistoricalEventsOutputSchema + >; + referenceHistoricalFigures: z.infer< + typeof ReferenceHistoricalFiguresOutputSchema + >; + referenceDayInHistory: z.infer; + referenceCelebrities: z.infer; + referenceBabyNames: z.infer; + entertainmentJokes: z.infer; + entertainmentDadJokes: z.infer; + entertainmentChuckNorris: z.infer< + typeof EntertainmentChuckNorrisOutputSchema + >; + entertainmentJokeOfTheDay: z.infer< + typeof EntertainmentJokeOfTheDayOutputSchema + >; + entertainmentFacts: z.infer; + entertainmentFactOfTheDay: z.infer< + typeof EntertainmentFactOfTheDayOutputSchema + >; + entertainmentQuotes: z.infer; + entertainmentRandomQuotes: z.infer< + typeof EntertainmentRandomQuotesOutputSchema + >; + entertainmentQuoteOfTheDay: z.infer< + typeof EntertainmentQuoteOfTheDayOutputSchema + >; + entertainmentAdvice: z.infer; + entertainmentBucketList: z.infer; + entertainmentHobbies: z.infer; + entertainmentHoroscope: z.infer; + entertainmentRiddles: z.infer; + entertainmentTrivia: z.infer; + entertainmentTriviaOfTheDay: z.infer< + typeof EntertainmentTriviaOfTheDayOutputSchema + >; + entertainmentGenerateSudoku: z.infer< + typeof EntertainmentGenerateSudokuOutputSchema + >; + entertainmentSolveSudoku: z.infer< + typeof EntertainmentSolveSudokuOutputSchema + >; +}; diff --git a/packages/apininjas/endpoints/utility.ts b/packages/apininjas/endpoints/utility.ts new file mode 100644 index 000000000..8b52cd272 --- /dev/null +++ b/packages/apininjas/endpoints/utility.ts @@ -0,0 +1,360 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeApiNinjasRequest } from '../client'; +import type { ApiNinjasEndpoints } from '../index'; +import { auditPayload, withCount } from './logging'; +import { cacheEmoji } from './persist'; +import { asArray, imageContentType, imageEncoding } from './shared'; +import type { ApiNinjasEndpointOutputs } from './types'; + +/** + * Generators, converters and small stateful helpers. + * + * Every operation here is a single documented endpoint under + * https://api.api-ninjas.com. Inputs map one-to-one onto the documented query + * parameters, so nothing is renamed on the way through. + */ + +/** Returns a random password string adhering to the specified parameters. */ +export const password: ApiNinjasEndpoints['utilityPassword'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['utilityPassword'] + >('passwordgenerator', ctx.key, { + version: 'v1', + query: { + length: input.length, + exclude_numbers: input.exclude_numbers, + exclude_special_chars: input.exclude_special_chars, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.utility.password', + withCount( + auditPayload(input, [ + 'length', + 'exclude_numbers', + 'exclude_special_chars', + ]), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Returns fake random user profiles. Supports customizable fields, + * filtering, and localization. + */ +export const randomUser: ApiNinjasEndpoints['utilityRandomUser'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['utilityRandomUser'] + >('randomuser', ctx.key, { + version: 'v2', + query: { + count: input.count, + gender: input.gender, + min_age: input.min_age, + max_age: input.max_age, + locale: input.locale, + fields: input.fields, + exclude: input.exclude, + seed: input.seed, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.utility.randomUser', + withCount( + auditPayload(input, [ + 'count', + 'gender', + 'min_age', + 'max_age', + 'locale', + 'fields', + 'exclude', + 'seed', + ]), + result, + ), + 'completed', + ); + return result; +}; + +/** Fetch and possibly update a counter. */ +export const counter: ApiNinjasEndpoints['utilityCounter'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['utilityCounter'] + >('counter', ctx.key, { + version: 'v1', + query: { + id: input.id, + hit: input.hit, + value: input.value, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.utility.counter', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** + * Returns conversions between different units of the same measurement + * type. + */ +export const convertUnit: ApiNinjasEndpoints['utilityConvertUnit'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['utilityConvertUnit'] + >('unitconversion', ctx.key, { + version: 'v1', + query: { + amount: input.amount, + unit: input.unit, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.utility.convertUnit', + withCount(auditPayload(input, ['unit']), result), + 'completed', + ); + return result; +}; + +/** + * Get a list of company names, ticker symbols, and logo image URLs + * matching the input parameters. Returns at most 10 results. + */ +export const logo: ApiNinjasEndpoints['utilityLogo'] = async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['utilityLogo'] + >('logo', ctx.key, { + version: 'v1', + query: { + name: input.name, + ticker: input.ticker, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.utility.logo', + withCount(auditPayload(input, ['name', 'ticker']), result), + 'completed', + ); + return result; +}; + +/** + * Get a country's flag as SVG image URLs. Both 1:1 and 4:3 aspect ratios + * are supported and returned in the response. + */ +export const countryFlag: ApiNinjasEndpoints['utilityCountryFlag'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['utilityCountryFlag'] + >('countryflag', ctx.key, { + version: 'v1', + query: { + country: input.country, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.utility.countryFlag', + withCount(auditPayload(input, ['country']), result), + 'completed', + ); + return result; +}; + +/** + * Returns a list of emojis according to input parameters. Returns at most + * 30 results. To access more than 30 results, use the offset parameter to + * offset results in multiple API calls. + */ +export const emoji: ApiNinjasEndpoints['utilityEmoji'] = async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['utilityEmoji'] + >('emoji', ctx.key, { + version: 'v1', + query: { + name: input.name, + code: input.code, + group: input.group, + subgroup: input.subgroup, + offset: input.offset, + }, + }); + + await cacheEmoji(ctx.db.emoji, asArray(result), new Date()); + + await logEventFromContext( + ctx, + 'apininjas.utility.emoji', + withCount( + auditPayload(input, ['name', 'code', 'group', 'subgroup', 'offset']), + result, + ), + 'completed', + ); + return result; +}; + +/** + * Generates a QR code image. + * + * The response is an image rather than JSON, and the shared transport decodes + * any non-JSON body as text - so SVG and EPS come back byte-for-byte while a + * raster format does not survive the round trip. `format` therefore defaults to + * `svg` here rather than to the provider's own default of `png`: a caller who + * does not state a format gets an exact payload instead of a corrupted one. + * + * A caller who does ask for `png` or `jpg` still gets the call made, and the + * result says so: `encoding` is `lossy-text` rather than `text`, so the payload + * is never presented as a usable image when it is not one. Deciding that for + * the caller by rejecting the request would be the other defensible choice; it + * is not taken here because the catalog lists these operations and the raster + * bytes are still useful for length and content checks. + * + * The underlying limitation is in the core transport's response handling, not + * in this plugin; it is raised as a suggestion in the pull request rather than + * patched here, because this package may not change core files. + */ +export const qrCode: ApiNinjasEndpoints['utilityQrCode'] = async ( + ctx, + input, +) => { + const format = input.format ?? 'svg'; + const contentType = imageContentType(format); + + const result = await makeApiNinjasRequest('qrcode', ctx.key, { + version: 'v1', + query: { + data: input.data, + format, + size: input.size, + fg_color: input.fg_color, + bg_color: input.bg_color, + }, + accept: contentType, + }); + + await logEventFromContext( + ctx, + 'apininjas.utility.qrCode', + auditPayload(input, ['format', 'size', 'fg_color', 'bg_color']), + 'completed', + ); + return { + content_type: contentType, + encoding: imageEncoding(format), + data: String(result ?? ''), + }; +}; + +/** + * Generates a barcode image. + * + * Same transport constraint as {@link qrCode}: `format` defaults to `svg` so + * the returned payload is exact, and `encoding` reports `lossy-text` when a + * caller asks for a raster format. The provider defaults `type` to `upc`, which + * rejects text that is not a valid UPC, so the caller's `type` is passed + * through untouched rather than guessed at. + */ +export const barcode: ApiNinjasEndpoints['utilityBarcode'] = async ( + ctx, + input, +) => { + const format = input.format ?? 'svg'; + const contentType = imageContentType(format); + + const result = await makeApiNinjasRequest( + 'barcodegenerate', + ctx.key, + { + version: 'v1', + query: { + text: input.text, + type: input.type, + format, + include_text: input.include_text, + }, + accept: contentType, + }, + ); + + await logEventFromContext( + ctx, + 'apininjas.utility.barcode', + auditPayload(input, ['type', 'format', 'include_text']), + 'completed', + ); + return { + content_type: contentType, + encoding: imageEncoding(format), + data: String(result ?? ''), + }; +}; + +/** + * Returns a random image. + * + * This endpoint only ever answers with JPEG bytes - there is no text format to + * ask for - so its payload is always `lossy-text` and should be treated as + * opaque. It is the one operation here that cannot return a usable image until + * the core transport can carry binary responses. + */ +export const randomImage: ApiNinjasEndpoints['utilityRandomImage'] = async ( + ctx, + input, +) => { + const contentType = imageContentType('jpg'); + + const result = await makeApiNinjasRequest('randomimage', ctx.key, { + version: 'v1', + query: { + category: input.category, + width: input.width, + height: input.height, + }, + accept: contentType, + }); + + await logEventFromContext( + ctx, + 'apininjas.utility.randomImage', + auditPayload(input, ['category', 'width', 'height']), + 'completed', + ); + return { + content_type: contentType, + encoding: 'lossy-text', + data: String(result ?? ''), + }; +}; diff --git a/packages/apininjas/endpoints/validation.ts b/packages/apininjas/endpoints/validation.ts new file mode 100644 index 000000000..c8114abec --- /dev/null +++ b/packages/apininjas/endpoints/validation.ts @@ -0,0 +1,196 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeApiNinjasRequest } from '../client'; +import type { ApiNinjasEndpoints } from '../index'; +import { auditPayload, withCount } from './logging'; +import type { ApiNinjasEndpointOutputs } from './types'; + +/** + * Email, phone and bank identifier validation. + * + * Every operation here is a single documented endpoint under + * https://api.api-ninjas.com. Inputs map one-to-one onto the documented query + * parameters, so nothing is renamed on the way through. + */ + +/** + * Returns metadata (including whether it is valid) for a given email + * address. This API will check the formatting of the email and the + * existence of DNS records for the domain to make sure it is a valid email + * address. + */ +export const email: ApiNinjasEndpoints['validationEmail'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['validationEmail'] + >('validateemail', ctx.key, { + version: 'v1', + query: { + email: input.email, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.validation.email', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** + * Returns metadata for a given email address, including whether it is from + * a disposable email provider. We maintain a large database of hundreds of + * thousands of disposable domains and check against it for every email + * address. + */ +export const disposableEmail: ApiNinjasEndpoints['validationDisposableEmail'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['validationDisposableEmail'] + >('disposableemailchecker', ctx.key, { + version: 'v1', + query: { + email: input.email, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.validation.disposableEmail', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; + }; + +/** + * Returns metadata (including whether it is valid) for a given phone + * number. + */ +export const phone: ApiNinjasEndpoints['validationPhone'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['validationPhone'] + >('validatephone', ctx.key, { + version: 'v1', + query: { + number: input.number, + country: input.country, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.validation.phone', + withCount(auditPayload(input, ['country']), result), + 'completed', + ); + return result; +}; + +/** Returns detailed information about a bank based on its routing number. */ +export const routingNumber: ApiNinjasEndpoints['validationRoutingNumber'] = + async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['validationRoutingNumber'] + >('routingnumber', ctx.key, { + version: 'v1', + query: { + routing_number: input.routing_number, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.validation.routingNumber', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; + }; + +/** Returns detailed information on a given IBAN. */ +export const iban: ApiNinjasEndpoints['validationIban'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['validationIban'] + >('iban', ctx.key, { + version: 'v1', + query: { + iban: input.iban, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.validation.iban', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** + * Returns detailed information about a bank based on the BIN number + * provided. + */ +export const bin: ApiNinjasEndpoints['validationBin'] = async (ctx, input) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['validationBin'] + >('bin', ctx.key, { + version: 'v2', + query: { + bin: input.bin, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.validation.bin', + withCount(auditPayload(input, []), result), + 'completed', + ); + return result; +}; + +/** + * Returns a list of bank information (including SWIFT/BIC Code) that match + * the input parameter. Returns at most 100 results. For more results, use + * the offset parameter. + */ +export const swiftCode: ApiNinjasEndpoints['validationSwiftCode'] = async ( + ctx, + input, +) => { + const result = await makeApiNinjasRequest< + ApiNinjasEndpointOutputs['validationSwiftCode'] + >('swiftcode', ctx.key, { + version: 'v1', + query: { + swift: input.swift, + bank: input.bank, + city: input.city, + country: input.country, + routing_number: input.routing_number, + offset: input.offset, + }, + }); + + await logEventFromContext( + ctx, + 'apininjas.validation.swiftCode', + withCount( + auditPayload(input, ['bank', 'city', 'country', 'offset']), + result, + ), + 'completed', + ); + return result; +}; diff --git a/packages/apininjas/error-handlers.test.ts b/packages/apininjas/error-handlers.test.ts new file mode 100644 index 000000000..965a5a205 --- /dev/null +++ b/packages/apininjas/error-handlers.test.ts @@ -0,0 +1,437 @@ +/** + * Error routing, end to end. + * + * API Ninjas answers a missing key, an invalid key, a premium-gated endpoint, + * an exhausted quota and an ordinary bad parameter all with `400`, so the + * status code cannot decide anything on its own and every one of these + * decisions is made by reading the body. That makes the matchers worth testing + * individually, and the retry strategies worth testing at all: a wrong answer + * here either spends a month's quota on a request that will never succeed, or + * reports a plan problem as a caller bug. + * + * `endpoints.test.ts` checks which handler wins for each failure. This file + * checks what each handler then does. + */ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; +import { errorHandlers } from './error-handlers'; + +type Context = { + pluginId: string; + operation: string; + input: Record; + originalError: Error; +}; + +const context: Context = { + pluginId: 'apininjas', + operation: 'text.sentiment', + input: {}, + originalError: new Error('test'), +}; + +type Handler = { + match: (error: Error, context: Context) => boolean; + handler: ( + error: Error, + context: Context, + ) => Promise<{ + maxRetries?: number; + retryStrategy?: string; + headersRetryAfterMs?: number; + }>; +}; + +const handlers = errorHandlers as unknown as Record; + +/** Builds an ApiError the way the transport builds one. */ +function apiError( + status: number, + body: unknown, + rateLimitInfo?: { retryAfter?: number }, +): ApiError { + return new ApiError( + { method: 'GET', url: 'https://api.api-ninjas.com/v1/sentiment' }, + { + url: 'https://api.api-ninjas.com/v1/sentiment', + ok: false, + status, + statusText: 'Error', + body, + }, + typeof body === 'object' && body !== null && 'error' in body + ? String((body as { error: unknown }).error) + : 'Error', + rateLimitInfo, + ); +} + +beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + jest.spyOn(console, 'error').mockImplementation(() => undefined); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('reading the body', () => { + it('reads the message out of a JSON body under `error`', () => { + expect( + handlers.AUTH_ERROR?.match( + apiError(400, { error: 'Invalid API Key.' }), + context, + ), + ).toBe(true); + }); + + it('reads it out of a body under `message`, which 404s and 5xx use', () => { + expect( + handlers.NOT_FOUND_ERROR?.match( + apiError(404, { message: 'Endpoint not found.' }), + context, + ), + ).toBe(true); + }); + + it('reads a body that arrived as a plain string', () => { + // Not every failure comes back as JSON - a gateway error can be text. + expect( + handlers.PERMISSION_ERROR?.match( + apiError( + 400, + 'This endpoint is available to premium subscribers only.', + ), + context, + ), + ).toBe(true); + }); + + it('falls back to the error message when there is no body at all', () => { + expect( + handlers.NETWORK_ERROR?.match(new Error('fetch failed'), context), + ).toBe(true); + }); + + it('is not confused by a body of an unexpected shape', () => { + const odd = apiError(400, [1, 2, 3]); + + expect(handlers.AUTH_ERROR?.match(odd, context)).toBe(false); + expect(handlers.BAD_REQUEST_ERROR?.match(odd, context)).toBe(true); + }); +}); + +describe('quota and throttling', () => { + it('does not retry an exhausted monthly quota', async () => { + // The allowance does not come back inside a retry window; retrying only + // spends attempts on a request that cannot succeed until the month turns. + const strategy = await handlers.RATE_LIMIT_ERROR?.handler( + apiError(400, { + error: 'Monthly quota exceeded. Consider upgrading your subscription.', + }), + context, + ); + + expect(strategy?.maxRetries).toBe(0); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Monthly quota exhausted'), + ); + }); + + it('matches the other wording the provider uses for an exhausted quota', () => { + expect( + handlers.RATE_LIMIT_ERROR?.match( + apiError(400, { error: 'Your quota has been used up for this month.' }), + context, + ), + ).toBe(true); + }); + + it('retries a genuine 429 five times', async () => { + const strategy = await handlers.RATE_LIMIT_ERROR?.handler( + apiError(429, { error: 'Too Many Requests' }), + context, + ); + + expect(strategy?.maxRetries).toBe(5); + }); + + it('honours a Retry-After when the provider ever sends one', async () => { + // It does not today - there are no rate-limit headers on this API at all - + // but the client declares the header, so the handler passes it through. + const strategy = await handlers.RATE_LIMIT_ERROR?.handler( + apiError(429, { error: 'Too Many Requests' }, { retryAfter: 30_000 }), + context, + ); + + expect(strategy?.headersRetryAfterMs).toBe(30_000); + }); + + it('leaves the retry delay to backoff when no header is present', async () => { + const strategy = await handlers.RATE_LIMIT_ERROR?.handler( + apiError(429, { error: 'Too Many Requests' }), + context, + ); + + expect(strategy?.headersRetryAfterMs).toBeUndefined(); + }); +}); + +describe('credentials', () => { + it.each(['Missing API Key.', 'Invalid API Key.'])( + 'treats %j as an authentication failure, not a bad request', + (message) => { + // Both are 400s. Reporting them as validation errors would send a caller + // looking at their parameters instead of at their key. + expect( + handlers.AUTH_ERROR?.match(apiError(400, { error: message }), context), + ).toBe(true); + expect( + handlers.BAD_REQUEST_ERROR?.match( + apiError(400, { error: message }), + context, + ), + ).toBe(false); + }, + ); + + it('never retries an authentication failure', async () => { + const strategy = await handlers.AUTH_ERROR?.handler( + apiError(400, { error: 'Invalid API Key.' }), + context, + ); + + expect(strategy?.maxRetries).toBe(0); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Authentication failed'), + ); + }); +}); + +describe('plan gating', () => { + it.each([ + 'This endpoint is available to premium subscribers only.', + 'This API endpoint is only available to premium subscribers.', + 'year parameter is for premium subscribers only', + 'This currency pair is for premium subscribers only.', + 'This interest rate is available to premium subscribers only.', + 'This endpoint is currently down for free users. Please upgrade.', + ])('recognises %j as a plan problem', (message) => { + expect( + handlers.PERMISSION_ERROR?.match( + apiError(400, { error: message }), + context, + ), + ).toBe(true); + }); + + it('never retries a plan problem, because the answer will not change', async () => { + const strategy = await handlers.PERMISSION_ERROR?.handler( + apiError(400, { + error: 'This endpoint is available to premium subscribers only.', + }), + context, + ); + + expect(strategy?.maxRetries).toBe(0); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Not available on this plan'), + ); + }); +}); + +describe('unknown routes', () => { + it('matches on the message as well as the status', () => { + // The status is the reliable signal, but the body is what a caller reads. + expect( + handlers.NOT_FOUND_ERROR?.match( + apiError(404, { + message: + 'Endpoint not found. Please check your spelling and try again.', + }), + context, + ), + ).toBe(true); + }); + + it('does not retry an endpoint that does not exist', async () => { + const strategy = await handlers.NOT_FOUND_ERROR?.handler( + apiError(404, { message: 'Endpoint not found.' }), + context, + ); + + expect(strategy?.maxRetries).toBe(0); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Endpoint not found'), + ); + }); +}); + +describe('bad requests', () => { + it('claims an ordinary 400 that no earlier handler wanted', () => { + expect( + handlers.BAD_REQUEST_ERROR?.match( + apiError(400, { error: 'Invalid text parameter.' }), + context, + ), + ).toBe(true); + }); + + it('stands aside for quota, credential and plan failures', () => { + const yielded = [ + { error: 'Monthly quota exceeded.' }, + { error: 'Invalid API Key.' }, + { error: 'This endpoint is available to premium subscribers only.' }, + ]; + + for (const body of yielded) { + expect( + handlers.BAD_REQUEST_ERROR?.match(apiError(400, body), context), + ).toBe(false); + } + }); + + it('does not claim a 404 or a 500', () => { + expect( + handlers.BAD_REQUEST_ERROR?.match( + apiError(404, { message: 'x' }), + context, + ), + ).toBe(false); + expect( + handlers.BAD_REQUEST_ERROR?.match( + apiError(500, { message: 'x' }), + context, + ), + ).toBe(false); + }); + + it('does not retry a request the provider rejected', async () => { + const strategy = await handlers.BAD_REQUEST_ERROR?.handler( + apiError(400, { error: 'Invalid text parameter.' }), + context, + ); + + expect(strategy?.maxRetries).toBe(0); + }); +}); + +describe('server errors', () => { + it.each([500, 502, 503])('claims a %d', (status) => { + expect( + handlers.SERVER_ERROR?.match( + apiError(status, { message: 'Internal server error' }), + context, + ), + ).toBe(true); + }); + + it('does not retry a 502, which is also how a bad parameter is reported', async () => { + // `postalcode?code=...` and an unsolvable Sudoku both answer 502. Retrying + // a malformed request five times only spends quota. + const strategy = await handlers.SERVER_ERROR?.handler( + apiError(502, { message: 'Internal server error' }), + context, + ); + + expect(strategy?.maxRetries).toBe(0); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('502')); + }); + + it.each([500, 503])('retries a %d twice with backoff', async (status) => { + const strategy = await handlers.SERVER_ERROR?.handler( + apiError(status, { message: 'Service Unavailable' }), + context, + ); + + expect(strategy).toMatchObject({ + maxRetries: 2, + retryStrategy: 'exponential_backoff', + }); + }); +}); + +describe('network failures', () => { + it.each([ + 'fetch failed', + 'network timeout', + 'connection reset', + 'ECONNREFUSED 127.0.0.1:443', + 'getaddrinfo ENOTFOUND api.api-ninjas.com', + 'ETIMEDOUT', + ])('recognises %j', (message) => { + expect(handlers.NETWORK_ERROR?.match(new Error(message), context)).toBe( + true, + ); + }); + + it('retries three times, since the request may never have arrived', async () => { + const strategy = await handlers.NETWORK_ERROR?.handler( + new Error('fetch failed'), + context, + ); + + expect(strategy?.maxRetries).toBe(3); + }); +}); + +describe('the default handler', () => { + it('matches anything left over', () => { + expect( + handlers.DEFAULT?.match(new Error('something unexpected'), context), + ).toBe(true); + }); + + it('reports rather than retries, and logs as an error not a warning', async () => { + const strategy = await handlers.DEFAULT?.handler( + new Error('something unexpected'), + context, + ); + + expect(strategy?.maxRetries).toBe(0); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Unhandled error'), + ); + }); +}); + +describe('handler set', () => { + it('declares every handler the core can dispatch to', () => { + expect(Object.keys(errorHandlers)).toEqual([ + 'RATE_LIMIT_ERROR', + 'AUTH_ERROR', + 'PERMISSION_ERROR', + 'NOT_FOUND_ERROR', + 'BAD_REQUEST_ERROR', + 'SERVER_ERROR', + 'NETWORK_ERROR', + 'DEFAULT', + ]); + }); + + it('orders quota ahead of the handlers that would otherwise claim it', () => { + // `handleCorsairError` takes the first matching handler in insertion + // order, so this ordering is behaviour, not style. + const names = Object.keys(errorHandlers); + + expect(names.indexOf('RATE_LIMIT_ERROR')).toBeLessThan( + names.indexOf('BAD_REQUEST_ERROR'), + ); + expect(names.indexOf('AUTH_ERROR')).toBeLessThan( + names.indexOf('BAD_REQUEST_ERROR'), + ); + expect(names.indexOf('PERMISSION_ERROR')).toBeLessThan( + names.indexOf('BAD_REQUEST_ERROR'), + ); + expect(names[names.length - 1]).toBe('DEFAULT'); + }); + + it('satisfies the core handler contract', () => { + const asContract: CorsairErrorHandler = errorHandlers; + + for (const entry of Object.values(asContract)) { + expect(typeof entry?.match).toBe('function'); + expect(typeof entry?.handler).toBe('function'); + } + }); +}); diff --git a/packages/apininjas/error-handlers.ts b/packages/apininjas/error-handlers.ts new file mode 100644 index 000000000..f5249f283 --- /dev/null +++ b/packages/apininjas/error-handlers.ts @@ -0,0 +1,217 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; + +/** + * API Ninjas answers almost every failure with `400`. + * + * A missing key, an invalid key, a premium-gated endpoint, an exhausted monthly + * quota and an ordinary bad parameter all share that status, so the status code + * on its own cannot say what went wrong - the body has to be read. There is no + * `401` and no `403` anywhere on this surface. + * + * The message also lives under two different keys: `error` on a 400, `message` + * on a 404 or a 5xx. + * + * @see https://api-ninjas.com/error-codes + */ +function errorText(error: Error): string { + const parts: string[] = [error.message]; + + if (error instanceof ApiError) { + const body = error.body as unknown; + if (typeof body === 'string') { + parts.push(body); + } else if (body && typeof body === 'object') { + const record = body as Record; + for (const key of ['error', 'message', 'detail'] as const) { + if (typeof record[key] === 'string') { + parts.push(record[key] as string); + } + } + } + } + + return parts.join(' ').toLowerCase(); +} + +const isCredentialFailure = (error: Error): boolean => { + const text = errorText(error); + return text.includes('missing api key') || text.includes('invalid api key'); +}; + +/** + * Quota exhaustion arrives as a 400 reading "Monthly quota exceeded", not as a + * 429, so a caller that only watched the status would report it as a bad + * request for the rest of the billing month. + */ +const isQuotaFailure = (error: Error): boolean => { + const text = errorText(error); + return text.includes('quota exceeded') || text.includes('quota has been'); +}; + +/** + * The free tier withholds data in two visible ways: it rejects a whole endpoint, + * and it rejects an individual parameter. Both are plan problems rather than + * caller mistakes, so both are reported as a permission failure and never + * retried - the answer will not change until the plan does. + */ +const isPlanFailure = (error: Error): boolean => { + const text = errorText(error); + return ( + text.includes('premium subscriber') || + text.includes('premium subscription') || + text.includes('is for premium') || + text.includes('available to premium') || + text.includes('down for free users') + ); +}; + +const status = (error: Error): number | undefined => + error instanceof ApiError ? error.status : undefined; + +export const errorHandlers = { + /** + * Matched before the plan and bad-request handlers so an exhausted quota is + * never mistaken for either. `maxRetries` is 0 deliberately: the monthly + * allowance does not come back within a retry window, and every attempt + * spends an hour's worth of goodwill for nothing. + */ + RATE_LIMIT_ERROR: { + match: (error) => { + if (status(error) === 429) return true; + return isQuotaFailure(error); + }, + handler: async (error, context) => { + if (isQuotaFailure(error)) { + console.warn( + `[APININJAS:${context.operation}] Monthly quota exhausted - requests will keep failing until the quota renews or the plan is upgraded`, + ); + return { maxRetries: 0 }; + } + + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + + return { + maxRetries: 5, + headersRetryAfterMs: retryAfterMs, + }; + }, + }, + /** + * A missing or invalid key is a 400 here, so this matcher reads the body + * rather than the status. + */ + AUTH_ERROR: { + match: (error) => isCredentialFailure(error), + handler: async (error, context) => { + console.warn( + `[APININJAS:${context.operation}] Authentication failed - check the API key from your API Ninjas account page`, + ); + return { maxRetries: 0 }; + }, + }, + /** + * Premium gating. Distinct from AUTH_ERROR: the key is valid, the plan is + * not sufficient for this endpoint or this parameter. + */ + PERMISSION_ERROR: { + match: (error) => isPlanFailure(error), + handler: async (error, context) => { + console.warn( + `[APININJAS:${context.operation}] Not available on this plan: ${error.message}`, + ); + return { maxRetries: 0 }; + }, + }, + NOT_FOUND_ERROR: { + match: (error) => { + if (status(error) === 404) return true; + return errorText(error).includes('endpoint not found'); + }, + handler: async (error, context) => { + console.warn( + `[APININJAS:${context.operation}] Endpoint not found: ${error.message}`, + ); + return { maxRetries: 0 }; + }, + }, + /** + * Everything else that arrives as a 400: a missing required parameter, a + * value the provider rejected, an unsupported currency pair. + */ + BAD_REQUEST_ERROR: { + match: (error) => { + if ( + isQuotaFailure(error) || + isPlanFailure(error) || + isCredentialFailure(error) + ) { + return false; + } + return status(error) === 400; + }, + handler: async (error, context) => { + console.warn( + `[APININJAS:${context.operation}] Invalid request: ${error.message}`, + ); + return { maxRetries: 0 }; + }, + }, + /** + * The provider also answers `502` when a parameter name is wrong or a value + * cannot be processed - a wrong postal-code parameter and an unsolvable + * Sudoku both return one - so a 502 is not retried. Retrying a malformed + * request five times only spends quota. A 500 or 503 is a genuine server + * fault and is worth two attempts. + */ + SERVER_ERROR: { + match: (error) => { + const code = status(error); + return code === 500 || code === 502 || code === 503; + }, + handler: async (error, context) => { + if (status(error) === 502) { + console.warn( + `[APININJAS:${context.operation}] Provider returned 502 - this is also how it reports an unusable parameter, so the request is not retried: ${error.message}`, + ); + return { maxRetries: 0 }; + } + + console.warn( + `[APININJAS:${context.operation}] Provider error: ${error.message}`, + ); + return { maxRetries: 2, retryStrategy: 'exponential_backoff' }; + }, + }, + NETWORK_ERROR: { + match: (error) => { + const text = error.message.toLowerCase(); + return ( + text.includes('network') || + text.includes('connection') || + text.includes('econnrefused') || + text.includes('enotfound') || + text.includes('etimedout') || + text.includes('fetch failed') + ); + }, + handler: async (error, context) => { + console.warn( + `[APININJAS:${context.operation}] Network error: ${error.message}`, + ); + return { maxRetries: 3 }; + }, + }, + DEFAULT: { + match: () => true, + handler: async (error, context) => { + console.error( + `[APININJAS:${context.operation}] Unhandled error: ${error.message}`, + ); + return { maxRetries: 0 }; + }, + }, +} satisfies CorsairErrorHandler; diff --git a/packages/apininjas/fixtures.ts b/packages/apininjas/fixtures.ts new file mode 100644 index 000000000..0effa7248 --- /dev/null +++ b/packages/apininjas/fixtures.ts @@ -0,0 +1,2916 @@ +import type { ApiNinjasEndpointOutputSchemas } from './endpoints/types'; + +/** + * Responses captured from api.api-ninjas.com on 2026-08-15 with a free-tier + * key, one per operation, trimmed to the first row of each collection. + * + * These are the evidence the output schemas were built from, so the tests parse + * them rather than hand-written payloads: a schema that drifts from what the + * provider actually sends fails here. Every value is public reference data - + * these endpoints return no personal data, and the key never appears in a + * response. + */ +export const CAPTURED_RESPONSES: Partial< + Record +> = { + locationGeocode: [ + { + name: 'Example Name', + latitude: 51.5074456, + longitude: -0.1277653, + country: 'GB', + state: 'Example State', + }, + ], + locationReverseGeocode: [ + { + name: 'Example Name', + country: 'GB', + state: 'Example State', + }, + ], + locationCities: [ + { + name: 'Example Name', + latitude: 51.5072, + longitude: -0.1275, + country: 'GB', + population: 10979000, + region: 'England', + is_capital: true, + }, + ], + locationCountry: [ + { + gdp: 3949549, + sex_ratio: 97.8, + surface_area: 357376, + life_expectancy_male: 78.7, + unemployment: 3, + imports: 1240700, + homicide_rate: 0.9, + currency: { + code: 'EUR', + name: 'Example Name', + }, + iso2: 'DE', + employment_services: 72.1, + employment_industry: 26.8, + urban_population_growth: 0.3, + secondary_school_enrollment_female: 95.2, + employment_agriculture: 1.2, + capital: 'Berlin', + co2_emissions: 718.8, + forested_area: 32.7, + tourists: 38881, + exports: 1493090, + life_expectancy_female: 83.6, + post_secondary_enrollment_female: 70.7, + post_secondary_enrollment_male: 69.8, + primary_school_enrollment_female: 104.2, + infant_mortality: 3.2, + gdp_growth: 1.5, + threatened_species: 200, + population: 83784, + urban_population: 77.4, + secondary_school_enrollment_male: 101.4, + name: 'Example Name', + pop_growth: 0.5, + region: 'Western Europe', + pop_density: 240.4, + internet_users: 89.7, + gdp_per_capita: 47513.7, + fertility: 1.6, + refugees: 1461, + primary_school_enrollment_male: 103.9, + telephone_country_codes: ['49'], + }, + ], + locationCounty: [ + { + county_name: 'Los Angeles County', + county_fips: '06037', + state_code: 'CA', + state_name: 'California', + latitude: 'This field is available to premium subscribers only.', + longitude: 'This field is available to premium subscribers only.', + zip_codes: 'This field is available to premium subscribers only.', + timezone: 'America/Los_Angeles', + population: 9848410, + median_age: 37.9, + }, + ], + locationZipCode: [ + { + zip_code: '90210', + valid: + 'This field is reserved for premium subscribers only. Please visit https://api-ninjas.com/pricing for more information.', + city: 'Exampleton', + state: 'Example State', + county: + 'This field is reserved for premium subscribers only. Please visit https://api-ninjas.com/pricing for more information.', + timezone: 'America/Los_Angeles', + area_codes: + 'This field is reserved for premium subscribers only. Please visit https://api-ninjas.com/pricing for more information.', + country: 'US', + lat: '34.1031', + lon: '-118.4163', + }, + ], + locationPostalCode: [ + { + city: 'Exampleton', + province: 'ON', + postal_code: '00000', + area_code: '613', + timezone: 'America/Toronto', + lat: '45.4168', + lon: '-75.7002', + }, + ], + locationUniversities: [ + { + name: 'Example Name', + degree_types: [ + "Associate's degree", + "Bachelor's degree", + 'Postbaccalaureate certificate', + "Master's degree", + "Post-master's certificate", + ], + address: '1 Example Street, Exampleton, EX 00000', + city: 'Exampleton', + state: 'Example State', + postal_code: '00000', + country: 'USA', + county: 'Middlesex', + timezone: 'EST', + latitude: '42.3802', + longitude: '-71.1347', + phone: '+15550100', + website: 'http://www.harvard.edu/', + institution_type: 'Private (Not For Profit)', + years: '4 Years', + enrollment: '27651', + student_faculty_ratio: '7 to 1', + }, + ], + locationHospitals: [ + { + name: 'Example Name', + care_type: 'ACUTE CARE - VETERANS ADMINISTRATION', + address: '1 Example Street, Exampleton, EX 00000', + city: 'Exampleton', + state: 'Example State', + zipcode: '77030', + county: 'Harris', + location_area_code: '713', + fips_code: '48201', + timezone: 'CST', + latitude: '29.7059', + longitude: '-95.4026', + phone_number: '(713) 794-7100', + website: null, + ownership: 'Government Federal', + bedcount: null, + }, + ], + locationEvChargers: [ + { + is_active: true, + name: 'Example Name', + address: '1 Example Street, Exampleton, EX 00000', + city: 'Exampleton', + region: 'CA', + country: 'US', + latitude: 37.78881, + longitude: -122.401, + connections: [ + { + type_name: 'Type 1 (J1772)', + type_official: 'SAE J1772-2009', + level: 2, + num_connectors: 2, + }, + { + type_name: 'Type 1 (J1772)', + type_official: 'SAE J1772-2009', + level: 2, + num_connectors: 1, + }, + ], + }, + ], + locationWeather: { + cloud_pct: 16, + temp: 32, + feels_like: 31, + humidity: 38, + min_temp: 30, + max_temp: 32, + wind_speed: 2.57, + wind_degrees: 280, + sunrise: 1786682654, + sunset: 1786735587, + }, + locationWeatherForecast: [ + { + timestamp: 1786741200, + temp: 30, + feels_like: 29, + humidity: 36, + min_temp: 26, + max_temp: 30, + weather: 'Clouds', + cloud_pct: 43, + wind_speed: 2.12, + wind_degrees: 329, + }, + ], + locationAirQuality: { + CO: { + concentration: 95.47, + aqi: 1, + }, + NO2: { + concentration: 15.54, + aqi: 19, + }, + O3: { + concentration: 70.43, + aqi: 85, + }, + SO2: { + concentration: 6.92, + aqi: 10, + }, + 'PM2.5': { + concentration: 4.34, + aqi: 14, + }, + PM10: { + concentration: 9.47, + aqi: 8, + }, + overall_aqi: 85, + }, + calendarTimezone: { + timezone: 'America/New_York', + utc_offset: -14400, + local_time: '2026-08-14 14:47:59', + }, + calendarHolidays: [ + { + country: 'United States', + iso: 'US', + year: 2026, + date: '2026-01-01', + day: 'Thursday', + name: 'Example Name', + type: 'STATE_HOLIDAY', + }, + ], + calendarPublicHolidays: [ + { + name: 'Example Name', + local_name: "New Year's Day", + date: '2026-01-01', + country: 'US', + year: 2026, + regions: [], + federal: true, + }, + ], + calendarIsPublicHoliday: { + date: '2026-12-25', + country: 'US', + is_public_holiday: true, + public_holiday_name: 'Christmas Day', + }, + calendarIsWorkingDay: { + date: '2026-12-25', + country: 'US', + day_of_week: 'Friday', + is_workday: false, + public_holiday_name: 'Christmas Day', + non_working_reason: ['public holiday'], + }, + calendarWorkingDays: { + num_working_days: 249, + num_non_working_days: 116, + working_days: [ + '2026-01-02', + '2026-01-05', + '2026-01-06', + '2026-01-07', + '2026-01-08', + '2026-01-09', + '2026-01-12', + '2026-01-13', + '2026-01-14', + '2026-01-15', + '2026-01-16', + '2026-01-20', + '2026-01-21', + '2026-01-22', + '2026-01-23', + '2026-01-26', + '2026-01-27', + '2026-01-28', + '2026-01-29', + '2026-01-30', + '2026-02-02', + '2026-02-03', + '2026-02-04', + '2026-02-05', + '2026-02-06', + '2026-02-09', + '2026-02-10', + '2026-02-11', + '2026-02-12', + '2026-02-13', + '2026-02-17', + '2026-02-18', + '2026-02-19', + '2026-02-20', + '2026-02-23', + '2026-02-24', + '2026-02-25', + '2026-02-26', + '2026-02-27', + '2026-03-02', + '2026-03-03', + '2026-03-04', + '2026-03-05', + '2026-03-06', + '2026-03-09', + '2026-03-10', + '2026-03-11', + '2026-03-12', + '2026-03-13', + '2026-03-16', + '2026-03-17', + '2026-03-18', + '2026-03-19', + '2026-03-20', + '2026-03-23', + '2026-03-24', + '2026-03-25', + '2026-03-26', + '2026-03-27', + '2026-03-30', + '2026-03-31', + '2026-04-01', + '2026-04-02', + '2026-04-06', + '2026-04-07', + '2026-04-08', + '2026-04-09', + '2026-04-10', + '2026-04-13', + '2026-04-14', + '2026-04-15', + '2026-04-16', + '2026-04-17', + '2026-04-20', + '2026-04-21', + '2026-04-22', + '2026-04-23', + '2026-04-24', + '2026-04-27', + '2026-04-28', + '2026-04-29', + '2026-04-30', + '2026-05-01', + '2026-05-04', + '2026-05-05', + '2026-05-06', + '2026-05-07', + '2026-05-08', + '2026-05-11', + '2026-05-12', + '2026-05-13', + '2026-05-14', + '2026-05-15', + '2026-05-18', + '2026-05-19', + '2026-05-20', + '2026-05-21', + '2026-05-22', + '2026-05-26', + '2026-05-27', + '2026-05-28', + '2026-05-29', + '2026-06-01', + '2026-06-02', + '2026-06-03', + '2026-06-04', + '2026-06-05', + '2026-06-08', + '2026-06-09', + '2026-06-10', + '2026-06-11', + '2026-06-12', + '2026-06-15', + '2026-06-16', + '2026-06-17', + '2026-06-18', + '2026-06-22', + '2026-06-23', + '2026-06-24', + '2026-06-25', + '2026-06-26', + '2026-06-29', + '2026-06-30', + '2026-07-01', + '2026-07-02', + '2026-07-06', + '2026-07-07', + '2026-07-08', + '2026-07-09', + '2026-07-10', + '2026-07-13', + '2026-07-14', + '2026-07-15', + '2026-07-16', + '2026-07-17', + '2026-07-20', + '2026-07-21', + '2026-07-22', + '2026-07-23', + '2026-07-24', + '2026-07-27', + '2026-07-28', + '2026-07-29', + '2026-07-30', + '2026-07-31', + '2026-08-03', + '2026-08-04', + '2026-08-05', + '2026-08-06', + '2026-08-07', + '2026-08-10', + '2026-08-11', + '2026-08-12', + '2026-08-13', + '2026-08-14', + '2026-08-17', + '2026-08-18', + '2026-08-19', + '2026-08-20', + '2026-08-21', + '2026-08-24', + '2026-08-25', + '2026-08-26', + '2026-08-27', + '2026-08-28', + '2026-08-31', + '2026-09-01', + '2026-09-02', + '2026-09-03', + '2026-09-04', + '2026-09-08', + '2026-09-09', + '2026-09-10', + '2026-09-11', + '2026-09-14', + '2026-09-15', + '2026-09-16', + '2026-09-17', + '2026-09-18', + '2026-09-21', + '2026-09-22', + '2026-09-23', + '2026-09-24', + '2026-09-25', + '2026-09-28', + '2026-09-29', + '2026-09-30', + '2026-10-01', + '2026-10-02', + '2026-10-05', + '2026-10-06', + '2026-10-07', + '2026-10-08', + '2026-10-09', + '2026-10-13', + '2026-10-14', + '2026-10-15', + '2026-10-16', + '2026-10-19', + '2026-10-20', + '2026-10-21', + '2026-10-22', + '2026-10-23', + '2026-10-26', + '2026-10-27', + '2026-10-28', + '2026-10-29', + '2026-10-30', + '2026-11-02', + '2026-11-03', + '2026-11-04', + '2026-11-05', + '2026-11-06', + '2026-11-09', + '2026-11-10', + '2026-11-12', + '2026-11-13', + '2026-11-16', + '2026-11-17', + '2026-11-18', + '2026-11-19', + '2026-11-20', + '2026-11-23', + '2026-11-24', + '2026-11-25', + '2026-11-27', + '2026-11-30', + '2026-12-01', + '2026-12-02', + '2026-12-03', + '2026-12-04', + '2026-12-07', + '2026-12-08', + '2026-12-09', + '2026-12-10', + '2026-12-11', + '2026-12-14', + '2026-12-15', + '2026-12-16', + '2026-12-17', + '2026-12-18', + '2026-12-21', + '2026-12-22', + '2026-12-23', + '2026-12-24', + '2026-12-28', + '2026-12-29', + '2026-12-30', + '2026-12-31', + ], + non_working_days: [ + { + date: '2026-01-01', + reasons: ['public holiday'], + holiday_name: "New Year's Day", + }, + { + date: '2026-01-03', + reasons: ['weekend'], + }, + { + date: '2026-01-04', + reasons: ['weekend'], + }, + { + date: '2026-01-10', + reasons: ['weekend'], + }, + { + date: '2026-01-11', + reasons: ['weekend'], + }, + { + date: '2026-01-17', + reasons: ['weekend'], + }, + { + date: '2026-01-18', + reasons: ['weekend'], + }, + { + date: '2026-01-19', + reasons: ['public holiday'], + holiday_name: 'Martin Luther King, Jr. Day', + }, + { + date: '2026-01-24', + reasons: ['weekend'], + }, + { + date: '2026-01-25', + reasons: ['weekend'], + }, + { + date: '2026-01-31', + reasons: ['weekend'], + }, + { + date: '2026-02-01', + reasons: ['weekend'], + }, + { + date: '2026-02-07', + reasons: ['weekend'], + }, + { + date: '2026-02-08', + reasons: ['weekend'], + }, + { + date: '2026-02-14', + reasons: ['weekend'], + }, + { + date: '2026-02-15', + reasons: ['weekend'], + }, + { + date: '2026-02-16', + reasons: ['public holiday'], + holiday_name: 'Presidents Day', + }, + { + date: '2026-02-21', + reasons: ['weekend'], + }, + { + date: '2026-02-22', + reasons: ['weekend'], + }, + { + date: '2026-02-28', + reasons: ['weekend'], + }, + { + date: '2026-03-01', + reasons: ['weekend'], + }, + { + date: '2026-03-07', + reasons: ['weekend'], + }, + { + date: '2026-03-08', + reasons: ['weekend'], + }, + { + date: '2026-03-14', + reasons: ['weekend'], + }, + { + date: '2026-03-15', + reasons: ['weekend'], + }, + { + date: '2026-03-21', + reasons: ['weekend'], + }, + { + date: '2026-03-22', + reasons: ['weekend'], + }, + { + date: '2026-03-28', + reasons: ['weekend'], + }, + { + date: '2026-03-29', + reasons: ['weekend'], + }, + { + date: '2026-04-03', + reasons: ['public holiday'], + holiday_name: 'Good Friday', + }, + { + date: '2026-04-04', + reasons: ['weekend'], + }, + { + date: '2026-04-05', + reasons: ['weekend'], + }, + { + date: '2026-04-11', + reasons: ['weekend'], + }, + { + date: '2026-04-12', + reasons: ['weekend'], + }, + { + date: '2026-04-18', + reasons: ['weekend'], + }, + { + date: '2026-04-19', + reasons: ['weekend'], + }, + { + date: '2026-04-25', + reasons: ['weekend'], + }, + { + date: '2026-04-26', + reasons: ['weekend'], + }, + { + date: '2026-05-02', + reasons: ['weekend'], + }, + { + date: '2026-05-03', + reasons: ['weekend'], + }, + { + date: '2026-05-09', + reasons: ['weekend'], + }, + { + date: '2026-05-10', + reasons: ['weekend'], + }, + { + date: '2026-05-16', + reasons: ['weekend'], + }, + { + date: '2026-05-17', + reasons: ['weekend'], + }, + { + date: '2026-05-23', + reasons: ['weekend'], + }, + { + date: '2026-05-24', + reasons: ['weekend'], + }, + { + date: '2026-05-25', + reasons: ['public holiday'], + holiday_name: 'Memorial Day', + }, + { + date: '2026-05-30', + reasons: ['weekend'], + }, + { + date: '2026-05-31', + reasons: ['weekend'], + }, + { + date: '2026-06-06', + reasons: ['weekend'], + }, + { + date: '2026-06-07', + reasons: ['weekend'], + }, + { + date: '2026-06-13', + reasons: ['weekend'], + }, + { + date: '2026-06-14', + reasons: ['weekend'], + }, + { + date: '2026-06-19', + reasons: ['public holiday'], + holiday_name: 'Juneteenth National Independence Day', + }, + { + date: '2026-06-20', + reasons: ['weekend'], + }, + { + date: '2026-06-21', + reasons: ['weekend'], + }, + { + date: '2026-06-27', + reasons: ['weekend'], + }, + { + date: '2026-06-28', + reasons: ['weekend'], + }, + { + date: '2026-07-03', + reasons: ['public holiday'], + holiday_name: 'Independence Day', + }, + { + date: '2026-07-04', + reasons: ['weekend'], + }, + { + date: '2026-07-05', + reasons: ['weekend'], + }, + { + date: '2026-07-11', + reasons: ['weekend'], + }, + { + date: '2026-07-12', + reasons: ['weekend'], + }, + { + date: '2026-07-18', + reasons: ['weekend'], + }, + { + date: '2026-07-19', + reasons: ['weekend'], + }, + { + date: '2026-07-25', + reasons: ['weekend'], + }, + { + date: '2026-07-26', + reasons: ['weekend'], + }, + { + date: '2026-08-01', + reasons: ['weekend'], + }, + { + date: '2026-08-02', + reasons: ['weekend'], + }, + { + date: '2026-08-08', + reasons: ['weekend'], + }, + { + date: '2026-08-09', + reasons: ['weekend'], + }, + { + date: '2026-08-15', + reasons: ['weekend'], + }, + { + date: '2026-08-16', + reasons: ['weekend'], + }, + { + date: '2026-08-22', + reasons: ['weekend'], + }, + { + date: '2026-08-23', + reasons: ['weekend'], + }, + { + date: '2026-08-29', + reasons: ['weekend'], + }, + { + date: '2026-08-30', + reasons: ['weekend'], + }, + { + date: '2026-09-05', + reasons: ['weekend'], + }, + { + date: '2026-09-06', + reasons: ['weekend'], + }, + { + date: '2026-09-07', + reasons: ['public holiday'], + holiday_name: 'Labor Day', + }, + { + date: '2026-09-12', + reasons: ['weekend'], + }, + { + date: '2026-09-13', + reasons: ['weekend'], + }, + { + date: '2026-09-19', + reasons: ['weekend'], + }, + { + date: '2026-09-20', + reasons: ['weekend'], + }, + { + date: '2026-09-26', + reasons: ['weekend'], + }, + { + date: '2026-09-27', + reasons: ['weekend'], + }, + { + date: '2026-10-03', + reasons: ['weekend'], + }, + { + date: '2026-10-04', + reasons: ['weekend'], + }, + { + date: '2026-10-10', + reasons: ['weekend'], + }, + { + date: '2026-10-11', + reasons: ['weekend'], + }, + { + date: '2026-10-12', + reasons: ['public holiday'], + holiday_name: "Indigenous Peoples' Day", + }, + { + date: '2026-10-17', + reasons: ['weekend'], + }, + { + date: '2026-10-18', + reasons: ['weekend'], + }, + { + date: '2026-10-24', + reasons: ['weekend'], + }, + { + date: '2026-10-25', + reasons: ['weekend'], + }, + { + date: '2026-10-31', + reasons: ['weekend'], + }, + { + date: '2026-11-01', + reasons: ['weekend'], + }, + { + date: '2026-11-07', + reasons: ['weekend'], + }, + { + date: '2026-11-08', + reasons: ['weekend'], + }, + { + date: '2026-11-11', + reasons: ['public holiday'], + holiday_name: 'Veterans Day', + }, + { + date: '2026-11-14', + reasons: ['weekend'], + }, + { + date: '2026-11-15', + reasons: ['weekend'], + }, + { + date: '2026-11-21', + reasons: ['weekend'], + }, + { + date: '2026-11-22', + reasons: ['weekend'], + }, + { + date: '2026-11-26', + reasons: ['public holiday'], + holiday_name: 'Thanksgiving Day', + }, + { + date: '2026-11-28', + reasons: ['weekend'], + }, + { + date: '2026-11-29', + reasons: ['weekend'], + }, + { + date: '2026-12-05', + reasons: ['weekend'], + }, + { + date: '2026-12-06', + reasons: ['weekend'], + }, + { + date: '2026-12-12', + reasons: ['weekend'], + }, + { + date: '2026-12-13', + reasons: ['weekend'], + }, + { + date: '2026-12-19', + reasons: ['weekend'], + }, + { + date: '2026-12-20', + reasons: ['weekend'], + }, + { + date: '2026-12-25', + reasons: ['public holiday'], + holiday_name: 'Christmas Day', + }, + { + date: '2026-12-26', + reasons: ['weekend'], + }, + { + date: '2026-12-27', + reasons: ['weekend'], + }, + ], + year: 2026, + }, + internetDomain: { + domain: 'example.com', + available: false, + creation_date: 808372800, + expiration_date: 1818129600, + registrar: 'reserved-internet assigned numbers authority', + age_days: 11323, + }, + internetDnsRecords: [ + { + record_type: 'A', + value: 'Available for premium subscribers only.', + }, + ], + internetMxRecords: [ + { + priority: 0, + value: '.', + }, + ], + internetIpLookup: { + is_valid: true, + country: 'United States', + country_code: 'US', + region_code: 'VA', + region: 'Virginia', + city: 'Exampleton', + zip: 'Available for premium subscribers only.', + lat: 39.03, + lon: -77.5, + timezone: 'America/New_York', + isp: 'Available for premium subscribers only.', + address: '1 Example Street, Exampleton, EX 00000', + }, + internetUrlLookup: { + is_valid: true, + country: 'Canada', + country_code: 'CA', + region_code: 'ON', + region: 'Ontario', + city: 'Exampleton', + zip: 'M5A', + lat: 43.6532, + lon: -79.3832, + timezone: 'America/Toronto', + isp: 'Cloudflare, Inc.', + url: 'example.com', + }, + internetWebpage: { + url: 'https://example.com', + domain: 'example.com', + url_path: '', + url_parameters: {}, + page_title: 'Example Domain', + page_description: '', + meta_tags: { + viewport: 'width=device-width, initial-scale=1', + }, + favicon: 'data:,', + }, + internetScrape: { + data: 'Example Domain

Example Domain

This domain is for use in documentation examples without needing permission. Avoid use in operations.

Learn more

\n', + }, + internetUserAgent: { + user_agent: 'Mozilla/5.0 (Example) ExampleBrowser/1.0', + }, + validationEmail: { + is_valid: false, + email: 'someone@example.com', + is_disposable: false, + is_public: false, + main_category: null, + sub_category: null, + }, + validationDisposableEmail: { + email: 'someone@example.com', + domain: 'mailinator.com', + is_disposable: true, + }, + validationPhone: { + is_valid: true, + is_formatted_properly: true, + country: 'United States', + location: 'San Francisco, CA', + timezones: ['America/Los_Angeles'], + format_national: '(415) 555-2671', + format_international: '+1 415-555-2671', + format_e164: '+14155552671', + country_code: 1, + }, + validationRoutingNumber: [ + { + bank_name: 'This field is for premium subscribers only.', + routing_number: '121000248', + street_address: '1 Example Street', + city: 'Exampleton', + state: 'Example State', + zip_code: '55401', + country: 'USA', + county: 'Hennepin', + timezone: 'CST', + latitude: '44.9847', + longitude: '-93.2709', + phone_number: 'This field is for premium subscribers only.', + ach_supported: true, + fedwire_supported: true, + checksum_valid: true, + }, + ], + validationIban: { + iban: 'GB00EXAM00000000000000', + bank_name: 'This field is for premium subscribers only.', + bank_address: 'This field is for premium subscribers only.', + account_number: '0532013000', + bank_code: '37040044', + country: 'DE', + checksum: '89', + valid: 'This field is for premium subscribers only.', + invalid_reason: 'This field is for premium subscribers only.', + bban: '370400440532013000', + swift_code: 'This field is for premium subscribers only.', + }, + validationBin: [ + { + bin: '411111', + country_iso2: 'PL', + country: 'Poland', + brand: 'Brand information is for premium subscribers only', + type: 'Type information is for premium subscribers only', + categories: 'Category information is for premium subscribers only', + issuer: 'Issuer information is for premium subscribers only', + is_valid: 'BIN validation is available for premium subscribers only', + }, + ], + validationSwiftCode: [ + { + swift_code: 'BOFAUS3N', + bank_name: 'Bank name is for premium subscribers only.', + address: '1 Example Street, Exampleton, EX 00000', + city: 'Exampleton', + region: 'Region is for premium subscribers only.', + postal_code: '00000', + country: 'United States', + country_code: 'US', + }, + ], + marketsStockPrice: { + ticker: 'AAPL', + name: 'Example Name', + price: 305.79, + exchange: 'This field is for premium subscribers only.', + updated: 1786733311, + currency: 'This field is for premium subscribers only.', + volume: 16020092.94593, + }, + marketsTicker: { + name: 'Example Name', + ticker: 'AAPL', + chief_executive_officer: 'Timothy D. Cook', + address: { + address: '1 Example Street, Exampleton, EX 00000', + city: 'Exampleton', + state: 'Example State', + zip: '95014', + }, + latest_price: 'latest_price is reserved for premium subscribers only.', + latest_market_cap: + 'latest_market_cap is reserved for premium subscribers only.', + latest_dividend: + 'latest_dividend is reserved for premium subscribers only.', + cik: '0000320193', + cusip: '037833100', + isin: 'US0378331005', + exchange: 'NASDAQ', + website: 'https://www.apple.com', + phone_number: '(408) 996-1010', + ipo_date: 'ipo_date is reserved for premium subscribers only.', + latest_earnings: { + year: 2026, + quarter: 3, + }, + sector: 'sector is reserved for premium subscribers only.', + industry: 'industry is reserved for premium subscribers only.', + sic_code: 'sic_code is reserved for premium subscribers only.', + sic_description: + 'sic_description is reserved for premium subscribers only.', + }, + marketsStockExchanges: [ + { + mic: 'XNAS', + name: 'Example Name', + city: 'Exampleton', + country: 'United States', + iso2: 'US', + description: + 'The NASDAQ Global Market is a major stock market in the United States, known for its high-tech listings and electronic trading platform.', + address: '1 Example Street, Exampleton, EX 00000', + website: 'https://www.nasdaq.com', + founded: '1971', + num_listings: 3300, + market_cap_usd: 19000000000000, + currency: 'USD', + timezone: 'America/New_York', + }, + ], + marketsSp500: [ + { + ticker: 'MSFT', + company_name: 'Microsoft', + sector: 'Information Technology', + date_added: '1994-06-01', + cik: '0000789019', + sub_industry: 'Systems Software', + headquarters: 'Redmond, Washington', + }, + ], + marketsMarketCap: { + ticker: 'NVDA', + name: 'Example Name', + market_cap: 5451904890000, + currency: 'USD', + updated: 1786733319, + }, + marketsEarnings: [ + { + company_info: { + ticker: 'AAPL', + cik: '320193', + company_name: 'Apple Inc.', + fiscal_year: 2026, + fiscal_quarter: 3, + }, + income_statement: { + weighted_average_shares_basic: 14656110000, + weighted_average_shares_diluted: 14714676000, + earnings_per_share_basic: 2.03, + earnings_per_share_diluted: 2.02, + total_revenue: 109417000000, + cost_of_revenue: 54647000000, + gross_profit: 54770000000, + research_and_development: 11729000000, + general_and_administrative: 2312000000, + sales_and_marketing: 5034000000, + operating_income: 35695000000, + interest_expense: null, + tax_provision: 6478000000, + net_income: 29789000000, + net_income_available_to_common: null, + depreciation_and_amortization: 9973000000, + stock_based_compensation: 10523000000, + }, + balance_sheet: { + cash_and_equivalents: 39544000000, + accounts_receivable: 31398000000, + inventory: 11092000000, + current_assets: 149818000000, + property_plant_equipment: 51431000000, + goodwill: null, + intangible_assets: 25417000000, + total_assets: 383266000000, + accounts_payable: 64525000000, + current_liabilities: 149326000000, + long_term_debt: 71340000000, + total_debt: 82347000000, + total_liabilities: 275746000000, + stockholders_equity: 107520000000, + retained_earnings: 11326000000, + working_capital: 492000000, + temporary_equity: null, + }, + cash_flow: { + operating_cash_flow: 116996000000, + capital_expenditures: 6799000000, + free_cash_flow: 110197000000, + dividends_paid: 11778000000, + share_repurchases: 62094000000, + net_cash_investing: -18811000000, + net_cash_financing: -94575000000, + }, + filing_info: { + filing_type: '10-Q', + filing_date: '2026-07-31', + period_end_date: '2026-06-27', + }, + }, + ], + marketsEarningsCalendar: [ + { + date: '2026-07-30', + ticker: 'AAPL', + earnings_timing: + 'This field is available for premium subscribers only. Please visit https://api-ninjas.com/pricing to upgrade.', + earnings_call_timestamp: + 'This field is available for premium subscribers only. Please visit https://api-ninjas.com/pricing to upgrade.', + actual_revenue: 109417000000, + estimated_revenue: + 'This field is available for premium subscribers only. Please visit https://api-ninjas.com/pricing to upgrade.', + revenue_difference: + 'This field is available for premium subscribers only. Please visit https://api-ninjas.com/pricing to upgrade.', + revenue_difference_pct: + 'This field is available for premium subscribers only. Please visit https://api-ninjas.com/pricing to upgrade.', + actual_eps: 2.02, + estimated_eps: + 'This field is available for premium subscribers only. Please visit https://api-ninjas.com/pricing to upgrade.', + eps_difference: + 'This field is available for premium subscribers only. Please visit https://api-ninjas.com/pricing to upgrade.', + eps_difference_pct: + 'This field is available for premium subscribers only. Please visit https://api-ninjas.com/pricing to upgrade.', + report_date_status: + 'This field is available to Business-tier subscribers and above. Please visit https://api-ninjas.com/pricing to upgrade.', + date_confirmed: + 'This field is available to Business-tier subscribers and above. Please visit https://api-ninjas.com/pricing to upgrade.', + report_datetime: + 'This field is available to Business-tier subscribers and above. Please visit https://api-ninjas.com/pricing to upgrade.', + sec_8k_url: + 'This field is available to Business-tier subscribers and above. Please visit https://api-ninjas.com/pricing to upgrade.', + eps_beat_miss: + 'This field is available to Business-tier subscribers and above. Please visit https://api-ninjas.com/pricing to upgrade.', + revenue_beat_miss: + 'This field is available to Business-tier subscribers and above. Please visit https://api-ninjas.com/pricing to upgrade.', + eps_surprise_streak: + 'This field is available to Business-tier subscribers and above. Please visit https://api-ninjas.com/pricing to upgrade.', + avg_eps_surprise_pct_4q: + 'This field is available to Business-tier subscribers and above. Please visit https://api-ninjas.com/pricing to upgrade.', + eps_sue: + 'This field is available to Business-tier subscribers and above. Please visit https://api-ninjas.com/pricing to upgrade.', + last_earnings_move_pct: + 'This field is available to Business-tier subscribers and above. Please visit https://api-ninjas.com/pricing to upgrade.', + avg_earnings_move_pct: + 'This field is available to Business-tier subscribers and above. Please visit https://api-ninjas.com/pricing to upgrade.', + days_to_next_earnings: + 'This field is available to Business-tier subscribers and above. Please visit https://api-ninjas.com/pricing to upgrade.', + next_earnings_date: + 'This field is available to Business-tier subscribers and above. Please visit https://api-ninjas.com/pricing to upgrade.', + has_transcript: + 'This field is available to Business-tier subscribers and above. Please visit https://api-ninjas.com/pricing to upgrade.', + surprise_history: + 'This field is available to Business-tier subscribers and above. Please visit https://api-ninjas.com/pricing to upgrade.', + }, + ], + marketsInsiderTransactions: [ + { + accession_number: '0000789019-26-000143', + form: 'Form 4', + filing_date: '2026-08-06', + sec_filing_url: + 'https://www.sec.gov/Archives/edgar/data/1868758/0000789019-26-000143-index.html', + cik: '1868758', + ticker: 'MSFT', + company_name: 'MICROSOFT CORP', + insider_name: 'Judson Althoff', + insider_position: 'CEO Microsoft Commercial', + transaction_code: 'S', + transaction_name: 'Open Market Sale', + transaction_type: 'sale', + transaction_price: 487.893, + shares: 10000, + transaction_value: 4878930, + pre_transaction_shares: 110447, + pre_transaction_shares_value: 53886318.171, + remaining_shares: 100447, + remaining_shares_value: 49007388.171, + }, + ], + marketsSecFilings: [ + { + ticker: 'AAPL', + filing_date: '2025-10-31', + filing_url: + 'https://www.sec.gov/Archives/edgar/data/320193/000032019325000079/aapl-20250927.htm', + form_type: '10-K', + }, + ], + marketsEtf: { + etf_ticker: 'SPY', + price: 'This data is for premium users only.', + etf_name: 'State Street SPDR S&P 500 ETF', + isin: 'US78462F1030', + cusip: '78462F103', + country: 'US', + domicile: 'US', + expense_ratio: 'This data is for premium users only.', + aum: 'This data is for premium users only.', + aum_currency: 'This data is for premium users only.', + aum_usd: 'This data is for premium users only.', + holdings: 'This data is for premium users only.', + num_holdings: 'This data is for premium users only.', + }, + marketsMutualFund: { + fund_ticker: 'VFIAX', + fund_name: 'Vanguard 500 Index Fund Admiral Shares', + isin: 'US9229087104', + cusip: '922908710', + country: 'US', + expense_ratio: 'This field is for premium subscribers only.', + aum: 'This field is for premium subscribers only.', + price: 'This field is for premium subscribers only.', + holdings: 'This field is for premium subscribers only.', + num_holdings: 'This field is for premium subscribers only.', + }, + marketsCryptoPrice: { + symbol: 'BTCUSDT', + price: '62886.03000000', + timestamp: 1786733334, + }, + marketsBitcoin: { + price: '62886.03000000', + timestamp: 1786733335, + '24h_price_change': '-328.06000000', + '24h_price_change_percent': '-0.519', + '24h_high': '63648.30000000', + '24h_low': '62541.43000000', + '24h_volume': '86.19515000', + }, + marketsCommodityPrice: { + exchange: 'CME', + name: 'Example Name', + value: 'gold', + unit: 'troy_ounce', + currency_unit: 'USD', + price: 4438.4, + change_24h_percent: 0.4072, + change_24h: 18, + low_24h: 4365.5, + high_24h: 4454.6, + previous_close: 4420.4, + updated: 1786732736, + }, + economicsGdp: [ + { + country: 'USA', + year: 1980, + gdp_growth: -0.3, + gdp_nominal: 2857.325, + gdp_per_capita_nominal: 12552.943, + gdp_ppp: 2857.325, + gdp_per_capita_ppp: 12552.943, + gdp_ppp_share: 21.579, + }, + ], + economicsUnemployment: [ + { + country: 'USA', + year: 1980, + unemployment_rate: 7.2, + }, + ], + economicsPopulation: { + historical_population: [ + { + year: 2024, + population: 123753041, + yearly_change_percentage: -0.5, + yearly_change: -617906, + migrants: 153357, + median_age: 49.4, + fertility_rate: 1.22, + density: 339, + urban_population_pct: 92.9, + urban_population: 114979260, + percentage_of_world_population: 1.52, + rank: 12, + }, + { + year: 2023, + population: 124370947, + yearly_change_percentage: -0.5, + yearly_change: -626631, + migrants: 175003, + median_age: 49, + fertility_rate: 1.21, + density: 341, + urban_population_pct: 92.7, + urban_population: 115292289, + percentage_of_world_population: 1.54, + rank: 12, + }, + { + year: 2022, + population: 124997578, + yearly_change_percentage: -0.54, + yearly_change: -681760, + migrants: 175003, + median_age: 48.5, + fertility_rate: 1.26, + density: 343, + urban_population_pct: 92.5, + urban_population: 115583843, + percentage_of_world_population: 1.56, + rank: 12, + }, + { + year: 2020, + population: 126304543, + yearly_change_percentage: -0.31, + yearly_change: -394881, + migrants: 42001, + median_age: 47.7, + fertility_rate: 1.3, + density: 346, + urban_population_pct: 91.9, + urban_population: 116099672, + percentage_of_world_population: 1.6, + rank: 11, + }, + { + year: 2015, + population: 127275872, + yearly_change_percentage: -0.14, + yearly_change: -181881, + migrants: 168896, + median_age: 45.8, + fertility_rate: 1.42, + density: 349, + urban_population_pct: 91.9, + urban_population: 116944428, + percentage_of_world_population: 1.7, + rank: 10, + }, + { + year: 2010, + population: 128185275, + yearly_change_percentage: 0.04, + yearly_change: 54389, + migrants: 131860, + median_age: 44.2, + fertility_rate: 1.36, + density: 352, + urban_population_pct: 91.1, + urban_population: 116741034, + percentage_of_world_population: 1.83, + rank: 10, + }, + { + year: 2005, + population: 127913330, + yearly_change_percentage: 0.14, + yearly_change: 177108, + migrants: 113017, + median_age: 42.6, + fertility_rate: 1.25, + density: 351, + urban_population_pct: 86.3, + urban_population: 110340709, + percentage_of_world_population: 1.94, + rank: 10, + }, + { + year: 2000, + population: 127027789, + yearly_change_percentage: 0.21, + yearly_change: 271025, + migrants: 23468, + median_age: 40.8, + fertility_rate: 1.35, + density: 348, + urban_population_pct: 79, + urban_population: 100303716, + percentage_of_world_population: 2.06, + rank: 9, + }, + { + year: 1995, + population: 125672665, + yearly_change_percentage: 0.37, + yearly_change: 454580, + migrants: 87714, + median_age: 39, + fertility_rate: 1.41, + density: 345, + urban_population_pct: 78.5, + urban_population: 98593178, + percentage_of_world_population: 2.18, + rank: 8, + }, + { + year: 1990, + population: 123399765, + yearly_change_percentage: 0.42, + yearly_change: 513520, + migrants: 124582, + median_age: 36.9, + fertility_rate: 1.51, + density: 338, + urban_population_pct: 78, + urban_population: 96298507, + percentage_of_world_population: 2.32, + rank: 7, + }, + { + year: 1985, + population: 120832163, + yearly_change_percentage: 0.41, + yearly_change: 494681, + migrants: -141323, + median_age: 34.5, + fertility_rate: 1.74, + density: 331, + urban_population_pct: 77.4, + urban_population: 93507944, + percentage_of_world_population: 2.48, + rank: 7, + }, + { + year: 1980, + population: 118358756, + yearly_change_percentage: 0.87, + yearly_change: 1007512, + migrants: -92314, + median_age: 31.7, + fertility_rate: 1.74, + density: 325, + urban_population_pct: 75.8, + urban_population: 89755553, + percentage_of_world_population: 2.66, + rank: 7, + }, + { + year: 1975, + population: 113321196, + yearly_change_percentage: 1.21, + yearly_change: 1321766, + migrants: 107, + median_age: 29.7, + fertility_rate: 1.92, + density: 311, + urban_population_pct: 75.1, + urban_population: 85121987, + percentage_of_world_population: 2.78, + rank: 6, + }, + { + year: 1970, + population: 106712368, + yearly_change_percentage: 1.12, + yearly_change: 1159270, + migrants: 33731, + median_age: 28.3, + fertility_rate: 2.04, + density: 293, + urban_population_pct: 70.7, + urban_population: 75417163, + percentage_of_world_population: 2.89, + rank: 6, + }, + { + year: 1965, + population: 100916019, + yearly_change_percentage: 0.92, + yearly_change: 903253, + migrants: -50667, + median_age: 26.6, + fertility_rate: 2.09, + density: 277, + urban_population_pct: 66.2, + urban_population: 66812422, + percentage_of_world_population: 3.03, + rank: 5, + }, + { + year: 1960, + population: 96399754, + yearly_change_percentage: 0.85, + yearly_change: 794855, + migrants: -150371, + median_age: 24.7, + fertility_rate: 1.98, + density: 264, + urban_population_pct: 61.5, + urban_population: 59269408, + percentage_of_world_population: 3.2, + rank: 5, + }, + { + year: 1955, + population: 92425478, + yearly_change_percentage: 1.35, + yearly_change: 1196440, + migrants: -95975, + median_age: 22.7, + fertility_rate: 2.35, + density: 254, + urban_population_pct: 56.3, + urban_population: 52005319, + percentage_of_world_population: 3.37, + rank: 5, + }, + ], + population_forecast: [ + { + year: 2025, + population: 123103479, + yearly_change_percentage: -0.51, + yearly_change: -640213, + migrants: 140579, + median_age: 49.8, + fertility_rate: 1.23, + density: 338, + urban_population_pct: 93.1, + urban_population: 114645589, + percentage_of_world_population: 1.5, + rank: 12, + }, + { + year: 2030, + population: 119584121, + yearly_change_percentage: -0.58, + yearly_change: -703872, + migrants: 123993, + median_age: 51.5, + fertility_rate: 1.26, + density: 328, + urban_population_pct: 94.3, + urban_population: 112710068, + percentage_of_world_population: 1.4, + rank: 15, + }, + { + year: 2035, + population: 115876149, + yearly_change_percentage: -0.63, + yearly_change: -741594, + migrants: 116871, + median_age: 52.5, + fertility_rate: 1.29, + density: 318, + urban_population_pct: 95.3, + urban_population: 110450118, + percentage_of_world_population: 1.3, + rank: 15, + }, + { + year: 2040, + population: 112158303, + yearly_change_percentage: -0.65, + yearly_change: -743569, + migrants: 118186, + median_age: 53.1, + fertility_rate: 1.31, + density: 308, + urban_population_pct: 96.3, + urban_population: 107981843, + percentage_of_world_population: 1.22, + rank: 15, + }, + { + year: 2045, + population: 108551995, + yearly_change_percentage: -0.65, + yearly_change: -721262, + migrants: 113424, + median_age: 53, + fertility_rate: 1.33, + density: 298, + urban_population_pct: 97.2, + urban_population: 105471938, + percentage_of_world_population: 1.15, + rank: 17, + }, + { + year: 2050, + population: 105123167, + yearly_change_percentage: -0.64, + yearly_change: -685766, + migrants: '', + median_age: 52.8, + fertility_rate: 1.35, + density: 288, + urban_population_pct: 98, + urban_population: 103038909, + percentage_of_world_population: 1.09, + rank: 17, + }, + ], + country_name: 'Japan', + }, + economicsMortgageRate: [ + { + date: '2026-08-12', + frm_30: '6.652', + frm_15: '5.991', + }, + ], + economicsMortgageCalculator: { + monthly_payment: { + total: 1796, + mortgage: 1796, + property_tax: 0, + hoa: 0, + annual_home_ins: 0, + }, + annual_payment: { + total: 21554, + mortgage: 21554, + property_tax: 0, + hoa: 0, + home_insurance: 0, + }, + total_interest_paid: 246624, + }, + economicsIncomeTax: { + country: 'US', + year: 2024, + fica: 'premium subscription required.', + states: 'premium subscription required.', + federal: { + married: { + brackets: [ + { + rate: 0.1, + min: 1, + max: 23200, + }, + { + rate: 0.12, + min: 23201, + max: 94300, + }, + { + rate: 0.22, + min: 94301, + max: 201050, + }, + { + rate: 0.24, + min: 201051, + max: 383900, + }, + { + rate: 0.32, + min: 383901, + max: 487450, + }, + { + rate: 0.35, + min: 487451, + max: 731200, + }, + { + rate: 0.37, + min: 731201, + max: 'Infinity', + }, + ], + }, + married_separate: { + brackets: [ + { + rate: 0.1, + min: 1, + max: 11600, + }, + { + rate: 0.12, + min: 11601, + max: 47150, + }, + { + rate: 0.22, + min: 47151, + max: 100525, + }, + { + rate: 0.24, + min: 100526, + max: 191950, + }, + { + rate: 0.32, + min: 191951, + max: 243725, + }, + { + rate: 0.35, + min: 243726, + max: 365600, + }, + { + rate: 0.37, + min: 365601, + max: 'Infinity', + }, + ], + }, + single: { + brackets: [ + { + rate: 0.1, + min: 1, + max: 11600, + }, + { + rate: 0.12, + min: 11601, + max: 47150, + }, + { + rate: 0.22, + min: 47151, + max: 100525, + }, + { + rate: 0.24, + min: 100526, + max: 191950, + }, + { + rate: 0.32, + min: 191951, + max: 243725, + }, + { + rate: 0.35, + min: 243726, + max: 609350, + }, + { + rate: 0.37, + min: 609351, + max: 'Infinity', + }, + ], + }, + head_of_household: { + brackets: [ + { + rate: 0.1, + min: 1, + max: 16550, + }, + { + rate: 0.12, + min: 16551, + max: 63100, + }, + { + rate: 0.22, + min: 63101, + max: 100500, + }, + { + rate: 0.24, + min: 100501, + max: 191950, + }, + { + rate: 0.32, + min: 191951, + max: 243700, + }, + { + rate: 0.35, + min: 243701, + max: 609350, + }, + { + rate: 0.37, + min: 609351, + max: 'Infinity', + }, + ], + }, + }, + }, + economicsIncomeTaxCalculator: { + country: 'US', + region: 'CALIFORNIA', + income: 100000, + taxable_income: 100000, + deductions: 0, + credits: 0, + tax_year: '2026', + federal_effective_rate: 0.1671188, + federal_taxes_owed: 16711.88, + fica_social_security: 'premium subscription required', + fica_social_security_rate: 'premium subscription required', + fica_social_security_cap: 'premium subscription required', + fica_medicare: 'premium subscription required', + fica_medicare_rate: 'premium subscription required', + fica_total: 'premium subscription required', + region_effective_rate: 'premium subscription required', + region_taxes_owed: 'premium subscription required', + total_taxes_owed: 'premium subscription required', + income_after_tax: 'premium subscription required', + total_effective_tax_rate: 'premium subscription required', + }, + economicsSalesTax: [ + { + zip_code: '90210', + state_rate: '0.06', + city_rate: 'This field is for premium subscribers only', + county_rate: 'This field is for premium subscribers only', + additional_rate: 'This field is for premium subscribers only', + total_rate: 'This field is for premium subscribers only', + }, + ], + economicsSalesTaxCalculator: [ + { + zip_code: '90210', + pre_tax_amount: '100', + state_rate: 0.06, + total_rate: 'This field is for premium subscribers only', + city_rate: 'This field is for premium subscribers only', + county_rate: 'This field is for premium subscribers only', + additional_rate: 'This field is for premium subscribers only', + state_tax: 6, + city_tax: 'This field is for premium subscribers only', + county_tax: 'This field is for premium subscribers only', + additional_tax: 'This field is for premium subscribers only', + total_tax: 'This field is for premium subscribers only', + total_price: 'This field is for premium subscribers only', + }, + ], + economicsPropertyTax: [ + { + state: 'Example State', + county: 'Los Angeles', + city: 'Exampleton', + zip: '90210', + property_tax_25th_percentile: 0.0117039, + property_tax_50th_percentile: 0.0117863, + property_tax_75th_percentile: 0.0119225, + }, + ], + economicsVatRates: [ + { + country: 'DE', + type: 'exempted', + rate: 'This rate is reserved for premium subscribers only. Please visit https://api-ninjas.com/pricing for more information.', + date: '2026-07-01', + category: 'Supply of electricity', + }, + ], + textSentiment: { + score: 0.593, + text: 'I am loving this new integration', + sentiment: 'POSITIVE', + }, + textSimilarity: { + similarity: 0.919060468673706, + }, + textEmbeddings: { + embeddings: [ + -0.007610933855175972, -0.10587363690137863, 0.02516034245491028, + ], + }, + textLanguage: { + iso: 'de', + language: 'German', + }, + textSpellCheck: { + original: 'helo wrld thsi is a tset', + corrected: 'help world this is a set', + corrections: [ + { + word: 'tset', + index: 5, + correction: 'set', + candidates: ['stet', 'tet', 'test', 'tret', 'set'], + }, + { + word: 'helo', + index: 0, + correction: 'help', + candidates: [ + 'helm', + 'hero', + 'held', + 'hel', + 'help', + 'halo', + 'hell', + 'helot', + 'hello', + ], + }, + { + word: 'wrld', + index: 1, + correction: 'world', + candidates: ['weld', 'wild', 'wald', 'wold', 'world'], + }, + { + word: 'thsi', + index: 2, + correction: 'this', + candidates: ['this', 'thai'], + }, + ], + }, + textProfanityFilter: { + original: 'damn this thing', + censored: '**** this thing', + has_profanity: true, + }, + textDictionary: { + definition: 'See Halloo.', + word: 'hello', + valid: true, + }, + textThesaurus: { + word: 'happy', + synonyms: [ + 'fortunate', + 'lucky', + 'convenient', + 'favorable', + 'fortuitous', + 'coincidental', + 'unexpected', + 'promising', + 'hopeful', + 'providential', + 'chance', + 'heaven-sent', + 'timely', + 'bright', + 'flukey', + 'beneficial', + 'encouraging', + 'fluky', + 'good', + 'opportune', + 'benign', + 'seasonable', + 'profitable', + 'unforeseen', + 'accidental', + 'propitious', + 'auspicious', + 'fair', + 'heartening', + 'golden', + 'advantageous', + 'serendipitous', + 'unlooked-for', + 'halcyon', + ], + antonyms: [ + 'unhappy', + 'unfortunate', + 'unlucky', + 'hapless', + 'expected', + 'luckless', + 'anticipated', + 'inconvenient', + 'untimely', + 'foreseen', + 'intentional', + 'deliberate', + 'planned', + 'inopportune', + 'unseasonable', + 'ill-fated', + 'ill-starred', + 'star-crossed', + 'disastrous', + 'catastrophic', + 'inauspicious', + 'unpromising', + 'calamitous', + ], + }, + textRhymes: ['arnatt'], + textRandomWord: ['unpleasantly'], + textLoremIpsum: { + text: 'Lorem ipsum aliquet sagittis id consectetur purus ut faucibus pulvinar elementum integer. Quis vel eros donec ac odio tempor orci. Augue eget arcu dictum varius duis at consectetur lorem donec. Donec adipiscing tristique risus nec feugiat in fermentum posuere. Duis at tellus at urna condimentum mattis pellentesque id nibh. Sed nisi lacus sed viverra tellus. Tortor aliquam nulla facilisi cras fermentum odio eu. Vulputate mi sit amet mauris commodo quis imperdiet massa. Vulputate enim nulla aliquet porttitor lacus luctus. Cursus vitae congue mauris rhoncus aenean vel elit scelerisque. Venenatis tellus in metus vulputate eu. Neque sodales ut etiam sit amet. Id interdum velit laoreet id donec ultrices tincidunt arcu. Venenatis a condimentum vitae sapien pellentesque habitant morbi tristique. Dolor sit amet consectetur adipiscing elit pellentesque habitant morbi. Viverra vitae congue eu consequat ac. Pretium nibh ipsum consequat nisl vel pretium lectus quam id. Volutpat diam ut venenatis tellus in metus vulputate eu. Nibh tellus molestie nunc non.\n', + }, + utilityQrCode: { + content_type: 'image/svg+xml', + encoding: 'text', + data: 'PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0nVVRGLTgnPz4KPHN2ZyB3aWR0aD0iMzNtbSIgaGVpZ2h0PSIzM21tIiB2ZXJzaW9uPSIxLjEiIHZpZXdC', + }, + utilityBarcode: { + content_type: 'image/svg+xml', + encoding: 'text', + data: 'PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCFET0NUWVBFIHN2ZwogIFBVQkxJQyAnLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4nCiAg', + }, + utilityPassword: { + random_password: '4*rStOB2ZXHUSiDP5r%I', + }, + utilityRandomUser: [ + { + id: '00000000-0000-4000-8000-000000000000', + username: 'example_user', + password: 'not-a-real-password', + email: 'someone@example.com', + name: 'Example Name', + first_name: 'Example', + last_name: 'Name', + full_name: 'Example Name', + prefix: 'Dr.', + suffix: 'Jr.', + phone: '+15550100', + cell: '+15550100', + address: '1 Example Street, Exampleton, EX 00000', + street_address: '1 Example Street', + city: 'Exampleton', + state: 'Example State', + postal_code: '00000', + country: 'Moldova', + latitude: 51.103961, + longitude: 42.496861, + timezone: 'Africa/Niamey', + dob: '1970-01-01', + age: 93, + gender: 'nonbinary', + job: 'Example Occupation', + company: 'Example Company', + company_email: 'someone@example.com', + ssn: '000-00-0000', + credit_card: '0000000000000000', + credit_card_provider: 'Maestro', + iban: 'GB00EXAM00000000000000', + ipv4: '203.0.113.1', + ipv6: '2001:db8::1', + mac_address: '00:00:5e:00:53:00', + user_agent: 'Mozilla/5.0 (Example) ExampleBrowser/1.0', + url: 'https://bennett.com/', + domain: 'sherman-miles.com', + picture: 'https://placeimg.com/826/99/any', + avatar: 'https://placekitten.com/200/200', + uuid: '00000000-0000-4000-8000-000000000000', + md5: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + sha1: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + sha256: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + locale: 'te_IN', + }, + ], + utilityCounter: { + id: '00000000-0000-4000-8000-000000000000', + value: 0, + }, + utilityConvertUnit: { + type: 'length', + unit: 'kilometer', + amount: 100, + conversions: { + meter: 100000, + kilometer: 100, + centimeter: 10000000, + millimeter: 100000000, + micrometer: 100000000000, + nanometer: 100000000000000, + mile: 62.1372737, + yard: 109361.33, + foot: 328083.99, + inch: 3937007.87, + nautical_mile: 53.9956803, + furlong: 497.096954, + light_year: 0, + astronomical_unit: 6.7e-7, + }, + }, + utilityLogo: [ + { + name: 'Example Name', + image: + 'https://api-ninjas-data.s3.us-west-2.amazonaws.com/logos/l11f3242118ff2add5d117cbf216f29ac578f6ba6.png', + ticker: 'MSFT', + }, + ], + utilityCountryFlag: { + country: 'US', + square_image_url: + 'https://api-ninjas-data.s3.us-west-2.amazonaws.com/flags/1x1/KoHP0ZTO/us.svg', + rectangle_image_url: + 'https://api-ninjas-data.s3.us-west-2.amazonaws.com/flags/4x3/8L07WqeX/us.svg', + }, + utilityRandomImage: { + content_type: 'image/jpeg', + encoding: 'lossy-text', + data: '', + }, + utilityEmoji: [ + { + code: 'U+1F63C', + character: '😼', + image: + 'https://api-ninjas-data.s3.us-west-2.amazonaws.com/emojis/U%2B1F63C.png', + name: 'Example Name', + group: 'smileys_emotion', + subgroup: 'cat_face', + }, + ], + transportAircraft: [ + { + manufacturer: 'Boeing', + model: '737 Max 7', + engine_type: 'Jet', + max_speed_knots: '547', + ceiling_ft: '41000', + gross_weight_lbs: '177000', + length_ft: '116.7', + height_ft: '40.3', + wing_span_ft: '117.8', + range_nautical_miles: '3850', + }, + ], + transportAirlines: [ + { + name: 'Example Name', + country: 'Singapore', + year_created: '1947', + base: 'Singapore Changi Airport', + iata: 'SQ', + icao: 'SIA', + fleet: { + A359: 59, + A388: 17, + B38M: 15, + B738: 9, + B744: 7, + B772: 1, + B773: 2, + B77W: 27, + B78X: 18, + total: 155, + }, + logo_url: + 'https://api-ninjas-data.s3.us-west-2.amazonaws.com/airline_logos/full_logo/singapore_airlines.png', + brandmark_url: + 'https://api-ninjas-data.s3.us-west-2.amazonaws.com/airline_logos/brandmark/singapore_airlines.png', + tail_logo_url: + 'https://api-ninjas-data.s3.us-west-2.amazonaws.com/airline_logos/tail_logo/singapore_airlines.png', + }, + ], + transportAirports: [ + { + icao: 'EGLL', + ident: 'EGLL', + iata: 'LHR', + name: 'Example Name', + city: 'Exampleton', + region: 'England', + region_code: 'GB-ENG', + country: 'GB', + country_name: 'United Kingdom', + continent: 'EU', + elevation_ft: 83, + elevation_m: 25.3, + latitude: 51.470748, + longitude: -0.459909, + timezone: 'Europe/London', + type: 'large_airport', + size: 'large', + scheduled_service: true, + is_closed: false, + gps_code: 'EGLL', + local_code: null, + home_link: 'http://www.heathrow.com/', + wikipedia_link: 'https://en.wikipedia.org/wiki/Heathrow_Airport', + keywords: ['LON', 'Londres'], + num_runways: 2, + longest_runway_ft: 12799, + runways: [ + { + length: 12799, + width: 164, + has_lights: true, + surface: 'ASP', + surface_category: 'paved', + closed: false, + le_ident: '09L', + he_ident: '27R', + le_heading_deg: 90, + he_heading_deg: 270, + }, + { + length: 12001, + width: 164, + has_lights: true, + surface: 'ASP', + surface_category: 'paved', + closed: false, + le_ident: '09R', + he_ident: '27L', + le_heading_deg: 90, + he_heading_deg: 270, + }, + ], + estimated_annual_passengers: 42898537, + }, + ], + transportHelicopters: [ + { + manufacturer: 'Bell Helicopter', + model: '430', + max_speed_sl_knots: '150', + cruise_speed_sl_knots: '139', + vne_speed_knots: '150', + range_nautical_miles: '383', + fuel_consumption_gallons_pr_hr: '80', + fuel_capacity_gallons: '247', + fuel_opt_gallons: '48', + gross_external_load_lbs: '9300', + external_load_limit_lbs: '3500', + main_rotor_diameter_ft: '42.0', + num_blades: '4', + blade_material: 'comp', + storage_width_ft: '11.25', + length_ft: '44.083', + height_ft: '11.167', + }, + ], + transportCars: [ + { + city_mpg: 'this field is for premium subscribers only', + class: 'compact car', + combination_mpg: 'this field is for premium subscribers only', + cylinders: 4, + displacement: 1.6, + drive: 'fwd', + fuel_type: 'gas', + highway_mpg: 'this field is for premium subscribers only', + make: 'toyota', + model: 'corolla', + transmission: 'a', + year: 1993, + }, + ], + transportMotorcycles: [ + { + make: 'Kawasaki', + model: 'Brute Force 300', + year: '2022', + type: 'ATV', + displacement: '271.0 ccm (16.54 cubic inches)', + engine: 'Single cylinder, four-stroke', + compression: '11.0:1', + bore_stroke: '72.7 x 65.2 mm (2.9 x 2.6 inches)', + valves_per_cylinder: null, + fuel_system: 'Carburettor. Keihin CVK32', + fuel_control: 'Single Overhead Cams (SOHC)', + lubrication: null, + cooling: 'Liquid', + gearbox: 'Automatic', + transmission: 'Shaft drive (cardan)   (final drive)', + clutch: 'Centrifugal clutch', + frame: 'Double cradle, steel', + front_suspension: 'Double wishbone with 5-way adjustable spring preload', + front_wheel_travel: '131 mm (5.2 inches)', + rear_suspension: 'Swingarm', + rear_wheel_travel: '141 mm (5.6 inches)', + front_tire: '22/7-10 ', + rear_tire: '22/10-10 ', + front_brakes: 'Double disc', + rear_brakes: 'Single disc', + seat_height: '845 mm (33.3 inches) If adjustable, lowest setting.', + ground_clearance: '155 mm (6.1 inches)', + wheelbase: '1165 mm (45.9 inches)', + fuel_capacity: '12.00 litres (3.17 US gallons)', + starter: 'Electric', + power: '21.5 HP (15.7 kW)) @ 7500 RPM', + torque: '21.6 Nm (2.2 kgf-m or 15.9 ft.lbs) @ 6500 RPM', + top_speed: null, + fuel_consumption: null, + emission: null, + total_weight: '243.0 kg (535.7 pounds)', + total_height: '1170 mm (46.1 inches)', + total_length: '1915 mm (75.4 inches)', + total_width: '1080 mm (42.5 inches)', + ignition: 'DC-CDI', + dry_weight: null, + }, + ], + transportElectricVehicles: [ + { + make: 'Tesla', + model: 'Model S 85D', + year_start: '2015', + battery_capacity: 'This field is for premium subscribers only.', + battery_type: 'Lithium-ion', + battery_number_of_cells: 'No Data', + battery_architecture: '400 V', + battery_useable_capacity: '80.8 kWh', + battery_cathode_material: 'No Data', + battery_pack_configuration: 'No Data', + battery_voltage: 'No Data', + battery_form_factor: 'No Data', + battery_name: 'No Data', + charge_port: 'Supercharger', + charge_port_location: 'Left Side - Rear', + charge_power: '11 kW AC', + charge_speed: '460 km/h', + charge_power_max: '120 kW DC', + charge_power_10p_80p: '95 kW DC', + autocharge_supported: 'No', + plug_charge_supported: 'No', + supported_charging_protocol: '-', + preconditioning_possible: 'Yes', + acceleration_0_100_kmh: '4.4 sec', + top_speed: 'This field is for premium subscribers only.', + electric_range: 'This field is for premium subscribers only.', + total_power: '311 kW (423 PS)', + total_torque: '660 Nm', + drive: 'AWD', + vehicle_consumption: 'This field is for premium subscribers only.', + co2_emissions: '0 g/km', + vehicle_fuel_equivalent: '1.7 l/100km', + rated_consumption: 'No Data', + rated_fuel_equivalent: 'No Data', + length: '4970 mm', + width: '1964 mm', + width_with_mirrors: 'No Data', + height: '1445 mm', + wheelbase: '2960 mm', + gross_vehicle_weight: '2640 kg', + max_payload: '531 kg', + cargo_volume: '895 L', + cargo_volume_frunk: 'No Data', + seats: '5 people', + turning_circle: '12.4 m', + platform: 'TESLA S/X', + car_body: 'Liftback Sedan', + segment: 'F - Luxury', + }, + ], + transportVin: { + vin: 'JH4TB2H26CC000000', + country: 'Japan', + manufacturer: 'Only available for premium subscribers.', + model: 'Only available for premium subscribers.', + class: 'Only available for premium subscribers.', + region: 'Asia', + wmi: 'JH4', + vds: 'TB2H26', + vis: 'CC000000', + year: 2012, + }, + healthCaloriesBurned: [ + { + name: 'Example Name', + calories_per_hour: 435, + duration_minutes: 60, + total_calories: 435, + }, + ], + healthExercises: [ + { + name: 'Example Name', + type: 'strength', + muscle: 'biceps', + difficulty: 'beginner', + instructions: + 'Seat yourself on an incline bench with a dumbbell in each hand. You should pressed firmly against he back with your feet together. Allow the dumbbells to hang straight down at your side, holding them with a neutral grip. This will be your starting position. Initiate the movement by flexing at the elbow, attempting to keep the upper arm stationary. Continue to the top of the movement and pause, then slowly return to the start position.', + equipments: ['dumbbells', 'incline bench'], + safety_info: + 'Keep your back firmly against the incline bench with feet together; keep upper arms stationary and move only at the elbows. Lift and lower under control without swinging, use a manageable weight, and avoid locking the elbows or arching your back.', + }, + ], + healthRecipes: [ + { + title: 'Emerald Pea Pasta', + ingredients: [ + { + name: 'Example Name', + quantity: 1, + unit: 'cup', + }, + { + name: 'Example Name', + quantity: 2, + unit: 'tablespoon', + }, + { + name: 'Example Name', + quantity: 1.5, + unit: 'cup', + }, + { + name: 'Example Name', + quantity: 1, + unit: 'pinch', + }, + { + name: 'Example Name', + quantity: 1, + unit: 'pinch', + }, + ], + servings: '2 Servings', + instructions: [ + 'Place peas and mint in a food processor and puree.', + 'Add remaining ingredients and pulse until a ball of dough forms.', + 'Turn out onto a floured surface and knead until smooth and pliable like a firm bread dough.', + 'Place dough in a bowl, cover with plastic wrap, and let rest for at least 30 minutes.', + 'Shape and cook dough as desired.', + ], + nutrition: + 'Nutrition information is available for premium subscribers only.', + }, + ], + healthCocktails: [ + { + ingredients: [ + '4.5 cl (3 parts) vodka', + '9 cl (6 parts) Tomato juice', + '1.5 cl (1 part) Lemon juice', + '2 to 3 dashes of Worcestershire Sauce', + 'Tabasco sauce', + 'Celery salt', + 'Black pepper', + ], + instructions: + 'Stirring gently, pour all ingredients into highball glass. Garnish.', + name: 'Example Name', + }, + ], + referenceAnimals: [ + { + name: 'Example Name', + taxonomy: { + kingdom: 'Animalia', + phylum: 'Chordata', + class: 'Mammalia', + order: 'Carnivora', + family: 'Felidae', + genus: 'Acinonyx', + scientific_name: 'Acinonyx jubatus', + }, + locations: ['Africa', 'Asia', 'Eurasia'], + characteristics: { + prey: 'Gazelle, Wildebeest, Hare', + name_of_young: 'Cub', + group_behavior: 'Solitary/Pairs', + estimated_population_size: '8,500', + biggest_threat: 'Habitat loss', + most_distinctive_feature: 'Yellowish fur covered in small black spots', + gestation_period: '90 days', + habitat: 'Open grassland', + diet: 'Carnivore', + average_litter_size: '3', + lifestyle: 'Diurnal', + common_name: 'Cheetah', + number_of_species: '5', + location: 'Asia and Africa', + slogan: 'The fastest land mammal in the world!', + group: 'Mammal', + color: 'BrownYellowBlackTan', + skin_type: 'Fur', + top_speed: '70 mph', + lifespan: '10 - 12 years', + weight: '40kg - 65kg (88lbs - 140lbs)', + height: '115cm - 136cm (45in - 53in)', + age_of_sexual_maturity: '20 - 24 months', + age_of_weaning: '3 months', + }, + }, + ], + referenceCats: [ + { + length: 'Medium', + origin: 'Greece', + image_link: 'https://api-ninjas.com/images/cats/aegean.jpg', + family_friendly: 5, + shedding: 3, + general_health: 4, + playfulness: 4, + meowing: 4, + children_friendly: 5, + stranger_friendly: 4, + grooming: 4, + intelligence: 4, + other_pets_friendly: 3, + min_weight: 7, + max_weight: 10, + min_life_expectancy: 9, + max_life_expectancy: 10, + name: 'Example Name', + }, + ], + referenceDogs: [ + { + image_link: 'https://api-ninjas.com/images/dogs/golden_retriever.jpg', + good_with_children: 5, + good_with_other_dogs: 5, + shedding: 4, + grooming: 2, + drooling: 2, + coat_length: 1, + good_with_strangers: 5, + playfulness: 4, + protectiveness: 3, + trainability: 5, + energy: 3, + barking: 1, + min_life_expectancy: 10, + max_life_expectancy: 12, + max_height_male: 24, + max_height_female: 24, + max_weight_male: 75, + max_weight_female: 65, + min_height_male: 23, + min_height_female: 23, + min_weight_male: 65, + min_weight_female: 55, + name: 'Example Name', + }, + ], + referencePlanets: [ + { + name: 'Example Name', + mass: 0.000338, + radius: 0.0488, + period: 687, + semi_major_axis: 1.542, + temperature: 210, + distance_light_year: 0.000037, + host_star_mass: 1, + host_star_temperature: 6000, + }, + ], + referenceStars: [ + { + name: 'Example Name', + constellation: 'Lyra', + right_ascension: '18h 36m 56.19s', + declination: '+38° 46′ 58.8″', + apparent_magnitude: '0.03', + absolute_magnitude: '0.58', + distance_light_year: '25', + spectral_class: 'A0Vvar', + }, + ], + referenceHistoricalEvents: [ + { + year: '1945', + month: '01', + day: '01', + event: + 'World War II: The German Luftwaffe launches Operation Bodenplatte, a massive, but failed, attempt to knock out Allied air power in northern Europe in a single blow.', + }, + ], + referenceHistoricalFigures: [ + { + name: 'Example Name', + title: 'French guitarist and composer', + info: {}, + }, + ], + referenceDayInHistory: [ + { + year: 1720, + month: 8, + day: 14, + event: + 'The Spanish military Villasur expedition is defeated by Pawnee and Otoe warriors near present-day Columbus, Nebraska.', + }, + ], + referenceCelebrities: [ + { + name: 'Example Name', + net_worth: 2200000000, + gender: 'male', + nationality: 'us', + occupation: [ + 'basketball_player', + 'athlete', + 'spokesperson', + 'entrepreneur', + 'actor', + ], + height: 1.98, + birthday: '1970-01-01', + age: 63, + is_alive: true, + }, + ], + referenceBabyNames: ['Stephen'], + entertainmentJokes: [ + { + joke: 'Want to hear a dirty joke? This boy trips and falls into some mud.', + }, + ], + entertainmentDadJokes: [ + { + joke: 'The wedding was so beautiful, even the cake was in tiers.', + }, + ], + entertainmentChuckNorris: { + joke: 'Whereas most people are killed by a BAC of .45, Chuck Norris only passes out when the alcohol in his veins is less than 5% blood.', + }, + entertainmentJokeOfTheDay: [ + { + joke: 'As I watched the dog chasing his tail, I thought, Dogs sure are easily amused!... ...then I realized I was watching the dog chasing his tail.', + }, + ], + entertainmentFacts: [ + { + fact: 'The longest distance a deepwater lobster has been recorded to travel is 225 miles', + }, + ], + entertainmentFactOfTheDay: [ + { + fact: 'At the White House, president John Adams was said to be the first to display fireworks there', + }, + ], + entertainmentQuotes: [ + { + quote: + "You've gotta dance like there's nobody watching,Love like you'll never be hurt,Sing like there's nobody listening,And live like it's heaven on earth.", + author: 'William W. Purkey', + work: '', + categories: ['love', 'life', 'happiness', 'inspirational'], + }, + ], + entertainmentRandomQuotes: [ + { + quote: + "We could never muster the strength to make lasting changes in our lives. That's why we must rely on God's strength to transform us.", + author: 'Alisa Hope Wagner', + work: 'Eve of Awakening', + categories: ['faith', 'life', 'inspirational', 'wisdom'], + }, + ], + entertainmentQuoteOfTheDay: [ + { + quote: + 'Spend time with people who enrich your mind, nourish your heart, and illuminate your soul.', + author: 'Matshona Dhliwayo', + work: '', + categories: ['life', 'inspirational', 'wisdom', 'relationships'], + }, + ], + entertainmentAdvice: { + advice: "Repeat people's names when you meet them.", + }, + entertainmentBucketList: { + item: 'Fire a gun', + }, + entertainmentHobbies: { + hobby: 'Cosplaying', + link: 'https://wikipedia.org/wiki/Cosplaying', + category: 'general', + }, + entertainmentHoroscope: { + date: '2026-08-14', + sign: 'Aries', + horoscope: + "Aries, it's time to carve out some time for enjoyment! Anticipating a fun activity can significantly ease the burdens of challenging moments. You'll be surprised at your resilience when you have a goal to aim for. This shift in perspective can uplift your spirits, so seize the opportunity to take charge and set up your own rewards. Treat yourself to something delightful as a nod to the effort you put in daily.", + }, + entertainmentRiddles: [ + { + title: 'Weight', + question: 'what weighs more a pound of lead or a pound of feathers?', + answer: 'they both weigh one pound.', + }, + ], + entertainmentTrivia: [ + { + category: '', + question: + 'On an old-fashioned rotary phone,what number requires the longest turn of the dial?', + answer: '0 *correct*', + }, + ], + entertainmentTriviaOfTheDay: [ + { + category: '', + question: + 'Originally made in Canada, canola is a vegetable oil made from what crop?', + answer: 'Rapeseed', + }, + ], + entertainmentGenerateSudoku: { + puzzle: [ + [7, null, 4, 2, 8, null, null, null, 5], + [2, 1, 6, 3, 5, null, 4, null, 8], + ], + solution: [ + [7, 9, 4, 2, 8, 6, 1, 3, 5], + [2, 1, 6, 3, 5, 9, 4, 7, 8], + ], + }, + entertainmentSolveSudoku: { + status: 'solved', + solution: [ + [7, 9, 4, 2, 8, 6, 1, 3, 5], + [2, 1, 6, 3, 5, 9, 4, 7, 8], + ], + }, +}; diff --git a/packages/apininjas/index.ts b/packages/apininjas/index.ts new file mode 100644 index 000000000..60939be90 --- /dev/null +++ b/packages/apininjas/index.ts @@ -0,0 +1,1605 @@ +import type { + AuthTypes, + BindEndpoints, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { + Calendar, + Economics, + Entertainment, + Health, + Internet, + Location, + Markets, + Reference, + Text, + Transport, + Utility, + Validation, +} from './endpoints'; +import type { + ApiNinjasEndpointInputs, + ApiNinjasEndpointOutputs, +} from './endpoints/types'; +import { + ApiNinjasEndpointInputSchemas, + ApiNinjasEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { ApiNinjasSchema } from './schema'; + +/** + * API Ninjas exposes about 150 unrelated single-fact services behind one API + * key: weather, geocoding, market data, tax rates, dictionary lookups, + * validation, generators. This plugin covers the 129 operations listed in the + * Corsair catalog. + * + * Two provider behaviours are worth knowing before reading the endpoints: + * + * - Almost every failure is a `400`. A missing key, an invalid key, a + * premium-gated endpoint, an exhausted monthly quota and an ordinary bad + * parameter all share that status, so `error-handlers.ts` reads the body + * rather than the status. + * - The free tier answers `200` with prose in place of values it withholds, + * so output schemas accept both the documented type and a string. + * + * @see https://api-ninjas.com/api + */ + +export type ApiNinjasPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + hooks?: InternalApiNinjasPlugin['hooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type ApiNinjasContext = CorsairPluginContext< + typeof ApiNinjasSchema, + ApiNinjasPluginOptions +>; + +export type ApiNinjasKeyBuilderContext = + KeyBuilderContext; + +export type ApiNinjasBoundEndpoints = BindEndpoints< + typeof apiNinjasEndpointsNested +>; + +type ApiNinjasEndpoint = + CorsairEndpoint< + ApiNinjasContext, + ApiNinjasEndpointInputs[K], + ApiNinjasEndpointOutputs[K] + >; + +export type ApiNinjasEndpoints = { + locationGeocode: ApiNinjasEndpoint<'locationGeocode'>; + locationReverseGeocode: ApiNinjasEndpoint<'locationReverseGeocode'>; + locationCities: ApiNinjasEndpoint<'locationCities'>; + locationCountry: ApiNinjasEndpoint<'locationCountry'>; + locationCounty: ApiNinjasEndpoint<'locationCounty'>; + locationZipCode: ApiNinjasEndpoint<'locationZipCode'>; + locationPostalCode: ApiNinjasEndpoint<'locationPostalCode'>; + locationUniversities: ApiNinjasEndpoint<'locationUniversities'>; + locationHospitals: ApiNinjasEndpoint<'locationHospitals'>; + locationEvChargers: ApiNinjasEndpoint<'locationEvChargers'>; + locationWeather: ApiNinjasEndpoint<'locationWeather'>; + locationWeatherForecast: ApiNinjasEndpoint<'locationWeatherForecast'>; + locationAirQuality: ApiNinjasEndpoint<'locationAirQuality'>; + calendarTimezone: ApiNinjasEndpoint<'calendarTimezone'>; + calendarWorldTime: ApiNinjasEndpoint<'calendarWorldTime'>; + calendarHolidays: ApiNinjasEndpoint<'calendarHolidays'>; + calendarPublicHolidays: ApiNinjasEndpoint<'calendarPublicHolidays'>; + calendarIsPublicHoliday: ApiNinjasEndpoint<'calendarIsPublicHoliday'>; + calendarIsWorkingDay: ApiNinjasEndpoint<'calendarIsWorkingDay'>; + calendarWorkingDays: ApiNinjasEndpoint<'calendarWorkingDays'>; + internetDomain: ApiNinjasEndpoint<'internetDomain'>; + internetDnsRecords: ApiNinjasEndpoint<'internetDnsRecords'>; + internetMxRecords: ApiNinjasEndpoint<'internetMxRecords'>; + internetWhois: ApiNinjasEndpoint<'internetWhois'>; + internetIpLookup: ApiNinjasEndpoint<'internetIpLookup'>; + internetUrlLookup: ApiNinjasEndpoint<'internetUrlLookup'>; + internetWebpage: ApiNinjasEndpoint<'internetWebpage'>; + internetScrape: ApiNinjasEndpoint<'internetScrape'>; + internetUserAgent: ApiNinjasEndpoint<'internetUserAgent'>; + validationEmail: ApiNinjasEndpoint<'validationEmail'>; + validationDisposableEmail: ApiNinjasEndpoint<'validationDisposableEmail'>; + validationPhone: ApiNinjasEndpoint<'validationPhone'>; + validationRoutingNumber: ApiNinjasEndpoint<'validationRoutingNumber'>; + validationIban: ApiNinjasEndpoint<'validationIban'>; + validationBin: ApiNinjasEndpoint<'validationBin'>; + validationSwiftCode: ApiNinjasEndpoint<'validationSwiftCode'>; + marketsStockPrice: ApiNinjasEndpoint<'marketsStockPrice'>; + marketsTicker: ApiNinjasEndpoint<'marketsTicker'>; + marketsTickerList: ApiNinjasEndpoint<'marketsTickerList'>; + marketsStockExchanges: ApiNinjasEndpoint<'marketsStockExchanges'>; + marketsSp500: ApiNinjasEndpoint<'marketsSp500'>; + marketsMarketCap: ApiNinjasEndpoint<'marketsMarketCap'>; + marketsEarnings: ApiNinjasEndpoint<'marketsEarnings'>; + marketsEarningsCalendar: ApiNinjasEndpoint<'marketsEarningsCalendar'>; + marketsEarningsTranscript: ApiNinjasEndpoint<'marketsEarningsTranscript'>; + marketsInsiderTransactions: ApiNinjasEndpoint<'marketsInsiderTransactions'>; + marketsSecFilings: ApiNinjasEndpoint<'marketsSecFilings'>; + marketsEtf: ApiNinjasEndpoint<'marketsEtf'>; + marketsMutualFund: ApiNinjasEndpoint<'marketsMutualFund'>; + marketsCryptoPrice: ApiNinjasEndpoint<'marketsCryptoPrice'>; + marketsBitcoin: ApiNinjasEndpoint<'marketsBitcoin'>; + marketsCommodityPrice: ApiNinjasEndpoint<'marketsCommodityPrice'>; + marketsConvertCurrency: ApiNinjasEndpoint<'marketsConvertCurrency'>; + marketsExchangeRate: ApiNinjasEndpoint<'marketsExchangeRate'>; + economicsGdp: ApiNinjasEndpoint<'economicsGdp'>; + economicsInflation: ApiNinjasEndpoint<'economicsInflation'>; + economicsUnemployment: ApiNinjasEndpoint<'economicsUnemployment'>; + economicsPopulation: ApiNinjasEndpoint<'economicsPopulation'>; + economicsInterestRate: ApiNinjasEndpoint<'economicsInterestRate'>; + economicsMortgageRate: ApiNinjasEndpoint<'economicsMortgageRate'>; + economicsMortgageCalculator: ApiNinjasEndpoint<'economicsMortgageCalculator'>; + economicsIncomeTax: ApiNinjasEndpoint<'economicsIncomeTax'>; + economicsIncomeTaxCalculator: ApiNinjasEndpoint<'economicsIncomeTaxCalculator'>; + economicsSalesTax: ApiNinjasEndpoint<'economicsSalesTax'>; + economicsSalesTaxCalculator: ApiNinjasEndpoint<'economicsSalesTaxCalculator'>; + economicsPropertyTax: ApiNinjasEndpoint<'economicsPropertyTax'>; + economicsVatRates: ApiNinjasEndpoint<'economicsVatRates'>; + textSentiment: ApiNinjasEndpoint<'textSentiment'>; + textSimilarity: ApiNinjasEndpoint<'textSimilarity'>; + textEmbeddings: ApiNinjasEndpoint<'textEmbeddings'>; + textLanguage: ApiNinjasEndpoint<'textLanguage'>; + textSpellCheck: ApiNinjasEndpoint<'textSpellCheck'>; + textProfanityFilter: ApiNinjasEndpoint<'textProfanityFilter'>; + textDictionary: ApiNinjasEndpoint<'textDictionary'>; + textThesaurus: ApiNinjasEndpoint<'textThesaurus'>; + textRhymes: ApiNinjasEndpoint<'textRhymes'>; + textRandomWord: ApiNinjasEndpoint<'textRandomWord'>; + textLoremIpsum: ApiNinjasEndpoint<'textLoremIpsum'>; + utilityQrCode: ApiNinjasEndpoint<'utilityQrCode'>; + utilityBarcode: ApiNinjasEndpoint<'utilityBarcode'>; + utilityPassword: ApiNinjasEndpoint<'utilityPassword'>; + utilityRandomUser: ApiNinjasEndpoint<'utilityRandomUser'>; + utilityCounter: ApiNinjasEndpoint<'utilityCounter'>; + utilityConvertUnit: ApiNinjasEndpoint<'utilityConvertUnit'>; + utilityLogo: ApiNinjasEndpoint<'utilityLogo'>; + utilityCountryFlag: ApiNinjasEndpoint<'utilityCountryFlag'>; + utilityRandomImage: ApiNinjasEndpoint<'utilityRandomImage'>; + utilityEmoji: ApiNinjasEndpoint<'utilityEmoji'>; + transportAircraft: ApiNinjasEndpoint<'transportAircraft'>; + transportAirlines: ApiNinjasEndpoint<'transportAirlines'>; + transportAirports: ApiNinjasEndpoint<'transportAirports'>; + transportHelicopters: ApiNinjasEndpoint<'transportHelicopters'>; + transportCars: ApiNinjasEndpoint<'transportCars'>; + transportMotorcycles: ApiNinjasEndpoint<'transportMotorcycles'>; + transportElectricVehicles: ApiNinjasEndpoint<'transportElectricVehicles'>; + transportVin: ApiNinjasEndpoint<'transportVin'>; + healthCaloriesBurned: ApiNinjasEndpoint<'healthCaloriesBurned'>; + healthNutrition: ApiNinjasEndpoint<'healthNutrition'>; + healthExercises: ApiNinjasEndpoint<'healthExercises'>; + healthRecipes: ApiNinjasEndpoint<'healthRecipes'>; + healthCocktails: ApiNinjasEndpoint<'healthCocktails'>; + referenceAnimals: ApiNinjasEndpoint<'referenceAnimals'>; + referenceCats: ApiNinjasEndpoint<'referenceCats'>; + referenceDogs: ApiNinjasEndpoint<'referenceDogs'>; + referencePlanets: ApiNinjasEndpoint<'referencePlanets'>; + referenceStars: ApiNinjasEndpoint<'referenceStars'>; + referenceHistoricalEvents: ApiNinjasEndpoint<'referenceHistoricalEvents'>; + referenceHistoricalFigures: ApiNinjasEndpoint<'referenceHistoricalFigures'>; + referenceDayInHistory: ApiNinjasEndpoint<'referenceDayInHistory'>; + referenceCelebrities: ApiNinjasEndpoint<'referenceCelebrities'>; + referenceBabyNames: ApiNinjasEndpoint<'referenceBabyNames'>; + entertainmentJokes: ApiNinjasEndpoint<'entertainmentJokes'>; + entertainmentDadJokes: ApiNinjasEndpoint<'entertainmentDadJokes'>; + entertainmentChuckNorris: ApiNinjasEndpoint<'entertainmentChuckNorris'>; + entertainmentJokeOfTheDay: ApiNinjasEndpoint<'entertainmentJokeOfTheDay'>; + entertainmentFacts: ApiNinjasEndpoint<'entertainmentFacts'>; + entertainmentFactOfTheDay: ApiNinjasEndpoint<'entertainmentFactOfTheDay'>; + entertainmentQuotes: ApiNinjasEndpoint<'entertainmentQuotes'>; + entertainmentRandomQuotes: ApiNinjasEndpoint<'entertainmentRandomQuotes'>; + entertainmentQuoteOfTheDay: ApiNinjasEndpoint<'entertainmentQuoteOfTheDay'>; + entertainmentAdvice: ApiNinjasEndpoint<'entertainmentAdvice'>; + entertainmentBucketList: ApiNinjasEndpoint<'entertainmentBucketList'>; + entertainmentHobbies: ApiNinjasEndpoint<'entertainmentHobbies'>; + entertainmentHoroscope: ApiNinjasEndpoint<'entertainmentHoroscope'>; + entertainmentRiddles: ApiNinjasEndpoint<'entertainmentRiddles'>; + entertainmentTrivia: ApiNinjasEndpoint<'entertainmentTrivia'>; + entertainmentTriviaOfTheDay: ApiNinjasEndpoint<'entertainmentTriviaOfTheDay'>; + entertainmentGenerateSudoku: ApiNinjasEndpoint<'entertainmentGenerateSudoku'>; + entertainmentSolveSudoku: ApiNinjasEndpoint<'entertainmentSolveSudoku'>; +}; + +const apiNinjasEndpointsNested = { + location: { + geocode: Location.geocode, + reverseGeocode: Location.reverseGeocode, + cities: Location.cities, + country: Location.country, + county: Location.county, + zipCode: Location.zipCode, + postalCode: Location.postalCode, + universities: Location.universities, + hospitals: Location.hospitals, + evChargers: Location.evChargers, + weather: Location.weather, + weatherForecast: Location.weatherForecast, + airQuality: Location.airQuality, + }, + calendar: { + timezone: Calendar.timezone, + worldTime: Calendar.worldTime, + holidays: Calendar.holidays, + publicHolidays: Calendar.publicHolidays, + isPublicHoliday: Calendar.isPublicHoliday, + isWorkingDay: Calendar.isWorkingDay, + workingDays: Calendar.workingDays, + }, + internet: { + domain: Internet.domain, + dnsRecords: Internet.dnsRecords, + mxRecords: Internet.mxRecords, + whois: Internet.whois, + ipLookup: Internet.ipLookup, + urlLookup: Internet.urlLookup, + webpage: Internet.webpage, + scrape: Internet.scrape, + userAgent: Internet.userAgent, + }, + validation: { + email: Validation.email, + disposableEmail: Validation.disposableEmail, + phone: Validation.phone, + routingNumber: Validation.routingNumber, + iban: Validation.iban, + bin: Validation.bin, + swiftCode: Validation.swiftCode, + }, + markets: { + stockPrice: Markets.stockPrice, + ticker: Markets.ticker, + tickerList: Markets.tickerList, + stockExchanges: Markets.stockExchanges, + sp500: Markets.sp500, + marketCap: Markets.marketCap, + earnings: Markets.earnings, + earningsCalendar: Markets.earningsCalendar, + earningsTranscript: Markets.earningsTranscript, + insiderTransactions: Markets.insiderTransactions, + secFilings: Markets.secFilings, + etf: Markets.etf, + mutualFund: Markets.mutualFund, + cryptoPrice: Markets.cryptoPrice, + bitcoin: Markets.bitcoin, + commodityPrice: Markets.commodityPrice, + convertCurrency: Markets.convertCurrency, + exchangeRate: Markets.exchangeRate, + }, + economics: { + gdp: Economics.gdp, + inflation: Economics.inflation, + unemployment: Economics.unemployment, + population: Economics.population, + interestRate: Economics.interestRate, + mortgageRate: Economics.mortgageRate, + mortgageCalculator: Economics.mortgageCalculator, + incomeTax: Economics.incomeTax, + incomeTaxCalculator: Economics.incomeTaxCalculator, + salesTax: Economics.salesTax, + salesTaxCalculator: Economics.salesTaxCalculator, + propertyTax: Economics.propertyTax, + vatRates: Economics.vatRates, + }, + text: { + sentiment: Text.sentiment, + similarity: Text.similarity, + embeddings: Text.embeddings, + language: Text.language, + spellCheck: Text.spellCheck, + profanityFilter: Text.profanityFilter, + dictionary: Text.dictionary, + thesaurus: Text.thesaurus, + rhymes: Text.rhymes, + randomWord: Text.randomWord, + loremIpsum: Text.loremIpsum, + }, + utility: { + qrCode: Utility.qrCode, + barcode: Utility.barcode, + password: Utility.password, + randomUser: Utility.randomUser, + counter: Utility.counter, + convertUnit: Utility.convertUnit, + logo: Utility.logo, + countryFlag: Utility.countryFlag, + randomImage: Utility.randomImage, + emoji: Utility.emoji, + }, + transport: { + aircraft: Transport.aircraft, + airlines: Transport.airlines, + airports: Transport.airports, + helicopters: Transport.helicopters, + cars: Transport.cars, + motorcycles: Transport.motorcycles, + electricVehicles: Transport.electricVehicles, + vin: Transport.vin, + }, + health: { + caloriesBurned: Health.caloriesBurned, + nutrition: Health.nutrition, + exercises: Health.exercises, + recipes: Health.recipes, + cocktails: Health.cocktails, + }, + reference: { + animals: Reference.animals, + cats: Reference.cats, + dogs: Reference.dogs, + planets: Reference.planets, + stars: Reference.stars, + historicalEvents: Reference.historicalEvents, + historicalFigures: Reference.historicalFigures, + dayInHistory: Reference.dayInHistory, + celebrities: Reference.celebrities, + babyNames: Reference.babyNames, + }, + entertainment: { + jokes: Entertainment.jokes, + dadJokes: Entertainment.dadJokes, + chuckNorris: Entertainment.chuckNorris, + jokeOfTheDay: Entertainment.jokeOfTheDay, + facts: Entertainment.facts, + factOfTheDay: Entertainment.factOfTheDay, + quotes: Entertainment.quotes, + randomQuotes: Entertainment.randomQuotes, + quoteOfTheDay: Entertainment.quoteOfTheDay, + advice: Entertainment.advice, + bucketList: Entertainment.bucketList, + hobbies: Entertainment.hobbies, + horoscope: Entertainment.horoscope, + riddles: Entertainment.riddles, + trivia: Entertainment.trivia, + triviaOfTheDay: Entertainment.triviaOfTheDay, + generateSudoku: Entertainment.generateSudoku, + solveSudoku: Entertainment.solveSudoku, + }, +} as const; + +export const apiNinjasEndpointSchemas = { + 'location.geocode': { + input: ApiNinjasEndpointInputSchemas.locationGeocode, + output: ApiNinjasEndpointOutputSchemas.locationGeocode, + }, + 'location.reverseGeocode': { + input: ApiNinjasEndpointInputSchemas.locationReverseGeocode, + output: ApiNinjasEndpointOutputSchemas.locationReverseGeocode, + }, + 'location.cities': { + input: ApiNinjasEndpointInputSchemas.locationCities, + output: ApiNinjasEndpointOutputSchemas.locationCities, + }, + 'location.country': { + input: ApiNinjasEndpointInputSchemas.locationCountry, + output: ApiNinjasEndpointOutputSchemas.locationCountry, + }, + 'location.county': { + input: ApiNinjasEndpointInputSchemas.locationCounty, + output: ApiNinjasEndpointOutputSchemas.locationCounty, + }, + 'location.zipCode': { + input: ApiNinjasEndpointInputSchemas.locationZipCode, + output: ApiNinjasEndpointOutputSchemas.locationZipCode, + }, + 'location.postalCode': { + input: ApiNinjasEndpointInputSchemas.locationPostalCode, + output: ApiNinjasEndpointOutputSchemas.locationPostalCode, + }, + 'location.universities': { + input: ApiNinjasEndpointInputSchemas.locationUniversities, + output: ApiNinjasEndpointOutputSchemas.locationUniversities, + }, + 'location.hospitals': { + input: ApiNinjasEndpointInputSchemas.locationHospitals, + output: ApiNinjasEndpointOutputSchemas.locationHospitals, + }, + 'location.evChargers': { + input: ApiNinjasEndpointInputSchemas.locationEvChargers, + output: ApiNinjasEndpointOutputSchemas.locationEvChargers, + }, + 'location.weather': { + input: ApiNinjasEndpointInputSchemas.locationWeather, + output: ApiNinjasEndpointOutputSchemas.locationWeather, + }, + 'location.weatherForecast': { + input: ApiNinjasEndpointInputSchemas.locationWeatherForecast, + output: ApiNinjasEndpointOutputSchemas.locationWeatherForecast, + }, + 'location.airQuality': { + input: ApiNinjasEndpointInputSchemas.locationAirQuality, + output: ApiNinjasEndpointOutputSchemas.locationAirQuality, + }, + 'calendar.timezone': { + input: ApiNinjasEndpointInputSchemas.calendarTimezone, + output: ApiNinjasEndpointOutputSchemas.calendarTimezone, + }, + 'calendar.worldTime': { + input: ApiNinjasEndpointInputSchemas.calendarWorldTime, + output: ApiNinjasEndpointOutputSchemas.calendarWorldTime, + }, + 'calendar.holidays': { + input: ApiNinjasEndpointInputSchemas.calendarHolidays, + output: ApiNinjasEndpointOutputSchemas.calendarHolidays, + }, + 'calendar.publicHolidays': { + input: ApiNinjasEndpointInputSchemas.calendarPublicHolidays, + output: ApiNinjasEndpointOutputSchemas.calendarPublicHolidays, + }, + 'calendar.isPublicHoliday': { + input: ApiNinjasEndpointInputSchemas.calendarIsPublicHoliday, + output: ApiNinjasEndpointOutputSchemas.calendarIsPublicHoliday, + }, + 'calendar.isWorkingDay': { + input: ApiNinjasEndpointInputSchemas.calendarIsWorkingDay, + output: ApiNinjasEndpointOutputSchemas.calendarIsWorkingDay, + }, + 'calendar.workingDays': { + input: ApiNinjasEndpointInputSchemas.calendarWorkingDays, + output: ApiNinjasEndpointOutputSchemas.calendarWorkingDays, + }, + 'internet.domain': { + input: ApiNinjasEndpointInputSchemas.internetDomain, + output: ApiNinjasEndpointOutputSchemas.internetDomain, + }, + 'internet.dnsRecords': { + input: ApiNinjasEndpointInputSchemas.internetDnsRecords, + output: ApiNinjasEndpointOutputSchemas.internetDnsRecords, + }, + 'internet.mxRecords': { + input: ApiNinjasEndpointInputSchemas.internetMxRecords, + output: ApiNinjasEndpointOutputSchemas.internetMxRecords, + }, + 'internet.whois': { + input: ApiNinjasEndpointInputSchemas.internetWhois, + output: ApiNinjasEndpointOutputSchemas.internetWhois, + }, + 'internet.ipLookup': { + input: ApiNinjasEndpointInputSchemas.internetIpLookup, + output: ApiNinjasEndpointOutputSchemas.internetIpLookup, + }, + 'internet.urlLookup': { + input: ApiNinjasEndpointInputSchemas.internetUrlLookup, + output: ApiNinjasEndpointOutputSchemas.internetUrlLookup, + }, + 'internet.webpage': { + input: ApiNinjasEndpointInputSchemas.internetWebpage, + output: ApiNinjasEndpointOutputSchemas.internetWebpage, + }, + 'internet.scrape': { + input: ApiNinjasEndpointInputSchemas.internetScrape, + output: ApiNinjasEndpointOutputSchemas.internetScrape, + }, + 'internet.userAgent': { + input: ApiNinjasEndpointInputSchemas.internetUserAgent, + output: ApiNinjasEndpointOutputSchemas.internetUserAgent, + }, + 'validation.email': { + input: ApiNinjasEndpointInputSchemas.validationEmail, + output: ApiNinjasEndpointOutputSchemas.validationEmail, + }, + 'validation.disposableEmail': { + input: ApiNinjasEndpointInputSchemas.validationDisposableEmail, + output: ApiNinjasEndpointOutputSchemas.validationDisposableEmail, + }, + 'validation.phone': { + input: ApiNinjasEndpointInputSchemas.validationPhone, + output: ApiNinjasEndpointOutputSchemas.validationPhone, + }, + 'validation.routingNumber': { + input: ApiNinjasEndpointInputSchemas.validationRoutingNumber, + output: ApiNinjasEndpointOutputSchemas.validationRoutingNumber, + }, + 'validation.iban': { + input: ApiNinjasEndpointInputSchemas.validationIban, + output: ApiNinjasEndpointOutputSchemas.validationIban, + }, + 'validation.bin': { + input: ApiNinjasEndpointInputSchemas.validationBin, + output: ApiNinjasEndpointOutputSchemas.validationBin, + }, + 'validation.swiftCode': { + input: ApiNinjasEndpointInputSchemas.validationSwiftCode, + output: ApiNinjasEndpointOutputSchemas.validationSwiftCode, + }, + 'markets.stockPrice': { + input: ApiNinjasEndpointInputSchemas.marketsStockPrice, + output: ApiNinjasEndpointOutputSchemas.marketsStockPrice, + }, + 'markets.ticker': { + input: ApiNinjasEndpointInputSchemas.marketsTicker, + output: ApiNinjasEndpointOutputSchemas.marketsTicker, + }, + 'markets.tickerList': { + input: ApiNinjasEndpointInputSchemas.marketsTickerList, + output: ApiNinjasEndpointOutputSchemas.marketsTickerList, + }, + 'markets.stockExchanges': { + input: ApiNinjasEndpointInputSchemas.marketsStockExchanges, + output: ApiNinjasEndpointOutputSchemas.marketsStockExchanges, + }, + 'markets.sp500': { + input: ApiNinjasEndpointInputSchemas.marketsSp500, + output: ApiNinjasEndpointOutputSchemas.marketsSp500, + }, + 'markets.marketCap': { + input: ApiNinjasEndpointInputSchemas.marketsMarketCap, + output: ApiNinjasEndpointOutputSchemas.marketsMarketCap, + }, + 'markets.earnings': { + input: ApiNinjasEndpointInputSchemas.marketsEarnings, + output: ApiNinjasEndpointOutputSchemas.marketsEarnings, + }, + 'markets.earningsCalendar': { + input: ApiNinjasEndpointInputSchemas.marketsEarningsCalendar, + output: ApiNinjasEndpointOutputSchemas.marketsEarningsCalendar, + }, + 'markets.earningsTranscript': { + input: ApiNinjasEndpointInputSchemas.marketsEarningsTranscript, + output: ApiNinjasEndpointOutputSchemas.marketsEarningsTranscript, + }, + 'markets.insiderTransactions': { + input: ApiNinjasEndpointInputSchemas.marketsInsiderTransactions, + output: ApiNinjasEndpointOutputSchemas.marketsInsiderTransactions, + }, + 'markets.secFilings': { + input: ApiNinjasEndpointInputSchemas.marketsSecFilings, + output: ApiNinjasEndpointOutputSchemas.marketsSecFilings, + }, + 'markets.etf': { + input: ApiNinjasEndpointInputSchemas.marketsEtf, + output: ApiNinjasEndpointOutputSchemas.marketsEtf, + }, + 'markets.mutualFund': { + input: ApiNinjasEndpointInputSchemas.marketsMutualFund, + output: ApiNinjasEndpointOutputSchemas.marketsMutualFund, + }, + 'markets.cryptoPrice': { + input: ApiNinjasEndpointInputSchemas.marketsCryptoPrice, + output: ApiNinjasEndpointOutputSchemas.marketsCryptoPrice, + }, + 'markets.bitcoin': { + input: ApiNinjasEndpointInputSchemas.marketsBitcoin, + output: ApiNinjasEndpointOutputSchemas.marketsBitcoin, + }, + 'markets.commodityPrice': { + input: ApiNinjasEndpointInputSchemas.marketsCommodityPrice, + output: ApiNinjasEndpointOutputSchemas.marketsCommodityPrice, + }, + 'markets.convertCurrency': { + input: ApiNinjasEndpointInputSchemas.marketsConvertCurrency, + output: ApiNinjasEndpointOutputSchemas.marketsConvertCurrency, + }, + 'markets.exchangeRate': { + input: ApiNinjasEndpointInputSchemas.marketsExchangeRate, + output: ApiNinjasEndpointOutputSchemas.marketsExchangeRate, + }, + 'economics.gdp': { + input: ApiNinjasEndpointInputSchemas.economicsGdp, + output: ApiNinjasEndpointOutputSchemas.economicsGdp, + }, + 'economics.inflation': { + input: ApiNinjasEndpointInputSchemas.economicsInflation, + output: ApiNinjasEndpointOutputSchemas.economicsInflation, + }, + 'economics.unemployment': { + input: ApiNinjasEndpointInputSchemas.economicsUnemployment, + output: ApiNinjasEndpointOutputSchemas.economicsUnemployment, + }, + 'economics.population': { + input: ApiNinjasEndpointInputSchemas.economicsPopulation, + output: ApiNinjasEndpointOutputSchemas.economicsPopulation, + }, + 'economics.interestRate': { + input: ApiNinjasEndpointInputSchemas.economicsInterestRate, + output: ApiNinjasEndpointOutputSchemas.economicsInterestRate, + }, + 'economics.mortgageRate': { + input: ApiNinjasEndpointInputSchemas.economicsMortgageRate, + output: ApiNinjasEndpointOutputSchemas.economicsMortgageRate, + }, + 'economics.mortgageCalculator': { + input: ApiNinjasEndpointInputSchemas.economicsMortgageCalculator, + output: ApiNinjasEndpointOutputSchemas.economicsMortgageCalculator, + }, + 'economics.incomeTax': { + input: ApiNinjasEndpointInputSchemas.economicsIncomeTax, + output: ApiNinjasEndpointOutputSchemas.economicsIncomeTax, + }, + 'economics.incomeTaxCalculator': { + input: ApiNinjasEndpointInputSchemas.economicsIncomeTaxCalculator, + output: ApiNinjasEndpointOutputSchemas.economicsIncomeTaxCalculator, + }, + 'economics.salesTax': { + input: ApiNinjasEndpointInputSchemas.economicsSalesTax, + output: ApiNinjasEndpointOutputSchemas.economicsSalesTax, + }, + 'economics.salesTaxCalculator': { + input: ApiNinjasEndpointInputSchemas.economicsSalesTaxCalculator, + output: ApiNinjasEndpointOutputSchemas.economicsSalesTaxCalculator, + }, + 'economics.propertyTax': { + input: ApiNinjasEndpointInputSchemas.economicsPropertyTax, + output: ApiNinjasEndpointOutputSchemas.economicsPropertyTax, + }, + 'economics.vatRates': { + input: ApiNinjasEndpointInputSchemas.economicsVatRates, + output: ApiNinjasEndpointOutputSchemas.economicsVatRates, + }, + 'text.sentiment': { + input: ApiNinjasEndpointInputSchemas.textSentiment, + output: ApiNinjasEndpointOutputSchemas.textSentiment, + }, + 'text.similarity': { + input: ApiNinjasEndpointInputSchemas.textSimilarity, + output: ApiNinjasEndpointOutputSchemas.textSimilarity, + }, + 'text.embeddings': { + input: ApiNinjasEndpointInputSchemas.textEmbeddings, + output: ApiNinjasEndpointOutputSchemas.textEmbeddings, + }, + 'text.language': { + input: ApiNinjasEndpointInputSchemas.textLanguage, + output: ApiNinjasEndpointOutputSchemas.textLanguage, + }, + 'text.spellCheck': { + input: ApiNinjasEndpointInputSchemas.textSpellCheck, + output: ApiNinjasEndpointOutputSchemas.textSpellCheck, + }, + 'text.profanityFilter': { + input: ApiNinjasEndpointInputSchemas.textProfanityFilter, + output: ApiNinjasEndpointOutputSchemas.textProfanityFilter, + }, + 'text.dictionary': { + input: ApiNinjasEndpointInputSchemas.textDictionary, + output: ApiNinjasEndpointOutputSchemas.textDictionary, + }, + 'text.thesaurus': { + input: ApiNinjasEndpointInputSchemas.textThesaurus, + output: ApiNinjasEndpointOutputSchemas.textThesaurus, + }, + 'text.rhymes': { + input: ApiNinjasEndpointInputSchemas.textRhymes, + output: ApiNinjasEndpointOutputSchemas.textRhymes, + }, + 'text.randomWord': { + input: ApiNinjasEndpointInputSchemas.textRandomWord, + output: ApiNinjasEndpointOutputSchemas.textRandomWord, + }, + 'text.loremIpsum': { + input: ApiNinjasEndpointInputSchemas.textLoremIpsum, + output: ApiNinjasEndpointOutputSchemas.textLoremIpsum, + }, + 'utility.qrCode': { + input: ApiNinjasEndpointInputSchemas.utilityQrCode, + output: ApiNinjasEndpointOutputSchemas.utilityQrCode, + }, + 'utility.barcode': { + input: ApiNinjasEndpointInputSchemas.utilityBarcode, + output: ApiNinjasEndpointOutputSchemas.utilityBarcode, + }, + 'utility.password': { + input: ApiNinjasEndpointInputSchemas.utilityPassword, + output: ApiNinjasEndpointOutputSchemas.utilityPassword, + }, + 'utility.randomUser': { + input: ApiNinjasEndpointInputSchemas.utilityRandomUser, + output: ApiNinjasEndpointOutputSchemas.utilityRandomUser, + }, + 'utility.counter': { + input: ApiNinjasEndpointInputSchemas.utilityCounter, + output: ApiNinjasEndpointOutputSchemas.utilityCounter, + }, + 'utility.convertUnit': { + input: ApiNinjasEndpointInputSchemas.utilityConvertUnit, + output: ApiNinjasEndpointOutputSchemas.utilityConvertUnit, + }, + 'utility.logo': { + input: ApiNinjasEndpointInputSchemas.utilityLogo, + output: ApiNinjasEndpointOutputSchemas.utilityLogo, + }, + 'utility.countryFlag': { + input: ApiNinjasEndpointInputSchemas.utilityCountryFlag, + output: ApiNinjasEndpointOutputSchemas.utilityCountryFlag, + }, + 'utility.randomImage': { + input: ApiNinjasEndpointInputSchemas.utilityRandomImage, + output: ApiNinjasEndpointOutputSchemas.utilityRandomImage, + }, + 'utility.emoji': { + input: ApiNinjasEndpointInputSchemas.utilityEmoji, + output: ApiNinjasEndpointOutputSchemas.utilityEmoji, + }, + 'transport.aircraft': { + input: ApiNinjasEndpointInputSchemas.transportAircraft, + output: ApiNinjasEndpointOutputSchemas.transportAircraft, + }, + 'transport.airlines': { + input: ApiNinjasEndpointInputSchemas.transportAirlines, + output: ApiNinjasEndpointOutputSchemas.transportAirlines, + }, + 'transport.airports': { + input: ApiNinjasEndpointInputSchemas.transportAirports, + output: ApiNinjasEndpointOutputSchemas.transportAirports, + }, + 'transport.helicopters': { + input: ApiNinjasEndpointInputSchemas.transportHelicopters, + output: ApiNinjasEndpointOutputSchemas.transportHelicopters, + }, + 'transport.cars': { + input: ApiNinjasEndpointInputSchemas.transportCars, + output: ApiNinjasEndpointOutputSchemas.transportCars, + }, + 'transport.motorcycles': { + input: ApiNinjasEndpointInputSchemas.transportMotorcycles, + output: ApiNinjasEndpointOutputSchemas.transportMotorcycles, + }, + 'transport.electricVehicles': { + input: ApiNinjasEndpointInputSchemas.transportElectricVehicles, + output: ApiNinjasEndpointOutputSchemas.transportElectricVehicles, + }, + 'transport.vin': { + input: ApiNinjasEndpointInputSchemas.transportVin, + output: ApiNinjasEndpointOutputSchemas.transportVin, + }, + 'health.caloriesBurned': { + input: ApiNinjasEndpointInputSchemas.healthCaloriesBurned, + output: ApiNinjasEndpointOutputSchemas.healthCaloriesBurned, + }, + 'health.nutrition': { + input: ApiNinjasEndpointInputSchemas.healthNutrition, + output: ApiNinjasEndpointOutputSchemas.healthNutrition, + }, + 'health.exercises': { + input: ApiNinjasEndpointInputSchemas.healthExercises, + output: ApiNinjasEndpointOutputSchemas.healthExercises, + }, + 'health.recipes': { + input: ApiNinjasEndpointInputSchemas.healthRecipes, + output: ApiNinjasEndpointOutputSchemas.healthRecipes, + }, + 'health.cocktails': { + input: ApiNinjasEndpointInputSchemas.healthCocktails, + output: ApiNinjasEndpointOutputSchemas.healthCocktails, + }, + 'reference.animals': { + input: ApiNinjasEndpointInputSchemas.referenceAnimals, + output: ApiNinjasEndpointOutputSchemas.referenceAnimals, + }, + 'reference.cats': { + input: ApiNinjasEndpointInputSchemas.referenceCats, + output: ApiNinjasEndpointOutputSchemas.referenceCats, + }, + 'reference.dogs': { + input: ApiNinjasEndpointInputSchemas.referenceDogs, + output: ApiNinjasEndpointOutputSchemas.referenceDogs, + }, + 'reference.planets': { + input: ApiNinjasEndpointInputSchemas.referencePlanets, + output: ApiNinjasEndpointOutputSchemas.referencePlanets, + }, + 'reference.stars': { + input: ApiNinjasEndpointInputSchemas.referenceStars, + output: ApiNinjasEndpointOutputSchemas.referenceStars, + }, + 'reference.historicalEvents': { + input: ApiNinjasEndpointInputSchemas.referenceHistoricalEvents, + output: ApiNinjasEndpointOutputSchemas.referenceHistoricalEvents, + }, + 'reference.historicalFigures': { + input: ApiNinjasEndpointInputSchemas.referenceHistoricalFigures, + output: ApiNinjasEndpointOutputSchemas.referenceHistoricalFigures, + }, + 'reference.dayInHistory': { + input: ApiNinjasEndpointInputSchemas.referenceDayInHistory, + output: ApiNinjasEndpointOutputSchemas.referenceDayInHistory, + }, + 'reference.celebrities': { + input: ApiNinjasEndpointInputSchemas.referenceCelebrities, + output: ApiNinjasEndpointOutputSchemas.referenceCelebrities, + }, + 'reference.babyNames': { + input: ApiNinjasEndpointInputSchemas.referenceBabyNames, + output: ApiNinjasEndpointOutputSchemas.referenceBabyNames, + }, + 'entertainment.jokes': { + input: ApiNinjasEndpointInputSchemas.entertainmentJokes, + output: ApiNinjasEndpointOutputSchemas.entertainmentJokes, + }, + 'entertainment.dadJokes': { + input: ApiNinjasEndpointInputSchemas.entertainmentDadJokes, + output: ApiNinjasEndpointOutputSchemas.entertainmentDadJokes, + }, + 'entertainment.chuckNorris': { + input: ApiNinjasEndpointInputSchemas.entertainmentChuckNorris, + output: ApiNinjasEndpointOutputSchemas.entertainmentChuckNorris, + }, + 'entertainment.jokeOfTheDay': { + input: ApiNinjasEndpointInputSchemas.entertainmentJokeOfTheDay, + output: ApiNinjasEndpointOutputSchemas.entertainmentJokeOfTheDay, + }, + 'entertainment.facts': { + input: ApiNinjasEndpointInputSchemas.entertainmentFacts, + output: ApiNinjasEndpointOutputSchemas.entertainmentFacts, + }, + 'entertainment.factOfTheDay': { + input: ApiNinjasEndpointInputSchemas.entertainmentFactOfTheDay, + output: ApiNinjasEndpointOutputSchemas.entertainmentFactOfTheDay, + }, + 'entertainment.quotes': { + input: ApiNinjasEndpointInputSchemas.entertainmentQuotes, + output: ApiNinjasEndpointOutputSchemas.entertainmentQuotes, + }, + 'entertainment.randomQuotes': { + input: ApiNinjasEndpointInputSchemas.entertainmentRandomQuotes, + output: ApiNinjasEndpointOutputSchemas.entertainmentRandomQuotes, + }, + 'entertainment.quoteOfTheDay': { + input: ApiNinjasEndpointInputSchemas.entertainmentQuoteOfTheDay, + output: ApiNinjasEndpointOutputSchemas.entertainmentQuoteOfTheDay, + }, + 'entertainment.advice': { + input: ApiNinjasEndpointInputSchemas.entertainmentAdvice, + output: ApiNinjasEndpointOutputSchemas.entertainmentAdvice, + }, + 'entertainment.bucketList': { + input: ApiNinjasEndpointInputSchemas.entertainmentBucketList, + output: ApiNinjasEndpointOutputSchemas.entertainmentBucketList, + }, + 'entertainment.hobbies': { + input: ApiNinjasEndpointInputSchemas.entertainmentHobbies, + output: ApiNinjasEndpointOutputSchemas.entertainmentHobbies, + }, + 'entertainment.horoscope': { + input: ApiNinjasEndpointInputSchemas.entertainmentHoroscope, + output: ApiNinjasEndpointOutputSchemas.entertainmentHoroscope, + }, + 'entertainment.riddles': { + input: ApiNinjasEndpointInputSchemas.entertainmentRiddles, + output: ApiNinjasEndpointOutputSchemas.entertainmentRiddles, + }, + 'entertainment.trivia': { + input: ApiNinjasEndpointInputSchemas.entertainmentTrivia, + output: ApiNinjasEndpointOutputSchemas.entertainmentTrivia, + }, + 'entertainment.triviaOfTheDay': { + input: ApiNinjasEndpointInputSchemas.entertainmentTriviaOfTheDay, + output: ApiNinjasEndpointOutputSchemas.entertainmentTriviaOfTheDay, + }, + 'entertainment.generateSudoku': { + input: ApiNinjasEndpointInputSchemas.entertainmentGenerateSudoku, + output: ApiNinjasEndpointOutputSchemas.entertainmentGenerateSudoku, + }, + 'entertainment.solveSudoku': { + input: ApiNinjasEndpointInputSchemas.entertainmentSolveSudoku, + output: ApiNinjasEndpointOutputSchemas.entertainmentSolveSudoku, + }, +} satisfies RequiredPluginEndpointSchemas; + +const apiNinjasEndpointMeta = { + 'location.geocode': { + riskLevel: 'read', + description: 'Get current city coordinates by city and country name', + }, + 'location.reverseGeocode': { + riskLevel: 'read', + description: + 'Returns a list of cities that contain a given latitude and longitude', + }, + 'location.cities': { + riskLevel: 'read', + description: 'Get city data from either a name or population range', + }, + 'location.country': { + riskLevel: 'read', + description: 'Get country data from given parameters', + }, + 'location.county': { + riskLevel: 'read', + description: + 'Returns details for one or more counties matching the input parameters', + }, + 'location.zipCode': { + riskLevel: 'read', + description: + 'Returns a list of ZIP Code details matching the input parameters', + }, + 'location.postalCode': { + riskLevel: 'read', + description: + 'Returns a list of postal code details matching the input parameters', + }, + 'location.universities': { + riskLevel: 'read', + description: + 'Returns information about universities matching the provided filters', + }, + 'location.hospitals': { + riskLevel: 'read', + description: 'Get hospital data based on given parameters', + }, + 'location.evChargers': { + riskLevel: 'read', + description: 'find ev charging stations', + }, + 'location.weather': { + riskLevel: 'read', + description: + 'Get current weather, wind speed and direction, humidity, and temperature data by city, ZIP code, or geolocation coordinates (latitude/longitude) [premium plan required]', + }, + 'location.weatherForecast': { + riskLevel: 'read', + description: + 'Returns a 5-day weather forecast in 3-hour intervals for a given city [premium plan required]', + }, + 'location.airQuality': { + riskLevel: 'read', + description: + 'Get air quality by city or location coordinates (latitude/longitude)', + }, + 'calendar.timezone': { + riskLevel: 'read', + description: + 'Get timezone info by city/state/country or location coordinates (latitude/longitude)', + }, + 'calendar.worldTime': { + riskLevel: 'read', + description: + 'Get the current date and time by city/state/country, location coordinates (latitude/longitude), or timezone [premium plan required]', + }, + 'calendar.holidays': { + riskLevel: 'read', + description: + 'Returns a list of holiday entries for a given country and year [premium plan required]', + }, + 'calendar.publicHolidays': { + riskLevel: 'read', + description: + 'Returns a list of public holidays for a given country and year [premium plan required]', + }, + 'calendar.isPublicHoliday': { + riskLevel: 'read', + description: + 'Returns whether a given date is a public holiday for a given country', + }, + 'calendar.isWorkingDay': { + riskLevel: 'read', + description: + 'Returns whether a given date is a working day for a given country', + }, + 'calendar.workingDays': { + riskLevel: 'read', + description: + 'Returns a list of working days and non-working days for a given country and year/month', + }, + 'internet.domain': { + riskLevel: 'read', + description: + 'Returns availability, registration lifecycle, and email/hosting intelligence for a given domain name', + }, + 'internet.dnsRecords': { + riskLevel: 'read', + description: + 'Returns a list of DNS records associated with a particular domain', + }, + 'internet.mxRecords': { + riskLevel: 'read', + description: + 'Returns a list of MX records associated with a particular domain', + }, + 'internet.whois': { + riskLevel: 'read', + description: + 'Returns domain registration details (e.g. registrar, contact information, expiration date, name servers) for a given domain name [premium plan required]', + }, + 'internet.ipLookup': { + riskLevel: 'read', + description: 'Returns the location of the IP address specified', + }, + 'internet.urlLookup': { + riskLevel: 'read', + description: + 'Returns the location of the IP address hosting the URL domain', + }, + 'internet.webpage': { + riskLevel: 'read', + description: + 'Returns the URL information and web page metadata from a given URL', + }, + 'internet.scrape': { + riskLevel: 'read', + description: 'Returns the HTML or plaintext data scraped from a given URL', + }, + 'internet.userAgent': { + riskLevel: 'read', + description: + 'Generates a realistic user agent string based on optional parameters', + }, + 'validation.email': { + riskLevel: 'read', + description: + 'Returns metadata (including whether it is valid) for a given email address', + }, + 'validation.disposableEmail': { + riskLevel: 'read', + description: + 'Returns metadata for a given email address, including whether it is from a disposable email provider', + }, + 'validation.phone': { + riskLevel: 'read', + description: + 'Returns metadata (including whether it is valid) for a given phone number', + }, + 'validation.routingNumber': { + riskLevel: 'read', + description: + 'Returns detailed information about a bank based on its routing number', + }, + 'validation.iban': { + riskLevel: 'read', + description: 'Returns detailed information on a given IBAN', + }, + 'validation.bin': { + riskLevel: 'read', + description: + 'Returns detailed information about a bank based on the BIN number provided', + }, + 'validation.swiftCode': { + riskLevel: 'read', + description: + 'Returns a list of bank information (including SWIFT/BIC Code) that match the input parameter', + }, + 'markets.stockPrice': { + riskLevel: 'read', + description: 'Returns price information for any given ticker symbol', + }, + 'markets.ticker': { + riskLevel: 'read', + description: + 'Returns comprehensive company profile information including company name, CEO, address, financial data, exchange information, identifiers...', + }, + 'markets.tickerList': { + riskLevel: 'read', + description: + 'Returns a list of all available companies and their ticker symbols', + }, + 'markets.stockExchanges': { + riskLevel: 'read', + description: + 'Returns detailed information about stock exchanges matching the specified criteria', + }, + 'markets.sp500': { + riskLevel: 'read', + description: + 'Returns S&P 500 index constituents, filterable by ticker, company name, sector or the date the company joined the index', + }, + 'markets.marketCap': { + riskLevel: 'read', + description: + 'Returns the current market cap data for any given company ticker', + }, + 'markets.earnings': { + riskLevel: 'read', + description: + 'Returns a JSON array of detailed earnings reports, each with comprehensive financial statements and key performance metrics', + }, + 'markets.earningsCalendar': { + riskLevel: 'read', + description: + 'Returns a list of past earnings results and upcoming earnings dates', + }, + 'markets.earningsTranscript': { + riskLevel: 'read', + description: + 'Returns the earnings transcript for a given company earning quarter [premium plan required]', + }, + 'markets.insiderTransactions': { + riskLevel: 'read', + description: + 'Returns a list of insider trading transactions that match the specified filters', + }, + 'markets.secFilings': { + riskLevel: 'read', + description: + 'Returns a list of SEC filing information (including the submission URL) corresponding to the given search parameters', + }, + 'markets.etf': { + riskLevel: 'read', + description: + 'Returns comprehensive information about any ETF by its ticker', + }, + 'markets.mutualFund': { + riskLevel: 'read', + description: + 'Returns comprehensive information about any Mutual Fund by its ticker', + }, + 'markets.cryptoPrice': { + riskLevel: 'read', + description: + 'Returns the current price and current time (in UNIX timestamp in seconds) for any cryptocurrency symbol', + }, + 'markets.bitcoin': { + riskLevel: 'read', + description: + 'Returns the latest Bitcoin price in USD and 24-hour market data', + }, + 'markets.commodityPrice': { + riskLevel: 'read', + description: + 'Returns the current price information for one or more commodities', + }, + 'markets.convertCurrency': { + riskLevel: 'read', + description: + 'Converts an existing currency and amount into a new currency [premium plan required]', + }, + 'markets.exchangeRate': { + riskLevel: 'read', + description: + 'Returns the exchange rate for a given currency pair [premium plan required]', + }, + 'economics.gdp': { + riskLevel: 'read', + description: 'Get GDP data from given parameters', + }, + 'economics.inflation': { + riskLevel: 'read', + description: + 'Returns current monthly and annual inflation percentages [premium plan required]', + }, + 'economics.unemployment': { + riskLevel: 'read', + description: 'Get unemployment data for a given country', + }, + 'economics.population': { + riskLevel: 'read', + description: 'Get population data from given parameters', + }, + 'economics.interestRate': { + riskLevel: 'read', + description: 'Get a specific interest rate by name', + }, + 'economics.mortgageRate': { + riskLevel: 'read', + description: + 'Returns the daily 30-year and 15-year fixed-rate mortgage (FRM) data', + }, + 'economics.mortgageCalculator': { + riskLevel: 'read', + description: + 'Returns monthly payment, annual payment, and interest rate information based on given mortgage parameters', + }, + 'economics.incomeTax': { + riskLevel: 'read', + description: + 'Returns comprehensive income tax information including tax brackets and rates at both federal and state/provincial levels (where applicable)', + }, + 'economics.incomeTaxCalculator': { + riskLevel: 'read', + description: + 'Returns comprehensive annual tax calculations including federal, state/provincial, and FICA taxes where applicable', + }, + 'economics.salesTax': { + riskLevel: 'read', + description: + 'Returns one or more sales tax breakdowns by ZIP code according to the specified parameters', + }, + 'economics.salesTaxCalculator': { + riskLevel: 'read', + description: 'Calculates sales tax for a given amount and location', + }, + 'economics.propertyTax': { + riskLevel: 'read', + description: + 'Returns a list of regions and corresponding 25th, 50th (median), and 75th percentile effective property tax rates', + }, + 'economics.vatRates': { + riskLevel: 'read', + description: 'Returns VAT rates for a specified EU country', + }, + 'text.sentiment': { + riskLevel: 'read', + description: + 'Returns sentiment analysis score and overall sentiment for a given block of text', + }, + 'text.similarity': { + riskLevel: 'read', + description: + 'Returns a similarity score between 0 and 1 (1 is similar and 0 is dissimilar) of two given texts', + }, + 'text.embeddings': { + riskLevel: 'read', + description: + 'Returns a 768-dimensional vector as an array that encodes the meaning of any given input text', + }, + 'text.language': { + riskLevel: 'read', + description: + 'Returns the language name and 2-letter ISO language code for a given block of text string', + }, + 'text.spellCheck': { + riskLevel: 'read', + description: + 'Returns spelling corrections and suggestions for any given text', + }, + 'text.profanityFilter': { + riskLevel: 'read', + description: + 'Returns the censored version (bad words replaced with asterisks) of any given text and whether the text contains profanity', + }, + 'text.dictionary': { + riskLevel: 'read', + description: 'Returns a string containing definitions for a given word', + }, + 'text.thesaurus': { + riskLevel: 'read', + description: + 'Returns a list of synonyms and a list of antonyms for a given word', + }, + 'text.rhymes': { + riskLevel: 'read', + description: 'Returns a list of rhyming words for any given word', + }, + 'text.randomWord': { + riskLevel: 'read', + description: 'Returns a random word [premium plan required]', + }, + 'text.loremIpsum': { + riskLevel: 'read', + description: + 'Returns one or more paragraphs of lorem ipsum placeholder text', + }, + 'utility.qrCode': { + riskLevel: 'read', + description: 'Returns a QRCode image binary specified by input parameters', + }, + 'utility.barcode': { + riskLevel: 'read', + description: 'Returns a barcode image binary specified by input parameters', + }, + 'utility.password': { + riskLevel: 'read', + description: + 'Returns a random password string adhering to the specified parameters', + }, + 'utility.randomUser': { + riskLevel: 'read', + description: 'Returns fake random user profiles', + }, + 'utility.counter': { + riskLevel: 'write', + description: 'Fetch and possibly update a counter', + }, + 'utility.convertUnit': { + riskLevel: 'read', + description: + 'Returns conversions between different units of the same measurement type', + }, + 'utility.logo': { + riskLevel: 'read', + description: + 'Get a list of company names, ticker symbols, and logo image URLs matching the input parameters', + }, + 'utility.countryFlag': { + riskLevel: 'read', + description: "Get a country's flag as SVG image URLs", + }, + 'utility.randomImage': { + riskLevel: 'read', + description: + 'Returns a random image in JPEG format [premium plan required]', + }, + 'utility.emoji': { + riskLevel: 'read', + description: 'Returns a list of emojis according to input parameters', + }, + 'transport.aircraft': { + riskLevel: 'read', + description: 'Returns a list of aircrafts that match the given parameters', + }, + 'transport.airlines': { + riskLevel: 'read', + description: + 'Returns airline details including fleet composition, base airport and branding assets, by name, IATA code or ICAO code', + }, + 'transport.airports': { + riskLevel: 'read', + description: 'Returns a list of up to 10 airport results', + }, + 'transport.helicopters': { + riskLevel: 'read', + description: + 'Get helicopter technical specifications that match the given parameters', + }, + 'transport.cars': { + riskLevel: 'read', + description: + 'Get car data from given parameters [deprecated by the provider]', + }, + 'transport.motorcycles': { + riskLevel: 'read', + description: + 'Returns up to 30 motorcycle results matching the input name parameters', + }, + 'transport.electricVehicles': { + riskLevel: 'read', + description: 'Get electric vehicle data from given parameters', + }, + 'transport.vin': { + riskLevel: 'read', + description: + 'Returns key vehicle information including manufacturer, country of origin, and model year for a given VIN', + }, + 'health.caloriesBurned': { + riskLevel: 'read', + description: + 'Returns the calories burned per hour and total calories burned according to given parameters for given activities (up to 10)', + }, + 'health.nutrition': { + riskLevel: 'read', + description: + 'This endpoint uses AI to automatically read any text and extract every food item it contains, along with the right portion for each', + }, + 'health.exercises': { + riskLevel: 'read', + description: + 'Returns up to 5 exercises that satisfy the given parameters [premium plan required]', + }, + 'health.recipes': { + riskLevel: 'read', + description: + 'Get a list of recipes for a given recipe name or ingredient(s)', + }, + 'health.cocktails': { + riskLevel: 'read', + description: + 'Returns up to 10 cocktail recipes matching the search parameters', + }, + 'reference.animals': { + riskLevel: 'read', + description: 'Returns up to 10 results matching the input name parameter', + }, + 'reference.cats': { + riskLevel: 'read', + description: 'Get a list of cat breeds matching specified parameters', + }, + 'reference.dogs': { + riskLevel: 'read', + description: 'Get a list of dog breeds matching specified parameters', + }, + 'reference.planets': { + riskLevel: 'read', + description: 'Get a list of planets matching specified parameters', + }, + 'reference.stars': { + riskLevel: 'read', + description: 'Get a list of stars matching specified parameters', + }, + 'reference.historicalEvents': { + riskLevel: 'read', + description: + 'Returns a list of up to 10 events that match the search parameters', + }, + 'reference.historicalFigures': { + riskLevel: 'read', + description: + 'Returns a list of up to 10 people that match the search parameters', + }, + 'reference.dayInHistory': { + riskLevel: 'read', + description: + 'Returns historical events that occurred on a specific date [premium plan required]', + }, + 'reference.celebrities': { + riskLevel: 'read', + description: + 'Returns a list of up to 30 celebrities that match the search parameters', + }, + 'reference.babyNames': { + riskLevel: 'read', + description: 'Returns 10 baby name results', + }, + 'entertainment.jokes': { + riskLevel: 'read', + description: 'Returns one (or more) random funny jokes', + }, + 'entertainment.dadJokes': { + riskLevel: 'read', + description: 'Returns one (or more) random dad jokes', + }, + 'entertainment.chuckNorris': { + riskLevel: 'read', + description: 'Returns a Chuck Norris joke', + }, + 'entertainment.jokeOfTheDay': { + riskLevel: 'read', + description: 'Returns a single joke for the current day', + }, + 'entertainment.facts': { + riskLevel: 'read', + description: 'Returns one (or more) random facts', + }, + 'entertainment.factOfTheDay': { + riskLevel: 'read', + description: 'Returns a single fact for the current day', + }, + 'entertainment.quotes': { + riskLevel: 'read', + description: + 'Returns high-quality quotes with advanced filtering by categories (include/exclude), author, work, and pagination support [premium plan required]', + }, + 'entertainment.randomQuotes': { + riskLevel: 'read', + description: + 'Returns random high-quality quotes with advanced filtering by categories (include/exclude), author, and work [premium plan required]', + }, + 'entertainment.quoteOfTheDay': { + riskLevel: 'read', + description: 'Returns a single aphoristic quote for the current day', + }, + 'entertainment.advice': { + riskLevel: 'read', + description: 'Returns a random piece of life advice', + }, + 'entertainment.bucketList': { + riskLevel: 'read', + description: 'Returns a random bucket list idea', + }, + 'entertainment.hobbies': { + riskLevel: 'read', + description: + 'Returns a random hobby and a Wikipedia link detailing the hobby', + }, + 'entertainment.horoscope': { + riskLevel: 'read', + description: 'Returns the daily horoscope for a specific zodiac sign', + }, + 'entertainment.riddles': { + riskLevel: 'read', + description: 'Returns one or more random riddles', + }, + 'entertainment.trivia': { + riskLevel: 'read', + description: 'Returns a random trivia question and answer', + }, + 'entertainment.triviaOfTheDay': { + riskLevel: 'read', + description: + 'Returns a single trivia question and answer for the current day', + }, + 'entertainment.generateSudoku': { + riskLevel: 'read', + description: 'Generate a new Sudoku puzzle with specified parameters', + }, + 'entertainment.solveSudoku': { + riskLevel: 'read', + description: 'Solve an existing Sudoku puzzle', + }, +} satisfies RequiredPluginEndpointMeta; + +// `handleCorsairError` selects the first handler whose `match` returns true, +// walking keys in insertion order. DEFAULT matches everything, so it has to be +// last: spreading caller-supplied handlers after it would leave them +// unreachable. +function mergeErrorHandlers( + builtIn: CorsairErrorHandler, + overrides?: CorsairErrorHandler, +): CorsairErrorHandler { + const { DEFAULT: builtInDefault, ...builtInRest } = builtIn; + const { DEFAULT: overrideDefault, ...overrideRest } = overrides ?? {}; + + return { + ...builtInRest, + ...overrideRest, + DEFAULT: overrideDefault ?? builtInDefault, + }; +} + +const defaultAuthType: AuthTypes = 'api_key' as const; + +/** + * One key, sent as `X-Api-Key`. There is no OAuth flow, no account-specific + * host and no second credential to resolve. + */ +export const apiNinjasAuthConfig = { + api_key: { + account: ['one'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BaseApiNinjasPlugin = + CorsairPlugin< + 'apininjas', + typeof ApiNinjasSchema, + typeof apiNinjasEndpointsNested, + {}, + T, + typeof defaultAuthType, + typeof apiNinjasAuthConfig + >; + +export type InternalApiNinjasPlugin = + BaseApiNinjasPlugin; + +export type ExternalApiNinjasPlugin = + BaseApiNinjasPlugin; + +// The assertion is safe: ApiNinjasPluginOptions has no required fields, so an +// empty object satisfies the constraint at runtime even though TypeScript +// cannot verify it without the assertion. +export function apininjas( + incomingOptions: ApiNinjasPluginOptions & T = {} as ApiNinjasPluginOptions & + T, +): ExternalApiNinjasPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + + return { + id: 'apininjas', + schema: ApiNinjasSchema, + options, + hooks: options.hooks, + endpoints: apiNinjasEndpointsNested, + webhooks: {}, + endpointMeta: apiNinjasEndpointMeta, + endpointSchemas: apiNinjasEndpointSchemas, + authConfig: apiNinjasAuthConfig, + // API Ninjas is request/response only: it has no webhooks, no event + // subscriptions and nothing that calls back. + pluginWebhookMatcher: () => false, + errorHandlers: mergeErrorHandlers(errorHandlers, options.errorHandlers), + keyBuilder: async (ctx: ApiNinjasKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const key = await ctx.keys.get_api_key(); + + if (!key) { + throw new AuthMissingError('apininjas', 'api_key'); + } + + return key; + } + + throw new AuthMissingError('apininjas', 'api_key'); + }, + } satisfies InternalApiNinjasPlugin; +} + +export type { + ApiNinjasEndpointInputs, + ApiNinjasEndpointOutputs, +} from './endpoints/types'; +export type { + ApiNinjasAircraftEntity, + ApiNinjasAirlineEntity, + ApiNinjasAirportEntity, + ApiNinjasAnimalEntity, + ApiNinjasCityEntity, + ApiNinjasCountryEntity, + ApiNinjasEmojiEntity, + ApiNinjasPlanetEntity, + ApiNinjasSp500Entity, + ApiNinjasStarEntity, + ApiNinjasStockExchangeEntity, + ApiNinjasUniversityEntity, + ApiNinjasVehicleEntity, +} from './schema/database'; diff --git a/packages/apininjas/jest.config.cjs b/packages/apininjas/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/apininjas/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/apininjas/logging.test.ts b/packages/apininjas/logging.test.ts new file mode 100644 index 000000000..7ae84cb51 --- /dev/null +++ b/packages/apininjas/logging.test.ts @@ -0,0 +1,220 @@ +/** + * What may and may not reach `corsair_events`. + * + * The audit payload is deny-by-default: a parameter's value is written only if + * it is on the loggable list. This suite proves that for every parameter of + * every operation rather than for the handful anyone thought to check, because + * the failure this replaces was exactly a list that looked complete and was + * not - it put income, deductions, street addresses, IBANs and routing numbers + * into permanent storage. + */ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { DOCUMENTED_OPERATIONS } from './docs-contract'; +import { auditPayload, isLoggableKey, loggableKeys } from './endpoints/logging'; + +/** Every parameter name across all 129 documented operations. */ +const ALL_PARAMETERS = [ + ...new Set( + Object.values(DOCUMENTED_OPERATIONS).flatMap((operation) => + operation.params.map((param) => param.name), + ), + ), +].sort(); + +/** A value distinctive enough that finding it in a payload proves a leak. */ +const CANARY = 'CANARY-VALUE-9c3f1a'; + +describe('the surface being protected', () => { + it('covers every parameter the plugin accepts', () => { + expect(ALL_PARAMETERS.length).toBeGreaterThan(200); + }); +}); + +describe('deny by default', () => { + test.each(ALL_PARAMETERS)( + '%s is either loggable by review or redacted', + (parameter) => { + const payload = auditPayload({ [parameter]: CANARY }, [parameter]); + + if (isLoggableKey(parameter)) { + expect(payload[parameter]).toBe(CANARY); + } else { + expect(payload[parameter]).toBeUndefined(); + expect(JSON.stringify(payload)).not.toContain(CANARY); + } + }, + ); + + test.each(ALL_PARAMETERS)( + '%s is recorded by name whether or not its value is', + (parameter) => { + // Redaction must not hide that a call supplied the field: an operator + // still needs to see what an operation was asked to do. + const payload = auditPayload({ [parameter]: CANARY }, [parameter]); + + expect(payload.supplied_fields).toEqual([parameter]); + }, + ); + + it('records a length for a redacted value, so calls stay comparable', () => { + const payload = auditPayload({ income: 125000, text: 'hello there' }, [ + 'income', + 'text', + ]); + + expect(payload.text_length).toBe(11); + // A number has no length; the field name is still recorded. + expect(payload.income_length).toBeUndefined(); + expect(payload.supplied_fields).toEqual(['income', 'text']); + }); + + it('cannot be widened by an endpoint naming a field as an identifier', () => { + // `identifierKeys` is a hint from the handler. If it could override the + // list, every one of the 129 handlers would be a place to get this wrong. + const payload = auditPayload( + { iban: 'DE89370400440532013000', routing_number: '121000248' }, + ['iban', 'routing_number'], + ); + + expect(payload.iban).toBeUndefined(); + expect(payload.routing_number).toBeUndefined(); + expect(JSON.stringify(payload)).not.toContain('DE89'); + expect(JSON.stringify(payload)).not.toContain('121000248'); + }); + + it('redacts a parameter this plugin has never seen', () => { + // The point of the inversion: a parameter added by a future operation is + // protected before anyone reviews it. + const payload = auditPayload({ social_security_number: CANARY }, [ + 'social_security_number', + ]); + + expect(payload.social_security_number).toBeUndefined(); + expect(payload.social_security_number_length).toBe(CANARY.length); + }); +}); + +describe('the fields this was reported for', () => { + // Each of these was previously written to the event log in full. + it.each([ + ['income', 125_000], + ['deductions', 12_400], + ['credits', 2_000], + ['home_value', 750_000], + ['loan_amount', 400_000], + ['downpayment', 80_000], + ['annual_property_tax', 9_500], + ['annual_home_insurance', 2_100], + ['monthly_hoa', 350], + ['interest_rate', 3.5], + ['filing_status', 'married'], + ['self_employed', true], + ['street_address', '1 Example Street'], + ['zip_code', '90210'], + ['postal_code', 'K1A 0B1'], + ['routing_number', '121000248'], + ['iban', 'DE89370400440532013000'], + ['bin', '411111'], + ['swift', 'BOFAUS3N'], + ['transaction_code', 'P'], + ['vin', 'JH4TB2H26CC000000'], + ['email', 'someone@example.com'], + ['number', '+15550100'], + ['address', '203.0.113.7'], + ['lat', 51.5074], + ['lon', -0.1278], + ['word', 'secret-lookup-term'], + ])('never records %s by value', (key, value) => { + const payload = auditPayload({ [key]: value }, [key]); + + expect(payload[key]).toBeUndefined(); + expect(JSON.stringify(payload)).not.toContain(String(value)); + }); +}); + +describe('the loggable list itself', () => { + it('contains nothing that names money, an account or a precise location', () => { + // A guard against the list drifting back: these describe a caller rather + // than a public thing, and none of them belong here. + // + // Matched on whole words. Substring matching was the first attempt and it + // rejected `province` for containing "vin". + const forbidden = + /\b(iban|routing|routing_number|swift|account|income|salary|deduction|deductions|credit|credits|balance|address|street_address|zip|zip_code|zipcode|postal_code|lat|lon|latitude|longitude|vin|password|token|secret|ssn|filing_status|self_employed|loan_amount|downpayment|home_value|monthly_hoa|interest_rate|transaction_code)\b/i; + + const offenders = loggableKeys().filter((key) => forbidden.test(key)); + + expect(offenders).toEqual([]); + }); + + it('holds only parameters the plugin actually accepts', () => { + // A stale entry is a claim that something was reviewed when it no longer + // exists, and it hides the fact that a real parameter is unreviewed. + const unknown = loggableKeys().filter( + (key) => !ALL_PARAMETERS.includes(key), + ); + + expect(unknown).toEqual([]); + }); + + it('is what every handler actually asks to record', () => { + // Enforcement is central - `auditPayload` filters whatever it is handed - + // but a handler naming a forbidden field would still read as though the + // plugin intended to log it, and would start logging it the moment anyone + // relaxed the filter. This reads the 12 endpoint modules and checks the + // identifier list of all 129 call sites. + const modules = readdirSync(join(__dirname, 'endpoints')).filter((file) => + file.endsWith('.ts'), + ); + const named = new Set(); + let callSites = 0; + let occurrences = 0; + const unmatched: string[] = []; + + for (const file of modules) { + const source = readFileSync(join(__dirname, 'endpoints', file), 'utf8'); + const calls = [...source.matchAll(/auditPayload\(/g)]; + const parsed = [...source.matchAll(/auditPayload\(input, \[([^\]]*)\]/g)]; + occurrences += calls.length; + callSites += parsed.length; + + const parsedAt = new Set(parsed.map((match) => match.index)); + for (const call of calls) { + if (parsedAt.has(call.index)) continue; + const line = source.slice(0, call.index).split('\n').length; + unmatched.push(`${file}:${line}`); + } + + for (const match of parsed) { + for (const key of match[1]?.matchAll(/'([a-z0-9_]+)'/g) ?? []) { + named.add(key[1] as string); + } + } + } + + expect(unmatched).toEqual([]); + expect(occurrences).toBe(callSites); + expect(callSites).toBe(129); + expect([...named].filter((key) => !isLoggableKey(key))).toEqual([]); + }); + + it('still records enough to make the log useful', () => { + // Deny-by-default is only correct if the log still answers "what was + // asked for". These are the keys that carry that meaning. + for (const key of ['ticker', 'country', 'city', 'iata', 'year', 'limit']) { + expect(isLoggableKey(key)).toBe(true); + } + + const payload = auditPayload( + { ticker: 'AAPL', year: 2026, filing: '10-K' }, + ['ticker', 'year', 'filing'], + ); + expect(payload).toEqual({ + ticker: 'AAPL', + year: 2026, + filing: '10-K', + supplied_fields: ['ticker', 'year', 'filing'], + }); + }); +}); diff --git a/packages/apininjas/package.json b/packages/apininjas/package.json new file mode 100644 index 000000000..f42d53b9a --- /dev/null +++ b/packages/apininjas/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/apininjas", + "version": "0.1.0", + "description": "API Ninjas plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "apininjas", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/apininjas/persist.test.ts b/packages/apininjas/persist.test.ts new file mode 100644 index 000000000..4b585ffdb --- /dev/null +++ b/packages/apininjas/persist.test.ts @@ -0,0 +1,839 @@ +/** + * The cache layer, store by store. + * + * Two things decide whether a mirror is useful here: the key a row is stored + * under, and whether a withheld value is stored at all. Most of these endpoints + * return no identifier, so keys are composed from natural keys - and a wrong + * key means either a duplicate row or a row that can never be found again. + * + * Every write is also best-effort by design: a lookup must not fail because the + * local mirror could not be written. + */ +import { + cacheAircraft, + cacheAirlines, + cacheAirports, + cacheAnimals, + cacheCars, + cacheCities, + cacheCountries, + cacheElectricVehicles, + cacheEmoji, + cacheMotorcycles, + cachePlanets, + cacheSp500, + cacheStars, + cacheStockExchanges, + cacheUniversities, +} from './endpoints/persist'; + +type Store = { upsertByEntityId: jest.Mock }; + +function makeStore(): Store { + return { upsertByEntityId: jest.fn(async () => undefined) }; +} + +/** The id and row a cache helper wrote, for the single row it was given. */ +function written(store: Store): [string, Record] { + expect(store.upsertByEntityId).toHaveBeenCalledTimes(1); + return store.upsertByEntityId.mock.calls[0] as [ + string, + Record, + ]; +} + +const AT = new Date('2026-08-15T00:00:00.000Z'); +const MASKED = 'This field is for premium subscribers only.'; + +beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => undefined); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('airports', () => { + it('keys on ident and stores the useful columns', async () => { + const store = makeStore(); + + await cacheAirports( + store, + [ + { + ident: 'EGLL', + icao: 'EGLL', + iata: 'LHR', + name: 'London Heathrow Airport', + city: 'London', + country: 'GB', + region: 'England', + latitude: 51.470748, + longitude: -0.459909, + timezone: 'Europe/London', + }, + ], + AT, + ); + + const [id, row] = written(store); + expect(id).toBe('egll'); + expect(row).toEqual({ + id: 'egll', + ident: 'EGLL', + iata: 'LHR', + icao: 'EGLL', + name: 'London Heathrow Airport', + city: 'London', + country: 'GB', + region: 'England', + latitude: 51.470748, + longitude: -0.459909, + timezone: 'Europe/London', + captured_at: AT, + }); + }); + + it('falls back through icao, iata and name for its key', async () => { + const store = makeStore(); + + await cacheAirports( + store, + [{ icao: 'KJFK' }, { iata: 'CDG' }, { name: 'Some Airstrip' }], + AT, + ); + + expect(store.upsertByEntityId.mock.calls.map((call) => call[0])).toEqual([ + 'kjfk', + 'cdg', + 'some airstrip', + ]); + }); + + it('skips a row with nothing to key on', async () => { + const store = makeStore(); + + await cacheAirports(store, [{ city: 'Nowhere' }], AT); + + expect(store.upsertByEntityId).not.toHaveBeenCalled(); + }); + + it('coerces coordinates that arrive as strings', async () => { + const store = makeStore(); + + await cacheAirports( + store, + [ + { + icao: 'EGLL', + latitude: '51.47' as never, + longitude: '-0.45' as never, + }, + ], + AT, + ); + + const [, row] = written(store); + expect(row.latitude).toBe(51.47); + expect(row.longitude).toBe(-0.45); + }); + + it('drops masked fields nested inside runways', async () => { + const store = makeStore(); + + await cacheAirports( + store, + [ + { + icao: 'EGLL', + runways: [ + { length: 12799, surface: MASKED }, + { + length: MASKED as never, + surface: 'ASP', + lights: { has_lights: MASKED }, + }, + ], + }, + ], + AT, + ); + + expect(written(store)[1].runways).toEqual([ + { length: 12799 }, + { surface: 'ASP' }, + ]); + }); + + it('does nothing at all when the store is not configured', async () => { + // A plugin instance without a database still has to serve lookups. + await expect( + cacheAirports(undefined, [{ icao: 'EGLL' }], AT), + ).resolves.toBeUndefined(); + }); + + it('does nothing for an empty result', async () => { + const store = makeStore(); + + await cacheAirports(store, [], AT); + + expect(store.upsertByEntityId).not.toHaveBeenCalled(); + }); + + it('swallows a store failure and warns rather than failing the call', async () => { + const store = makeStore(); + store.upsertByEntityId.mockRejectedValueOnce(new Error('database is gone')); + + await expect( + cacheAirports(store, [{ icao: 'EGLL' }], AT), + ).resolves.toBeUndefined(); + expect(console.warn).toHaveBeenCalled(); + }); +}); + +describe('airlines', () => { + it('keys on IATA and stores the official fleet object', async () => { + const store = makeStore(); + + await cacheAirlines( + store, + [ + { + name: 'Singapore Airlines', + iata: 'SQ', + icao: 'SIA', + country: 'Singapore', + base: 'Singapore Changi Airport', + fleet: { A359: 59, B77W: 27, total: 155 }, + logo_url: 'https://example.com/logo.png', + }, + ], + AT, + ); + + const [id, row] = written(store); + expect(id).toBe('sq'); + expect(row.fleet).toEqual({ A359: 59, B77W: 27, total: 155 }); + expect(row.base).toBe('Singapore Changi Airport'); + }); + + it('leaves the fleet unset when the fleet object is absent', async () => { + const store = makeStore(); + + await cacheAirlines(store, [{ iata: 'SQ' }], AT); + + expect(written(store)[1].fleet).toBeUndefined(); + }); + + it('ignores a fleet that is not an object', async () => { + const store = makeStore(); + + await cacheAirlines(store, [{ iata: 'SQ', fleet: 'unknown' as never }], AT); + + expect(written(store)[1].fleet).toBeUndefined(); + }); + + it('falls back to icao then name', async () => { + const store = makeStore(); + + await cacheAirlines(store, [{ icao: 'BAW' }, { name: 'Tiny Air' }], AT); + + expect(store.upsertByEntityId.mock.calls.map((call) => call[0])).toEqual([ + 'baw', + 'tiny air', + ]); + }); +}); + +describe('aircraft', () => { + it('keys on manufacturer and model together', async () => { + const store = makeStore(); + + await cacheAircraft( + store, + [ + { + manufacturer: 'Boeing', + model: '737 Max 7', + engine_type: 'Jet', + max_speed_knots: '547', + range_nautical_miles: '3850', + }, + ], + AT, + ); + + const [id, row] = written(store); + expect(id).toBe('boeing|737 max 7'); + // The provider sends these as strings; the entity accepts both. + expect(row.max_speed_knots).toBe('547'); + }); + + it('drops a masked specification instead of storing the sentence', async () => { + const store = makeStore(); + + await cacheAircraft( + store, + [{ manufacturer: 'Boeing', model: '737', max_speed_knots: MASKED }], + AT, + ); + + expect(written(store)[1].max_speed_knots).toBeUndefined(); + }); + + it('skips a row with neither manufacturer nor model', async () => { + const store = makeStore(); + + await cacheAircraft(store, [{ engine_type: 'Jet' }], AT); + + expect(store.upsertByEntityId).not.toHaveBeenCalled(); + }); +}); + +describe('vehicles', () => { + it('prefixes each source so three endpoints can share one store', async () => { + const store = makeStore(); + + await cacheCars( + store, + [{ make: 'toyota', model: 'corolla', year: 1993 }], + AT, + ); + await cacheMotorcycles( + store, + [{ make: 'toyota', model: 'corolla', year: '1993' }], + AT, + ); + await cacheElectricVehicles( + store, + [{ make: 'toyota', model: 'corolla', year_start: '1993' }], + AT, + ); + + const ids = store.upsertByEntityId.mock.calls.map((call) => call[0]); + // Without the prefix these three rows would collide on one key. + expect(ids).toEqual([ + 'car|toyota|corolla|1993', + 'motorcycle|toyota|corolla|1993', + 'electric|toyota|corolla|1993', + ]); + expect(new Set(ids).size).toBe(3); + }); + + it('records the kind and the vehicle class for a car', async () => { + const store = makeStore(); + + await cacheCars( + store, + [ + { + make: 'toyota', + model: 'corolla', + year: 1993, + fuel_type: 'gas', + class: 'compact car', + }, + ], + AT, + ); + + const [, row] = written(store); + expect(row.kind).toBe('car'); + expect(row.class).toBe('compact car'); + expect(row.fuel_type).toBe('gas'); + }); + + it('takes the motorcycle type as its class', async () => { + const store = makeStore(); + + await cacheMotorcycles( + store, + [ + { + make: 'Kawasaki', + model: 'Brute Force 300', + year: '2022', + type: 'ATV', + }, + ], + AT, + ); + + const [, row] = written(store); + expect(row.kind).toBe('motorcycle'); + expect(row.type).toBe('ATV'); + }); + + it('marks an electric vehicle as electric without being told', async () => { + const store = makeStore(); + + await cacheElectricVehicles( + store, + [{ make: 'Tesla', model: 'Model S 85D', year_start: '2015' }], + AT, + ); + + const [, row] = written(store); + expect(row.kind).toBe('electric'); + expect(row.year_start).toBe('2015'); + }); + + it('drops the masked fields the electric endpoint is full of', async () => { + const store = makeStore(); + + await cacheElectricVehicles( + store, + [{ make: 'Tesla', model: 'Model S', year_start: MASKED }], + AT, + ); + + expect(written(store)[1].year_start).toBeUndefined(); + }); +}); + +describe('countries and cities', () => { + it('keys a country on its ISO code and lifts the currency code out', async () => { + const store = makeStore(); + + await cacheCountries( + store, + [ + { + iso2: 'DE', + name: 'Germany', + capital: 'Berlin', + region: 'Western Europe', + currency: { code: 'EUR', name: 'Euro' }, + population: 83000, + surface_area: 357376, + }, + ], + AT, + ); + + const [id, row] = written(store); + expect(id).toBe('de'); + expect(row.currency).toEqual({ code: 'EUR', name: 'Euro' }); + expect(row.capital).toBe('Berlin'); + }); + + it('falls back to the country name when no ISO code is sent', async () => { + const store = makeStore(); + + await cacheCountries(store, [{ name: 'Germany' }], AT); + + expect(written(store)[0]).toBe('germany'); + }); + + it('leaves the currency code unset when the object is missing', async () => { + const store = makeStore(); + + await cacheCountries(store, [{ iso2: 'DE' }], AT); + + expect(written(store)[1].currency).toBeUndefined(); + }); + + it('keys a city on name and country, because it has no id', async () => { + const store = makeStore(); + + await cacheCities( + store, + [ + { + name: 'London', + country: 'GB', + latitude: 51.5072, + longitude: -0.1275, + population: 10979000, + is_capital: true, + }, + ], + AT, + ); + + const [id, row] = written(store); + expect(id).toBe('london|gb'); + expect(row.is_capital).toBe(true); + }); + + it('keeps two same-named cities in different countries apart', async () => { + const store = makeStore(); + + await cacheCities( + store, + [ + { name: 'London', country: 'GB' }, + { name: 'London', country: 'CA' }, + ], + AT, + ); + + expect(store.upsertByEntityId.mock.calls.map((call) => call[0])).toEqual([ + 'london|gb', + 'london|ca', + ]); + }); + + it('skips a city row with neither name nor country', async () => { + const store = makeStore(); + + await cacheCities(store, [{ population: 1 }], AT); + + expect(store.upsertByEntityId).not.toHaveBeenCalled(); + }); +}); + +describe('universities, exchanges and index membership', () => { + it('keys a university on name and country', async () => { + const store = makeStore(); + + await cacheUniversities( + store, + [ + { + name: 'Harvard University', + country: 'USA', + city: 'Cambridge', + state: 'MA', + website: 'http://www.harvard.edu/', + institution_type: 'Private (Not For Profit)', + }, + ], + AT, + ); + + const [id, row] = written(store); + expect(id).toBe('harvard university|usa'); + expect(row.institution_type).toBe('Private (Not For Profit)'); + }); + + it('skips a university row with nothing to key on', async () => { + const store = makeStore(); + + await cacheUniversities(store, [{ city: 'Cambridge' }], AT); + + expect(store.upsertByEntityId).not.toHaveBeenCalled(); + }); + + it('keys a stock exchange on its MIC', async () => { + const store = makeStore(); + + await cacheStockExchanges( + store, + [ + { + mic: 'XNAS', + name: 'NASDAQ Global Market', + city: 'New York City', + country: 'United States', + currency: 'USD', + timezone: 'America/New_York', + }, + ], + AT, + ); + + expect(written(store)[0]).toBe('xnas'); + }); + + it('falls back to the exchange name without a MIC', async () => { + const store = makeStore(); + + await cacheStockExchanges(store, [{ name: 'Some Exchange' }], AT); + + expect(written(store)[0]).toBe('some exchange'); + }); + + it('keys an index constituent on its ticker', async () => { + const store = makeStore(); + + await cacheSp500( + store, + [ + { + ticker: 'MSFT', + company_name: 'Microsoft', + sector: 'Information Technology', + sub_industry: 'Systems Software', + headquarters: 'Redmond, Washington', + date_added: '1994-06-01', + cik: '0000789019', + }, + ], + AT, + ); + + const [id, row] = written(store); + expect(id).toBe('msft'); + expect(row.date_added).toBe('1994-06-01'); + }); + + it('skips a constituent with no ticker', async () => { + const store = makeStore(); + + await cacheSp500(store, [{ company_name: 'Microsoft' }], AT); + + expect(store.upsertByEntityId).not.toHaveBeenCalled(); + }); +}); + +describe('emoji, animals and astronomy', () => { + it('keys an emoji on its code point', async () => { + const store = makeStore(); + + await cacheEmoji( + store, + [ + { + code: 'U+1F63C', + character: ':cat:', + name: 'cat with wry smile', + group: 'smileys_emotion', + subgroup: 'cat_face', + image: 'https://example.com/emoji.png', + }, + ], + AT, + ); + + const [id, row] = written(store); + expect(id).toBe('u+1f63c'); + expect(row.subgroup).toBe('cat_face'); + }); + + it('falls back to the emoji name when no code is sent', async () => { + const store = makeStore(); + + await cacheEmoji(store, [{ name: 'grinning face' }], AT); + + expect(written(store)[0]).toBe('grinning face'); + }); + + it('lifts taxonomy and characteristics onto the animal row', async () => { + const store = makeStore(); + + await cacheAnimals( + store, + [ + { + name: 'Cheetah', + taxonomy: { + family: 'Felidae', + scientific_name: 'Acinonyx jubatus', + }, + characteristics: { habitat: 'Open grassland', diet: 'Carnivore' }, + locations: ['Africa', 'Asia'], + }, + ], + AT, + ); + + const [id, row] = written(store); + expect(id).toBe('cheetah'); + expect(row.taxonomy).toEqual({ + family: 'Felidae', + scientific_name: 'Acinonyx jubatus', + }); + expect(row.characteristics).toEqual({ + habitat: 'Open grassland', + diet: 'Carnivore', + }); + expect(row.locations).toEqual(['Africa', 'Asia']); + }); + + it('drops masked values nested inside taxonomy', async () => { + const store = makeStore(); + + await cacheAnimals( + store, + [ + { + name: 'Cheetah', + taxonomy: { + family: 'Felidae', + scientific_name: MASKED, + rank: { order: 'Carnivora', note: MASKED }, + }, + }, + ], + AT, + ); + + expect(written(store)[1].taxonomy).toEqual({ + family: 'Felidae', + rank: { order: 'Carnivora' }, + }); + }); + + it('handles an animal with neither nested object', async () => { + const store = makeStore(); + + await cacheAnimals(store, [{ name: 'Cheetah' }], AT); + + const [, row] = written(store); + expect(row.taxonomy).toBeUndefined(); + expect(row.characteristics).toBeUndefined(); + expect(row.locations).toBeUndefined(); + }); + + it('keeps only string entries in the locations list', async () => { + const store = makeStore(); + + await cacheAnimals( + store, + [{ name: 'Cheetah', locations: ['Africa', 42 as never, null as never] }], + AT, + ); + + expect(written(store)[1].locations).toEqual(['Africa']); + }); + + it('stores a planet with its orbital figures', async () => { + const store = makeStore(); + + await cachePlanets( + store, + [ + { + name: 'Mars', + mass: 0.000338, + radius: 0.0488, + period: 687, + temperature: 210, + distance_light_year: 0.000037, + }, + ], + AT, + ); + + const [id, row] = written(store); + expect(id).toBe('mars'); + expect(row.period).toBe(687); + }); + + it('stores a star with its catalogue values as sent', async () => { + const store = makeStore(); + + await cacheStars( + store, + [ + { + name: 'Vega', + constellation: 'Lyra', + spectral_class: 'A0Vvar', + apparent_magnitude: '0.03', + distance_light_year: '25', + }, + ], + AT, + ); + + const [id, row] = written(store); + expect(id).toBe('vega'); + // Strings here are the provider's own format, not a masked value. + expect(row.apparent_magnitude).toBe('0.03'); + }); + + it.each([ + ['planets', cachePlanets], + ['stars', cacheStars], + ['animals', cacheAnimals], + ])('skips an unnamed %s row', async (_label, cache) => { + const store = makeStore(); + + await cache(store, [{}], AT); + + expect(store.upsertByEntityId).not.toHaveBeenCalled(); + }); +}); + +describe('rows that cannot be keyed', () => { + // Every store composes its key from whatever the row happens to carry. When + // none of the candidates are present the row is dropped rather than written + // under a blank key, where it would collide with every other blank row. + it.each([ + ['airline', cacheAirlines, { country: 'Singapore' }], + ['country', cacheCountries, { capital: 'Berlin' }], + ['stock exchange', cacheStockExchanges, { city: 'New York City' }], + ['emoji', cacheEmoji, { group: 'smileys_emotion' }], + ['aircraft', cacheAircraft, { engine_type: 'Jet' }], + ['city', cacheCities, { population: 1 }], + ['university', cacheUniversities, { city: 'Cambridge' }], + // The vehicle helpers key on three parts, so a row that carried none of + // them used to be stored under 'car|||' - and the next such row overwrote + // it. A key of more than two parts is also why the guard checks the parts + // rather than comparing the joined string to '|'. + ['car', cacheCars, { fuel_type: 'gas' }], + ['motorcycle', cacheMotorcycles, { type: 'ATV' }], + [ + 'electric vehicle', + cacheElectricVehicles, + { battery_type: 'Lithium-ion' }, + ], + ])('drops an unkeyable %s', async (_label, cache, row) => { + const store = makeStore(); + + await cache(store, [row as never], AT); + + expect(store.upsertByEntityId).not.toHaveBeenCalled(); + }); + + it('still stores a vehicle identified by only one of its three parts', async () => { + // The guard rejects a row with nothing to key on, not a partial one. + const store = makeStore(); + + await cacheCars(store, [{ model: 'corolla' }], AT); + + expect(store.upsertByEntityId).toHaveBeenCalledTimes(1); + expect(store.upsertByEntityId.mock.calls[0]?.[0]).toBe('car||corolla|'); + }); +}); + +describe('every store', () => { + const helpers = [ + ['airports', cacheAirports, { icao: 'EGLL' }], + ['airlines', cacheAirlines, { iata: 'SQ' }], + ['aircraft', cacheAircraft, { manufacturer: 'Boeing', model: '737' }], + ['cars', cacheCars, { make: 'toyota', model: 'corolla' }], + ['motorcycles', cacheMotorcycles, { make: 'Kawasaki', model: 'KLR' }], + ['electric vehicles', cacheElectricVehicles, { make: 'Tesla', model: 'S' }], + ['countries', cacheCountries, { iso2: 'DE' }], + ['cities', cacheCities, { name: 'London', country: 'GB' }], + ['universities', cacheUniversities, { name: 'Harvard', country: 'USA' }], + ['stock exchanges', cacheStockExchanges, { mic: 'XNAS' }], + ['sp500', cacheSp500, { ticker: 'MSFT' }], + ['emoji', cacheEmoji, { code: 'U+1F600' }], + ['animals', cacheAnimals, { name: 'Cheetah' }], + ['planets', cachePlanets, { name: 'Mars' }], + ['stars', cacheStars, { name: 'Vega' }], + ] as const; + + it.each(helpers)('%s stamps the capture time', async (_label, cache, row) => { + const store = makeStore(); + + await cache(store, [row as never], AT); + + // Nothing in this API deletes, so age is the only basis for deciding a + // mirrored row is stale. + expect(written(store)[1].captured_at).toBe(AT); + }); + + it.each(helpers)( + '%s tolerates a missing store', + async (_label, cache, row) => { + await expect( + cache(undefined, [row as never], AT), + ).resolves.toBeUndefined(); + }, + ); + + it.each(helpers)( + '%s keeps working after one row fails to write', + async (_label, cache, row) => { + const store = makeStore(); + store.upsertByEntityId.mockRejectedValue(new Error('write failed')); + + await expect(cache(store, [row as never], AT)).resolves.toBeUndefined(); + }, + ); +}); diff --git a/packages/apininjas/routing.test.ts b/packages/apininjas/routing.test.ts new file mode 100644 index 000000000..8d044363c --- /dev/null +++ b/packages/apininjas/routing.test.ts @@ -0,0 +1,1231 @@ +/** + * Exercises all 129 operations against a mocked transport. + * + * Every case replays the response captured from the live API for that + * operation, so a handler is checked against the payload the provider actually + * sends rather than an invented one. The inputs are the parameter names the + * documentation lists and a live call confirmed. + * + * What each case asserts: the versioned URL, the HTTP method, that the key + * travels in the `X-Api-Key` header and never in the query string, and that no + * unset parameter is serialised into the URL. + */ +import { + Calendar, + Economics, + Entertainment, + Health, + Internet, + Location, + Markets, + Reference, + Text, + Transport, + Utility, + Validation, +} from './endpoints'; +import { CAPTURED_RESPONSES } from './fixtures'; +import { apiNinjasEndpointSchemas } from './index'; + +const TEST_KEY = 'test-api-key-not-a-real-credential'; + +type Store = { + upsertByEntityId: jest.Mock; + deleteByEntityId: jest.Mock; +}; + +function makeStore(): Store { + return { + upsertByEntityId: jest.fn(async () => undefined), + deleteByEntityId: jest.fn(async () => true), + }; +} + +type Ctx = Parameters[0]; + +function makeCtx() { + const db = { + airports: makeStore(), + airlines: makeStore(), + aircraft: makeStore(), + vehicles: makeStore(), + countries: makeStore(), + cities: makeStore(), + universities: makeStore(), + stockExchanges: makeStore(), + sp500: makeStore(), + emoji: makeStore(), + animals: makeStore(), + planets: makeStore(), + stars: makeStore(), + }; + const ctx = { + key: TEST_KEY, + db, + database: undefined, + $getAccountId: async () => 'test-account', + } as unknown as Ctx; + return { ctx, db }; +} + +let lastCall: { url: string; init: RequestInit } | undefined; + +/** Stubs global fetch with one response and records the request it received. */ +function mockResponse(body: unknown, contentType = 'application/json') { + global.fetch = (async (url: string, init: RequestInit) => { + lastCall = { url, init }; + const isJson = contentType.includes('json'); + return { + ok: true, + status: 200, + statusText: 'OK', + url, + headers: new Headers({ 'Content-Type': contentType }), + json: async () => body, + text: async () => (isJson ? JSON.stringify(body) : String(body)), + }; + }) as unknown as typeof global.fetch; +} + +type Case = { + key: string; + path: string; + call: (ctx: Ctx, input: unknown) => Promise; + input: Record; + url: string; + method: string; +}; + +const CASES: Case[] = [ + { + key: 'locationGeocode', + path: 'location.geocode', + call: (ctx, input) => Location.geocode(ctx, input as never), + input: { city: 'London' }, + url: 'https://api.api-ninjas.com/v1/geocoding', + method: 'GET', + }, + { + key: 'locationReverseGeocode', + path: 'location.reverseGeocode', + call: (ctx, input) => Location.reverseGeocode(ctx, input as never), + input: { lat: 51.5074, lon: -0.1278 }, + url: 'https://api.api-ninjas.com/v1/reversegeocoding', + method: 'GET', + }, + { + key: 'locationCities', + path: 'location.cities', + call: (ctx, input) => Location.cities(ctx, input as never), + input: { name: 'London' }, + url: 'https://api.api-ninjas.com/v1/city', + method: 'GET', + }, + { + key: 'locationCountry', + path: 'location.country', + call: (ctx, input) => Location.country(ctx, input as never), + input: { name: 'Germany' }, + url: 'https://api.api-ninjas.com/v1/country', + method: 'GET', + }, + { + key: 'locationCounty', + path: 'location.county', + call: (ctx, input) => Location.county(ctx, input as never), + input: { county: 'Los Angeles', state: 'CA' }, + url: 'https://api.api-ninjas.com/v1/county', + method: 'GET', + }, + { + key: 'locationZipCode', + path: 'location.zipCode', + call: (ctx, input) => Location.zipCode(ctx, input as never), + input: { zip: '90210' }, + url: 'https://api.api-ninjas.com/v1/zipcode', + method: 'GET', + }, + { + key: 'locationPostalCode', + path: 'location.postalCode', + call: (ctx, input) => Location.postalCode(ctx, input as never), + input: { postal_code: 'K1A0B1' }, + url: 'https://api.api-ninjas.com/v1/postalcode', + method: 'GET', + }, + { + key: 'locationUniversities', + path: 'location.universities', + call: (ctx, input) => Location.universities(ctx, input as never), + input: { name: 'harvard' }, + url: 'https://api.api-ninjas.com/v1/university', + method: 'GET', + }, + { + key: 'locationHospitals', + path: 'location.hospitals', + call: (ctx, input) => Location.hospitals(ctx, input as never), + input: { city: 'Houston' }, + url: 'https://api.api-ninjas.com/v1/hospitals', + method: 'GET', + }, + { + key: 'locationEvChargers', + path: 'location.evChargers', + call: (ctx, input) => Location.evChargers(ctx, input as never), + input: { lat: 37.7749, lon: -122.4194 }, + url: 'https://api.api-ninjas.com/v1/evcharger', + method: 'GET', + }, + { + key: 'locationWeather', + path: 'location.weather', + call: (ctx, input) => Location.weather(ctx, input as never), + input: { lat: 51.5074, lon: -0.1278 }, + url: 'https://api.api-ninjas.com/v1/weather', + method: 'GET', + }, + { + key: 'locationWeatherForecast', + path: 'location.weatherForecast', + call: (ctx, input) => Location.weatherForecast(ctx, input as never), + input: { lat: 51.5074, lon: -0.1278 }, + url: 'https://api.api-ninjas.com/v1/weatherforecast', + method: 'GET', + }, + { + key: 'locationAirQuality', + path: 'location.airQuality', + call: (ctx, input) => Location.airQuality(ctx, input as never), + input: { lat: 51.5074, lon: -0.1278 }, + url: 'https://api.api-ninjas.com/v1/airquality', + method: 'GET', + }, + { + key: 'calendarTimezone', + path: 'calendar.timezone', + call: (ctx, input) => Calendar.timezone(ctx, input as never), + input: { timezone: 'America/New_York' }, + url: 'https://api.api-ninjas.com/v1/timezone', + method: 'GET', + }, + { + key: 'calendarWorldTime', + path: 'calendar.worldTime', + call: (ctx, input) => Calendar.worldTime(ctx, input as never), + input: { timezone: 'America/New_York' }, + url: 'https://api.api-ninjas.com/v1/worldtime', + method: 'GET', + }, + { + key: 'calendarHolidays', + path: 'calendar.holidays', + call: (ctx, input) => Calendar.holidays(ctx, input as never), + input: { country: 'us' }, + url: 'https://api.api-ninjas.com/v2/holidays', + method: 'GET', + }, + { + key: 'calendarPublicHolidays', + path: 'calendar.publicHolidays', + call: (ctx, input) => Calendar.publicHolidays(ctx, input as never), + input: { country: 'us' }, + url: 'https://api.api-ninjas.com/v1/publicholidays', + method: 'GET', + }, + { + key: 'calendarIsPublicHoliday', + path: 'calendar.isPublicHoliday', + call: (ctx, input) => Calendar.isPublicHoliday(ctx, input as never), + input: { country: 'us', date: '2026-12-25' }, + url: 'https://api.api-ninjas.com/v1/ispublicholiday', + method: 'GET', + }, + { + key: 'calendarIsWorkingDay', + path: 'calendar.isWorkingDay', + call: (ctx, input) => Calendar.isWorkingDay(ctx, input as never), + input: { country: 'us', date: '2026-12-25' }, + url: 'https://api.api-ninjas.com/v1/isworkingday', + method: 'GET', + }, + { + key: 'calendarWorkingDays', + path: 'calendar.workingDays', + call: (ctx, input) => Calendar.workingDays(ctx, input as never), + input: { country: 'us', start_date: '2026-08-01', end_date: '2026-08-31' }, + url: 'https://api.api-ninjas.com/v1/workingdays', + method: 'GET', + }, + { + key: 'internetDomain', + path: 'internet.domain', + call: (ctx, input) => Internet.domain(ctx, input as never), + input: { domain: 'example.com' }, + url: 'https://api.api-ninjas.com/v1/domain', + method: 'GET', + }, + { + key: 'internetDnsRecords', + path: 'internet.dnsRecords', + call: (ctx, input) => Internet.dnsRecords(ctx, input as never), + input: { domain: 'example.com' }, + url: 'https://api.api-ninjas.com/v1/dnslookup', + method: 'GET', + }, + { + key: 'internetMxRecords', + path: 'internet.mxRecords', + call: (ctx, input) => Internet.mxRecords(ctx, input as never), + input: { domain: 'example.com' }, + url: 'https://api.api-ninjas.com/v1/mxlookup', + method: 'GET', + }, + { + key: 'internetWhois', + path: 'internet.whois', + call: (ctx, input) => Internet.whois(ctx, input as never), + input: { domain: 'example.com' }, + url: 'https://api.api-ninjas.com/v1/whois', + method: 'GET', + }, + { + key: 'internetIpLookup', + path: 'internet.ipLookup', + call: (ctx, input) => Internet.ipLookup(ctx, input as never), + input: { address: '8.8.8.8' }, + url: 'https://api.api-ninjas.com/v1/iplookup', + method: 'GET', + }, + { + key: 'internetUrlLookup', + path: 'internet.urlLookup', + call: (ctx, input) => Internet.urlLookup(ctx, input as never), + input: { url: 'https://example.com' }, + url: 'https://api.api-ninjas.com/v1/urllookup', + method: 'GET', + }, + { + key: 'internetWebpage', + path: 'internet.webpage', + call: (ctx, input) => Internet.webpage(ctx, input as never), + input: { url: 'https://example.com' }, + url: 'https://api.api-ninjas.com/v1/webpage', + method: 'GET', + }, + { + key: 'internetScrape', + path: 'internet.scrape', + call: (ctx, input) => Internet.scrape(ctx, input as never), + input: { url: 'https://example.com' }, + url: 'https://api.api-ninjas.com/v1/webscraper', + method: 'GET', + }, + { + key: 'internetUserAgent', + path: 'internet.userAgent', + call: (ctx, input) => Internet.userAgent(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v1/useragentgenerate', + method: 'GET', + }, + { + key: 'validationEmail', + path: 'validation.email', + call: (ctx, input) => Validation.email(ctx, input as never), + input: { email: 'test@example.com' }, + url: 'https://api.api-ninjas.com/v1/validateemail', + method: 'GET', + }, + { + key: 'validationDisposableEmail', + path: 'validation.disposableEmail', + call: (ctx, input) => Validation.disposableEmail(ctx, input as never), + input: { email: 'someone@example.com' }, + url: 'https://api.api-ninjas.com/v1/disposableemailchecker', + method: 'GET', + }, + { + key: 'validationPhone', + path: 'validation.phone', + call: (ctx, input) => Validation.phone(ctx, input as never), + input: { number: '+14155552671' }, + url: 'https://api.api-ninjas.com/v1/validatephone', + method: 'GET', + }, + { + key: 'validationRoutingNumber', + path: 'validation.routingNumber', + call: (ctx, input) => Validation.routingNumber(ctx, input as never), + input: { routing_number: '121000248' }, + url: 'https://api.api-ninjas.com/v1/routingnumber', + method: 'GET', + }, + { + key: 'validationIban', + path: 'validation.iban', + call: (ctx, input) => Validation.iban(ctx, input as never), + input: { iban: 'DE89370400440532013000' }, + url: 'https://api.api-ninjas.com/v1/iban', + method: 'GET', + }, + { + key: 'validationBin', + path: 'validation.bin', + call: (ctx, input) => Validation.bin(ctx, input as never), + input: { bin: '411111' }, + url: 'https://api.api-ninjas.com/v2/bin', + method: 'GET', + }, + { + key: 'validationSwiftCode', + path: 'validation.swiftCode', + call: (ctx, input) => Validation.swiftCode(ctx, input as never), + input: { swift: 'BOFAUS3N' }, + url: 'https://api.api-ninjas.com/v1/swiftcode', + method: 'GET', + }, + { + key: 'marketsStockPrice', + path: 'markets.stockPrice', + call: (ctx, input) => Markets.stockPrice(ctx, input as never), + input: { ticker: 'AAPL' }, + url: 'https://api.api-ninjas.com/v1/stockprice', + method: 'GET', + }, + { + key: 'marketsTicker', + path: 'markets.ticker', + call: (ctx, input) => Markets.ticker(ctx, input as never), + input: { ticker: 'AAPL' }, + url: 'https://api.api-ninjas.com/v1/ticker', + method: 'GET', + }, + { + key: 'marketsTickerList', + path: 'markets.tickerList', + call: (ctx, input) => Markets.tickerList(ctx, input as never), + input: { limit: 3 }, + url: 'https://api.api-ninjas.com/v1/stockpricelist', + method: 'GET', + }, + { + key: 'marketsStockExchanges', + path: 'markets.stockExchanges', + call: (ctx, input) => Markets.stockExchanges(ctx, input as never), + input: { mic: 'XNAS' }, + url: 'https://api.api-ninjas.com/v1/stockexchange', + method: 'GET', + }, + { + key: 'marketsSp500', + path: 'markets.sp500', + call: (ctx, input) => Markets.sp500(ctx, input as never), + input: { ticker: 'MSFT' }, + url: 'https://api.api-ninjas.com/v1/sp500', + method: 'GET', + }, + { + key: 'marketsMarketCap', + path: 'markets.marketCap', + call: (ctx, input) => Markets.marketCap(ctx, input as never), + input: { ticker: 'NVDA' }, + url: 'https://api.api-ninjas.com/v1/marketcap', + method: 'GET', + }, + { + key: 'marketsEarnings', + path: 'markets.earnings', + call: (ctx, input) => Markets.earnings(ctx, input as never), + input: { ticker: 'AAPL', year: 2026 }, + url: 'https://api.api-ninjas.com/v2/earnings', + method: 'GET', + }, + { + key: 'marketsEarningsCalendar', + path: 'markets.earningsCalendar', + call: (ctx, input) => Markets.earningsCalendar(ctx, input as never), + input: { ticker: 'AAPL' }, + url: 'https://api.api-ninjas.com/v1/earningscalendar', + method: 'GET', + }, + { + key: 'marketsEarningsTranscript', + path: 'markets.earningsTranscript', + call: (ctx, input) => Markets.earningsTranscript(ctx, input as never), + input: { ticker: 'AAPL', year: 2024, quarter: 1 }, + url: 'https://api.api-ninjas.com/v1/earningstranscript', + method: 'GET', + }, + { + key: 'marketsInsiderTransactions', + path: 'markets.insiderTransactions', + call: (ctx, input) => Markets.insiderTransactions(ctx, input as never), + input: { ticker: 'MSFT' }, + url: 'https://api.api-ninjas.com/v1/insidertransactions', + method: 'GET', + }, + { + key: 'marketsSecFilings', + path: 'markets.secFilings', + call: (ctx, input) => Markets.secFilings(ctx, input as never), + input: { ticker: 'AAPL', filing: '10-K' }, + url: 'https://api.api-ninjas.com/v1/sec', + method: 'GET', + }, + { + key: 'marketsEtf', + path: 'markets.etf', + call: (ctx, input) => Markets.etf(ctx, input as never), + input: { ticker: 'SPY' }, + url: 'https://api.api-ninjas.com/v1/etf', + method: 'GET', + }, + { + key: 'marketsMutualFund', + path: 'markets.mutualFund', + call: (ctx, input) => Markets.mutualFund(ctx, input as never), + input: { ticker: 'VFIAX' }, + url: 'https://api.api-ninjas.com/v1/mutualfund', + method: 'GET', + }, + { + key: 'marketsCryptoPrice', + path: 'markets.cryptoPrice', + call: (ctx, input) => Markets.cryptoPrice(ctx, input as never), + input: { symbol: 'BTCUSDT' }, + url: 'https://api.api-ninjas.com/v1/cryptoprice', + method: 'GET', + }, + { + key: 'marketsBitcoin', + path: 'markets.bitcoin', + call: (ctx, input) => Markets.bitcoin(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v1/bitcoin', + method: 'GET', + }, + { + key: 'marketsCommodityPrice', + path: 'markets.commodityPrice', + call: (ctx, input) => Markets.commodityPrice(ctx, input as never), + input: { name: 'gold' }, + url: 'https://api.api-ninjas.com/v1/commodityprice', + method: 'GET', + }, + { + key: 'marketsConvertCurrency', + path: 'markets.convertCurrency', + call: (ctx, input) => Markets.convertCurrency(ctx, input as never), + input: { have: 'USD', want: 'EUR', amount: 100 }, + url: 'https://api.api-ninjas.com/v1/convertcurrency', + method: 'GET', + }, + { + key: 'marketsExchangeRate', + path: 'markets.exchangeRate', + call: (ctx, input) => Markets.exchangeRate(ctx, input as never), + input: { pair: 'USD_EUR' }, + url: 'https://api.api-ninjas.com/v1/exchangerate', + method: 'GET', + }, + { + key: 'economicsGdp', + path: 'economics.gdp', + call: (ctx, input) => Economics.gdp(ctx, input as never), + input: { country: 'us' }, + url: 'https://api.api-ninjas.com/v1/gdp', + method: 'GET', + }, + { + key: 'economicsInflation', + path: 'economics.inflation', + call: (ctx, input) => Economics.inflation(ctx, input as never), + input: { country: 'united states' }, + url: 'https://api.api-ninjas.com/v1/inflation', + method: 'GET', + }, + { + key: 'economicsUnemployment', + path: 'economics.unemployment', + call: (ctx, input) => Economics.unemployment(ctx, input as never), + input: { country: 'united states' }, + url: 'https://api.api-ninjas.com/v1/unemployment', + method: 'GET', + }, + { + key: 'economicsPopulation', + path: 'economics.population', + call: (ctx, input) => Economics.population(ctx, input as never), + input: { country: 'Japan' }, + url: 'https://api.api-ninjas.com/v1/population', + method: 'GET', + }, + { + key: 'economicsInterestRate', + path: 'economics.interestRate', + call: (ctx, input) => Economics.interestRate(ctx, input as never), + input: { rate: 'fed_funds' }, + url: 'https://api.api-ninjas.com/v2/interestrate', + method: 'GET', + }, + { + key: 'economicsMortgageRate', + path: 'economics.mortgageRate', + call: (ctx, input) => Economics.mortgageRate(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v2/mortgagerate', + method: 'GET', + }, + { + key: 'economicsMortgageCalculator', + path: 'economics.mortgageCalculator', + call: (ctx, input) => Economics.mortgageCalculator(ctx, input as never), + input: { loan_amount: 400000, interest_rate: 3.5, duration_years: 30 }, + url: 'https://api.api-ninjas.com/v1/mortgagecalculator', + method: 'GET', + }, + { + key: 'economicsIncomeTax', + path: 'economics.incomeTax', + call: (ctx, input) => Economics.incomeTax(ctx, input as never), + input: { country: 'us', year: 2024 }, + url: 'https://api.api-ninjas.com/v2/incometax', + method: 'GET', + }, + { + key: 'economicsIncomeTaxCalculator', + path: 'economics.incomeTaxCalculator', + call: (ctx, input) => Economics.incomeTaxCalculator(ctx, input as never), + input: { + country: 'us', + region: 'California', + income: 100000, + filing_status: 'single', + }, + url: 'https://api.api-ninjas.com/v1/incometaxcalculator', + method: 'GET', + }, + { + key: 'economicsSalesTax', + path: 'economics.salesTax', + call: (ctx, input) => Economics.salesTax(ctx, input as never), + input: { zip_code: '90210' }, + url: 'https://api.api-ninjas.com/v1/salestax', + method: 'GET', + }, + { + key: 'economicsSalesTaxCalculator', + path: 'economics.salesTaxCalculator', + call: (ctx, input) => Economics.salesTaxCalculator(ctx, input as never), + input: { amount: 100, zip_code: '90210' }, + url: 'https://api.api-ninjas.com/v1/salestaxcalculator', + method: 'GET', + }, + { + key: 'economicsPropertyTax', + path: 'economics.propertyTax', + call: (ctx, input) => Economics.propertyTax(ctx, input as never), + input: { zip: '90210' }, + url: 'https://api.api-ninjas.com/v1/propertytax', + method: 'GET', + }, + { + key: 'economicsVatRates', + path: 'economics.vatRates', + call: (ctx, input) => Economics.vatRates(ctx, input as never), + input: { country: 'DE' }, + url: 'https://api.api-ninjas.com/v1/vat', + method: 'GET', + }, + { + key: 'textSentiment', + path: 'text.sentiment', + call: (ctx, input) => Text.sentiment(ctx, input as never), + input: { text: 'I am loving this new integration' }, + url: 'https://api.api-ninjas.com/v1/sentiment', + method: 'GET', + }, + { + key: 'textSimilarity', + path: 'text.similarity', + call: (ctx, input) => Text.similarity(ctx, input as never), + input: { text_1: 'hello there', text_2: 'hi there' }, + url: 'https://api.api-ninjas.com/v1/textsimilarity', + method: 'POST', + }, + { + key: 'textEmbeddings', + path: 'text.embeddings', + call: (ctx, input) => Text.embeddings(ctx, input as never), + input: { text: 'corsair integration' }, + url: 'https://api.api-ninjas.com/v1/embeddings', + method: 'POST', + }, + { + key: 'textLanguage', + path: 'text.language', + call: (ctx, input) => Text.language(ctx, input as never), + input: { text: 'Guten Tag wie geht es Ihnen heute mein Freund' }, + url: 'https://api.api-ninjas.com/v1/textlanguage', + method: 'GET', + }, + { + key: 'textSpellCheck', + path: 'text.spellCheck', + call: (ctx, input) => Text.spellCheck(ctx, input as never), + input: { text: 'helo wrld thsi is a tset' }, + url: 'https://api.api-ninjas.com/v1/spellcheck', + method: 'GET', + }, + { + key: 'textProfanityFilter', + path: 'text.profanityFilter', + call: (ctx, input) => Text.profanityFilter(ctx, input as never), + input: { text: 'damn this thing' }, + url: 'https://api.api-ninjas.com/v1/profanityfilter', + method: 'GET', + }, + { + key: 'textDictionary', + path: 'text.dictionary', + call: (ctx, input) => Text.dictionary(ctx, input as never), + input: { word: 'hello' }, + url: 'https://api.api-ninjas.com/v1/dictionary', + method: 'GET', + }, + { + key: 'textThesaurus', + path: 'text.thesaurus', + call: (ctx, input) => Text.thesaurus(ctx, input as never), + input: { word: 'happy' }, + url: 'https://api.api-ninjas.com/v1/thesaurus', + method: 'GET', + }, + { + key: 'textRhymes', + path: 'text.rhymes', + call: (ctx, input) => Text.rhymes(ctx, input as never), + input: { word: 'cat' }, + url: 'https://api.api-ninjas.com/v1/rhyme', + method: 'GET', + }, + { + key: 'textRandomWord', + path: 'text.randomWord', + call: (ctx, input) => Text.randomWord(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v2/randomword', + method: 'GET', + }, + { + key: 'textLoremIpsum', + path: 'text.loremIpsum', + call: (ctx, input) => Text.loremIpsum(ctx, input as never), + input: { paragraphs: 1 }, + url: 'https://api.api-ninjas.com/v1/loremipsum', + method: 'GET', + }, + { + key: 'utilityQrCode', + path: 'utility.qrCode', + call: (ctx, input) => Utility.qrCode(ctx, input as never), + input: { data: 'https://example.com', format: 'svg' }, + url: 'https://api.api-ninjas.com/v1/qrcode', + method: 'GET', + }, + { + key: 'utilityBarcode', + path: 'utility.barcode', + call: (ctx, input) => Utility.barcode(ctx, input as never), + input: { text: 'hello', type: 'code128', format: 'svg' }, + url: 'https://api.api-ninjas.com/v1/barcodegenerate', + method: 'GET', + }, + { + key: 'utilityPassword', + path: 'utility.password', + call: (ctx, input) => Utility.password(ctx, input as never), + input: { length: 20 }, + url: 'https://api.api-ninjas.com/v1/passwordgenerator', + method: 'GET', + }, + { + key: 'utilityRandomUser', + path: 'utility.randomUser', + call: (ctx, input) => Utility.randomUser(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v2/randomuser', + method: 'GET', + }, + { + key: 'utilityCounter', + path: 'utility.counter', + call: (ctx, input) => Utility.counter(ctx, input as never), + input: { id: 'corsair_recon_probe' }, + url: 'https://api.api-ninjas.com/v1/counter', + method: 'GET', + }, + { + key: 'utilityConvertUnit', + path: 'utility.convertUnit', + call: (ctx, input) => Utility.convertUnit(ctx, input as never), + input: { amount: 100, unit: 'kilometer' }, + url: 'https://api.api-ninjas.com/v1/unitconversion', + method: 'GET', + }, + { + key: 'utilityLogo', + path: 'utility.logo', + call: (ctx, input) => Utility.logo(ctx, input as never), + input: { name: 'Microsoft' }, + url: 'https://api.api-ninjas.com/v1/logo', + method: 'GET', + }, + { + key: 'utilityCountryFlag', + path: 'utility.countryFlag', + call: (ctx, input) => Utility.countryFlag(ctx, input as never), + input: { country: 'us' }, + url: 'https://api.api-ninjas.com/v1/countryflag', + method: 'GET', + }, + { + key: 'utilityRandomImage', + path: 'utility.randomImage', + call: (ctx, input) => Utility.randomImage(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v1/randomimage', + method: 'GET', + }, + { + key: 'utilityEmoji', + path: 'utility.emoji', + call: (ctx, input) => Utility.emoji(ctx, input as never), + input: { name: 'smile' }, + url: 'https://api.api-ninjas.com/v1/emoji', + method: 'GET', + }, + { + key: 'transportAircraft', + path: 'transport.aircraft', + call: (ctx, input) => Transport.aircraft(ctx, input as never), + input: { manufacturer: 'Boeing', model: '737' }, + url: 'https://api.api-ninjas.com/v1/aircraft', + method: 'GET', + }, + { + key: 'transportAirlines', + path: 'transport.airlines', + call: (ctx, input) => Transport.airlines(ctx, input as never), + input: { iata: 'SQ' }, + url: 'https://api.api-ninjas.com/v1/airlines', + method: 'GET', + }, + { + key: 'transportAirports', + path: 'transport.airports', + call: (ctx, input) => Transport.airports(ctx, input as never), + input: { iata: 'LHR' }, + url: 'https://api.api-ninjas.com/v1/airports', + method: 'GET', + }, + { + key: 'transportHelicopters', + path: 'transport.helicopters', + call: (ctx, input) => Transport.helicopters(ctx, input as never), + input: { manufacturer: 'Bell', model: '430' }, + url: 'https://api.api-ninjas.com/v1/helicopter', + method: 'GET', + }, + { + key: 'transportCars', + path: 'transport.cars', + call: (ctx, input) => Transport.cars(ctx, input as never), + input: { model: 'corolla' }, + url: 'https://api.api-ninjas.com/v1/cars', + method: 'GET', + }, + { + key: 'transportMotorcycles', + path: 'transport.motorcycles', + call: (ctx, input) => Transport.motorcycles(ctx, input as never), + input: { make: 'Kawasaki' }, + url: 'https://api.api-ninjas.com/v1/motorcycles', + method: 'GET', + }, + { + key: 'transportElectricVehicles', + path: 'transport.electricVehicles', + call: (ctx, input) => Transport.electricVehicles(ctx, input as never), + input: { make: 'Tesla' }, + url: 'https://api.api-ninjas.com/v1/electricvehicle', + method: 'GET', + }, + { + key: 'transportVin', + path: 'transport.vin', + call: (ctx, input) => Transport.vin(ctx, input as never), + input: { vin: 'JH4TB2H26CC000000' }, + url: 'https://api.api-ninjas.com/v1/vinlookup', + method: 'GET', + }, + { + key: 'healthCaloriesBurned', + path: 'health.caloriesBurned', + call: (ctx, input) => Health.caloriesBurned(ctx, input as never), + input: { activity: 'skiing' }, + url: 'https://api.api-ninjas.com/v1/caloriesburned', + method: 'GET', + }, + { + key: 'healthNutrition', + path: 'health.nutrition', + call: (ctx, input) => Health.nutrition(ctx, input as never), + input: { query: '1lb brisket and fries' }, + url: 'https://api.api-ninjas.com/v1/nutrition', + method: 'GET', + }, + { + key: 'healthExercises', + path: 'health.exercises', + call: (ctx, input) => Health.exercises(ctx, input as never), + input: { muscle: 'biceps' }, + url: 'https://api.api-ninjas.com/v1/exercises', + method: 'GET', + }, + { + key: 'healthRecipes', + path: 'health.recipes', + call: (ctx, input) => Health.recipes(ctx, input as never), + input: { title: 'pasta' }, + url: 'https://api.api-ninjas.com/v3/recipe', + method: 'GET', + }, + { + key: 'healthCocktails', + path: 'health.cocktails', + call: (ctx, input) => Health.cocktails(ctx, input as never), + input: { name: 'bloody mary' }, + url: 'https://api.api-ninjas.com/v1/cocktail', + method: 'GET', + }, + { + key: 'referenceAnimals', + path: 'reference.animals', + call: (ctx, input) => Reference.animals(ctx, input as never), + input: { name: 'cheetah' }, + url: 'https://api.api-ninjas.com/v1/animals', + method: 'GET', + }, + { + key: 'referenceCats', + path: 'reference.cats', + call: (ctx, input) => Reference.cats(ctx, input as never), + input: { name: 'aegean' }, + url: 'https://api.api-ninjas.com/v1/cats', + method: 'GET', + }, + { + key: 'referenceDogs', + path: 'reference.dogs', + call: (ctx, input) => Reference.dogs(ctx, input as never), + input: { name: 'golden retriever' }, + url: 'https://api.api-ninjas.com/v1/dogs', + method: 'GET', + }, + { + key: 'referencePlanets', + path: 'reference.planets', + call: (ctx, input) => Reference.planets(ctx, input as never), + input: { name: 'Mars' }, + url: 'https://api.api-ninjas.com/v1/planets', + method: 'GET', + }, + { + key: 'referenceStars', + path: 'reference.stars', + call: (ctx, input) => Reference.stars(ctx, input as never), + input: { name: 'vega' }, + url: 'https://api.api-ninjas.com/v1/stars', + method: 'GET', + }, + { + key: 'referenceHistoricalEvents', + path: 'reference.historicalEvents', + call: (ctx, input) => Reference.historicalEvents(ctx, input as never), + input: { text: 'world war' }, + url: 'https://api.api-ninjas.com/v1/historicalevents', + method: 'GET', + }, + { + key: 'referenceHistoricalFigures', + path: 'reference.historicalFigures', + call: (ctx, input) => Reference.historicalFigures(ctx, input as never), + input: { name: 'napoleon' }, + url: 'https://api.api-ninjas.com/v1/historicalfigures', + method: 'GET', + }, + { + key: 'referenceDayInHistory', + path: 'reference.dayInHistory', + call: (ctx, input) => Reference.dayInHistory(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v1/dayinhistory', + method: 'GET', + }, + { + key: 'referenceCelebrities', + path: 'reference.celebrities', + call: (ctx, input) => Reference.celebrities(ctx, input as never), + input: { name: 'Michael Jordan' }, + url: 'https://api.api-ninjas.com/v1/celebrity', + method: 'GET', + }, + { + key: 'referenceBabyNames', + path: 'reference.babyNames', + call: (ctx, input) => Reference.babyNames(ctx, input as never), + input: { gender: 'boy' }, + url: 'https://api.api-ninjas.com/v1/babynames', + method: 'GET', + }, + { + key: 'entertainmentJokes', + path: 'entertainment.jokes', + call: (ctx, input) => Entertainment.jokes(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v1/jokes', + method: 'GET', + }, + { + key: 'entertainmentDadJokes', + path: 'entertainment.dadJokes', + call: (ctx, input) => Entertainment.dadJokes(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v1/dadjokes', + method: 'GET', + }, + { + key: 'entertainmentChuckNorris', + path: 'entertainment.chuckNorris', + call: (ctx, input) => Entertainment.chuckNorris(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v1/chucknorris', + method: 'GET', + }, + { + key: 'entertainmentJokeOfTheDay', + path: 'entertainment.jokeOfTheDay', + call: (ctx, input) => Entertainment.jokeOfTheDay(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v1/jokeoftheday', + method: 'GET', + }, + { + key: 'entertainmentFacts', + path: 'entertainment.facts', + call: (ctx, input) => Entertainment.facts(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v1/facts', + method: 'GET', + }, + { + key: 'entertainmentFactOfTheDay', + path: 'entertainment.factOfTheDay', + call: (ctx, input) => Entertainment.factOfTheDay(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v1/factoftheday', + method: 'GET', + }, + { + key: 'entertainmentQuotes', + path: 'entertainment.quotes', + call: (ctx, input) => Entertainment.quotes(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v2/quotes', + method: 'GET', + }, + { + key: 'entertainmentRandomQuotes', + path: 'entertainment.randomQuotes', + call: (ctx, input) => Entertainment.randomQuotes(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v2/randomquotes', + method: 'GET', + }, + { + key: 'entertainmentQuoteOfTheDay', + path: 'entertainment.quoteOfTheDay', + call: (ctx, input) => Entertainment.quoteOfTheDay(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v2/quoteoftheday', + method: 'GET', + }, + { + key: 'entertainmentAdvice', + path: 'entertainment.advice', + call: (ctx, input) => Entertainment.advice(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v1/advice', + method: 'GET', + }, + { + key: 'entertainmentBucketList', + path: 'entertainment.bucketList', + call: (ctx, input) => Entertainment.bucketList(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v1/bucketlist', + method: 'GET', + }, + { + key: 'entertainmentHobbies', + path: 'entertainment.hobbies', + call: (ctx, input) => Entertainment.hobbies(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v1/hobbies', + method: 'GET', + }, + { + key: 'entertainmentHoroscope', + path: 'entertainment.horoscope', + call: (ctx, input) => Entertainment.horoscope(ctx, input as never), + input: { zodiac: 'aries' }, + url: 'https://api.api-ninjas.com/v1/horoscope', + method: 'GET', + }, + { + key: 'entertainmentRiddles', + path: 'entertainment.riddles', + call: (ctx, input) => Entertainment.riddles(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v1/riddles', + method: 'GET', + }, + { + key: 'entertainmentTrivia', + path: 'entertainment.trivia', + call: (ctx, input) => Entertainment.trivia(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v1/trivia', + method: 'GET', + }, + { + key: 'entertainmentTriviaOfTheDay', + path: 'entertainment.triviaOfTheDay', + call: (ctx, input) => Entertainment.triviaOfTheDay(ctx, input as never), + input: {}, + url: 'https://api.api-ninjas.com/v1/triviaoftheday', + method: 'GET', + }, + { + key: 'entertainmentGenerateSudoku', + path: 'entertainment.generateSudoku', + call: (ctx, input) => Entertainment.generateSudoku(ctx, input as never), + input: { width: 3, height: 3, difficulty: 'easy' }, + url: 'https://api.api-ninjas.com/v1/sudokugenerate', + method: 'GET', + }, + { + key: 'entertainmentSolveSudoku', + path: 'entertainment.solveSudoku', + call: (ctx, input) => Entertainment.solveSudoku(ctx, input as never), + input: { + width: 3, + height: 3, + puzzle: [ + [0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 3, 0, 8, 5], + [0, 0, 1, 0, 2, 0, 0, 0, 0], + [0, 0, 0, 5, 0, 7, 0, 0, 0], + [0, 0, 4, 0, 0, 0, 1, 0, 0], + [0, 9, 0, 0, 0, 0, 0, 0, 0], + [5, 0, 0, 0, 0, 0, 0, 7, 3], + [0, 0, 2, 0, 1, 0, 0, 0, 0], + [0, 0, 0, 0, 4, 0, 0, 0, 9], + ], + }, + url: 'https://api.api-ninjas.com/v1/sudokusolve', + method: 'GET', + }, +]; + +const capturedResponses = CAPTURED_RESPONSES; + +/** Operations whose response is an image, so the mock must not claim JSON. */ +const IMAGE_OPERATIONS = new Set([ + 'utility.qrCode', + 'utility.barcode', + 'utility.randomImage', +]); + +/** + * Operations the free tier refuses, so no response could be captured. Listed + * explicitly: a fixture missing for any other operation is a mistake. + */ +const UNCAPTURED = new Set([ + 'calendarWorldTime', + 'internetWhois', + 'marketsTickerList', + 'marketsEarningsTranscript', + 'marketsConvertCurrency', + 'marketsExchangeRate', + 'economicsInflation', + 'economicsInterestRate', + 'healthNutrition', +]); + +describe('every operation issues the documented request', () => { + beforeEach(() => { + lastCall = undefined; + }); + + test.each(CASES.map((c) => [c.path, c] as const))( + '%s', + async (_path, testCase) => { + const { ctx } = makeCtx(); + // Nine operations are premium-gated on the free tier and have no + // capture; every other case must have one, so a missing fixture fails + // here rather than quietly testing against an empty object. + const body = UNCAPTURED.has(testCase.key) + ? {} + : capturedResponses[testCase.key as keyof typeof capturedResponses]; + if (!UNCAPTURED.has(testCase.key)) { + expect(body).toBeDefined(); + } + const isImage = IMAGE_OPERATIONS.has(testCase.path); + mockResponse(body, isImage ? 'image/svg+xml' : 'application/json'); + + await testCase.call(ctx, testCase.input); + + expect(lastCall).toBeDefined(); + const call = lastCall as { url: string; init: RequestInit }; + const requested = new URL(call.url); + + expect(`${requested.origin}${requested.pathname}`).toBe(testCase.url); + expect(call.init.method).toBe(testCase.method); + + const headers = new Headers(call.init.headers); + expect(headers.get('X-Api-Key')).toBe(TEST_KEY); + + // The credential must never reach the query string, where it would be + // captured by any log that records request URLs. + expect(call.url).not.toContain(TEST_KEY.slice(0, 12)); + expect(requested.search).not.toMatch(/api[-_]?key/i); + + // `undefined` in a query string is a value the provider would match on. + expect(requested.search).not.toContain('undefined'); + }, + ); +}); + +describe('coverage', () => { + it('exercises every registered operation exactly once', () => { + const exercised = CASES.map((c) => c.path).sort(); + const registered = Object.keys(apiNinjasEndpointSchemas).sort(); + + expect(exercised).toEqual(registered); + expect(new Set(exercised).size).toBe(exercised.length); + expect(registered).toHaveLength(129); + }); +}); diff --git a/packages/apininjas/schema.test.ts b/packages/apininjas/schema.test.ts new file mode 100644 index 000000000..4dad0ecc3 --- /dev/null +++ b/packages/apininjas/schema.test.ts @@ -0,0 +1,379 @@ +/** + * Checks the zod schemas against two independent sources of truth: the + * provider's documentation, and responses captured from the live API. + * + * The documentation says which parameters an endpoint accepts and which fields + * it returns; a captured response says what it actually sent. A schema has to + * satisfy both, and the two disagree often enough on this API that checking + * only one would miss real gaps - the free tier returns prose where the + * documentation promises a number, and several endpoints return fields the + * documentation never mentions. + */ +import { z } from 'zod'; +import { DOCUMENTED_OPERATIONS } from './docs-contract'; +import { + ApiNinjasEndpointInputSchemas, + ApiNinjasEndpointOutputSchemas, +} from './endpoints/types'; +import { CAPTURED_RESPONSES } from './fixtures'; +import { ApiNinjasSchema } from './schema'; + +type AnySchema = z.ZodType; + +/** + * Unwraps optional, nullable, array and union wrappers to reach the object at + * the centre of a schema, so its declared keys can be read. + */ +function objectShape(schema: AnySchema): Record | undefined { + let current: AnySchema | undefined = schema; + + for (let depth = 0; current && depth < 10; depth++) { + if (current instanceof z.ZodObject) { + return current.shape as Record; + } + if (current instanceof z.ZodArray) { + current = current.element as AnySchema; + continue; + } + if (current instanceof z.ZodOptional || current instanceof z.ZodNullable) { + current = current.unwrap() as AnySchema; + continue; + } + if (current instanceof z.ZodUnion) { + const objectMember: AnySchema | undefined = ( + current.options as AnySchema[] + ).find((option) => objectShape(option) !== undefined); + if (!objectMember) return undefined; + current = objectMember; + continue; + } + return undefined; + } + + return undefined; +} + +/** True when a schema accepts `undefined`, which is how zod models optional. */ +function isOptional(schema: AnySchema): boolean { + return schema.safeParse(undefined).success; +} + +const OPERATIONS = Object.keys(DOCUMENTED_OPERATIONS); + +/** Reads a documented operation, failing loudly rather than skipping silently. */ +function documentedFor(key: string) { + const documented = DOCUMENTED_OPERATIONS[key]; + if (!documented) throw new Error(`no documented contract for ${key}`); + return documented; +} + +describe('documented parameters', () => { + it('covers every operation in the registry', () => { + expect(OPERATIONS.sort()).toEqual( + Object.keys(ApiNinjasEndpointInputSchemas).sort(), + ); + expect(OPERATIONS).toHaveLength(129); + }); + + test.each(OPERATIONS)('%s accepts every documented parameter', (key) => { + const documented = documentedFor(key); + const schema = ApiNinjasEndpointInputSchemas[ + key as keyof typeof ApiNinjasEndpointInputSchemas + ] as AnySchema; + const shape = objectShape(schema); + + expect(shape).toBeDefined(); + const declared = Object.keys(shape ?? {}); + + for (const param of documented.params) { + expect(declared).toContain(param.name); + } + }); + + test.each(OPERATIONS)('%s declares no undocumented parameter', (key) => { + const documented = documentedFor(key); + const shape = objectShape( + ApiNinjasEndpointInputSchemas[ + key as keyof typeof ApiNinjasEndpointInputSchemas + ] as AnySchema, + ); + const documentedNames = documented.params.map((param) => param.name); + + for (const declared of Object.keys(shape ?? {})) { + expect(documentedNames).toContain(declared); + } + }); + + test.each(OPERATIONS)( + '%s requires what the documentation requires', + (key) => { + const documented = documentedFor(key); + const shape = objectShape( + ApiNinjasEndpointInputSchemas[ + key as keyof typeof ApiNinjasEndpointInputSchemas + ] as AnySchema, + ); + + for (const param of documented.params) { + const field = shape?.[param.name]; + if (!field) continue; + + // A premium parameter cannot be required of a free-tier caller, and a + // documented combination is validated by the provider rather than + // here, so both stay optional whatever the parameter table says. + // + // `enforced: false` marks the three parameters the table calls + // required and the provider accepts as missing - the QR code format, + // and two of the three car filters. The schema follows the provider + // there, because demanding a parameter the API does not need would + // reject calls that work. + const shouldBeRequired = + param.required && + !param.premium && + !documented.combination && + param.enforced !== false; + + expect({ name: param.name, optional: isOptional(field) }).toEqual({ + name: param.name, + optional: !shouldBeRequired, + }); + } + }, + ); +}); + +describe('documented response fields', () => { + const withFields = OPERATIONS.filter( + (key) => documentedFor(key).responseFields.length > 0, + ); + + it('has documented response fields to check', () => { + expect(withFields.length).toBeGreaterThan(80); + }); + + test.each(withFields)('%s declares every documented field', (key) => { + const documented = documentedFor(key); + const shape = objectShape( + ApiNinjasEndpointOutputSchemas[ + key as keyof typeof ApiNinjasEndpointOutputSchemas + ] as AnySchema, + ); + + expect(shape).toBeDefined(); + const declared = Object.keys(shape ?? {}); + + for (const field of documented.responseFields) { + expect(declared).toContain(field); + } + }); +}); + +describe('captured responses', () => { + const captured = Object.keys(CAPTURED_RESPONSES); + + it('has a capture for most operations', () => { + // Nine operations are premium-gated or quota-exhausted on the free tier and + // could not be captured; their schemas come from the documentation alone. + expect(captured).toHaveLength(120); + }); + + test.each(Object.keys(CAPTURED_RESPONSES))( + '%s parses the response the provider actually sent', + (key) => { + const schema = ApiNinjasEndpointOutputSchemas[ + key as keyof typeof ApiNinjasEndpointOutputSchemas + ] as AnySchema; + + const result = schema.safeParse( + CAPTURED_RESPONSES[key as keyof typeof CAPTURED_RESPONSES], + ); + if (!result.success) { + throw new Error( + `${key} rejected its own captured response: ${JSON.stringify( + result.error.issues.slice(0, 3), + )}`, + ); + } + expect(result.success).toBe(true); + }, + ); + + /** + * The three image operations return a wrapper this plugin builds - the + * content type plus the payload - rather than a provider object, so there is + * no such thing as a field the provider might add to them. + */ + const PLUGIN_SHAPED = new Set([ + 'utilityQrCode', + 'utilityBarcode', + 'utilityRandomImage', + ]); + + test.each( + Object.keys(CAPTURED_RESPONSES).filter((key) => !PLUGIN_SHAPED.has(key)), + )('%s keeps fields it does not declare', (key) => { + const captured = CAPTURED_RESPONSES[key as keyof typeof CAPTURED_RESPONSES]; + const row = Array.isArray(captured) ? captured[0] : captured; + if (!row || typeof row !== 'object') return; + + const schema = ApiNinjasEndpointOutputSchemas[ + key as keyof typeof ApiNinjasEndpointOutputSchemas + ] as AnySchema; + + // Loose objects pass unknown keys through. An endpoint that dropped them + // would silently lose data the provider added after this was written. + const withExtra = Array.isArray(captured) + ? [{ ...row, corsair_unknown_field: 'kept' }] + : { ...row, corsair_unknown_field: 'kept' }; + + const parsed = schema.parse(withExtra) as + | Record + | Record[]; + const parsedRow = Array.isArray(parsed) ? parsed[0] : parsed; + + expect(parsedRow?.corsair_unknown_field).toBe('kept'); + }); +}); + +describe('premium masking', () => { + it('accepts prose in the fields the free tier withholds', () => { + // This is a real free-tier stock quote: the price and volume arrive as + // numbers, while the company name, exchange and currency arrive as a + // sentence explaining they are premium. A schema that typed those three as + // strings only, or as numbers only, would be wrong on one plan or the + // other. + const freeTier = { + ticker: 'AAPL', + name: 'This field is for premium subscribers only.', + price: 305.79, + exchange: 'This field is for premium subscribers only.', + updated: 1786733311, + currency: 'This field is for premium subscribers only.', + volume: 16020092.94593, + }; + + expect( + ApiNinjasEndpointOutputSchemas.marketsStockPrice.safeParse(freeTier) + .success, + ).toBe(true); + }); + + it('accepts a numeric field that a paid plan fills in', () => { + // The nutrition endpoint masks every macro on the free tier, so each one + // has to accept the sentence and the number it replaces. + const masked = ApiNinjasEndpointOutputSchemas.healthNutrition.safeParse([ + { + name: 'brisket', + calories: 'Only available for premium subscribers.', + fat_total_g: 'Only available for premium subscribers.', + }, + ]); + const real = ApiNinjasEndpointOutputSchemas.healthNutrition.safeParse([ + { name: 'brisket', calories: 1312.3, fat_total_g: 82.9 }, + ]); + + expect({ masked: masked.success, real: real.success }).toEqual({ + masked: true, + real: true, + }); + }); + + it('accepts the same row with real values on a paid plan', () => { + const paid = { + ticker: 'AAPL', + name: 'Apple Inc.', + price: 305.79, + exchange: 'NASDAQ', + updated: 1786733311, + currency: 'USD', + volume: 16020092.94593, + }; + + expect( + ApiNinjasEndpointOutputSchemas.marketsStockPrice.safeParse(paid).success, + ).toBe(true); + }); + + it('accepts a row that carries only its identifying field', () => { + // Rows arrive with different field sets by plan, by record and by + // endpoint, so a row stripped to one field still has to parse. + expect( + ApiNinjasEndpointOutputSchemas.transportAirports.safeParse([ + { icao: 'EGLL' }, + ]).success, + ).toBe(true); + }); +}); + +describe('persisted entities', () => { + const entities = Object.keys(ApiNinjasSchema.entities); + + it('mirrors reference data only', () => { + expect(entities.sort()).toEqual( + [ + 'aircraft', + 'airlines', + 'airports', + 'animals', + 'cities', + 'countries', + 'emoji', + 'planets', + 'sp500', + 'stars', + 'stockExchanges', + 'universities', + 'vehicles', + ].sort(), + ); + }); + + it('mirrors nothing that is a price, a generated value or caller data', () => { + // A cached price is wrong rather than merely old, a cached random value is + // not random, and caller data does not belong in a shared mirror. + const forbidden = [ + 'stockPrice', + 'cryptoPrice', + 'bitcoin', + 'commodityPrice', + 'exchangeRate', + 'marketCap', + 'mortgageRate', + 'interestRate', + 'jokes', + 'quotes', + 'facts', + 'randomUser', + 'password', + 'sentiment', + 'ipLookup', + 'email', + ]; + + for (const name of forbidden) { + expect(entities).not.toContain(name); + } + }); + + it('gives every entity a primary key and a capture time', () => { + for (const [name, entity] of Object.entries(ApiNinjasSchema.entities)) { + const shape = objectShape(entity as AnySchema); + expect({ name, hasId: Boolean(shape?.id) }).toEqual({ + name, + hasId: true, + }); + expect({ name, hasCapturedAt: Boolean(shape?.captured_at) }).toEqual({ + name, + hasCapturedAt: true, + }); + // Only the key is required: a row that arrives with nothing else must + // still be storable. + expect({ name, idRequired: !isOptional(shape?.id as AnySchema) }).toEqual( + { + name, + idRequired: true, + }, + ); + } + }); +}); diff --git a/packages/apininjas/schema/database.ts b/packages/apininjas/schema/database.ts new file mode 100644 index 000000000..249b2f295 --- /dev/null +++ b/packages/apininjas/schema/database.ts @@ -0,0 +1,565 @@ +import { z } from 'zod'; + +/** + * Locally mirrored API Ninjas reference data. + * + * Field names match the official JSON keys exactly. Each field is labeled + * from the provider's documentation (live 2026-08-15). Local-only keys are + * `id` and `captured_at`. + * + * Prices, random/daily values, generators and caller-supplied lookups are + * not stored. Masked free-tier prose is dropped at write time, not here. + * + * Docs: https://api-ninjas.com/api + */ + +/** Official scalar that the free tier may replace with placeholder prose. */ +const Text = z.string().nullable().optional(); +const Num = z.union([z.number(), z.string()]).nullable().optional(); +const Flag = z.union([z.boolean(), z.string()]).nullable().optional(); + +/** + * Airports (`GET /v1/airports`). + * + * Official: https://api-ninjas.com/api/airports + */ +export const ApiNinjasAirportEntity = z.object({ + id: z.string(), + /** 3-character IATA airport code. */ + iata: Text, + /** 4-character ICAO airport code. May be empty — use `ident`. */ + icao: Text, + /** Identifier that is always present when ICAO is empty. */ + ident: Text, + /** Airport name. */ + name: Text, + /** City where the airport is located. */ + city: Text, + /** Administrative region (state or province). */ + region: Text, + /** Administrative region code. */ + region_code: Text, + /** 2-letter ISO country code. */ + country: Text, + /** Country name. */ + country_name: Text, + /** Continent code: AF, AN, AS, EU, NA, OC, SA. */ + continent: Text, + /** Airport elevation in feet. */ + elevation_ft: Num, + /** Airport elevation in metres. */ + elevation_m: Num, + /** Latitude coordinate. */ + latitude: Num, + /** Longitude coordinate. */ + longitude: Num, + /** Airport timezone (e.g. Europe/London). */ + timezone: Text, + /** Facility type (large_airport, heliport, …). */ + type: Text, + /** Airport size: large, medium, small. */ + size: Text, + /** Whether the airport has scheduled airline service. */ + scheduled_service: Flag, + /** Whether the airport is permanently closed. */ + is_closed: Flag, + /** GPS code. */ + gps_code: Text, + /** Local airport code. */ + local_code: Text, + /** Official airport website. */ + home_link: Text, + /** Wikipedia page for the airport. */ + wikipedia_link: Text, + /** Alternate-name keywords. */ + keywords: z.array(z.string()).nullable().optional(), + /** Number of runways. */ + num_runways: Num, + /** Longest runway length in feet. */ + longest_runway_ft: Num, + /** Runway records from the official response. */ + runways: z.array(z.record(z.string(), z.unknown())).nullable().optional(), + /** Estimated annual passengers. */ + estimated_annual_passengers: Num, + captured_at: z.coerce.date(), +}); +export type ApiNinjasAirportEntity = z.infer; + +/** + * Airlines (`GET /v1/airlines`). + * + * No documentation page. Keys confirmed against live `/v1/airlines`. + */ +export const ApiNinjasAirlineEntity = z.object({ + id: z.string(), + /** Airline name. */ + name: Text, + /** Two-character IATA airline code. */ + iata: Text, + /** Three-character ICAO airline code. */ + icao: Text, + /** Country the airline is based in. */ + country: Text, + /** Year the airline was created. */ + year_created: Text, + /** Base airport. */ + base: Text, + /** Fleet composition, including `total`. */ + fleet: z.record(z.string(), z.unknown()).nullable().optional(), + /** Airline logo URL. */ + logo_url: Text, + /** Brand mark URL. */ + brandmark_url: Text, + /** Tail logo URL. */ + tail_logo_url: Text, + captured_at: z.coerce.date(), +}); +export type ApiNinjasAirlineEntity = z.infer; + +/** + * Aircraft (`GET /v1/aircraft`). + * + * Official: https://api-ninjas.com/api/aircraft + * Sample values arrive as strings. + */ +export const ApiNinjasAircraftEntity = z.object({ + id: z.string(), + /** Company that designed and built the aircraft. */ + manufacturer: Text, + /** Aircraft model name. */ + model: Text, + /** Type of engine (e.g. Jet, Piston, Propjet). */ + engine_type: Text, + /** Engine thrust in pounds-force. */ + engine_thrust_lb_ft: Num, + /** Maximum air speed in knots. */ + max_speed_knots: Num, + /** Cruise speed in knots. */ + cruise_speed_knots: Num, + /** Service ceiling in feet. */ + ceiling_ft: Num, + /** Takeoff ground run distance in feet. */ + takeoff_ground_run_ft: Num, + /** Landing ground roll distance in feet. */ + landing_ground_roll_ft: Num, + /** Gross weight in pounds. */ + gross_weight_lbs: Num, + /** Empty weight in pounds. */ + empty_weight_lbs: Num, + /** Length in feet. */ + length_ft: Num, + /** Height in feet. */ + height_ft: Num, + /** Wingspan in feet. */ + wing_span_ft: Num, + /** Range in nautical miles. */ + range_nautical_miles: Num, + captured_at: z.coerce.date(), +}); +export type ApiNinjasAircraftEntity = z.infer; + +/** + * Road vehicles from `/v1/cars` (deprecated), `/v1/motorcycles` and + * `/v1/electricvehicle`. `kind` is local — the three official shapes share + * make/model keys. + * + * Cars: https://api-ninjas.com/api/cars + */ +export const ApiNinjasVehicleEntity = z.object({ + id: z.string(), + /** Local discriminator for the three vehicle endpoints. */ + kind: z.enum(['car', 'motorcycle', 'electric']), + /** Manufacturer name. */ + make: Text, + /** Model name. */ + model: Text, + /** Model year (`/v1/cars`, `/v1/motorcycles`). */ + year: Num, + /** First production year (`/v1/electricvehicle` `year_start`). */ + year_start: Num, + /** Official `/v1/cars` `class` (e.g. compact car). */ + class: Text, + /** Official `/v1/motorcycles` `type` (e.g. ATV). */ + type: Text, + /** Fuel type (`/v1/cars`). */ + fuel_type: Text, + /** City MPG (`/v1/cars`). */ + city_mpg: Num, + /** Combined MPG (`/v1/cars`). */ + combination_mpg: Num, + /** Highway MPG (`/v1/cars`). */ + highway_mpg: Num, + /** Cylinder count (`/v1/cars`). */ + cylinders: Num, + /** Engine displacement (`/v1/cars`, `/v1/motorcycles`). */ + displacement: Num, + /** Drive layout. */ + drive: Text, + /** Transmission. */ + transmission: Text, + /** Usable battery capacity (`/v1/electricvehicle`). */ + battery_capacity: Text, + /** Electric range (`/v1/electricvehicle`). */ + electric_range: Num, + captured_at: z.coerce.date(), +}); +export type ApiNinjasVehicleEntity = z.infer; + +/** + * Countries (`GET /v1/country`). + * + * Official: https://api-ninjas.com/api/country + * `population` is documented in thousands. + */ +export const ApiNinjasCountryEntity = z.object({ + id: z.string(), + /** Country name. */ + name: Text, + /** 2-letter ISO-3166 alpha-2 code. */ + iso2: Text, + /** Capital city. */ + capital: Text, + /** Geographic region (e.g. Northern America). */ + region: Text, + /** Official currency object. */ + currency: z + .object({ + /** 3-letter currency code. */ + code: Text, + /** Currency name. */ + name: Text, + }) + .loose() + .nullable() + .optional(), + /** Gross domestic product in US dollars. */ + gdp: Num, + /** GDP per capita. */ + gdp_per_capita: Num, + /** GDP growth rate in %. */ + gdp_growth: Num, + /** Population in thousands. */ + population: Num, + /** Population density. */ + pop_density: Num, + /** Population growth rate. */ + pop_growth: Num, + /** Surface area in km². */ + surface_area: Num, + /** Urban population rate in %. */ + urban_population: Num, + /** Urban population growth rate. */ + urban_population_growth: Num, + /** Unemployment rate in %. */ + unemployment: Num, + /** Fertility rate (children per woman). */ + fertility: Num, + /** Infant mortality per 1,000 live births. */ + infant_mortality: Num, + /** Male life expectancy. */ + life_expectancy_male: Num, + /** Female life expectancy. */ + life_expectancy_female: Num, + /** Sex ratio. */ + sex_ratio: Num, + /** Employment in services (%). */ + employment_services: Num, + /** Employment in industry (%). */ + employment_industry: Num, + /** Employment in agriculture (%). */ + employment_agriculture: Num, + /** Imports. */ + imports: Num, + /** Exports. */ + exports: Num, + /** CO₂ emissions. */ + co2_emissions: Num, + /** Forested area (%). */ + forested_area: Num, + /** Annual tourists. */ + tourists: Num, + /** Homicide rate. */ + homicide_rate: Num, + /** Threatened species count. */ + threatened_species: Num, + /** Internet users (%). */ + internet_users: Num, + /** Refugees. */ + refugees: Num, + /** Primary school enrollment, female. */ + primary_school_enrollment_female: Num, + /** Primary school enrollment, male. */ + primary_school_enrollment_male: Num, + /** Secondary school enrollment, female. */ + secondary_school_enrollment_female: Num, + /** Secondary school enrollment, male. */ + secondary_school_enrollment_male: Num, + /** Post-secondary enrollment, female. */ + post_secondary_enrollment_female: Num, + /** Post-secondary enrollment, male. */ + post_secondary_enrollment_male: Num, + /** Telephone country codes. */ + telephone_country_codes: z.array(z.string()).nullable().optional(), + captured_at: z.coerce.date(), +}); +export type ApiNinjasCountryEntity = z.infer; + +/** + * Cities (`GET /v1/city`). + * + * Official: https://api-ninjas.com/api/city + * Documented fields only — the API does not return `region`. + */ +export const ApiNinjasCityEntity = z.object({ + id: z.string(), + /** The name of the city. */ + name: Text, + /** Latitude coordinate of the city. */ + latitude: Num, + /** Longitude coordinate of the city. */ + longitude: Num, + /** 2-letter ISO 3166 alpha-2 country code. */ + country: Text, + /** City population count. */ + population: Num, + /** Whether the city is a capital city. */ + is_capital: Flag, + captured_at: z.coerce.date(), +}); +export type ApiNinjasCityEntity = z.infer; + +/** + * Universities (`GET /v1/university`). + * + * Official: https://api-ninjas.com/api/university + */ +export const ApiNinjasUniversityEntity = z.object({ + id: z.string(), + /** The full name of the university. */ + name: Text, + /** Degree types offered. */ + degree_types: z.array(z.string()).nullable().optional(), + /** Street address. */ + address: Text, + /** City where the university is located. */ + city: Text, + /** State or province abbreviation. */ + state: Text, + /** Postal/zip code. */ + postal_code: Text, + /** Country (e.g. USA, Canada). */ + country: Text, + /** County. */ + county: Text, + /** Timezone (e.g. EST). */ + timezone: Text, + /** Latitude coordinate. */ + latitude: Text, + /** Longitude coordinate. */ + longitude: Text, + /** Contact phone number. */ + phone: Text, + /** Contact email. Only returned for some records. */ + email: Text, + /** Official website URL. */ + website: Text, + /** Institution type (e.g. Private (Not For Profit)). */ + institution_type: Text, + /** Typical undergraduate duration (e.g. 4 Years). */ + years: Text, + /** Enrolled students, as a string. */ + enrollment: Text, + /** Student-to-faculty ratio (e.g. 7 to 1). */ + student_faculty_ratio: Text, + /** Annual tuition in USD. Frequently omitted. */ + tuition: Num, + captured_at: z.coerce.date(), +}); +export type ApiNinjasUniversityEntity = z.infer< + typeof ApiNinjasUniversityEntity +>; + +/** + * Stock exchanges (`GET /v1/stockexchange`). + * + * Official: https://api-ninjas.com/api/stockexchange + */ +export const ApiNinjasStockExchangeEntity = z.object({ + id: z.string(), + /** Market Identifier Code (e.g. XNYS). */ + mic: Text, + /** Stock exchange name. */ + name: Text, + /** City where the exchange is located. */ + city: Text, + /** Country name or code, as returned. */ + country: Text, + /** ISO2 country code. */ + iso2: Text, + /** Description of the stock exchange. */ + description: Text, + /** Physical address. */ + address: Text, + /** Official website. */ + website: Text, + /** Year the exchange was established, as a string. */ + founded: Text, + /** Number of listings. */ + num_listings: Num, + /** Total market cap of listed companies, in USD. */ + market_cap_usd: Num, + /** Market cap in local `currency` when `market_cap_usd` is absent. */ + market_cap: Num, + /** Local trading currency. */ + currency: Text, + /** Timezone of the stock exchange. */ + timezone: Text, + /** Opening time. Business/Professional tier. */ + market_open: Text, + /** Closing time. Business/Professional tier. */ + market_close: Text, + /** Whether the exchange is currently open. Business/Professional tier. */ + is_market_open: Flag, + /** Reason the exchange is closed, or null if open. */ + closed_reason: Text, + captured_at: z.coerce.date(), +}); +export type ApiNinjasStockExchangeEntity = z.infer< + typeof ApiNinjasStockExchangeEntity +>; + +/** + * S&P 500 constituents (`GET /v1/sp500`). + * + * No documentation page. Keys confirmed against live `/v1/sp500`. + */ +export const ApiNinjasSp500Entity = z.object({ + id: z.string(), + /** Stock ticker symbol of a constituent. */ + ticker: Text, + /** Company name. */ + company_name: Text, + /** GICS sector. */ + sector: Text, + /** GICS sub-industry. */ + sub_industry: Text, + /** Headquarters location. */ + headquarters: Text, + /** Date the company was added, YYYY-MM-DD. */ + date_added: Text, + /** SEC Central Index Key. */ + cik: Text, + captured_at: z.coerce.date(), +}); +export type ApiNinjasSp500Entity = z.infer; + +/** + * Emoji (`GET /v1/emoji`). + * + * Official: https://api-ninjas.com/api/emoji + */ +export const ApiNinjasEmojiEntity = z.object({ + id: z.string(), + /** Unicode character code (e.g. U+1F642). */ + code: Text, + /** The emoji character itself. */ + character: Text, + /** URL to an image of the emoji. */ + image: Text, + /** Descriptive name of the emoji. */ + name: Text, + /** Main category. */ + group: Text, + /** Sub-category. */ + subgroup: Text, + captured_at: z.coerce.date(), +}); +export type ApiNinjasEmojiEntity = z.infer; + +/** + * Animals (`GET /v1/animals`). + * + * Official: https://api-ninjas.com/api/animals + * Nested `taxonomy` and `characteristics` are stored as returned. + */ +export const ApiNinjasAnimalEntity = z.object({ + id: z.string(), + /** Common name of the animal. */ + name: Text, + /** Taxonomic classification. */ + taxonomy: z + .object({ + kingdom: Text, + phylum: Text, + class: Text, + order: Text, + family: Text, + genus: Text, + scientific_name: Text, + }) + .loose() + .nullable() + .optional(), + /** Geographic locations where the animal is found. */ + locations: z.array(z.string()).nullable().optional(), + /** Detailed characteristics from the official response. */ + characteristics: z.record(z.string(), z.unknown()).nullable().optional(), + captured_at: z.coerce.date(), +}); +export type ApiNinjasAnimalEntity = z.infer; + +/** + * Planets (`GET /v1/planets`). + * + * Official: https://api-ninjas.com/api/planets + */ +export const ApiNinjasPlanetEntity = z.object({ + id: z.string(), + /** The name of the planet. */ + name: Text, + /** Mass in Jupiters (1 Jupiter = 1.898 × 10²⁷ kg). */ + mass: Num, + /** Average radius in Jupiters (1 Jupiter = 69911 km). */ + radius: Num, + /** Orbital period in Earth days. */ + period: Num, + /** Semi-major axis in astronomical units (AU). */ + semi_major_axis: Num, + /** Average surface temperature in Kelvin. */ + temperature: Num, + /** Distance from Earth in light years. */ + distance_light_year: Num, + /** Host star mass in solar masses. */ + host_star_mass: Num, + /** Host star temperature in Kelvin. */ + host_star_temperature: Num, + captured_at: z.coerce.date(), +}); +export type ApiNinjasPlanetEntity = z.infer; + +/** + * Stars (`GET /v1/stars`). + * + * Official: https://api-ninjas.com/api/stars + */ +export const ApiNinjasStarEntity = z.object({ + id: z.string(), + /** The name of the star. */ + name: Text, + /** The constellation that the star belongs to. */ + constellation: Text, + /** Right ascension coordinate. */ + right_ascension: Text, + /** Declination coordinate. */ + declination: Text, + /** Apparent magnitude (brightness as seen from Earth). */ + apparent_magnitude: Num, + /** Absolute magnitude (intrinsic brightness). */ + absolute_magnitude: Num, + /** Distance from Earth in light years. */ + distance_light_year: Num, + /** Spectral classification. */ + spectral_class: Text, + captured_at: z.coerce.date(), +}); +export type ApiNinjasStarEntity = z.infer; diff --git a/packages/apininjas/schema/index.ts b/packages/apininjas/schema/index.ts new file mode 100644 index 000000000..78f98fae3 --- /dev/null +++ b/packages/apininjas/schema/index.ts @@ -0,0 +1,34 @@ +import { + ApiNinjasAircraftEntity, + ApiNinjasAirlineEntity, + ApiNinjasAirportEntity, + ApiNinjasAnimalEntity, + ApiNinjasCityEntity, + ApiNinjasCountryEntity, + ApiNinjasEmojiEntity, + ApiNinjasPlanetEntity, + ApiNinjasSp500Entity, + ApiNinjasStarEntity, + ApiNinjasStockExchangeEntity, + ApiNinjasUniversityEntity, + ApiNinjasVehicleEntity, +} from './database'; + +export const ApiNinjasSchema = { + version: '1.0.0', + entities: { + airports: ApiNinjasAirportEntity, + airlines: ApiNinjasAirlineEntity, + aircraft: ApiNinjasAircraftEntity, + vehicles: ApiNinjasVehicleEntity, + countries: ApiNinjasCountryEntity, + cities: ApiNinjasCityEntity, + universities: ApiNinjasUniversityEntity, + stockExchanges: ApiNinjasStockExchangeEntity, + sp500: ApiNinjasSp500Entity, + emoji: ApiNinjasEmojiEntity, + animals: ApiNinjasAnimalEntity, + planets: ApiNinjasPlanetEntity, + stars: ApiNinjasStarEntity, + }, +} as const; diff --git a/packages/apininjas/shared.test.ts b/packages/apininjas/shared.test.ts new file mode 100644 index 000000000..e2776fdb5 --- /dev/null +++ b/packages/apininjas/shared.test.ts @@ -0,0 +1,330 @@ +/** + * The helpers every endpoint module leans on. + * + * These are small, but three of them decide whether a value reaches the cache + * or the event log at all - so their edge cases are worth stating explicitly + * rather than covering incidentally through an endpoint test. + */ +import { auditPayload, withCount } from './endpoints/logging'; +import { + asArray, + asNumber, + entityId, + imageContentType, + imageEncoding, + isMaskedValue, + keyed, + unmasked, +} from './endpoints/shared'; + +describe('isMaskedValue', () => { + it.each([ + 'This field is for premium subscribers only.', + 'this field is for premium subscribers only', + 'Only available for premium subscribers.', + 'Available for premium subscribers only.', + 'premium subscription required.', + 'Bank name is for premium subscribers only.', + 'No Data', + ])('recognises %j as withheld rather than as data', (prose) => { + // The wording differs between endpoints, which is why this matches a stem + // rather than a phrase. + expect(isMaskedValue(prose)).toBe(true); + }); + + it.each([ + ['a real string value', 'London Heathrow Airport'], + ['a number', 306.04], + ['a boolean', true], + ['null', null], + ['undefined', undefined], + ['an object', { premium: true }], + ['an array', ['premium']], + ])('treats %s as data', (_label, value) => { + expect(isMaskedValue(value)).toBe(false); + }); + + it.each([ + 'Premium Economy', + 'Vanguard Premium Fund', + 'premium unleaded', + 'No data available for this region yet', + ])('does not mistake %j for a placeholder', (realValue) => { + // A false positive here silently drops real data from the mirror, which is + // why the matcher names the subscription rather than the word "premium", + // and anchors the bare "No Data" form. + expect(isMaskedValue(realValue)).toBe(false); + }); +}); + +describe('unmasked', () => { + it('passes real values through unchanged', () => { + expect(unmasked('EGLL')).toBe('EGLL'); + expect(unmasked(42)).toBe(42); + expect(unmasked(null)).toBeNull(); + }); + + it('drops a masked value so it cannot be stored as data', () => { + expect( + unmasked('This field is for premium subscribers only.'), + ).toBeUndefined(); + }); +}); + +describe('asNumber', () => { + it('returns a finite number as itself', () => { + expect(asNumber(51.5074)).toBe(51.5074); + expect(asNumber(0)).toBe(0); + expect(asNumber(-1)).toBe(-1); + }); + + it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + 'rejects the non-finite number %p', + (value) => { + expect(asNumber(value)).toBeUndefined(); + }, + ); + + it('parses a numeric string, which is how most of this API sends numbers', () => { + // Aircraft speeds, star magnitudes and motorcycle displacements all arrive + // as strings. + expect(asNumber('547')).toBe(547); + expect(asNumber('0.03')).toBe(0.03); + expect(asNumber('-71.1347')).toBe(-71.1347); + }); + + it('strips thousands separators before parsing', () => { + expect(asNumber('8,500')).toBe(8500); + expect(asNumber('1,234,567')).toBe(1234567); + }); + + it('takes the leading number when a string carries a unit', () => { + // "271.0 ccm (16.54 cubic inches)" is a real motorcycle displacement. + expect(asNumber('271.0 ccm (16.54 cubic inches)')).toBe(271); + }); + + it('refuses a masked value rather than parsing prose', () => { + expect( + asNumber('This field is for premium subscribers only.'), + ).toBeUndefined(); + }); + + it.each([ + ['unparseable text', 'not a number'], + ['an empty string', ''], + ['null', null], + ['undefined', undefined], + ['a boolean', true], + ['an object', {}], + ])('returns undefined for %s', (_label, value) => { + expect(asNumber(value)).toBeUndefined(); + }); +}); + +describe('entityId', () => { + it('joins the parts of a natural key', () => { + expect(entityId('Boeing', '737 Max 7')).toBe('boeing|737 max 7'); + }); + + it('lowercases and trims so the same row always keys the same', () => { + // The provider is inconsistent about casing between endpoints - `cars` + // returns "toyota" while `sp500` returns "Microsoft". + expect(entityId(' EGLL ')).toBe('egll'); + expect(entityId('London', 'GB')).toBe(entityId('LONDON ', ' gb')); + }); + + it('keeps a missing part as an empty segment rather than collapsing it', () => { + // Collapsing would let two different rows share a key. + expect(entityId('car', null, 'corolla', undefined)).toBe('car||corolla|'); + expect(entityId('car', 'toyota', 'corolla', 1993)).not.toBe( + entityId('car', null, 'corolla', 1993), + ); + }); + + it('accepts numbers, which is how years arrive on some endpoints', () => { + expect(entityId('car', 'toyota', 'corolla', 1993)).toBe( + 'car|toyota|corolla|1993', + ); + }); + + it('produces an empty string when nothing identifies the row', () => { + // The cache helpers check for this and skip the row. + expect(entityId(undefined)).toBe(''); + expect(entityId(null)).toBe(''); + }); +}); + +describe('keyed', () => { + it('accepts a key with any part present', () => { + expect(keyed('toyota', null, undefined)).toBe(true); + expect(keyed(null, 'corolla')).toBe(true); + expect(keyed(undefined, undefined, 1993)).toBe(true); + }); + + it('rejects a key with nothing in any part', () => { + expect(keyed(null, undefined)).toBe(false); + expect(keyed()).toBe(false); + }); + + it('treats whitespace as absent, matching entityId trimming', () => { + expect(keyed(' ', '')).toBe(false); + }); + + it('is independent of how many parts the key has', () => { + // Comparing entityId's output against '|' only catches a two-part key. + expect(keyed(null, null, null)).toBe(false); + expect(entityId(null, null, null)).toBe('||'); + }); +}); + +describe('imageContentType', () => { + it.each([ + ['png', 'image/png'], + ['jpg', 'image/jpeg'], + ['jpeg', 'image/jpeg'], + ['svg', 'image/svg+xml'], + ['eps', 'application/postscript'], + ])('maps %s to %s', (format, expected) => { + expect(imageContentType(format)).toBe(expected); + }); + + it('is case-insensitive, because the parameter is free text', () => { + expect(imageContentType('SVG')).toBe('image/svg+xml'); + expect(imageContentType('PNG')).toBe('image/png'); + }); + + it('defaults to PNG when no format is given, matching the provider', () => { + expect(imageContentType(undefined)).toBe('image/png'); + expect(imageContentType('')).toBe('image/png'); + }); + + it('falls back to octet-stream for a format it does not know', () => { + // Better than claiming a content type the payload does not have. + expect(imageContentType('webp')).toBe('application/octet-stream'); + }); +}); + +describe('imageEncoding', () => { + it.each(['svg', 'eps', 'SVG'])( + 'reports %s as exact, because the payload is text', + (format) => { + expect(imageEncoding(format)).toBe('text'); + }, + ); + + it.each(['png', 'jpg', 'jpeg', 'webp', undefined, ''])( + 'reports %s as lossy, because the transport decodes bytes as text', + (format) => { + // The caller needs to know the difference: one of these can be written + // back out as an image and the other cannot. + expect(imageEncoding(format)).toBe('lossy-text'); + }, + ); +}); + +describe('asArray', () => { + it('passes an array through', () => { + const rows = [{ id: 1 }, { id: 2 }]; + expect(asArray(rows)).toBe(rows); + }); + + it('wraps a bare object, which some endpoints return instead of a list', () => { + expect(asArray({ icao: 'EGLL' })).toEqual([{ icao: 'EGLL' }]); + }); + + it('returns an empty list for nothing, so a caller can always iterate', () => { + expect(asArray(null)).toEqual([]); + expect(asArray(undefined)).toEqual([]); + }); + + it('keeps an empty array empty', () => { + expect(asArray([])).toEqual([]); + }); +}); + +describe('auditPayload', () => { + it('records named identifiers by value', () => { + expect(auditPayload({ ticker: 'AAPL', year: 2026 }, ['ticker'])).toEqual({ + ticker: 'AAPL', + supplied_fields: ['ticker', 'year'], + }); + }); + + it('lists supplied field names even when they are not identifiers', () => { + // An operator can see what a call attempted to change without seeing the + // values it used. + const payload = auditPayload({ city: 'London', state: 'England' }, []); + + expect(payload.supplied_fields).toEqual(['city', 'state']); + expect(payload.city).toBeUndefined(); + }); + + it('ignores an identifier that was not supplied', () => { + expect( + auditPayload({ ticker: undefined, limit: 5 }, ['ticker', 'limit']), + ).toEqual({ limit: 5, supplied_fields: ['limit'] }); + }); + + it('omits the fields list entirely when nothing was supplied', () => { + expect(auditPayload({}, [])).toEqual({}); + expect(auditPayload({ ticker: undefined }, ['ticker'])).toEqual({}); + }); + + it.each([ + ['text', 'a sentence the caller wrote'], + ['email', 'someone@example.com'], + ['number', '+15550100'], + ['address', '203.0.113.7'], + ['url', 'https://internal.example.com/private'], + ['query', '1lb brisket and fries'], + ])('reduces %s to a length instead of a value', (key, value) => { + const payload = auditPayload({ [key]: value }, [key]); + + expect(payload[key]).toBeUndefined(); + expect(payload[`${key}_length`]).toBe(value.length); + expect(JSON.stringify(payload)).not.toContain(value); + }); + + it('reduces a structured input to its length rather than its contents', () => { + const payload = auditPayload( + { + puzzle: [ + [0, 1], + [1, 0], + ], + }, + ['puzzle'], + ); + + expect(payload.puzzle).toBeUndefined(); + expect(payload.puzzle_length).toBe(2); + }); + + it('leaves a sensitive field with no measurable size out entirely', () => { + const payload = auditPayload({ data: 42 }, ['data']); + + expect(payload.data).toBeUndefined(); + expect(payload.data_length).toBeUndefined(); + expect(payload.supplied_fields).toEqual(['data']); + }); +}); + +describe('withCount', () => { + it('adds the row count for a collection', () => { + expect(withCount({ name: 'London' }, [{ a: 1 }, { b: 2 }])).toEqual({ + name: 'London', + result_count: 2, + }); + }); + + it('counts an empty collection as zero rather than omitting it', () => { + // "No rows" and "not a collection" are different facts. + expect(withCount({}, [])).toEqual({ result_count: 0 }); + }); + + it('leaves a single-object response alone', () => { + expect(withCount({ ticker: 'AAPL' }, { price: 1 })).toEqual({ + ticker: 'AAPL', + }); + }); +}); diff --git a/packages/apininjas/tsconfig.json b/packages/apininjas/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/apininjas/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/apininjas/tsup.config.ts b/packages/apininjas/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/apininjas/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 41011f6e7..0b2e9468f 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -36,6 +36,7 @@ export const BaseProviders = [ 'apibible', 'apify', 'apilabz', + 'apininjas', 'apisports', 'asana', 'ayrshare', @@ -166,6 +167,7 @@ export const ProviderDisplayNames = { apibible: 'API.Bible', apify: 'Apify', apilabz: 'API Labz', + apininjas: 'API Ninjas', apisports: 'API-Sports', asana: 'Asana', ayrshare: 'Ayrshare', @@ -303,6 +305,7 @@ export type AllProviders = | 'apibible' | 'apify' | 'apilabz' + | 'apininjas' | 'apisports' | 'asana' | 'ayrshare' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5f3e3a29..f3cc99527 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -873,6 +873,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/apininjas: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/apisports: devDependencies: '@types/jest': @@ -4157,10 +4181,6 @@ packages: resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} @@ -4866,10 +4886,6 @@ packages: resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -15191,7 +15207,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-annotate-as-pure@7.29.7': dependencies: @@ -15273,13 +15289,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.28.6 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-imports@7.29.7': dependencies: '@babel/traverse': 7.29.7 @@ -15539,6 +15548,11 @@ snapshots: '@babel/core': 7.28.6 '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -15925,7 +15939,7 @@ snapshots: '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-display-name@7.29.7(@babel/core@7.29.7)': dependencies: @@ -15949,31 +15963,31 @@ snapshots: '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.6) - '@babel/types': 7.28.6 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.6) + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -15992,7 +16006,7 @@ snapshots: dependencies: '@babel/core': 7.28.6 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-pure-annotations@7.29.7(@babel/core@7.29.7)': dependencies: @@ -16273,11 +16287,6 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -20734,8 +20743,8 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 @@ -21891,8 +21900,7 @@ snapshots: confbox@0.2.2: {} - confbox@0.2.4: - optional: true + confbox@0.2.4: {} config-chain@1.1.13: dependencies: @@ -25130,7 +25138,7 @@ snapshots: pkg-types@2.3.0: dependencies: - confbox: 0.2.2 + confbox: 0.2.4 exsolve: 1.0.8 pathe: 2.0.3