diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d75225 --- /dev/null +++ b/.gitignore @@ -0,0 +1,172 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +\*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +\*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +\*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +\*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.cache +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +.cache/ + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp +.cache + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.\* + +# IntelliJ based IDEs +.idea diff --git a/README.md b/README.md index bfb53e6..8388643 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,18 @@ -# Topsort.js +# topsort.js + +This repository holds the official Topsort.js client library. It is built using TypeScript. + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run src/index.ts +``` + -This repository holds the official Topsort.js client library. It is built using [TypeScript][typescript]. -[typescript]: https://www.typescriptlang.org diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..db79239 Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..e8eefed --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "topsort.js", + "version": "1.0.0", + "description": "", + "private": true, + "packageManager": "bun@1.1.17", + "main": "src/index.ts", + "author": "Márcio Barbosa ", + "license": "UNLICENSED", + "scripts": { + "build": "bun build", + "test": "bun test", + "lint": "bun lint", + "format": "bun format", + "prepublish": "bun prepublish", + "doctest": "bun run src/lib/doctest.test.ts" + }, + "devDependencies": { + "@supabase/doctest-js": "^0.1.0", + "bun-types": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": {} +} diff --git a/src/functions/report-event.ts b/src/functions/report-event.ts new file mode 100644 index 0000000..d1791e6 --- /dev/null +++ b/src/functions/report-event.ts @@ -0,0 +1,40 @@ +import { version } from "../../package.json"; +import { Config, TopsortEvent } from "../interfaces/events.interface"; + +/** + * Reports an event to the Topsort API. + * + * @example + * ```js + * const event = { eventType: "test", eventData: {} }; + * const config = { token: "my-token" }; + * const result = await reportEvent(event, config); + * console.log(result); // { ok: true, retry: false } + * ``` + * + * @param {TopsortEvent} e - The event to report. + * @param {Config} config - The configuration object containing URL and token. + * @returns {Promise<{ok: boolean, retry: boolean}>} The result of the report, indicating success and if a retry is needed. + */ +export async function reportEvent(e: TopsortEvent, config: Config): Promise<{ ok: boolean, retry: boolean }> { + try { + const url = (config.url || "https://api.topsort.com") + "/v2/events"; + const r = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + // Can't use User-Agent header because of + // https://bugs.chromium.org/p/chromium/issues/detail?id=571722 + "X-UA": `ts.js/${version}`, + Authorization: "Bearer " + config.token, + }, + body: JSON.stringify(e), + // This parameter ensures in most browsers that the request is performed even in case the browser navigates to another page. + keepalive: true, + }); + return { ok: r.ok, retry: r.status === 429 || r.status >= 500 }; + } catch (error) { + console.error("Error reporting event:", error); + return { ok: false, retry: true }; + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..12bc3db --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export * from './functions/report-event'; +export * from './interfaces/events.interface'; \ No newline at end of file diff --git a/src/interfaces/events.interface.ts b/src/interfaces/events.interface.ts new file mode 100644 index 0000000..8becc7b --- /dev/null +++ b/src/interfaces/events.interface.ts @@ -0,0 +1,52 @@ +interface Placement { + path: string; +} + +export interface Entity { + type: "product"; + id: string; +} + +interface Impression { + resolvedBidId?: string; + entity?: Entity; + additionalAttribution?: Entity; + placement: Placement; + occurredAt: string; + opaqueUserId: string; + id: string; +} + +interface Click { + resolvedBidId?: string; + entity?: Entity; + additionalAttribution?: Entity; + placement: Placement; + occurredAt: string; + opaqueUserId: string; + id: string; +} + +interface Item { + productId: string; + quantity: number; + unitPrice: number; +} + +interface Purchase { + occurredAt: string; + opaqueUserId: string; + id: string; + items: Item[]; +} + +export interface TopsortEvent { + impressions?: Impression[]; + clicks?: Click[]; + purchases?: Purchase[]; +} + +export interface Config { + token: string; + url?: string; +} diff --git a/src/lib/extract-comments.ts b/src/lib/extract-comments.ts new file mode 100644 index 0000000..a02d7a5 --- /dev/null +++ b/src/lib/extract-comments.ts @@ -0,0 +1,13 @@ +import * as fs from "fs"; + +export function extractJSDocComments(filePath: string): string[] { + const content = fs.readFileSync(filePath, "utf-8"); + const comments = []; + const regex = /\/\*\*([\s\S]*?)\*\//g; + let match; + while ((match = regex.exec(content)) !== null) { + console.log("JSDoc Comment:", match[1]); // Log each extracted JSDoc comment + comments.push(match[1].trim()); + } + return comments; +} \ No newline at end of file diff --git a/src/lib/generate-test-cases.ts b/src/lib/generate-test-cases.ts new file mode 100644 index 0000000..5622bc6 --- /dev/null +++ b/src/lib/generate-test-cases.ts @@ -0,0 +1,33 @@ +import { extractJSDocComments } from "./extract-comments"; + +interface TestCase { + code: string; + expected: any; +} + +export function generateTestCases(filePath: string): TestCase[] { + const comments = extractJSDocComments(filePath); + const testCases: TestCase[] = []; + + comments.forEach((comment) => { + console.log(comment) + const exampleMatch = comment.match(/@example\s+\*?\s*```js([\s\S]*?)\s*\*?\s*```/); + if (exampleMatch) { + const exampleCode = exampleMatch[1].trim(); + const expectedMatch = exampleCode.match(/console\.log\((.*)\);?\s*\/\/\s*(.*)/); + if (expectedMatch) { + try { + const expectedOutput = JSON.parse(expectedMatch[2].trim()); + testCases.push({ + code: exampleCode.replace(/console\.log\(.*\);/, ""), + expected: expectedOutput, + }); + } catch (error) { + console.error("Failed to parse expected output:", expectedMatch[2].trim(), error); + } + } + } + }); + + return testCases; +} \ No newline at end of file diff --git a/src/lib/mock-fetch.ts b/src/lib/mock-fetch.ts new file mode 100644 index 0000000..6066358 --- /dev/null +++ b/src/lib/mock-fetch.ts @@ -0,0 +1,29 @@ +type RequestInfo = string | URL | Request; +export function mockFetch(responseData: any, status: number, urlPattern: string) { + globalThis.fetch = async (input: RequestInfo) => { + let url: string; + + if (typeof input === "string") { + url = input; + } else if (input instanceof URL) { + url = input.href; + } else { + url = input.url; + } + + if (url.includes(urlPattern)) { + return new Response(JSON.stringify(responseData), { + status: status, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response(null, { status: 404 }); + }; +} + +export function resetFetch() { + globalThis.fetch = async () => { + return new Response(null, { status: 404 }); + }; +} \ No newline at end of file diff --git a/src/tests/report-event.test.ts b/src/tests/report-event.test.ts new file mode 100644 index 0000000..ae53fbc --- /dev/null +++ b/src/tests/report-event.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, afterEach } from "bun:test"; +import { reportEvent } from "../functions/report-event"; +import { TopsortEvent } from "../interfaces/events.interface"; +import { mockFetch, resetFetch } from "../lib/mock-fetch"; + +describe("reportEvent", () => { + afterEach(() => { + resetFetch(); + }); + + it("should report event successfully", async () => { + mockFetch({}, 200, "https://api.topsort.com/v2/events"); + const result = await reportEvent({} as TopsortEvent, { token: "token" }); + expect(result).toEqual({ + ok: true, + retry: false, + }); + }); + + + it("should report event successfully", async () => { + mockFetch({}, 200, "https://api.topsort.com/v2/events"); + const result = await reportEvent({} as TopsortEvent, { token: "token" }); + expect(result).toEqual({ + ok: true, + retry: false, + }); + }); + + it("should handle network error and retry", async () => { + mockFetch({ error: "Server Error" }, 500, "https://error.api.topsort.com/v2/events"); + const result = await reportEvent({} as TopsortEvent, { + token: "token", + url: "https://error.api.topsort.com", + }); + expect(result).toEqual({ + ok: false, + retry: true, + }); + }); + + it("should handle permanent error", async () => { + mockFetch({ error: "Client Error" }, 400, "https://api.topsort.com/v2/events"); + const result = await reportEvent({} as TopsortEvent, { token: "token" }); + expect(result).toEqual({ + ok: false, + retry: false, + }); + }); + + it("should handle authentication error", async () => { + mockFetch({ error: "Unauthorized" }, 401, "https://api.topsort.com/v2/events"); + const result = await reportEvent({} as TopsortEvent, { token: "token" }); + expect(result).toEqual({ + ok: false, + retry: false, + }); + }); + + it("should handle retryable error", async () => { + mockFetch({ error: "Too Many Requests" }, 429, "https://api.topsort.com/v2/events"); + const result = await reportEvent({} as TopsortEvent, { token: "token" }); + expect(result).toEqual({ + ok: false, + retry: true, + }); + }); + + it("should handle server error", async () => { + mockFetch({ error: "Internal Server Error" }, 500, "https://api.topsort.com/v2/events"); + const result = await reportEvent({} as TopsortEvent, { token: "token" }); + expect(result).toEqual({ + ok: false, + retry: true, + }); + }); + + it("should handle custom url", async () => { + mockFetch({}, 200, "https://demo.api.topsort.com/v2/events"); + const result = await reportEvent({} as TopsortEvent, { + token: "token", + url: "https://demo.api.topsort.com", + }); + expect(result).toEqual({ ok: true, retry: false }); + }); +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7556e1d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "noEmit": true, + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "types": [ + "bun-types" // add Bun global + ] + } +}