From f7b5773c6820cbb12ba88dbad6a0fc1e190967cd Mon Sep 17 00:00:00 2001 From: siisee11 Date: Wed, 24 Jun 2026 22:00:35 +0900 Subject: [PATCH 1/3] feat: add YouTube Analytics source API --- .../__snapshots__/credentials.test.ts.snap | 1 + packages/db/src/credentials.test.ts | 54 ++++++++++++++++++- packages/db/src/credentials.ts | 26 ++++++++- .../__snapshots__/data-sources.test.ts.snap | 2 + packages/db/src/source-providers.ts | 34 ++++++++++++ .../adapters/simple-rest-providers.test.ts | 51 ++++++++++++++++++ .../source-api/adapters/youtube-analytics.ts | 43 +++++++++++++++ packages/server/src/source-api/registry.ts | 2 + 8 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/source-api/adapters/youtube-analytics.ts diff --git a/packages/db/src/__snapshots__/credentials.test.ts.snap b/packages/db/src/__snapshots__/credentials.test.ts.snap index 27f60f14..4ef3f575 100644 --- a/packages/db/src/__snapshots__/credentials.test.ts.snap +++ b/packages/db/src/__snapshots__/credentials.test.ts.snap @@ -36,6 +36,7 @@ exports[`credentials schemas > credentialSchemaMap > matches supported provider "snowflake", "tiktok_marketing", "vercel", + "youtube_analytics", ] `; diff --git a/packages/db/src/credentials.test.ts b/packages/db/src/credentials.test.ts index aab68141..1f125e9f 100644 --- a/packages/db/src/credentials.test.ts +++ b/packages/db/src/credentials.test.ts @@ -45,6 +45,7 @@ import { SnowflakeCredentialsSchema, TikTokMarketingCredentialsSchema, VercelCredentialsSchema, + YouTubeAnalyticsCredentialsSchema, safeValidateCredentials, validateCredentials, } from "./credentials"; @@ -83,6 +84,7 @@ import type { SnowflakeCredentials, TikTokMarketingCredentials, VercelCredentials, + YouTubeAnalyticsCredentials, } from "./credentials"; describe("credentials schemas", () => { @@ -499,6 +501,34 @@ describe("credentials schemas", () => { }); }); + describe("YouTubeAnalyticsCredentialsSchema", () => { + it("validates YouTube Analytics OAuth credentials", () => { + const credentials: YouTubeAnalyticsCredentials = { + accessToken: "ya29.youtube", + authType: "oauth", + expiresAt: Date.now() + 3_600_000, + refreshToken: "1//youtube", + type: "youtube_analytics", + }; + + const result = YouTubeAnalyticsCredentialsSchema.safeParse(credentials); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(credentials); + } + }); + + it("rejects missing refresh token", () => { + const result = YouTubeAnalyticsCredentialsSchema.safeParse({ + accessToken: "ya29.youtube", + expiresAt: Date.now() + 3_600_000, + type: "youtube_analytics", + }); + + expect(result.success).toBe(false); + }); + }); + describe("CloudflareD1CredentialsSchema", () => { it("should validate valid Cloudflare D1 credentials", () => { const credentials: CloudflareD1Credentials = { @@ -811,6 +841,16 @@ describe("credentials schemas", () => { }); describe("isOAuthCredentials", () => { + it("should return true for YouTube Analytics credentials", () => { + const credentials: YouTubeAnalyticsCredentials = { + accessToken: "token", + expiresAt: Date.now(), + refreshToken: "refresh", + type: "youtube_analytics", + }; + expect(isOAuthCredentials(credentials)).toBe(true); + }); + it("should return false for GA service account credentials", () => { const credentials: GoogleAnalyticsCredentials = { authType: "service_account", @@ -1952,6 +1992,12 @@ describe("credentials schemas", () => { expect(credentialSchemaMap.bigquery).toBe(BigQueryCredentialsSchema); }); + it("should map youtube_analytics to YouTubeAnalyticsCredentialsSchema", () => { + expect(credentialSchemaMap.youtube_analytics).toBe( + YouTubeAnalyticsCredentialsSchema + ); + }); + it("should map cloudflare_d1 to CloudflareD1CredentialsSchema", () => { expect(credentialSchemaMap.cloudflare_d1).toBe( CloudflareD1CredentialsSchema @@ -2434,11 +2480,17 @@ describe("credentials schemas", () => { apiKey: "lin_api_xxx", type: "linear", }, + { + accessToken: "ya29.youtube", + expiresAt: Date.now() + 3_600_000, + refreshToken: "1//youtube", + type: "youtube_analytics", + }, ]; const oauthCreds = allCredentials.filter(isOAuthCredentials); expect(oauthCreds.map((c) => c.type).toSorted()).toEqual( - ["bigquery", "ga"].toSorted() + ["bigquery", "ga", "youtube_analytics"].toSorted() ); const dbCreds = allCredentials.filter(isDatabaseCredentials); diff --git a/packages/db/src/credentials.ts b/packages/db/src/credentials.ts index 655f2ca3..360453e0 100644 --- a/packages/db/src/credentials.ts +++ b/packages/db/src/credentials.ts @@ -158,6 +158,19 @@ export type BigQueryServiceAccountCredentials = z.infer< typeof BigQueryServiceAccountCredentialsSchema >; +export const YouTubeAnalyticsCredentialsSchema = z.object({ + accessToken: requiredOpaqueString("Access token is required"), + apiBaseUrl: optionalTrimmedUrl("API base URL must be a valid URL"), + authType: z.literal("oauth").optional(), + expiresAt: z.number().int().min(0, "Expiration time must be positive"), + refreshToken: requiredOpaqueString("Refresh token is required"), + type: z.literal("youtube_analytics"), +}); + +export type YouTubeAnalyticsCredentials = z.infer< + typeof YouTubeAnalyticsCredentialsSchema +>; + export const LaminarCredentialsSchema = z.object({ apiBaseUrl: optionalTrimmedUrl("API base URL must be a valid URL"), apiKey: requiredOpaqueString("API key is required"), @@ -542,6 +555,7 @@ export const CredentialsSchema = z.union([ MongoDBCredentialsSchema, GoogleAnalyticsCredentialsSchema, BigQueryCredentialsSchema, + YouTubeAnalyticsCredentialsSchema, SnowflakeCredentialsSchema, LaminarCredentialsSchema, MotherDuckCredentialsSchema, @@ -643,6 +657,7 @@ export const credentialSchemaMap = { snowflake: SnowflakeCredentialsSchema, tiktok_marketing: TikTokMarketingCredentialsSchema, vercel: VercelCredentialsSchema, + youtube_analytics: YouTubeAnalyticsCredentialsSchema, } as const; export function validateCredentials(credentials: unknown): Credentials { @@ -662,8 +677,15 @@ export function isTokenExpired( export function isOAuthCredentials( credentials: Credentials -): credentials is GoogleAnalyticsOAuthCredentials | BigQueryOAuthCredentials { - if (credentials.type !== "ga" && credentials.type !== "bigquery") { +): credentials is + | GoogleAnalyticsOAuthCredentials + | BigQueryOAuthCredentials + | YouTubeAnalyticsCredentials { + if ( + credentials.type !== "ga" && + credentials.type !== "bigquery" && + credentials.type !== "youtube_analytics" + ) { return false; } if ("authType" in credentials && credentials.authType === "service_account") { diff --git a/packages/db/src/schema/__snapshots__/data-sources.test.ts.snap b/packages/db/src/schema/__snapshots__/data-sources.test.ts.snap index 7ba420d5..1d94e7c8 100644 --- a/packages/db/src/schema/__snapshots__/data-sources.test.ts.snap +++ b/packages/db/src/schema/__snapshots__/data-sources.test.ts.snap @@ -14,6 +14,7 @@ exports[`data-sources schema > matches provider type snapshots 1`] = ` "motherduck", "aws_athena_connector", "ga", + "youtube_analytics", "amplitude", "mixpanel", "posthog", @@ -51,6 +52,7 @@ exports[`data-sources schema > matches provider type snapshots 1`] = ` "motherduck", "aws_athena_connector", "ga", + "youtube_analytics", "amplitude", "mixpanel", "posthog", diff --git a/packages/db/src/source-providers.ts b/packages/db/src/source-providers.ts index d7cd5969..827d30cb 100644 --- a/packages/db/src/source-providers.ts +++ b/packages/db/src/source-providers.ts @@ -35,6 +35,7 @@ import { SnowflakeCredentialsSchema, TikTokMarketingCredentialsSchema, VercelCredentialsSchema, + YouTubeAnalyticsCredentialsSchema, } from "./credentials"; type ProviderCredentialSchema = z.ZodType<{ type: string }>; @@ -418,6 +419,39 @@ export const SOURCE_PROVIDER_REGISTRY = { }, }, }, + youtube_analytics: { + label: "YouTube Analytics", + credentialSchema: YouTubeAnalyticsCredentialsSchema, + credentialType: "youtube_analytics", + connectable: true, + analysisSource: true, + queryInterface: false, + sourceApiInterface: true, + testable: false, + dashboardConnectable: true, + dashboardCredentialForm: "google_oauth", + publicCategory: "Product analytics", + guide: { + summary: + "Connect YouTube Analytics with Google OAuth tokens that can read YouTube Analytics reports.", + steps: [ + "Enable the YouTube Analytics API in the Google Cloud project used for OAuth.", + "Authorize a Google account that owns or manages the YouTube channel or content owner data you need to analyze.", + "Use the `https://www.googleapis.com/auth/youtube.readonly` and `https://www.googleapis.com/auth/yt-analytics.readonly` scopes for non-monetary metrics. Revenue and ad performance metrics require `https://www.googleapis.com/auth/yt-analytics-monetary.readonly`.", + "Call `/reports` through Source API with query params such as `ids`, `startDate`, `endDate`, `metrics`, `dimensions`, and `filters`.", + ], + exampleInput: { + sourceKey: "youtube_analytics", + credentials: { + type: "youtube_analytics", + authType: "oauth", + accessToken: "ya29...", + refreshToken: "1//...", + expiresAt: 1_798_761_600_000, + }, + }, + }, + }, amplitude: { label: "Amplitude", credentialSchema: AmplitudeCredentialsSchema, diff --git a/packages/server/src/source-api/adapters/simple-rest-providers.test.ts b/packages/server/src/source-api/adapters/simple-rest-providers.test.ts index deb52c28..265bc7cf 100644 --- a/packages/server/src/source-api/adapters/simple-rest-providers.test.ts +++ b/packages/server/src/source-api/adapters/simple-rest-providers.test.ts @@ -20,6 +20,7 @@ import { import { sendGridSourceApiAdapter } from "./sendgrid"; import { tiktokMarketingSourceApiAdapter } from "./tiktok-marketing"; import { vercelSourceApiAdapter } from "./vercel"; +import { youTubeAnalyticsSourceApiAdapter } from "./youtube-analytics"; const originalFetch = globalThis.fetch; @@ -328,6 +329,56 @@ describe("simple REST source API providers", () => { ); }); + it("normalizes YouTube Analytics reports query params", async () => { + const source: PreparedSourceConnection = { + credentials: { + accessToken: "youtube_token", + authType: "oauth", + expiresAt: 1_798_761_600_000, + refreshToken: "youtube_refresh", + type: "youtube_analytics", + }, + displayName: "YouTube Analytics", + id: "source_yt", + provider: "youtube_analytics", + sourceKey: "youtube-main", + }; + const descriptor = await youTubeAnalyticsSourceApiAdapter.describe({ + actor, + source, + }); + + const plan = await youTubeAnalyticsSourceApiAdapter.normalize({ + actor, + descriptor, + request: { + body: { kind: "none" }, + fieldPatch: { + params: { + dimensions: "day", + endDate: "2026-05-07", + ids: "channel==MINE", + metrics: "views,estimatedMinutesWatched", + startDate: "2026-05-01", + }, + }, + headers: [], + operation: "fetch_api", + selector: "/reports", + }, + source, + }); + + expect(plan.kind).toBe("http_request"); + if (plan.kind !== "http_request") { + throw new Error("expected HTTP request plan"); + } + expect(plan.method).toBe("GET"); + expect(plan.url).toBe( + "https://youtubeanalytics.googleapis.com/v2/reports?dimensions=day&endDate=2026-05-07&ids=channel%3D%3DMINE&metrics=views%2CestimatedMinutesWatched&startDate=2026-05-01" + ); + }); + it("executes Confluence requests with Atlassian API token basic auth", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ results: [] }), { diff --git a/packages/server/src/source-api/adapters/youtube-analytics.ts b/packages/server/src/source-api/adapters/youtube-analytics.ts new file mode 100644 index 00000000..e41a38f2 --- /dev/null +++ b/packages/server/src/source-api/adapters/youtube-analytics.ts @@ -0,0 +1,43 @@ +import type { YouTubeAnalyticsCredentials } from "@onequery/db/server"; + +import { createSimpleRestSourceApiAdapter } from "./simple-rest"; + +const YOUTUBE_ANALYTICS_DEFAULT_API_BASE_URL = + "https://youtubeanalytics.googleapis.com/v2"; +const YOUTUBE_ANALYTICS_DESCRIPTOR_VERSION = "youtube-analytics.v1"; + +export const youTubeAnalyticsSourceApiAdapter = + createSimpleRestSourceApiAdapter({ + allowedMethods: ["GET"], + apiBaseUrl: (credentials) => + credentials.apiBaseUrl ?? YOUTUBE_ANALYTICS_DEFAULT_API_BASE_URL, + auth: (credentials) => ({ + token: credentials.accessToken, + type: "bearer", + }), + buildExamples: (sourceKey) => [ + { + command: `onequery api --source ${sourceKey} /reports -F 'params.ids="channel==MINE"' -F 'params.startDate="2026-05-01"' -F 'params.endDate="2026-05-07"' -F 'params.metrics="views,estimatedMinutesWatched"' -F 'params.dimensions="day"' -F 'params.sort="day"'`, + description: + "Query daily YouTube Analytics metrics for the authorized channel.", + label: "Query channel metrics", + }, + { + command: `onequery api --source ${sourceKey} /reports -F 'params.ids="channel==MINE"' -F 'params.startDate="2026-05-01"' -F 'params.endDate="2026-05-31"' -F 'params.metrics="views,likes,comments"' -F 'params.dimensions="video"' -F 'params.sort="-views"' -F 'params.maxResults=25'`, + description: + "Rank videos by views for the authorized channel over a date range.", + label: "Rank videos", + }, + ], + descriptorVersion: YOUTUBE_ANALYTICS_DESCRIPTOR_VERSION, + notes: [ + "YouTube Analytics private data requires Google OAuth authorization with youtube.readonly and yt-analytics.readonly scopes.", + "Revenue and ad performance metrics require a token granted the yt-analytics-monetary.readonly scope.", + ], + operationNotes: [ + "Use selector `/reports` with query params matching the YouTube Analytics reports.query API.", + "Common params include `ids`, `startDate`, `endDate`, `metrics`, `dimensions`, `filters`, `sort`, `maxResults`, and `startIndex`.", + ], + provider: "youtube_analytics", + providerLabel: "YouTube Analytics", + }); diff --git a/packages/server/src/source-api/registry.ts b/packages/server/src/source-api/registry.ts index c1a5ca58..e0fd982a 100644 --- a/packages/server/src/source-api/registry.ts +++ b/packages/server/src/source-api/registry.ts @@ -26,6 +26,7 @@ import { sentrySourceApiAdapter } from "./adapters/sentry"; import { slackSourceApiAdapter } from "./adapters/slack"; import { tiktokMarketingSourceApiAdapter } from "./adapters/tiktok-marketing"; import { vercelSourceApiAdapter } from "./adapters/vercel"; +import { youTubeAnalyticsSourceApiAdapter } from "./adapters/youtube-analytics"; import { SourceApiAdapterNotRegisteredError, SourceApiRegistryConfigurationError, @@ -96,4 +97,5 @@ export const sourceApiRegistry = createSourceApiRegistry([ slackSourceApiAdapter, tiktokMarketingSourceApiAdapter, vercelSourceApiAdapter, + youTubeAnalyticsSourceApiAdapter, ]); From 418964fac46bded93602b59d02e2389ab085cabc Mon Sep 17 00:00:00 2001 From: siisee11 Date: Wed, 24 Jun 2026 22:05:43 +0900 Subject: [PATCH 2/3] fix: add YouTube Analytics landing icon --- apps/landing/src/shared/icons/brands.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/landing/src/shared/icons/brands.tsx b/apps/landing/src/shared/icons/brands.tsx index 046cf556..fc99a77b 100644 --- a/apps/landing/src/shared/icons/brands.tsx +++ b/apps/landing/src/shared/icons/brands.tsx @@ -29,6 +29,7 @@ import { siSupabase, siTiktok, siVercel, + siYoutube, } from "simple-icons"; type BrandIconComponent = ComponentType; @@ -314,6 +315,7 @@ const BRAND_ICONS = { supabase: createSimpleBrandIcon(siSupabase), tiktok_marketing: createSimpleBrandIcon(siTiktok), vercel: createSimpleBrandIcon(siVercel), + youtube_analytics: createSimpleBrandIcon(siYoutube), } as const satisfies Record; export type BrandIconName = keyof typeof BRAND_ICONS; From 8e3720f3cc4e9670215ea1f465b52b4ad5b5a0d0 Mon Sep 17 00:00:00 2001 From: siisee11 Date: Wed, 24 Jun 2026 22:13:27 +0900 Subject: [PATCH 3/3] fix: add YouTube Analytics dashboard icon --- apps/dashboard/src/components/provider-icons.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/dashboard/src/components/provider-icons.tsx b/apps/dashboard/src/components/provider-icons.tsx index 2027f62f..2c9faa67 100644 --- a/apps/dashboard/src/components/provider-icons.tsx +++ b/apps/dashboard/src/components/provider-icons.tsx @@ -35,6 +35,7 @@ import { siSupabase, siTiktok, siVercel, + siYoutube, } from "simple-icons"; type ProviderIconProps = IconSvgProps; @@ -343,6 +344,7 @@ export const ProviderIcons = { snowflake: createSimpleProviderIcon(siSnowflake), tiktok_marketing: createSimpleProviderIcon(siTiktok), vercel: createSimpleProviderIcon(siVercel), + youtube_analytics: createSimpleProviderIcon(siYoutube), } as const satisfies Record; function hasProviderIcon(