diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index b5dca6fd6..8d720020b 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -88,6 +88,7 @@ export const BaseProviders = [ 'crowterminal', 'cursor', 'customgpt', + 'dadataru', 'databricks', 'datadog', 'deepseek', @@ -276,6 +277,7 @@ export const ProviderDisplayNames = { crowterminal: 'CrowTerminal', cursor: 'Cursor', customgpt: 'CustomGPT', + dadataru: 'Dadataru', databricks: 'Databricks', datadog: 'Datadog', deepseek: 'DeepSeek', @@ -471,6 +473,7 @@ export type AllProviders = | 'crowterminal' | 'cursor' | 'customgpt' + | 'dadataru' | 'databricks' | 'datadog' | 'deepseek' diff --git a/packages/dadataru/api.test.ts b/packages/dadataru/api.test.ts new file mode 100644 index 000000000..d714c0858 --- /dev/null +++ b/packages/dadataru/api.test.ts @@ -0,0 +1,423 @@ +import { request } from 'corsair/http'; +import { makeDadataruRequest } from './client'; +import { + Clean, + Find, + Geolocate, + IpLocate, + Profile, + Suggest, +} from './endpoints'; +import type { DadataruContext } from './index'; + +jest.mock('corsair/core', () => ({ + logEventFromContext: jest.fn(), +})); + +jest.mock('corsair/http', () => { + const original = jest.requireActual('corsair/http'); + return { + ...original, + request: jest.fn(), + }; +}); + +const mockRequest = request as jest.Mock; + +function lastCall() { + const call = mockRequest.mock.calls[mockRequest.mock.calls.length - 1]; + return { config: call?.[0], options: call?.[1] }; +} + +describe('Dadataru API Client', () => { + beforeEach(() => { + mockRequest.mockReset(); + mockRequest.mockResolvedValue({}); + }); + + it('routes suggestions requests correctly', async () => { + await makeDadataruRequest('suggest/address', 'test-api-key', { + method: 'POST', + body: { query: 'мск' }, + apiType: 'suggest', + }); + + const { config, options } = lastCall(); + expect(config.BASE).toBe( + 'https://suggestions.dadata.ru/suggestions/api/4_1/rs', + ); + expect(options.method).toBe('POST'); + expect(config.HEADERS['Authorization']).toBe('Token test-api-key'); + }); + + it('routes clean requests correctly with X-Secret', async () => { + await makeDadataruRequest('clean/address', 'test-api-key', { + method: 'POST', + body: ['мск сухонская'], + apiType: 'clean', + secretKey: 'test-secret-key', + }); + + const { config, options } = lastCall(); + expect(config.BASE).toBe('https://cleaner.dadata.ru/api/v1'); + expect(options.method).toBe('POST'); + expect(config.HEADERS['X-Secret']).toBe('test-secret-key'); + }); + + it('routes profile requests correctly', async () => { + await makeDadataruRequest('profile/balance', 'test-api-key', { + method: 'GET', + apiType: 'profile', + secretKey: 'test-secret-key', + }); + + const { config, options } = lastCall(); + expect(config.BASE).toBe('https://dadata.ru/api/v2'); + expect(options.method).toBe('GET'); + expect(config.HEADERS['X-Secret']).toBe('test-secret-key'); + }); +}); + +describe('Dadataru Endpoints', () => { + const mockCtx = { + key: 'test-key', + options: { + secret: 'test-secret', + }, + } as unknown as DadataruContext; + + beforeEach(() => { + mockRequest.mockReset(); + mockRequest.mockResolvedValue({}); + }); + + describe('Clean Endpoints (8)', () => { + it('cleanAddress', async () => { + await Clean.address(mockCtx, { queries: ['мск'] }); + expect(lastCall().options.url).toBe('clean/address'); + }); + + it('cleanBirthdate', async () => { + await Clean.birthdate(mockCtx, { queries: ['24.08.2026'] }); + expect(lastCall().options.url).toBe('clean/birthdate'); + }); + + it('cleanRecord (composite)', async () => { + await Clean.cleanRecord(mockCtx, { + structure: ['NAME'], + data: [['иванов']], + }); + expect(lastCall().options.url).toBe('clean'); + }); + + it('cleanEmail', async () => { + await Clean.email(mockCtx, { queries: ['test@domain.com'] }); + expect(lastCall().options.url).toBe('clean/email'); + }); + + it('cleanName', async () => { + await Clean.name(mockCtx, { queries: ['Иван'] }); + expect(lastCall().options.url).toBe('clean/name'); + }); + + it('cleanPassport', async () => { + await Clean.passport(mockCtx, { queries: ['123456'] }); + expect(lastCall().options.url).toBe('clean/passport'); + }); + + it('cleanPhone', async () => { + await Clean.phone(mockCtx, { queries: ['+79991112233'] }); + expect(lastCall().options.url).toBe('clean/phone'); + }); + + it('cleanVehicle', async () => { + await Clean.vehicle(mockCtx, { queries: ['ford'] }); + expect(lastCall().options.url).toBe('clean/vehicle'); + }); + }); + + describe('Find/Lookup Endpoints (24)', () => { + it('findAddress', async () => { + await Find.address(mockCtx, { query: 'id1' }); + expect(lastCall().options.url).toBe('findById/address'); + }); + + it('findFiasById', async () => { + await Find.fiasById(mockCtx, { query: 'id1' }); + expect(lastCall().options.url).toBe('findById/fias'); + }); + + it('findBank', async () => { + await Find.bank(mockCtx, { query: 'bic1' }); + expect(lastCall().options.url).toBe('findById/bank'); + }); + + it('findPartyBy', async () => { + await Find.partyBy(mockCtx, { query: 'unp1' }); + expect(lastCall().options.url).toBe('findById/party'); + }); + + it('findCarBrand', async () => { + await Find.carBrand(mockCtx, { query: 'brand1' }); + expect(lastCall().options.url).toBe('findById/car_brand'); + }); + + it('findCompanyByEmail', async () => { + await Find.companyByEmail(mockCtx, { query: 'email1' }); + expect(lastCall().options.url).toBe('findByEmail/company'); + }); + + it('findParty', async () => { + await Find.party(mockCtx, { query: 'inn1' }); + expect(lastCall().options.url).toBe('findById/party'); + }); + + it('findCountry', async () => { + await Find.country(mockCtx, { query: 'code1' }); + expect(lastCall().options.url).toBe('findById/country'); + }); + + it('findCourtById', async () => { + await Find.courtById(mockCtx, { query: 'court1' }); + expect(lastCall().options.url).toBe('findById/court'); + }); + + it('findCurrency', async () => { + await Find.currency(mockCtx, { query: 'currency1' }); + expect(lastCall().options.url).toBe('findById/currency'); + }); + + it('findDelivery', async () => { + await Find.delivery(mockCtx, { query: 'delivery1' }); + expect(lastCall().options.url).toBe('findById/delivery'); + }); + + it('findFmsUnit', async () => { + await Find.fmsUnit(mockCtx, { query: 'fms1' }); + expect(lastCall().options.url).toBe('findById/fms_unit'); + }); + + it('findFnsUnit', async () => { + await Find.fnsUnit(mockCtx, { query: 'fns1' }); + expect(lastCall().options.url).toBe('findById/fns_unit'); + }); + + it('findFtsUnit', async () => { + await Find.ftsUnit(mockCtx, { query: 'fts1' }); + expect(lastCall().options.url).toBe('findById/fts_unit'); + }); + + it('findPartyKz', async () => { + await Find.partyKz(mockCtx, { query: 'bin1' }); + expect(lastCall().options.url).toBe('findById/party'); + }); + + it('findMktu', async () => { + await Find.mktu(mockCtx, { query: 'mktu1' }); + expect(lastCall().options.url).toBe('findById/mktu'); + }); + + it('findMedicalPositionById', async () => { + await Find.medicalPositionById(mockCtx, { query: 'med1' }); + expect(lastCall().options.url).toBe('findById/medical_position'); + }); + + it('findOkpd2ById', async () => { + await Find.okpd2ById(mockCtx, { query: 'okpd1' }); + expect(lastCall().options.url).toBe('findById/okpd2'); + }); + + it('findOkpdtrPosition', async () => { + await Find.okpdtrPosition(mockCtx, { query: 'okpdtr1' }); + expect(lastCall().options.url).toBe('suggest/okpdtr_position'); + }); + + it('findOkpdtrProfession', async () => { + await Find.okpdtrProfession(mockCtx, { query: 'okpdtr2' }); + expect(lastCall().options.url).toBe('suggest/okpdtr_profession'); + }); + + it('findOkved2', async () => { + await Find.okved2(mockCtx, { query: 'okved1' }); + expect(lastCall().options.url).toBe('findById/okved2'); + }); + + it('findPostalOffice', async () => { + await Find.postalOffice(mockCtx, { query: 'post1' }); + expect(lastCall().options.url).toBe('findById/postal_office'); + }); + + it('findPostalUnitById', async () => { + await Find.postalUnitById(mockCtx, { query: 'unit1' }); + expect(lastCall().options.url).toBe('findById/postal_unit'); + }); + + it('findOktmoById', async () => { + await Find.oktmoById(mockCtx, { query: 'oktmo1' }); + expect(lastCall().options.url).toBe('findById/oktmo'); + }); + }); + + describe('Geolocate Endpoints (2)', () => { + it('geolocateAddress', async () => { + await Geolocate.address(mockCtx, { lat: 55, lon: 37 }); + expect(lastCall().options.url).toBe('geolocate/address'); + }); + + it('geolocatePostalUnit', async () => { + await Geolocate.postalUnit(mockCtx, { lat: 55, lon: 37 }); + expect(lastCall().options.url).toBe('geolocate/postal_unit'); + }); + }); + + describe('IP Locate Endpoints (1)', () => { + it('ipLocateAddress', async () => { + await IpLocate.address(mockCtx, { ip: '127.0.0.1' }); + expect(lastCall().options.url).toBe('iplocate/address'); + }); + }); + + describe('Profile Endpoints (3)', () => { + it('getProfileBalance', async () => { + await Profile.balance(mockCtx, {}); + expect(lastCall().options.url).toBe('profile/balance'); + }); + + it('getProfileStatistics', async () => { + await Profile.statistics(mockCtx, {}); + expect(lastCall().options.url).toBe('stat/daily'); + }); + + it('getReferenceVersions', async () => { + await Profile.versions(mockCtx, {}); + expect(lastCall().options.url).toBe('version'); + }); + }); + + describe('Suggest Endpoints (25)', () => { + it('suggestAddress', async () => { + await Suggest.address(mockCtx, { query: 'msk' }); + expect(lastCall().options.url).toBe('suggest/address'); + }); + + it('suggestBank', async () => { + await Suggest.bank(mockCtx, { query: 'sber' }); + expect(lastCall().options.url).toBe('suggest/bank'); + }); + + it('suggestPartyBy', async () => { + await Suggest.partyBy(mockCtx, { query: 'by' }); + expect(lastCall().options.url).toBe('suggest/party'); + }); + + it('suggestCarBrand', async () => { + await Suggest.carBrand(mockCtx, { query: 'bmw' }); + expect(lastCall().options.url).toBe('suggest/car_brand'); + }); + + it('suggestCountry', async () => { + await Suggest.country(mockCtx, { query: 'ru' }); + expect(lastCall().options.url).toBe('suggest/country'); + }); + + it('suggestCourt', async () => { + await Suggest.court(mockCtx, { query: 'court' }); + expect(lastCall().options.url).toBe('suggest/court'); + }); + + it('suggestCurrency', async () => { + await Suggest.currency(mockCtx, { query: 'rub' }); + expect(lastCall().options.url).toBe('suggest/currency'); + }); + + it('suggestEmail', async () => { + await Suggest.email(mockCtx, { query: 'mail' }); + expect(lastCall().options.url).toBe('suggest/email'); + }); + + it('suggestFias', async () => { + await Suggest.fias(mockCtx, { query: 'fias' }); + expect(lastCall().options.url).toBe('suggest/fias'); + }); + + it('suggestFmsUnit', async () => { + await Suggest.fmsUnit(mockCtx, { query: 'fms' }); + expect(lastCall().options.url).toBe('suggest/fms_unit'); + }); + + it('suggestFnsUnit', async () => { + await Suggest.fnsUnit(mockCtx, { query: 'fns' }); + expect(lastCall().options.url).toBe('suggest/fns_unit'); + }); + + it('suggestFtsUnit', async () => { + await Suggest.ftsUnit(mockCtx, { query: 'fts' }); + expect(lastCall().options.url).toBe('suggest/fts_unit'); + }); + + it('suggestPartyKz', async () => { + await Suggest.partyKz(mockCtx, { query: 'kz' }); + expect(lastCall().options.url).toBe('suggest/party'); + }); + + it('suggestMktu', async () => { + await Suggest.mktu(mockCtx, { query: 'mktu' }); + expect(lastCall().options.url).toBe('suggest/mktu'); + }); + + it('suggestMedicalPosition', async () => { + await Suggest.medicalPosition(mockCtx, { query: 'doc' }); + expect(lastCall().options.url).toBe('suggest/medical_position'); + }); + + it('suggestMetro', async () => { + await Suggest.metro(mockCtx, { query: 'metro' }); + expect(lastCall().options.url).toBe('suggest/metro'); + }); + + it('suggestName', async () => { + await Suggest.name(mockCtx, { query: 'name' }); + expect(lastCall().options.url).toBe('suggest/fio'); + }); + + it('suggestOkpd2', async () => { + await Suggest.okpd2(mockCtx, { query: 'okpd' }); + expect(lastCall().options.url).toBe('suggest/okpd2'); + }); + + it('suggestOkpdtrPosition', async () => { + await Suggest.okpdtrPosition(mockCtx, { query: 'okpdtr' }); + expect(lastCall().options.url).toBe('suggest/okpdtr_position'); + }); + + it('suggestOkpdtrProfession', async () => { + await Suggest.okpdtrProfession(mockCtx, { query: 'okpdtr' }); + expect(lastCall().options.url).toBe('suggest/okpdtr_profession'); + }); + + it('suggestOktmo', async () => { + await Suggest.oktmo(mockCtx, { query: 'oktmo' }); + expect(lastCall().options.url).toBe('suggest/oktmo'); + }); + + it('suggestOkved2', async () => { + await Suggest.okved2(mockCtx, { query: 'okved' }); + expect(lastCall().options.url).toBe('suggest/okved2'); + }); + + it('suggestParty', async () => { + await Suggest.party(mockCtx, { query: 'party' }); + expect(lastCall().options.url).toBe('suggest/party'); + }); + + it('suggestPostalOffice', async () => { + await Suggest.postalOffice(mockCtx, { query: 'post' }); + expect(lastCall().options.url).toBe('suggest/postal_office'); + }); + + it('suggestPostalUnit', async () => { + await Suggest.postalUnit(mockCtx, { query: 'unit' }); + expect(lastCall().options.url).toBe('suggest/postal_unit'); + }); + }); +}); diff --git a/packages/dadataru/client.ts b/packages/dadataru/client.ts new file mode 100644 index 000000000..c7e81adb4 --- /dev/null +++ b/packages/dadataru/client.ts @@ -0,0 +1,78 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { request } from 'corsair/http'; + +export class DadataruAPIError extends Error { + constructor( + message: string, + public readonly code?: string, + ) { + super(message); + this.name = 'DadataruAPIError'; + } +} + +const DADATARU_SUGGEST_BASE = + 'https://suggestions.dadata.ru/suggestions/api/4_1/rs'; +const DADATARU_CLEAN_BASE = 'https://cleaner.dadata.ru/api/v1'; +const DADATARU_PROFILE_BASE = 'https://dadata.ru/api/v2'; + +export async function makeDadataruRequest( + endpoint: string, + apiKey: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: unknown; + query?: Record; + apiType?: 'suggest' | 'clean' | 'profile'; + secretKey?: string; + } = {}, +): Promise { + const { + method = 'GET', + body, + query, + apiType = 'suggest', + secretKey, + } = options; + + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Token ${apiKey}`, + }; + + if (secretKey) { + headers['X-Secret'] = secretKey; + } + + const getBaseUrl = () => { + if (apiType === 'clean') return DADATARU_CLEAN_BASE; + if (apiType === 'profile') return DADATARU_PROFILE_BASE; + return DADATARU_SUGGEST_BASE; + }; + + const config: OpenAPIConfig = { + BASE: getBaseUrl(), + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + HEADERS: headers, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + body, + mediaType: 'application/json; charset=utf-8', + query: method === 'GET' ? query : undefined, + }; + + try { + return await request(config, requestOptions); + } catch (error) { + if (error instanceof Error) { + throw new DadataruAPIError(error.message); + } + throw new DadataruAPIError('Unknown error'); + } +} diff --git a/packages/dadataru/endpoints/clean.ts b/packages/dadataru/endpoints/clean.ts new file mode 100644 index 000000000..f62f9ccf3 --- /dev/null +++ b/packages/dadataru/endpoints/clean.ts @@ -0,0 +1,81 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeDadataruRequest } from '../client'; +import type { DadataruEndpoints } from '../index'; + +async function handleClean( + ctx: any, + queries: string[], + endpointPath: string, + eventName: string, +) { + const response = await makeDadataruRequest[]>( + endpointPath, + ctx.key, + { + method: 'POST', + body: queries, + apiType: 'clean', + secretKey: ctx.options.secret, + }, + ); + + await logEventFromContext( + ctx, + eventName, + { count: queries.length }, + 'completed', + ); + return { results: response }; +} + +export const address: DadataruEndpoints['cleanAddress'] = (ctx, input) => + handleClean(ctx, input.queries, 'clean/address', 'dadataru.clean.address'); + +export const birthdate: DadataruEndpoints['cleanBirthdate'] = (ctx, input) => + handleClean( + ctx, + input.queries, + 'clean/birthdate', + 'dadataru.clean.birthdate', + ); + +export const email: DadataruEndpoints['cleanEmail'] = (ctx, input) => + handleClean(ctx, input.queries, 'clean/email', 'dadataru.clean.email'); + +export const name: DadataruEndpoints['cleanName'] = (ctx, input) => + handleClean(ctx, input.queries, 'clean/name', 'dadataru.clean.name'); + +export const passport: DadataruEndpoints['cleanPassport'] = (ctx, input) => + handleClean(ctx, input.queries, 'clean/passport', 'dadataru.clean.passport'); + +export const phone: DadataruEndpoints['cleanPhone'] = (ctx, input) => + handleClean(ctx, input.queries, 'clean/phone', 'dadataru.clean.phone'); + +export const vehicle: DadataruEndpoints['cleanVehicle'] = (ctx, input) => + handleClean(ctx, input.queries, 'clean/vehicle', 'dadataru.clean.vehicle'); + +export const cleanRecord: DadataruEndpoints['cleanRecord'] = async ( + ctx, + input, +) => { + const response = await makeDadataruRequest<{ + structure: string[]; + data: Record[][]; + }>('clean', ctx.key, { + method: 'POST', + body: { + structure: input.structure, + data: input.data, + }, + apiType: 'clean', + secretKey: ctx.options.secret, + }); + + await logEventFromContext( + ctx, + 'dadataru.clean.record', + { fields: input.structure }, + 'completed', + ); + return response; +}; diff --git a/packages/dadataru/endpoints/find.ts b/packages/dadataru/endpoints/find.ts new file mode 100644 index 000000000..33fe7107a --- /dev/null +++ b/packages/dadataru/endpoints/find.ts @@ -0,0 +1,281 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeDadataruRequest } from '../client'; +import type { DadataruEndpoints } from '../index'; + +async function handleFind( + ctx: any, + query: string, + count: number | undefined, + kpp: string | undefined, + endpointPath: string, + eventName: string, +) { + const response = await makeDadataruRequest(endpointPath, ctx.key, { + method: 'POST', + body: { + query: query, + count: count ?? 5, + kpp: kpp, + }, + apiType: 'suggest', + }); + + await logEventFromContext(ctx, eventName, { query }, 'completed'); + return response; +} + +export const address: DadataruEndpoints['findAddress'] = (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/address', + 'dadataru.find.address', + ); + +export const fiasById: DadataruEndpoints['findFiasById'] = (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/fias', + 'dadataru.find.fiasById', + ); + +export const bank: DadataruEndpoints['findBank'] = (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + input.kpp, + 'findById/bank', + 'dadataru.find.bank', + ); + +export const partyBy: DadataruEndpoints['findPartyBy'] = (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/party', + 'dadataru.find.partyBy', + ); + +export const carBrand: DadataruEndpoints['findCarBrand'] = (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/car_brand', + 'dadataru.find.carBrand', + ); + +export const companyByEmail: DadataruEndpoints['findCompanyByEmail'] = ( + ctx, + input, +) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findByEmail/company', + 'dadataru.find.companyByEmail', + ); + +export const party: DadataruEndpoints['findParty'] = (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + input.kpp, + 'findById/party', + 'dadataru.find.party', + ); + +export const country: DadataruEndpoints['findCountry'] = (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/country', + 'dadataru.find.country', + ); + +export const courtById: DadataruEndpoints['findCourtById'] = (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/court', + 'dadataru.find.courtById', + ); + +export const currency: DadataruEndpoints['findCurrency'] = (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/currency', + 'dadataru.find.currency', + ); + +export const delivery: DadataruEndpoints['findDelivery'] = (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/delivery', + 'dadataru.find.delivery', + ); + +export const fmsUnit: DadataruEndpoints['findFmsUnit'] = (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/fms_unit', + 'dadataru.find.fmsUnit', + ); + +export const fnsUnit: DadataruEndpoints['findFnsUnit'] = (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/fns_unit', + 'dadataru.find.fnsUnit', + ); + +export const ftsUnit: DadataruEndpoints['findFtsUnit'] = (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/fts_unit', + 'dadataru.find.ftsUnit', + ); + +export const partyKz: DadataruEndpoints['findPartyKz'] = (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/party', + 'dadataru.find.partyKz', + ); + +export const mktu: DadataruEndpoints['findMktu'] = (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/mktu', + 'dadataru.find.mktu', + ); + +export const medicalPositionById: DadataruEndpoints['findMedicalPositionById'] = + (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/medical_position', + 'dadataru.find.medicalPositionById', + ); + +export const okpd2ById: DadataruEndpoints['findOkpd2ById'] = (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/okpd2', + 'dadataru.find.okpd2ById', + ); + +export const okpdtrPosition: DadataruEndpoints['findOkpdtrPosition'] = ( + ctx, + input, +) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'suggest/okpdtr_position', + 'dadataru.find.okpdtrPosition', + ); + +export const okpdtrProfession: DadataruEndpoints['findOkpdtrProfession'] = ( + ctx, + input, +) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'suggest/okpdtr_profession', + 'dadataru.find.okpdtrProfession', + ); + +export const okved2: DadataruEndpoints['findOkved2'] = (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/okved2', + 'dadataru.find.okved2', + ); + +export const postalOffice: DadataruEndpoints['findPostalOffice'] = ( + ctx, + input, +) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/postal_office', + 'dadataru.find.postalOffice', + ); + +export const postalUnitById: DadataruEndpoints['findPostalUnitById'] = ( + ctx, + input, +) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/postal_unit', + 'dadataru.find.postalUnitById', + ); + +export const oktmoById: DadataruEndpoints['findOktmoById'] = (ctx, input) => + handleFind( + ctx, + input.query, + input.count, + undefined, + 'findById/oktmo', + 'dadataru.find.oktmoById', + ); diff --git a/packages/dadataru/endpoints/geolocate.ts b/packages/dadataru/endpoints/geolocate.ts new file mode 100644 index 000000000..b3a133394 --- /dev/null +++ b/packages/dadataru/endpoints/geolocate.ts @@ -0,0 +1,48 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeDadataruRequest } from '../client'; +import type { DadataruEndpoints } from '../index'; + +async function handleGeolocate( + ctx: any, + input: { lat: number; lon: number; radius_meters?: number; count?: number }, + endpointPath: string, + eventName: string, +) { + const response = await makeDadataruRequest(endpointPath, ctx.key, { + method: 'POST', + body: { + lat: input.lat, + lon: input.lon, + radius_meters: input.radius_meters, + count: input.count ?? 5, + }, + apiType: 'suggest', + }); + + await logEventFromContext( + ctx, + eventName, + { lat: input.lat, lon: input.lon }, + 'completed', + ); + return response; +} + +export const address: DadataruEndpoints['geolocateAddress'] = (ctx, input) => + handleGeolocate( + ctx, + input, + 'geolocate/address', + 'dadataru.geolocate.address', + ); + +export const postalUnit: DadataruEndpoints['geolocatePostalUnit'] = ( + ctx, + input, +) => + handleGeolocate( + ctx, + input, + 'geolocate/postal_unit', + 'dadataru.geolocate.postalUnit', + ); diff --git a/packages/dadataru/endpoints/index.ts b/packages/dadataru/endpoints/index.ts new file mode 100644 index 000000000..2687593bc --- /dev/null +++ b/packages/dadataru/endpoints/index.ts @@ -0,0 +1,9 @@ +import * as Clean from './clean'; +import * as Find from './find'; +import * as Geolocate from './geolocate'; +import * as IpLocate from './iplocate'; +import * as Profile from './profile'; +import * as Suggest from './suggest'; + +export { Suggest, Clean, Find, Geolocate, IpLocate, Profile }; +export * from './types'; diff --git a/packages/dadataru/endpoints/iplocate.ts b/packages/dadataru/endpoints/iplocate.ts new file mode 100644 index 000000000..54e51ed30 --- /dev/null +++ b/packages/dadataru/endpoints/iplocate.ts @@ -0,0 +1,22 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeDadataruRequest } from '../client'; +import type { DadataruEndpoints } from '../index'; + +export const address: DadataruEndpoints['ipLocateAddress'] = async ( + ctx, + input, +) => { + const response = await makeDadataruRequest('iplocate/address', ctx.key, { + method: 'GET', + query: input.ip ? { ip: input.ip } : {}, + apiType: 'suggest', + }); + + await logEventFromContext( + ctx, + 'dadataru.iplocate.address', + { ip: input.ip }, + 'completed', + ); + return response; +}; diff --git a/packages/dadataru/endpoints/profile.ts b/packages/dadataru/endpoints/profile.ts new file mode 100644 index 000000000..88324389c --- /dev/null +++ b/packages/dadataru/endpoints/profile.ts @@ -0,0 +1,45 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeDadataruRequest } from '../client'; +import type { DadataruEndpoints } from '../index'; + +export const balance: DadataruEndpoints['getProfileBalance'] = async (ctx) => { + const response = await makeDadataruRequest('profile/balance', ctx.key, { + method: 'GET', + apiType: 'profile', + secretKey: ctx.options.secret, + }); + + await logEventFromContext(ctx, 'dadataru.profile.balance', {}, 'completed'); + return response; +}; + +export const statistics: DadataruEndpoints['getProfileStatistics'] = async ( + ctx, +) => { + const response = await makeDadataruRequest('stat/daily', ctx.key, { + method: 'GET', + apiType: 'profile', + secretKey: ctx.options.secret, + }); + + await logEventFromContext( + ctx, + 'dadataru.profile.statistics', + {}, + 'completed', + ); + return response; +}; + +export const versions: DadataruEndpoints['getReferenceVersions'] = async ( + ctx, +) => { + // versions does not require x-secret, only api token + const response = await makeDadataruRequest('version', ctx.key, { + method: 'GET', + apiType: 'profile', + }); + + await logEventFromContext(ctx, 'dadataru.profile.versions', {}, 'completed'); + return response; +}; diff --git a/packages/dadataru/endpoints/suggest.ts b/packages/dadataru/endpoints/suggest.ts new file mode 100644 index 000000000..056bca16c --- /dev/null +++ b/packages/dadataru/endpoints/suggest.ts @@ -0,0 +1,263 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeDadataruRequest } from '../client'; +import type { DadataruEndpoints } from '../index'; + +async function handleSuggest( + ctx: any, + query: string, + count: number | undefined, + endpointPath: string, + eventName: string, +) { + const response = await makeDadataruRequest(endpointPath, ctx.key, { + method: 'POST', + body: { + query: query, + count: count ?? 5, + }, + apiType: 'suggest', + }); + + await logEventFromContext(ctx, eventName, { query }, 'completed'); + return response; +} + +export const address: DadataruEndpoints['suggestAddress'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/address', + 'dadataru.suggest.address', + ); + +export const bank: DadataruEndpoints['suggestBank'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/bank', + 'dadataru.suggest.bank', + ); + +export const partyBy: DadataruEndpoints['suggestPartyBy'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/party', + 'dadataru.suggest.partyBy', + ); + +export const carBrand: DadataruEndpoints['suggestCarBrand'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/car_brand', + 'dadataru.suggest.carBrand', + ); + +export const country: DadataruEndpoints['suggestCountry'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/country', + 'dadataru.suggest.country', + ); + +export const court: DadataruEndpoints['suggestCourt'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/court', + 'dadataru.suggest.court', + ); + +export const currency: DadataruEndpoints['suggestCurrency'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/currency', + 'dadataru.suggest.currency', + ); + +export const email: DadataruEndpoints['suggestEmail'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/email', + 'dadataru.suggest.email', + ); + +export const fias: DadataruEndpoints['suggestFias'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/fias', + 'dadataru.suggest.fias', + ); + +export const fmsUnit: DadataruEndpoints['suggestFmsUnit'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/fms_unit', + 'dadataru.suggest.fmsUnit', + ); + +export const fnsUnit: DadataruEndpoints['suggestFnsUnit'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/fns_unit', + 'dadataru.suggest.fnsUnit', + ); + +export const ftsUnit: DadataruEndpoints['suggestFtsUnit'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/fts_unit', + 'dadataru.suggest.ftsUnit', + ); + +export const partyKz: DadataruEndpoints['suggestPartyKz'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/party', + 'dadataru.suggest.partyKz', + ); + +export const mktu: DadataruEndpoints['suggestMktu'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/mktu', + 'dadataru.suggest.mktu', + ); + +export const medicalPosition: DadataruEndpoints['suggestMedicalPosition'] = ( + ctx, + input, +) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/medical_position', + 'dadataru.suggest.medicalPosition', + ); + +export const metro: DadataruEndpoints['suggestMetro'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/metro', + 'dadataru.suggest.metro', + ); + +export const name: DadataruEndpoints['suggestName'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/fio', + 'dadataru.suggest.name', + ); + +export const okpd2: DadataruEndpoints['suggestOkpd2'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/okpd2', + 'dadataru.suggest.okpd2', + ); + +export const okpdtrPosition: DadataruEndpoints['suggestOkpdtrPosition'] = ( + ctx, + input, +) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/okpdtr_position', + 'dadataru.suggest.okpdtrPosition', + ); + +export const okpdtrProfession: DadataruEndpoints['suggestOkpdtrProfession'] = ( + ctx, + input, +) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/okpdtr_profession', + 'dadataru.suggest.okpdtrProfession', + ); + +export const oktmo: DadataruEndpoints['suggestOktmo'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/oktmo', + 'dadataru.suggest.oktmo', + ); + +export const okved2: DadataruEndpoints['suggestOkved2'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/okved2', + 'dadataru.suggest.okved2', + ); + +export const party: DadataruEndpoints['suggestParty'] = (ctx, input) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/party', + 'dadataru.suggest.party', + ); + +export const postalOffice: DadataruEndpoints['suggestPostalOffice'] = ( + ctx, + input, +) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/postal_office', + 'dadataru.suggest.postalOffice', + ); + +export const postalUnit: DadataruEndpoints['suggestPostalUnit'] = ( + ctx, + input, +) => + handleSuggest( + ctx, + input.query, + input.count, + 'suggest/postal_unit', + 'dadataru.suggest.postalUnit', + ); diff --git a/packages/dadataru/endpoints/types.ts b/packages/dadataru/endpoints/types.ts new file mode 100644 index 000000000..a216154d2 --- /dev/null +++ b/packages/dadataru/endpoints/types.ts @@ -0,0 +1,366 @@ +import { z } from 'zod'; + +// Shared Schemas +export const QueryInputSchema = z.object({ + query: z.string(), + count: z.number().optional(), + kpp: z.string().optional(), +}); +export type QueryInput = z.infer; + +export const CleanInputSchema = z.object({ + queries: z.array(z.string()), +}); +export type CleanInput = z.infer; + +export const SuggestResponseSchema = z.object({ + suggestions: z.array( + z.object({ + value: z.string(), + unrestricted_value: z.string(), + data: z.record(z.string(), z.unknown()), + }), + ), +}); +export type SuggestResponse = z.infer; + +export const CleanResponseSchema = z.object({ + results: z.array(z.record(z.string(), z.unknown())), +}); +export type CleanResponse = z.infer; + +// Specific Schemas +export const CompositeCleanInputSchema = z.object({ + structure: z.array(z.string()), + data: z.array(z.array(z.string())), +}); +export type CompositeCleanInput = z.infer; + +export const CompositeCleanResponseSchema = z.object({ + structure: z.array(z.string()), + data: z.array(z.array(z.record(z.string(), z.unknown()))), +}); +export type CompositeCleanResponse = z.infer< + typeof CompositeCleanResponseSchema +>; + +export const GeolocateInputSchema = z.object({ + lat: z.number(), + lon: z.number(), + radius_meters: z.number().optional(), + count: z.number().optional(), +}); +export type GeolocateInput = z.infer; + +export const IpLocateInputSchema = z.object({ + ip: z.string().optional(), +}); +export type IpLocateInput = z.infer; + +export const IpLocateResponseSchema = z.object({ + location: z.record(z.string(), z.unknown()).nullable(), +}); +export type IpLocateResponse = z.infer; + +export const EmptyInputSchema = z.object({}); +export type EmptyInput = z.infer; + +export const BalanceResponseSchema = z.object({ + balance: z.number(), +}); +export type BalanceResponse = z.infer; + +export const StatisticsResponseSchema = z.record(z.string(), z.unknown()); +export type StatisticsResponse = z.infer; + +export const VersionsResponseSchema = z.record(z.string(), z.unknown()); +export type VersionsResponse = z.infer; + +// Endpoint maps +export type DadataruEndpointInputs = { + // Clean + cleanAddress: CleanInput; + cleanBirthdate: CleanInput; + cleanRecord: CompositeCleanInput; + cleanEmail: CleanInput; + cleanName: CleanInput; + cleanPassport: CleanInput; + cleanPhone: CleanInput; + cleanVehicle: CleanInput; + // Find + findAddress: QueryInput; + findFiasById: QueryInput; + findBank: QueryInput; + findPartyBy: QueryInput; + findCarBrand: QueryInput; + findCompanyByEmail: QueryInput; + findParty: QueryInput; + findCountry: QueryInput; + findCourtById: QueryInput; + findCurrency: QueryInput; + findDelivery: QueryInput; + findFmsUnit: QueryInput; + findFnsUnit: QueryInput; + findFtsUnit: QueryInput; + findPartyKz: QueryInput; + findMktu: QueryInput; + findMedicalPositionById: QueryInput; + findOkpd2ById: QueryInput; + findOkpdtrPosition: QueryInput; + findOkpdtrProfession: QueryInput; + findOkved2: QueryInput; + findPostalOffice: QueryInput; + findPostalUnitById: QueryInput; + findOktmoById: QueryInput; + // Geolocate + geolocateAddress: GeolocateInput; + geolocatePostalUnit: GeolocateInput; + // Profile + getProfileBalance: EmptyInput; + getProfileStatistics: EmptyInput; + getReferenceVersions: EmptyInput; + // IP Locate + ipLocateAddress: IpLocateInput; + // Suggest + suggestAddress: QueryInput; + suggestBank: QueryInput; + suggestPartyBy: QueryInput; + suggestCarBrand: QueryInput; + suggestCountry: QueryInput; + suggestCourt: QueryInput; + suggestCurrency: QueryInput; + suggestEmail: QueryInput; + suggestFias: QueryInput; + suggestFmsUnit: QueryInput; + suggestFnsUnit: QueryInput; + suggestFtsUnit: QueryInput; + suggestPartyKz: QueryInput; + suggestMktu: QueryInput; + suggestMedicalPosition: QueryInput; + suggestMetro: QueryInput; + suggestName: QueryInput; + suggestOkpd2: QueryInput; + suggestOkpdtrPosition: QueryInput; + suggestOkpdtrProfession: QueryInput; + suggestOktmo: QueryInput; + suggestOkved2: QueryInput; + suggestParty: QueryInput; + suggestPostalOffice: QueryInput; + suggestPostalUnit: QueryInput; +}; + +export type DadataruEndpointOutputs = { + // Clean + cleanAddress: CleanResponse; + cleanBirthdate: CleanResponse; + cleanRecord: CompositeCleanResponse; + cleanEmail: CleanResponse; + cleanName: CleanResponse; + cleanPassport: CleanResponse; + cleanPhone: CleanResponse; + cleanVehicle: CleanResponse; + // Find + findAddress: SuggestResponse; + findFiasById: SuggestResponse; + findBank: SuggestResponse; + findPartyBy: SuggestResponse; + findCarBrand: SuggestResponse; + findCompanyByEmail: SuggestResponse; + findParty: SuggestResponse; + findCountry: SuggestResponse; + findCourtById: SuggestResponse; + findCurrency: SuggestResponse; + findDelivery: SuggestResponse; + findFmsUnit: SuggestResponse; + findFnsUnit: SuggestResponse; + findFtsUnit: SuggestResponse; + findPartyKz: SuggestResponse; + findMktu: SuggestResponse; + findMedicalPositionById: SuggestResponse; + findOkpd2ById: SuggestResponse; + findOkpdtrPosition: SuggestResponse; + findOkpdtrProfession: SuggestResponse; + findOkved2: SuggestResponse; + findPostalOffice: SuggestResponse; + findPostalUnitById: SuggestResponse; + findOktmoById: SuggestResponse; + // Geolocate + geolocateAddress: SuggestResponse; + geolocatePostalUnit: SuggestResponse; + // Profile + getProfileBalance: BalanceResponse; + getProfileStatistics: StatisticsResponse; + getReferenceVersions: VersionsResponse; + // IP Locate + ipLocateAddress: IpLocateResponse; + // Suggest + suggestAddress: SuggestResponse; + suggestBank: SuggestResponse; + suggestPartyBy: SuggestResponse; + suggestCarBrand: SuggestResponse; + suggestCountry: SuggestResponse; + suggestCourt: SuggestResponse; + suggestCurrency: SuggestResponse; + suggestEmail: SuggestResponse; + suggestFias: SuggestResponse; + suggestFmsUnit: SuggestResponse; + suggestFnsUnit: SuggestResponse; + suggestFtsUnit: SuggestResponse; + suggestPartyKz: SuggestResponse; + suggestMktu: SuggestResponse; + suggestMedicalPosition: SuggestResponse; + suggestMetro: SuggestResponse; + suggestName: SuggestResponse; + suggestOkpd2: SuggestResponse; + suggestOkpdtrPosition: SuggestResponse; + suggestOkpdtrProfession: SuggestResponse; + suggestOktmo: SuggestResponse; + suggestOkved2: SuggestResponse; + suggestParty: SuggestResponse; + suggestPostalOffice: SuggestResponse; + suggestPostalUnit: SuggestResponse; +}; + +export const DadataruEndpointInputSchemas = { + // Clean + cleanAddress: CleanInputSchema, + cleanBirthdate: CleanInputSchema, + cleanRecord: CompositeCleanInputSchema, + cleanEmail: CleanInputSchema, + cleanName: CleanInputSchema, + cleanPassport: CleanInputSchema, + cleanPhone: CleanInputSchema, + cleanVehicle: CleanInputSchema, + // Find + findAddress: QueryInputSchema, + findFiasById: QueryInputSchema, + findBank: QueryInputSchema, + findPartyBy: QueryInputSchema, + findCarBrand: QueryInputSchema, + findCompanyByEmail: QueryInputSchema, + findParty: QueryInputSchema, + findCountry: QueryInputSchema, + findCourtById: QueryInputSchema, + findCurrency: QueryInputSchema, + findDelivery: QueryInputSchema, + findFmsUnit: QueryInputSchema, + findFnsUnit: QueryInputSchema, + findFtsUnit: QueryInputSchema, + findPartyKz: QueryInputSchema, + findMktu: QueryInputSchema, + findMedicalPositionById: QueryInputSchema, + findOkpd2ById: QueryInputSchema, + findOkpdtrPosition: QueryInputSchema, + findOkpdtrProfession: QueryInputSchema, + findOkved2: QueryInputSchema, + findPostalOffice: QueryInputSchema, + findPostalUnitById: QueryInputSchema, + findOktmoById: QueryInputSchema, + // Geolocate + geolocateAddress: GeolocateInputSchema, + geolocatePostalUnit: GeolocateInputSchema, + // Profile + getProfileBalance: EmptyInputSchema, + getProfileStatistics: EmptyInputSchema, + getReferenceVersions: EmptyInputSchema, + // IP Locate + ipLocateAddress: IpLocateInputSchema, + // Suggest + suggestAddress: QueryInputSchema, + suggestBank: QueryInputSchema, + suggestPartyBy: QueryInputSchema, + suggestCarBrand: QueryInputSchema, + suggestCountry: QueryInputSchema, + suggestCourt: QueryInputSchema, + suggestCurrency: QueryInputSchema, + suggestEmail: QueryInputSchema, + suggestFias: QueryInputSchema, + suggestFmsUnit: QueryInputSchema, + suggestFnsUnit: QueryInputSchema, + suggestFtsUnit: QueryInputSchema, + suggestPartyKz: QueryInputSchema, + suggestMktu: QueryInputSchema, + suggestMedicalPosition: QueryInputSchema, + suggestMetro: QueryInputSchema, + suggestName: QueryInputSchema, + suggestOkpd2: QueryInputSchema, + suggestOkpdtrPosition: QueryInputSchema, + suggestOkpdtrProfession: QueryInputSchema, + suggestOktmo: QueryInputSchema, + suggestOkved2: QueryInputSchema, + suggestParty: QueryInputSchema, + suggestPostalOffice: QueryInputSchema, + suggestPostalUnit: QueryInputSchema, +} as const; + +export const DadataruEndpointOutputSchemas = { + // Clean + cleanAddress: CleanResponseSchema, + cleanBirthdate: CleanResponseSchema, + cleanRecord: CompositeCleanResponseSchema, + cleanEmail: CleanResponseSchema, + cleanName: CleanResponseSchema, + cleanPassport: CleanResponseSchema, + cleanPhone: CleanResponseSchema, + cleanVehicle: CleanResponseSchema, + // Find + findAddress: SuggestResponseSchema, + findFiasById: SuggestResponseSchema, + findBank: SuggestResponseSchema, + findPartyBy: SuggestResponseSchema, + findCarBrand: SuggestResponseSchema, + findCompanyByEmail: SuggestResponseSchema, + findParty: SuggestResponseSchema, + findCountry: SuggestResponseSchema, + findCourtById: SuggestResponseSchema, + findCurrency: SuggestResponseSchema, + findDelivery: SuggestResponseSchema, + findFmsUnit: SuggestResponseSchema, + findFnsUnit: SuggestResponseSchema, + findFtsUnit: SuggestResponseSchema, + findPartyKz: SuggestResponseSchema, + findMktu: SuggestResponseSchema, + findMedicalPositionById: SuggestResponseSchema, + findOkpd2ById: SuggestResponseSchema, + findOkpdtrPosition: SuggestResponseSchema, + findOkpdtrProfession: SuggestResponseSchema, + findOkved2: SuggestResponseSchema, + findPostalOffice: SuggestResponseSchema, + findPostalUnitById: SuggestResponseSchema, + findOktmoById: SuggestResponseSchema, + // Geolocate + geolocateAddress: SuggestResponseSchema, + geolocatePostalUnit: SuggestResponseSchema, + // Profile + getProfileBalance: BalanceResponseSchema, + getProfileStatistics: StatisticsResponseSchema, + getReferenceVersions: VersionsResponseSchema, + // IP Locate + ipLocateAddress: IpLocateResponseSchema, + // Suggest + suggestAddress: SuggestResponseSchema, + suggestBank: SuggestResponseSchema, + suggestPartyBy: SuggestResponseSchema, + suggestCarBrand: SuggestResponseSchema, + suggestCountry: SuggestResponseSchema, + suggestCourt: SuggestResponseSchema, + suggestCurrency: SuggestResponseSchema, + suggestEmail: SuggestResponseSchema, + suggestFias: SuggestResponseSchema, + suggestFmsUnit: SuggestResponseSchema, + suggestFnsUnit: SuggestResponseSchema, + suggestFtsUnit: SuggestResponseSchema, + suggestPartyKz: SuggestResponseSchema, + suggestMktu: SuggestResponseSchema, + suggestMedicalPosition: SuggestResponseSchema, + suggestMetro: SuggestResponseSchema, + suggestName: SuggestResponseSchema, + suggestOkpd2: SuggestResponseSchema, + suggestOkpdtrPosition: SuggestResponseSchema, + suggestOkpdtrProfession: SuggestResponseSchema, + suggestOktmo: SuggestResponseSchema, + suggestOkved2: SuggestResponseSchema, + suggestParty: SuggestResponseSchema, + suggestPostalOffice: SuggestResponseSchema, + suggestPostalUnit: SuggestResponseSchema, +} as const; diff --git a/packages/dadataru/error-handlers.ts b/packages/dadataru/error-handlers.ts new file mode 100644 index 000000000..5a4f4c19f --- /dev/null +++ b/packages/dadataru/error-handlers.ts @@ -0,0 +1,31 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; + +export const errorHandlers = { + RATE_LIMIT_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 429) return true; + const msg = error.message.toLowerCase(); + return msg.includes('rate_limited') || msg.includes('429'); + }, + handler: async (error: Error) => { + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; + }, + }, + AUTH_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 401) return true; + const msg = error.message.toLowerCase(); + return msg.includes('unauthorized') || msg.includes('invalid_auth'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/dadataru/index.ts b/packages/dadataru/index.ts new file mode 100644 index 000000000..20efb7276 --- /dev/null +++ b/packages/dadataru/index.ts @@ -0,0 +1,861 @@ +import type { + AuthTypes, + BindEndpoints, + BindWebhooks, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + CorsairWebhook, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, + RequiredPluginWebhookSchemas, +} from 'corsair/core'; +import { + Clean, + Find, + Geolocate, + IpLocate, + Profile, + Suggest, +} from './endpoints'; +import type { + DadataruEndpointInputs, + DadataruEndpointOutputs, +} from './endpoints/types'; +import { + DadataruEndpointInputSchemas, + DadataruEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { DadataruSchema } from './schema'; +import { ExampleWebhooks } from './webhooks'; +import { resolveDadataruOAuthWebhookTenantLink } from './webhooks/oauth-tenant-link'; +import { matchDadataruTenantWebhook } from './webhooks/tenant-matcher'; +import type { DadataruWebhookOutputs, ExampleEvent } from './webhooks/types'; +import { ExampleEventSchema } from './webhooks/types'; + +export type DadataruPluginOptions = { + authType?: PickAuth<'api_key' | 'oauth_2'>; + key?: string; + secret?: string; + webhookSecret?: string; + hooks?: InternalDadataruPlugin['hooks']; + webhookHooks?: InternalDadataruPlugin['webhookHooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type DadataruContext = CorsairPluginContext< + typeof DadataruSchema, + DadataruPluginOptions +>; + +export type DadataruKeyBuilderContext = + KeyBuilderContext; + +export type DadataruBoundEndpoints = BindEndpoints< + typeof dadataruEndpointsNested +>; + +type DadataruEndpoint = + CorsairEndpoint< + DadataruContext, + DadataruEndpointInputs[K], + DadataruEndpointOutputs[K] + >; + +export type DadataruEndpoints = { + // Clean + cleanAddress: DadataruEndpoint<'cleanAddress'>; + cleanBirthdate: DadataruEndpoint<'cleanBirthdate'>; + cleanRecord: DadataruEndpoint<'cleanRecord'>; + cleanEmail: DadataruEndpoint<'cleanEmail'>; + cleanName: DadataruEndpoint<'cleanName'>; + cleanPassport: DadataruEndpoint<'cleanPassport'>; + cleanPhone: DadataruEndpoint<'cleanPhone'>; + cleanVehicle: DadataruEndpoint<'cleanVehicle'>; + // Find + findAddress: DadataruEndpoint<'findAddress'>; + findFiasById: DadataruEndpoint<'findFiasById'>; + findBank: DadataruEndpoint<'findBank'>; + findPartyBy: DadataruEndpoint<'findPartyBy'>; + findCarBrand: DadataruEndpoint<'findCarBrand'>; + findCompanyByEmail: DadataruEndpoint<'findCompanyByEmail'>; + findParty: DadataruEndpoint<'findParty'>; + findCountry: DadataruEndpoint<'findCountry'>; + findCourtById: DadataruEndpoint<'findCourtById'>; + findCurrency: DadataruEndpoint<'findCurrency'>; + findDelivery: DadataruEndpoint<'findDelivery'>; + findFmsUnit: DadataruEndpoint<'findFmsUnit'>; + findFnsUnit: DadataruEndpoint<'findFnsUnit'>; + findFtsUnit: DadataruEndpoint<'findFtsUnit'>; + findPartyKz: DadataruEndpoint<'findPartyKz'>; + findMktu: DadataruEndpoint<'findMktu'>; + findMedicalPositionById: DadataruEndpoint<'findMedicalPositionById'>; + findOkpd2ById: DadataruEndpoint<'findOkpd2ById'>; + findOkpdtrPosition: DadataruEndpoint<'findOkpdtrPosition'>; + findOkpdtrProfession: DadataruEndpoint<'findOkpdtrProfession'>; + findOkved2: DadataruEndpoint<'findOkved2'>; + findPostalOffice: DadataruEndpoint<'findPostalOffice'>; + findPostalUnitById: DadataruEndpoint<'findPostalUnitById'>; + findOktmoById: DadataruEndpoint<'findOktmoById'>; + // Geolocate + geolocateAddress: DadataruEndpoint<'geolocateAddress'>; + geolocatePostalUnit: DadataruEndpoint<'geolocatePostalUnit'>; + // Profile + getProfileBalance: DadataruEndpoint<'getProfileBalance'>; + getProfileStatistics: DadataruEndpoint<'getProfileStatistics'>; + getReferenceVersions: DadataruEndpoint<'getReferenceVersions'>; + // IP Locate + ipLocateAddress: DadataruEndpoint<'ipLocateAddress'>; + // Suggest + suggestAddress: DadataruEndpoint<'suggestAddress'>; + suggestBank: DadataruEndpoint<'suggestBank'>; + suggestPartyBy: DadataruEndpoint<'suggestPartyBy'>; + suggestCarBrand: DadataruEndpoint<'suggestCarBrand'>; + suggestCountry: DadataruEndpoint<'suggestCountry'>; + suggestCourt: DadataruEndpoint<'suggestCourt'>; + suggestCurrency: DadataruEndpoint<'suggestCurrency'>; + suggestEmail: DadataruEndpoint<'suggestEmail'>; + suggestFias: DadataruEndpoint<'suggestFias'>; + suggestFmsUnit: DadataruEndpoint<'suggestFmsUnit'>; + suggestFnsUnit: DadataruEndpoint<'suggestFnsUnit'>; + suggestFtsUnit: DadataruEndpoint<'suggestFtsUnit'>; + suggestPartyKz: DadataruEndpoint<'suggestPartyKz'>; + suggestMktu: DadataruEndpoint<'suggestMktu'>; + suggestMedicalPosition: DadataruEndpoint<'suggestMedicalPosition'>; + suggestMetro: DadataruEndpoint<'suggestMetro'>; + suggestName: DadataruEndpoint<'suggestName'>; + suggestOkpd2: DadataruEndpoint<'suggestOkpd2'>; + suggestOkpdtrPosition: DadataruEndpoint<'suggestOkpdtrPosition'>; + suggestOkpdtrProfession: DadataruEndpoint<'suggestOkpdtrProfession'>; + suggestOktmo: DadataruEndpoint<'suggestOktmo'>; + suggestOkved2: DadataruEndpoint<'suggestOkved2'>; + suggestParty: DadataruEndpoint<'suggestParty'>; + suggestPostalOffice: DadataruEndpoint<'suggestPostalOffice'>; + suggestPostalUnit: DadataruEndpoint<'suggestPostalUnit'>; +}; + +type DadataruWebhook< + K extends keyof DadataruWebhookOutputs, + TEvent, +> = CorsairWebhook; + +export type DadataruWebhooks = { + example: DadataruWebhook<'example', ExampleEvent>; +}; + +export type DadataruBoundWebhooks = BindWebhooks; + +const dadataruEndpointsNested = { + clean: { + address: Clean.address, + birthdate: Clean.birthdate, + record: Clean.cleanRecord, + email: Clean.email, + name: Clean.name, + passport: Clean.passport, + phone: Clean.phone, + vehicle: Clean.vehicle, + }, + find: { + address: Find.address, + fiasById: Find.fiasById, + bank: Find.bank, + partyBy: Find.partyBy, + carBrand: Find.carBrand, + companyByEmail: Find.companyByEmail, + party: Find.party, + country: Find.country, + courtById: Find.courtById, + currency: Find.currency, + delivery: Find.delivery, + fmsUnit: Find.fmsUnit, + fnsUnit: Find.fnsUnit, + ftsUnit: Find.ftsUnit, + partyKz: Find.partyKz, + mktu: Find.mktu, + medicalPositionById: Find.medicalPositionById, + okpd2ById: Find.okpd2ById, + okpdtrPosition: Find.okpdtrPosition, + okpdtrProfession: Find.okpdtrProfession, + okved2: Find.okved2, + postalOffice: Find.postalOffice, + postalUnitById: Find.postalUnitById, + oktmoById: Find.oktmoById, + }, + geolocate: { + address: Geolocate.address, + postalUnit: Geolocate.postalUnit, + }, + profile: { + balance: Profile.balance, + statistics: Profile.statistics, + versions: Profile.versions, + }, + ipLocate: { + address: IpLocate.address, + }, + suggest: { + address: Suggest.address, + bank: Suggest.bank, + partyBy: Suggest.partyBy, + carBrand: Suggest.carBrand, + country: Suggest.country, + court: Suggest.court, + currency: Suggest.currency, + email: Suggest.email, + fias: Suggest.fias, + fmsUnit: Suggest.fmsUnit, + fnsUnit: Suggest.fnsUnit, + ftsUnit: Suggest.ftsUnit, + partyKz: Suggest.partyKz, + mktu: Suggest.mktu, + medicalPosition: Suggest.medicalPosition, + metro: Suggest.metro, + name: Suggest.name, + okpd2: Suggest.okpd2, + okpdtrPosition: Suggest.okpdtrPosition, + okpdtrProfession: Suggest.okpdtrProfession, + oktmo: Suggest.oktmo, + okved2: Suggest.okved2, + party: Suggest.party, + postalOffice: Suggest.postalOffice, + postalUnit: Suggest.postalUnit, + }, +} as const; + +const dadataruWebhooksNested = { + example: { + example: ExampleWebhooks.example, + }, +} as const; + +export const dadataruEndpointSchemas = { + // Clean + 'clean.address': { + input: DadataruEndpointInputSchemas.cleanAddress, + output: DadataruEndpointOutputSchemas.cleanAddress, + }, + 'clean.birthdate': { + input: DadataruEndpointInputSchemas.cleanBirthdate, + output: DadataruEndpointOutputSchemas.cleanBirthdate, + }, + 'clean.record': { + input: DadataruEndpointInputSchemas.cleanRecord, + output: DadataruEndpointOutputSchemas.cleanRecord, + }, + 'clean.email': { + input: DadataruEndpointInputSchemas.cleanEmail, + output: DadataruEndpointOutputSchemas.cleanEmail, + }, + 'clean.name': { + input: DadataruEndpointInputSchemas.cleanName, + output: DadataruEndpointOutputSchemas.cleanName, + }, + 'clean.passport': { + input: DadataruEndpointInputSchemas.cleanPassport, + output: DadataruEndpointOutputSchemas.cleanPassport, + }, + 'clean.phone': { + input: DadataruEndpointInputSchemas.cleanPhone, + output: DadataruEndpointOutputSchemas.cleanPhone, + }, + 'clean.vehicle': { + input: DadataruEndpointInputSchemas.cleanVehicle, + output: DadataruEndpointOutputSchemas.cleanVehicle, + }, + // Find + 'find.address': { + input: DadataruEndpointInputSchemas.findAddress, + output: DadataruEndpointOutputSchemas.findAddress, + }, + 'find.fiasById': { + input: DadataruEndpointInputSchemas.findFiasById, + output: DadataruEndpointOutputSchemas.findFiasById, + }, + 'find.bank': { + input: DadataruEndpointInputSchemas.findBank, + output: DadataruEndpointOutputSchemas.findBank, + }, + 'find.partyBy': { + input: DadataruEndpointInputSchemas.findPartyBy, + output: DadataruEndpointOutputSchemas.findPartyBy, + }, + 'find.carBrand': { + input: DadataruEndpointInputSchemas.findCarBrand, + output: DadataruEndpointOutputSchemas.findCarBrand, + }, + 'find.companyByEmail': { + input: DadataruEndpointInputSchemas.findCompanyByEmail, + output: DadataruEndpointOutputSchemas.findCompanyByEmail, + }, + 'find.party': { + input: DadataruEndpointInputSchemas.findParty, + output: DadataruEndpointOutputSchemas.findParty, + }, + 'find.country': { + input: DadataruEndpointInputSchemas.findCountry, + output: DadataruEndpointOutputSchemas.findCountry, + }, + 'find.courtById': { + input: DadataruEndpointInputSchemas.findCourtById, + output: DadataruEndpointOutputSchemas.findCourtById, + }, + 'find.currency': { + input: DadataruEndpointInputSchemas.findCurrency, + output: DadataruEndpointOutputSchemas.findCurrency, + }, + 'find.delivery': { + input: DadataruEndpointInputSchemas.findDelivery, + output: DadataruEndpointOutputSchemas.findDelivery, + }, + 'find.fmsUnit': { + input: DadataruEndpointInputSchemas.findFmsUnit, + output: DadataruEndpointOutputSchemas.findFmsUnit, + }, + 'find.fnsUnit': { + input: DadataruEndpointInputSchemas.findFnsUnit, + output: DadataruEndpointOutputSchemas.findFnsUnit, + }, + 'find.ftsUnit': { + input: DadataruEndpointInputSchemas.findFtsUnit, + output: DadataruEndpointOutputSchemas.findFtsUnit, + }, + 'find.partyKz': { + input: DadataruEndpointInputSchemas.findPartyKz, + output: DadataruEndpointOutputSchemas.findPartyKz, + }, + 'find.mktu': { + input: DadataruEndpointInputSchemas.findMktu, + output: DadataruEndpointOutputSchemas.findMktu, + }, + 'find.medicalPositionById': { + input: DadataruEndpointInputSchemas.findMedicalPositionById, + output: DadataruEndpointOutputSchemas.findMedicalPositionById, + }, + 'find.okpd2ById': { + input: DadataruEndpointInputSchemas.findOkpd2ById, + output: DadataruEndpointOutputSchemas.findOkpd2ById, + }, + 'find.okpdtrPosition': { + input: DadataruEndpointInputSchemas.findOkpdtrPosition, + output: DadataruEndpointOutputSchemas.findOkpdtrPosition, + }, + 'find.okpdtrProfession': { + input: DadataruEndpointInputSchemas.findOkpdtrProfession, + output: DadataruEndpointOutputSchemas.findOkpdtrProfession, + }, + 'find.okved2': { + input: DadataruEndpointInputSchemas.findOkved2, + output: DadataruEndpointOutputSchemas.findOkved2, + }, + 'find.postalOffice': { + input: DadataruEndpointInputSchemas.findPostalOffice, + output: DadataruEndpointOutputSchemas.findPostalOffice, + }, + 'find.postalUnitById': { + input: DadataruEndpointInputSchemas.findPostalUnitById, + output: DadataruEndpointOutputSchemas.findPostalUnitById, + }, + 'find.oktmoById': { + input: DadataruEndpointInputSchemas.findOktmoById, + output: DadataruEndpointOutputSchemas.findOktmoById, + }, + // Geolocate + 'geolocate.address': { + input: DadataruEndpointInputSchemas.geolocateAddress, + output: DadataruEndpointOutputSchemas.geolocateAddress, + }, + 'geolocate.postalUnit': { + input: DadataruEndpointInputSchemas.geolocatePostalUnit, + output: DadataruEndpointOutputSchemas.geolocatePostalUnit, + }, + // Profile + 'profile.balance': { + input: DadataruEndpointInputSchemas.getProfileBalance, + output: DadataruEndpointOutputSchemas.getProfileBalance, + }, + 'profile.statistics': { + input: DadataruEndpointInputSchemas.getProfileStatistics, + output: DadataruEndpointOutputSchemas.getProfileStatistics, + }, + 'profile.versions': { + input: DadataruEndpointInputSchemas.getReferenceVersions, + output: DadataruEndpointOutputSchemas.getReferenceVersions, + }, + // IP Locate + 'ipLocate.address': { + input: DadataruEndpointInputSchemas.ipLocateAddress, + output: DadataruEndpointOutputSchemas.ipLocateAddress, + }, + // Suggest + 'suggest.address': { + input: DadataruEndpointInputSchemas.suggestAddress, + output: DadataruEndpointOutputSchemas.suggestAddress, + }, + 'suggest.bank': { + input: DadataruEndpointInputSchemas.suggestBank, + output: DadataruEndpointOutputSchemas.suggestBank, + }, + 'suggest.partyBy': { + input: DadataruEndpointInputSchemas.suggestPartyBy, + output: DadataruEndpointOutputSchemas.suggestPartyBy, + }, + 'suggest.carBrand': { + input: DadataruEndpointInputSchemas.suggestCarBrand, + output: DadataruEndpointOutputSchemas.suggestCarBrand, + }, + 'suggest.country': { + input: DadataruEndpointInputSchemas.suggestCountry, + output: DadataruEndpointOutputSchemas.suggestCountry, + }, + 'suggest.court': { + input: DadataruEndpointInputSchemas.suggestCourt, + output: DadataruEndpointOutputSchemas.suggestCourt, + }, + 'suggest.currency': { + input: DadataruEndpointInputSchemas.suggestCurrency, + output: DadataruEndpointOutputSchemas.suggestCurrency, + }, + 'suggest.email': { + input: DadataruEndpointInputSchemas.suggestEmail, + output: DadataruEndpointOutputSchemas.suggestEmail, + }, + 'suggest.fias': { + input: DadataruEndpointInputSchemas.suggestFias, + output: DadataruEndpointOutputSchemas.suggestFias, + }, + 'suggest.fmsUnit': { + input: DadataruEndpointInputSchemas.suggestFmsUnit, + output: DadataruEndpointOutputSchemas.suggestFmsUnit, + }, + 'suggest.fnsUnit': { + input: DadataruEndpointInputSchemas.suggestFnsUnit, + output: DadataruEndpointOutputSchemas.suggestFnsUnit, + }, + 'suggest.ftsUnit': { + input: DadataruEndpointInputSchemas.suggestFtsUnit, + output: DadataruEndpointOutputSchemas.suggestFtsUnit, + }, + 'suggest.partyKz': { + input: DadataruEndpointInputSchemas.suggestPartyKz, + output: DadataruEndpointOutputSchemas.suggestPartyKz, + }, + 'suggest.mktu': { + input: DadataruEndpointInputSchemas.suggestMktu, + output: DadataruEndpointOutputSchemas.suggestMktu, + }, + 'suggest.medicalPosition': { + input: DadataruEndpointInputSchemas.suggestMedicalPosition, + output: DadataruEndpointOutputSchemas.suggestMedicalPosition, + }, + 'suggest.metro': { + input: DadataruEndpointInputSchemas.suggestMetro, + output: DadataruEndpointOutputSchemas.suggestMetro, + }, + 'suggest.name': { + input: DadataruEndpointInputSchemas.suggestName, + output: DadataruEndpointOutputSchemas.suggestName, + }, + 'suggest.okpd2': { + input: DadataruEndpointInputSchemas.suggestOkpd2, + output: DadataruEndpointOutputSchemas.suggestOkpd2, + }, + 'suggest.okpdtrPosition': { + input: DadataruEndpointInputSchemas.suggestOkpdtrPosition, + output: DadataruEndpointOutputSchemas.suggestOkpdtrPosition, + }, + 'suggest.okpdtrProfession': { + input: DadataruEndpointInputSchemas.suggestOkpdtrProfession, + output: DadataruEndpointOutputSchemas.suggestOkpdtrProfession, + }, + 'suggest.oktmo': { + input: DadataruEndpointInputSchemas.suggestOktmo, + output: DadataruEndpointOutputSchemas.suggestOktmo, + }, + 'suggest.okved2': { + input: DadataruEndpointInputSchemas.suggestOkved2, + output: DadataruEndpointOutputSchemas.suggestOkved2, + }, + 'suggest.party': { + input: DadataruEndpointInputSchemas.suggestParty, + output: DadataruEndpointOutputSchemas.suggestParty, + }, + 'suggest.postalOffice': { + input: DadataruEndpointInputSchemas.suggestPostalOffice, + output: DadataruEndpointOutputSchemas.suggestPostalOffice, + }, + 'suggest.postalUnit': { + input: DadataruEndpointInputSchemas.suggestPostalUnit, + output: DadataruEndpointOutputSchemas.suggestPostalUnit, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof dadataruEndpointsNested +>; + +const dadataruWebhookSchemas = { + 'example.example': { + description: 'An example webhook event', + payload: ExampleEventSchema, + response: ExampleEventSchema, + }, +} as const satisfies RequiredPluginWebhookSchemas< + typeof dadataruWebhooksNested +>; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +const dadataruEndpointMeta = { + // Clean + 'clean.address': { + riskLevel: 'read', + description: 'Clean and standardize Russian addresses', + }, + 'clean.birthdate': { + riskLevel: 'read', + description: 'Clean and standardize birthdates', + }, + 'clean.record': { + riskLevel: 'read', + description: 'Clean composite multi-field records', + }, + 'clean.email': { + riskLevel: 'read', + description: 'Clean and standardize email addresses', + }, + 'clean.name': { + riskLevel: 'read', + description: 'Clean and parse full names', + }, + 'clean.passport': { + riskLevel: 'read', + description: 'Clean and validate Russian passport numbers', + }, + 'clean.phone': { + riskLevel: 'read', + description: 'Clean and standardize phone numbers', + }, + 'clean.vehicle': { + riskLevel: 'read', + description: 'Clean and recognize vehicle models', + }, + // Find + 'find.address': { + riskLevel: 'read', + description: 'Find address details by ID', + }, + 'find.fiasById': { + riskLevel: 'read', + description: 'Find address by FIAS ID', + }, + 'find.bank': { riskLevel: 'read', description: 'Find bank details' }, + 'find.partyBy': { + riskLevel: 'read', + description: 'Find Belarus party by UNP', + }, + 'find.carBrand': { riskLevel: 'read', description: 'Find car brand details' }, + 'find.companyByEmail': { + riskLevel: 'read', + description: 'Find companies by email domain', + }, + 'find.party': { + riskLevel: 'read', + description: 'Find Russian party/company details', + }, + 'find.country': { riskLevel: 'read', description: 'Find country metadata' }, + 'find.courtById': { + riskLevel: 'read', + description: 'Find court details by ID', + }, + 'find.currency': { riskLevel: 'read', description: 'Find currency details' }, + 'find.delivery': { + riskLevel: 'read', + description: 'Find delivery city mapping IDs', + }, + 'find.fmsUnit': { + riskLevel: 'read', + description: 'Find passport issuing authority (FMS)', + }, + 'find.fnsUnit': { + riskLevel: 'read', + description: 'Find tax office (FNS) details', + }, + 'find.ftsUnit': { + riskLevel: 'read', + description: 'Find customs office (FTS) details', + }, + 'find.partyKz': { + riskLevel: 'read', + description: 'Find Kazakhstan company details', + }, + 'find.mktu': { + riskLevel: 'read', + description: 'Find MKTU trademark classification items', + }, + 'find.medicalPositionById': { + riskLevel: 'read', + description: 'Find medical positions by ID', + }, + 'find.okpd2ById': { + riskLevel: 'read', + description: 'Find OKPD2 classifications by ID', + }, + 'find.okpdtrPosition': { + riskLevel: 'read', + description: 'Find OKPDTR job positions by code', + }, + 'find.okpdtrProfession': { + riskLevel: 'read', + description: 'Find OKPDTR worker professions', + }, + 'find.okved2': { + riskLevel: 'read', + description: 'Find OKVED2 economic activities', + }, + 'find.postalOffice': { + riskLevel: 'read', + description: 'Find postal office details', + }, + 'find.postalUnitById': { + riskLevel: 'read', + description: 'Find postal unit details', + }, + 'find.oktmoById': { + riskLevel: 'read', + description: 'Find territory details by OKTMO code', + }, + // Geolocate + 'geolocate.address': { + riskLevel: 'read', + description: 'Find addresses near coordinates', + }, + 'geolocate.postalUnit': { + riskLevel: 'read', + description: 'Find postal units near coordinates', + }, + // Profile + 'profile.balance': { + riskLevel: 'read', + description: 'Get current account balance', + }, + 'profile.statistics': { + riskLevel: 'read', + description: 'Get usage statistics', + }, + 'profile.versions': { + riskLevel: 'read', + description: 'Get reference database update versions', + }, + // IP Locate + 'ipLocate.address': { + riskLevel: 'read', + description: 'Geolocate address by IP', + }, + // Suggest + 'suggest.address': { + riskLevel: 'read', + description: 'Autocomplete and suggest addresses', + }, + 'suggest.bank': { + riskLevel: 'read', + description: 'Autocomplete and suggest banks', + }, + 'suggest.partyBy': { + riskLevel: 'read', + description: 'Suggest Belarus parties/companies', + }, + 'suggest.carBrand': { + riskLevel: 'read', + description: 'Autocomplete and suggest car brands', + }, + 'suggest.country': { + riskLevel: 'read', + description: 'Autocomplete and suggest countries', + }, + 'suggest.court': { + riskLevel: 'read', + description: 'Autocomplete and suggest courts', + }, + 'suggest.currency': { + riskLevel: 'read', + description: 'Autocomplete and suggest currencies', + }, + 'suggest.email': { + riskLevel: 'read', + description: 'Autocomplete and suggest emails', + }, + 'suggest.fias': { + riskLevel: 'read', + description: 'Autocomplete and suggest FIAS addresses', + }, + 'suggest.fmsUnit': { + riskLevel: 'read', + description: 'Suggest passport issuing departments', + }, + 'suggest.fnsUnit': { + riskLevel: 'read', + description: 'Suggest tax inspection (FNS) offices', + }, + 'suggest.ftsUnit': { + riskLevel: 'read', + description: 'Suggest customs (FTS) offices', + }, + 'suggest.partyKz': { + riskLevel: 'read', + description: 'Suggest Kazakhstan parties/companies', + }, + 'suggest.mktu': { + riskLevel: 'read', + description: 'Suggest MKTU trademark classifications', + }, + 'suggest.medicalPosition': { + riskLevel: 'read', + description: 'Suggest medical positions/specialties', + }, + 'suggest.metro': { riskLevel: 'read', description: 'Suggest metro stations' }, + 'suggest.name': { + riskLevel: 'read', + description: 'Autocomplete and suggest names (FIO)', + }, + 'suggest.okpd2': { + riskLevel: 'read', + description: 'Suggest product classification (OKPD2) codes', + }, + 'suggest.okpdtrPosition': { + riskLevel: 'read', + description: 'Suggest job positions from OKPDTR classifier', + }, + 'suggest.okpdtrProfession': { + riskLevel: 'read', + description: 'Suggest worker professions from OKPDTR', + }, + 'suggest.oktmo': { + riskLevel: 'read', + description: 'Suggest municipal territories (OKTMO)', + }, + 'suggest.okved2': { + riskLevel: 'read', + description: 'Suggest economic activities (OKVED2)', + }, + 'suggest.party': { + riskLevel: 'read', + description: 'Suggest Russian parties/companies', + }, + 'suggest.postalOffice': { + riskLevel: 'read', + description: 'Suggest postal offices', + }, + 'suggest.postalUnit': { + riskLevel: 'read', + description: 'Suggest postal units', + }, +} as const satisfies RequiredPluginEndpointMeta; + +export const dadataruAuthConfig = { + api_key: { + account: ['tenant_external_id'] as const, + }, + oauth_2: { + account: ['tenant_external_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BaseDadataruPlugin = CorsairPlugin< + 'dadataru', + typeof DadataruSchema, + typeof dadataruEndpointsNested, + typeof dadataruWebhooksNested, + T, + typeof defaultAuthType +>; + +export type InternalDadataruPlugin = BaseDadataruPlugin; + +export type ExternalDadataruPlugin = + BaseDadataruPlugin; + +export function dadataru( + incomingOptions: DadataruPluginOptions & T = {} as DadataruPluginOptions & T, +): ExternalDadataruPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'dadataru', + authConfig: dadataruAuthConfig, + schema: DadataruSchema, + options: options, + hooks: options.hooks, + webhookHooks: options.webhookHooks, + endpoints: dadataruEndpointsNested, + webhooks: dadataruWebhooksNested, + endpointMeta: dadataruEndpointMeta, + endpointSchemas: dadataruEndpointSchemas, + webhookSchemas: dadataruWebhookSchemas, + pluginWebhookMatcher: (request) => { + const headers = request.headers; + // TODO: Update to match your webhook signature headers + return 'x-dadataru-signature' in headers; + }, + pluginTenantWebhookMatcher: matchDadataruTenantWebhook, + oauthWebhookTenantLinkResolver: resolveDadataruOAuthWebhookTenantLink, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: DadataruKeyBuilderContext, source) => { + if (source === 'webhook' && options.webhookSecret) { + return options.webhookSecret; + } + + if (source === 'webhook') { + const res = await ctx.keys.get_webhook_signature(); + return res ?? ''; + } + + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + return res ?? ''; + } + + if (source === 'endpoint' && ctx.authType === 'oauth_2') { + const res = await ctx.keys.get_access_token(); + return res ?? ''; + } + + return ''; + }, + } satisfies InternalDadataruPlugin; +} + +export type { + BalanceResponse, + CleanInput, + CleanResponse, + CompositeCleanInput, + CompositeCleanResponse, + DadataruEndpointInputs, + DadataruEndpointOutputs, + GeolocateInput, + IpLocateInput, + IpLocateResponse, + QueryInput, + StatisticsResponse, + SuggestResponse, + VersionsResponse, +} from './endpoints/types'; +export type { + DadataruWebhookOutputs, + ExampleEvent, +} from './webhooks/types'; diff --git a/packages/dadataru/jest.config.cjs b/packages/dadataru/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/dadataru/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/dadataru/package.json b/packages/dadataru/package.json new file mode 100644 index 000000000..5dc16b212 --- /dev/null +++ b/packages/dadataru/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/dadataru", + "version": "0.1.0", + "description": "Dadataru 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", + "dadataru", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/dadataru/schema.test.ts b/packages/dadataru/schema.test.ts new file mode 100644 index 000000000..f74278f87 --- /dev/null +++ b/packages/dadataru/schema.test.ts @@ -0,0 +1,20 @@ +import { DadataruSchema } from './schema'; + +describe('Dadataru schema', () => { + it('declares a semver version', () => { + expect(DadataruSchema.version).toBeDefined(); + expect(DadataruSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares an entities map', () => { + expect(typeof DadataruSchema.entities).toBe('object'); + expect(DadataruSchema.entities).not.toBeNull(); + expect(Array.isArray(Object.keys(DadataruSchema.entities))).toBe(true); + for (const entity of Object.values(DadataruSchema.entities)) { + expect(entity).toBeDefined(); + } + }); +}); + +// Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint +// needs a corresponding test. diff --git a/packages/dadataru/schema/database.ts b/packages/dadataru/schema/database.ts new file mode 100644 index 000000000..4b2d23812 --- /dev/null +++ b/packages/dadataru/schema/database.ts @@ -0,0 +1,7 @@ +// TODO: Define your database entities here +// export const DadataruExample = z.object({ +// id: z.string(), +// name: z.string(), +// created_at: z.coerce.date().nullable().optional(), +// }); +// export type DadataruExample = z.infer; diff --git a/packages/dadataru/schema/index.ts b/packages/dadataru/schema/index.ts new file mode 100644 index 000000000..12cd32f18 --- /dev/null +++ b/packages/dadataru/schema/index.ts @@ -0,0 +1,4 @@ +export const DadataruSchema = { + version: '1.0.0', + entities: {}, +} as const; diff --git a/packages/dadataru/tsconfig.json b/packages/dadataru/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/dadataru/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/dadataru/tsup.config.ts b/packages/dadataru/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/dadataru/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/dadataru/webhooks/example.ts b/packages/dadataru/webhooks/example.ts new file mode 100644 index 000000000..13f6ab995 --- /dev/null +++ b/packages/dadataru/webhooks/example.ts @@ -0,0 +1,32 @@ +import { logEventFromContext } from 'corsair/core'; +import type { DadataruWebhooks } from '..'; +import { createDadataruMatch, verifyDadataruWebhookSignature } from './types'; + +export const example: DadataruWebhooks['example'] = { + match: createDadataruMatch('example'), + + handler: async (ctx, request) => { + const verification = verifyDadataruWebhookSignature(request, ctx.key); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.type !== 'example') { + return { success: true, data: undefined }; + } + + await logEventFromContext( + ctx, + 'dadataru.webhook.example', + { ...event }, + 'completed', + ); + + return { success: true, data: event }; + }, +}; diff --git a/packages/dadataru/webhooks/index.ts b/packages/dadataru/webhooks/index.ts new file mode 100644 index 000000000..a12134e8a --- /dev/null +++ b/packages/dadataru/webhooks/index.ts @@ -0,0 +1,9 @@ +import { example } from './example'; + +export const ExampleWebhooks = { + example: example, +}; + +export * from './oauth-tenant-link'; +export * from './tenant-matcher'; +export * from './types'; diff --git a/packages/dadataru/webhooks/oauth-tenant-link.ts b/packages/dadataru/webhooks/oauth-tenant-link.ts new file mode 100644 index 000000000..403d3f29d --- /dev/null +++ b/packages/dadataru/webhooks/oauth-tenant-link.ts @@ -0,0 +1,31 @@ +import type { TokenResponse, WebhookTenantMatch } from 'corsair/core'; +import { toExternalId } from 'corsair/core'; + +// TODO: Rename linkType 'tenant_external_id' to match pluginTenantWebhookMatcher. +// Called after OAuth to store the routing id on corsair_accounts.config. +export async function resolveDadataruOAuthWebhookTenantLink( + tokens: TokenResponse, +): Promise { + // TODO: Read from token response when the provider includes a stable id. + // const externalId = toExternalId(asRecord(tokens.team)?.id); + const externalId = toExternalId(tokens.tenant_external_id); + if (externalId) { + return { linkType: 'tenant_external_id', externalId }; + } + + const accessToken = tokens.access_token; + if (!accessToken) return null; + + // TODO: Fetch from provider API when the token response omits the id. + // const response = await fetch('https://api.example.com/me', { + // headers: { Authorization: `Bearer ${accessToken}` }, + // }); + // if (!response.ok) return null; + // const payload = (await response.json()) as { id?: string }; + // const fetchedId = toExternalId(payload.id); + // return fetchedId + // ? { linkType: 'tenant_external_id', externalId: fetchedId } + // : null; + + return null; +} diff --git a/packages/dadataru/webhooks/tenant-matcher.ts b/packages/dadataru/webhooks/tenant-matcher.ts new file mode 100644 index 000000000..b94f9eb19 --- /dev/null +++ b/packages/dadataru/webhooks/tenant-matcher.ts @@ -0,0 +1,25 @@ +import type { RawWebhookRequest, WebhookTenantMatch } from 'corsair/core'; +import { asRecord, firstString, readBodyRecord } from 'corsair/core'; + +// TODO: Rename linkType 'tenant_external_id' to match the provider field +// (e.g. team_id, installation_id, organization_id). Must match authConfig.account +// and oauthWebhookTenantLinkResolver. +// Return null for URL verification / handshake payloads that have no tenant id. +export function matchDadataruTenantWebhook( + request: RawWebhookRequest, +): WebhookTenantMatch | null { + const body = readBodyRecord(request); + if (!body) return null; + + // TODO: Extract the stable external id from the webhook payload. + // Example: + // const externalId = firstString([body.tenant_external_id, asRecord(body.data)?.id]); + const externalId = firstString([ + body.tenant_external_id, + asRecord(body.data)?.tenant_external_id, + ]); + + if (!externalId) return null; + + return { linkType: 'tenant_external_id', externalId }; +} diff --git a/packages/dadataru/webhooks/types.ts b/packages/dadataru/webhooks/types.ts new file mode 100644 index 000000000..5730a77e5 --- /dev/null +++ b/packages/dadataru/webhooks/types.ts @@ -0,0 +1,64 @@ +import type { + CorsairWebhookMatcher, + RawWebhookRequest, + WebhookRequest, +} from 'corsair/core'; +import { z } from 'zod'; + +export const DadataruWebhookPayloadSchema = z.object({ + type: z.string(), + created_at: z.string(), + data: z.record(z.string(), z.unknown()), +}); + +export type DadataruWebhookPayload = z.infer< + typeof DadataruWebhookPayloadSchema +>; + +export const ExampleEventSchema = DadataruWebhookPayloadSchema.extend({ + type: z.literal('example'), + data: z + .object({ + id: z.string(), + }) + .loose(), +}); + +export type ExampleEvent = z.infer; + +export type DadataruWebhookOutputs = { + example: ExampleEvent; +}; + +function parseBody(body: unknown): Record | null { + if (typeof body === 'string') { + try { + const parsed = JSON.parse(body); + return parsed !== null && + typeof parsed === 'object' && + !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } + } + return body !== null && typeof body === 'object' && !Array.isArray(body) + ? (body as Record) + : null; +} + +export function createDadataruMatch(eventType: string): CorsairWebhookMatcher { + return (request: RawWebhookRequest) => { + const parsedBody = parseBody(request.body); + return parsedBody !== null && parsedBody.type === eventType; + }; +} + +export function verifyDadataruWebhookSignature( + request: WebhookRequest, + secret: string, +): { valid: boolean; error?: string } { + // TODO: Implement webhook signature verification + return { valid: true }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd3d10ddc..09b028566 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2332,6 +2332,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/dadataru: + 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/databricks: devDependencies: '@types/jest':