google-ads-cli-spec 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ import type { OperationBaseline } from './discovery.js';
2
+ export interface DocsCatalogEntry {
3
+ path: string;
4
+ topic: string;
5
+ url: string;
6
+ }
7
+ export interface CoverageThreshold {
8
+ totalCount: number;
9
+ coveredCountRequired: number;
10
+ }
11
+ export declare function collectDocsCatalogEntries(html: string): DocsCatalogEntry[];
12
+ export declare function computeCoverageThreshold(operations: OperationBaseline[]): CoverageThreshold;
@@ -0,0 +1,19 @@
1
+ const DOCS_BASE_URL = 'https://developers.google.com';
2
+ const DOCS_PATH_PATTERN = /\/google-ads\/api\/docs\/[^"]+/g;
3
+ export function collectDocsCatalogEntries(html) {
4
+ const paths = new Set(html.match(DOCS_PATH_PATTERN) ?? []);
5
+ return [...paths]
6
+ .sort((left, right) => left.localeCompare(right))
7
+ .map((path) => ({
8
+ path,
9
+ topic: path.split('/')[4] ?? 'unknown',
10
+ url: `${DOCS_BASE_URL}${path}`
11
+ }));
12
+ }
13
+ export function computeCoverageThreshold(operations) {
14
+ const totalCount = operations.length;
15
+ return {
16
+ totalCount,
17
+ coveredCountRequired: Math.ceil(totalCount * 0.9)
18
+ };
19
+ }
@@ -0,0 +1,17 @@
1
+ export interface DiscoveryMethod {
2
+ httpMethod?: string;
3
+ path?: string;
4
+ }
5
+ export interface DiscoveryResource {
6
+ methods?: Record<string, DiscoveryMethod>;
7
+ resources?: Record<string, DiscoveryResource>;
8
+ }
9
+ export interface DiscoveryDocument {
10
+ resources?: Record<string, DiscoveryResource>;
11
+ }
12
+ export interface OperationBaseline {
13
+ operationId: string;
14
+ httpMethod: string;
15
+ path: string;
16
+ }
17
+ export declare function collectOperationBaselines(discovery: DiscoveryDocument): OperationBaseline[];
@@ -0,0 +1,19 @@
1
+ function walkResources(segments, resource, output) {
2
+ for (const [methodName, method] of Object.entries(resource.methods ?? {})) {
3
+ output.push({
4
+ operationId: [...segments, methodName].join('.'),
5
+ httpMethod: method.httpMethod ?? 'GET',
6
+ path: method.path ?? ''
7
+ });
8
+ }
9
+ for (const [resourceName, childResource] of Object.entries(resource.resources ?? {})) {
10
+ walkResources([...segments, resourceName], childResource, output);
11
+ }
12
+ }
13
+ export function collectOperationBaselines(discovery) {
14
+ const output = [];
15
+ for (const [resourceName, resource] of Object.entries(discovery.resources ?? {})) {
16
+ walkResources([resourceName], resource, output);
17
+ }
18
+ return output.sort((left, right) => left.operationId.localeCompare(right.operationId));
19
+ }
@@ -0,0 +1,4 @@
1
+ export * from './discovery.js';
2
+ export * from './catalog.js';
3
+ export * from './sync.js';
4
+ export * from './shortcuts.js';
@@ -0,0 +1,4 @@
1
+ export * from './discovery.js';
2
+ export * from './catalog.js';
3
+ export * from './sync.js';
4
+ export * from './shortcuts.js';
@@ -0,0 +1,9 @@
1
+ import type { OperationBaseline } from './discovery.js';
2
+ export interface ShortcutEntry {
3
+ operationId: string;
4
+ commandPath: string[];
5
+ pathParams: string[];
6
+ httpMethod: string;
7
+ path: string;
8
+ }
9
+ export declare function generateShortcutEntries(operations: OperationBaseline[]): ShortcutEntry[];
@@ -0,0 +1,25 @@
1
+ const PATH_PARAM_PATTERN = /\{[+]?([^}]+)\}/g;
2
+ function extractPathParams(path) {
3
+ const params = new Set();
4
+ for (const match of path.matchAll(PATH_PARAM_PATTERN)) {
5
+ const paramName = match[1];
6
+ if (paramName) {
7
+ params.add(paramName);
8
+ }
9
+ }
10
+ return [...params];
11
+ }
12
+ function deriveCommandPath(operationId) {
13
+ return operationId.split('.').filter(Boolean);
14
+ }
15
+ export function generateShortcutEntries(operations) {
16
+ return [...operations]
17
+ .sort((left, right) => left.operationId.localeCompare(right.operationId))
18
+ .map((operation) => ({
19
+ commandPath: deriveCommandPath(operation.operationId),
20
+ httpMethod: operation.httpMethod,
21
+ operationId: operation.operationId,
22
+ path: operation.path,
23
+ pathParams: extractPathParams(operation.path)
24
+ }));
25
+ }
@@ -0,0 +1,13 @@
1
+ import { type CoverageThreshold, type DocsCatalogEntry } from './catalog.js';
2
+ import { type OperationBaseline } from './discovery.js';
3
+ export interface SyncGoogleAdsCatalogOptions {
4
+ version: string;
5
+ fetchText?: (url: string) => Promise<string>;
6
+ }
7
+ export interface SyncedGoogleAdsCatalog {
8
+ version: string;
9
+ operations: OperationBaseline[];
10
+ docsEntries: DocsCatalogEntry[];
11
+ coverage: CoverageThreshold;
12
+ }
13
+ export declare function syncGoogleAdsCatalog(options: SyncGoogleAdsCatalogOptions): Promise<SyncedGoogleAdsCatalog>;
@@ -0,0 +1,26 @@
1
+ import { collectDocsCatalogEntries, computeCoverageThreshold } from './catalog.js';
2
+ import { collectOperationBaselines } from './discovery.js';
3
+ const DISCOVERY_URL = 'https://googleads.googleapis.com/$discovery/rest?version=';
4
+ const DOCS_START_URL = 'https://developers.google.com/google-ads/api/docs/start';
5
+ async function fetchTextViaHttp(url) {
6
+ const response = await fetch(url);
7
+ if (!response.ok) {
8
+ throw new Error(`Failed to fetch ${url}: ${response.status}`);
9
+ }
10
+ return response.text();
11
+ }
12
+ export async function syncGoogleAdsCatalog(options) {
13
+ const fetchText = options.fetchText ?? fetchTextViaHttp;
14
+ const [discoveryText, docsHtml] = await Promise.all([
15
+ fetchText(`${DISCOVERY_URL}${options.version}`),
16
+ fetchText(DOCS_START_URL)
17
+ ]);
18
+ const operations = collectOperationBaselines(JSON.parse(discoveryText));
19
+ const docsEntries = collectDocsCatalogEntries(docsHtml);
20
+ return {
21
+ version: options.version,
22
+ operations,
23
+ docsEntries,
24
+ coverage: computeCoverageThreshold(operations)
25
+ };
26
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { collectDocsCatalogEntries, computeCoverageThreshold } from '../src/catalog.js';
3
+ describe('collectDocsCatalogEntries', () => {
4
+ it('extracts unique Google Ads docs urls and derives topics', () => {
5
+ const html = `
6
+ <a href="/google-ads/api/docs/campaigns/overview">Campaigns</a>
7
+ <a href="/google-ads/api/docs/campaigns/create-campaigns">Create</a>
8
+ <a href="/google-ads/api/docs/conversions/overview">Conversions</a>
9
+ <a href="/google-ads/api/docs/campaigns/overview">Campaigns</a>
10
+ `;
11
+ expect(collectDocsCatalogEntries(html)).toEqual([
12
+ {
13
+ path: '/google-ads/api/docs/campaigns/create-campaigns',
14
+ topic: 'campaigns',
15
+ url: 'https://developers.google.com/google-ads/api/docs/campaigns/create-campaigns'
16
+ },
17
+ {
18
+ path: '/google-ads/api/docs/campaigns/overview',
19
+ topic: 'campaigns',
20
+ url: 'https://developers.google.com/google-ads/api/docs/campaigns/overview'
21
+ },
22
+ {
23
+ path: '/google-ads/api/docs/conversions/overview',
24
+ topic: 'conversions',
25
+ url: 'https://developers.google.com/google-ads/api/docs/conversions/overview'
26
+ }
27
+ ]);
28
+ });
29
+ });
30
+ describe('computeCoverageThreshold', () => {
31
+ it('rounds up the required count for ninety percent coverage', () => {
32
+ expect(computeCoverageThreshold([
33
+ { operationId: 'a', httpMethod: 'GET', path: 'x' },
34
+ { operationId: 'b', httpMethod: 'GET', path: 'x' },
35
+ { operationId: 'c', httpMethod: 'GET', path: 'x' },
36
+ { operationId: 'd', httpMethod: 'GET', path: 'x' },
37
+ { operationId: 'e', httpMethod: 'GET', path: 'x' },
38
+ { operationId: 'f', httpMethod: 'GET', path: 'x' },
39
+ { operationId: 'g', httpMethod: 'GET', path: 'x' },
40
+ { operationId: 'h', httpMethod: 'GET', path: 'x' },
41
+ { operationId: 'i', httpMethod: 'GET', path: 'x' }
42
+ ])).toEqual({
43
+ coveredCountRequired: 9,
44
+ totalCount: 9
45
+ });
46
+ });
47
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { collectOperationBaselines } from '../src/discovery.js';
3
+ describe('collectOperationBaselines', () => {
4
+ it('normalizes Google Ads discovery resources into operation ids', () => {
5
+ const discovery = {
6
+ resources: {
7
+ customers: {
8
+ resources: {
9
+ campaigns: {
10
+ methods: {
11
+ mutate: {
12
+ httpMethod: 'POST',
13
+ path: 'v22/customers/{+customerId}/campaigns:mutate'
14
+ }
15
+ }
16
+ },
17
+ googleAds: {
18
+ methods: {
19
+ search: {
20
+ httpMethod: 'POST',
21
+ path: 'v22/customers/{+customerId}/googleAds:search'
22
+ }
23
+ }
24
+ }
25
+ }
26
+ }
27
+ }
28
+ };
29
+ expect(collectOperationBaselines(discovery)).toEqual([
30
+ {
31
+ httpMethod: 'POST',
32
+ operationId: 'customers.campaigns.mutate',
33
+ path: 'v22/customers/{+customerId}/campaigns:mutate'
34
+ },
35
+ {
36
+ httpMethod: 'POST',
37
+ operationId: 'customers.googleAds.search',
38
+ path: 'v22/customers/{+customerId}/googleAds:search'
39
+ }
40
+ ]);
41
+ });
42
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { generateShortcutEntries } from '../src/shortcuts.js';
3
+ describe('generateShortcutEntries', () => {
4
+ it('maps a customers mutate operation to a shortcut entry', () => {
5
+ expect(generateShortcutEntries([
6
+ {
7
+ httpMethod: 'POST',
8
+ operationId: 'customers.campaigns.mutate',
9
+ path: 'v22/customers/{+customerId}/campaigns:mutate'
10
+ }
11
+ ])).toEqual([
12
+ {
13
+ commandPath: ['customers', 'campaigns', 'mutate'],
14
+ httpMethod: 'POST',
15
+ operationId: 'customers.campaigns.mutate',
16
+ path: 'v22/customers/{+customerId}/campaigns:mutate',
17
+ pathParams: ['customerId']
18
+ }
19
+ ]);
20
+ });
21
+ it('keeps root level operations as a two segment command path', () => {
22
+ expect(generateShortcutEntries([
23
+ {
24
+ httpMethod: 'POST',
25
+ operationId: 'audienceInsights.listInsightsEligibleDates',
26
+ path: 'v22/audienceInsights:listInsightsEligibleDates'
27
+ }
28
+ ])).toEqual([
29
+ {
30
+ commandPath: ['audienceInsights', 'listInsightsEligibleDates'],
31
+ httpMethod: 'POST',
32
+ operationId: 'audienceInsights.listInsightsEligibleDates',
33
+ path: 'v22/audienceInsights:listInsightsEligibleDates',
34
+ pathParams: []
35
+ }
36
+ ]);
37
+ });
38
+ it('extracts resourceName path params for get operations', () => {
39
+ expect(generateShortcutEntries([
40
+ {
41
+ httpMethod: 'GET',
42
+ operationId: 'googleAdsFields.get',
43
+ path: 'v22/{+resourceName}'
44
+ }
45
+ ])).toEqual([
46
+ {
47
+ commandPath: ['googleAdsFields', 'get'],
48
+ httpMethod: 'GET',
49
+ operationId: 'googleAdsFields.get',
50
+ path: 'v22/{+resourceName}',
51
+ pathParams: ['resourceName']
52
+ }
53
+ ]);
54
+ });
55
+ it('retains googleAdsFields search command segments', () => {
56
+ expect(generateShortcutEntries([
57
+ {
58
+ httpMethod: 'POST',
59
+ operationId: 'googleAdsFields.search',
60
+ path: 'v22/googleAdsFields:search'
61
+ }
62
+ ])).toEqual([
63
+ {
64
+ commandPath: ['googleAdsFields', 'search'],
65
+ httpMethod: 'POST',
66
+ operationId: 'googleAdsFields.search',
67
+ path: 'v22/googleAdsFields:search',
68
+ pathParams: []
69
+ }
70
+ ]);
71
+ });
72
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { syncGoogleAdsCatalog } from '../src/sync.js';
3
+ describe('syncGoogleAdsCatalog', () => {
4
+ it('collects operations, docs entries, and a ninety percent threshold', async () => {
5
+ const responses = new Map([
6
+ [
7
+ 'https://googleads.googleapis.com/$discovery/rest?version=v22',
8
+ JSON.stringify({
9
+ resources: {
10
+ customers: {
11
+ resources: {
12
+ campaigns: {
13
+ methods: {
14
+ mutate: {
15
+ httpMethod: 'POST',
16
+ path: 'v22/customers/{+customerId}/campaigns:mutate'
17
+ }
18
+ }
19
+ }
20
+ }
21
+ }
22
+ }
23
+ })
24
+ ],
25
+ [
26
+ 'https://developers.google.com/google-ads/api/docs/start',
27
+ `
28
+ <a href="/google-ads/api/docs/campaigns/overview">Campaigns</a>
29
+ <a href="/google-ads/api/docs/conversions/overview">Conversions</a>
30
+ `
31
+ ]
32
+ ]);
33
+ const result = await syncGoogleAdsCatalog({
34
+ fetchText: async (url) => {
35
+ const value = responses.get(url);
36
+ if (!value) {
37
+ throw new Error(`Unexpected URL: ${url}`);
38
+ }
39
+ return value;
40
+ },
41
+ version: 'v22'
42
+ });
43
+ expect(result).toEqual({
44
+ coverage: {
45
+ coveredCountRequired: 1,
46
+ totalCount: 1
47
+ },
48
+ docsEntries: [
49
+ {
50
+ path: '/google-ads/api/docs/campaigns/overview',
51
+ topic: 'campaigns',
52
+ url: 'https://developers.google.com/google-ads/api/docs/campaigns/overview'
53
+ },
54
+ {
55
+ path: '/google-ads/api/docs/conversions/overview',
56
+ topic: 'conversions',
57
+ url: 'https://developers.google.com/google-ads/api/docs/conversions/overview'
58
+ }
59
+ ],
60
+ operations: [
61
+ {
62
+ httpMethod: 'POST',
63
+ operationId: 'customers.campaigns.mutate',
64
+ path: 'v22/customers/{+customerId}/campaigns:mutate'
65
+ }
66
+ ],
67
+ version: 'v22'
68
+ });
69
+ });
70
+ });
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "google-ads-cli-spec",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/src/index.js",
6
+ "types": "dist/src/index.d.ts",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc -p tsconfig.json",
15
+ "typecheck": "tsc -p tsconfig.json --noEmit"
16
+ }
17
+ }