Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/dashboard/src/components/provider-icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
siSupabase,
siTiktok,
siVercel,
siYoutube,
} from "simple-icons";

type ProviderIconProps = IconSvgProps;
Expand Down Expand Up @@ -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<string, ProviderIconComponent>;

function hasProviderIcon(
Expand Down
2 changes: 2 additions & 0 deletions apps/landing/src/shared/icons/brands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
siSupabase,
siTiktok,
siVercel,
siYoutube,
} from "simple-icons";

type BrandIconComponent = ComponentType<IconSvgProps>;
Expand Down Expand Up @@ -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<string, BrandIconComponent>;

export type BrandIconName = keyof typeof BRAND_ICONS;
Expand Down
1 change: 1 addition & 0 deletions packages/db/src/__snapshots__/credentials.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ exports[`credentials schemas > credentialSchemaMap > matches supported provider
"snowflake",
"tiktok_marketing",
"vercel",
"youtube_analytics",
]
`;

Expand Down
54 changes: 53 additions & 1 deletion packages/db/src/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
SnowflakeCredentialsSchema,
TikTokMarketingCredentialsSchema,
VercelCredentialsSchema,
YouTubeAnalyticsCredentialsSchema,
safeValidateCredentials,
validateCredentials,
} from "./credentials";
Expand Down Expand Up @@ -83,6 +84,7 @@ import type {
SnowflakeCredentials,
TikTokMarketingCredentials,
VercelCredentials,
YouTubeAnalyticsCredentials,
} from "./credentials";

describe("credentials schemas", () => {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
26 changes: 24 additions & 2 deletions packages/db/src/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -542,6 +555,7 @@ export const CredentialsSchema = z.union([
MongoDBCredentialsSchema,
GoogleAnalyticsCredentialsSchema,
BigQueryCredentialsSchema,
YouTubeAnalyticsCredentialsSchema,
SnowflakeCredentialsSchema,
LaminarCredentialsSchema,
MotherDuckCredentialsSchema,
Expand Down Expand Up @@ -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 {
Expand All @@ -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") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ exports[`data-sources schema > matches provider type snapshots 1`] = `
"motherduck",
"aws_athena_connector",
"ga",
"youtube_analytics",
"amplitude",
"mixpanel",
"posthog",
Expand Down Expand Up @@ -51,6 +52,7 @@ exports[`data-sources schema > matches provider type snapshots 1`] = `
"motherduck",
"aws_athena_connector",
"ga",
"youtube_analytics",
"amplitude",
"mixpanel",
"posthog",
Expand Down
34 changes: 34 additions & 0 deletions packages/db/src/source-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
SnowflakeCredentialsSchema,
TikTokMarketingCredentialsSchema,
VercelCredentialsSchema,
YouTubeAnalyticsCredentialsSchema,
} from "./credentials";

type ProviderCredentialSchema = z.ZodType<{ type: string }>;
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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: [] }), {
Expand Down
43 changes: 43 additions & 0 deletions packages/server/src/source-api/adapters/youtube-analytics.ts
Original file line number Diff line number Diff line change
@@ -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<YouTubeAnalyticsCredentials>({
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",
});
2 changes: 2 additions & 0 deletions packages/server/src/source-api/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -96,4 +97,5 @@ export const sourceApiRegistry = createSourceApiRegistry([
slackSourceApiAdapter,
tiktokMarketingSourceApiAdapter,
vercelSourceApiAdapter,
youTubeAnalyticsSourceApiAdapter,
]);