@channel47/linkedin-ads-mcp 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.
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/package.json +47 -0
- package/server/auth.js +146 -0
- package/server/http.js +216 -0
- package/server/index.js +254 -0
- package/server/prompts/templates.js +134 -0
- package/server/resources/analytics-fields-reference.md +63 -0
- package/server/resources/index.js +65 -0
- package/server/resources/marketing-api-reference.md +98 -0
- package/server/resources/rate-limits-and-quotas.md +34 -0
- package/server/tools/analytics.js +145 -0
- package/server/tools/list-accounts.js +117 -0
- package/server/tools/mutate.js +117 -0
- package/server/tools/query.js +147 -0
- package/server/utils/errors.js +12 -0
- package/server/utils/mutate-operations.js +211 -0
- package/server/utils/response-format.js +63 -0
- package/server/utils/restli.js +153 -0
- package/server/utils/validation.js +104 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { getApiVersion, RESTLI_PROTOCOL_VERSION } from '../http.js';
|
|
2
|
+
import { encodeRestliValue } from './restli.js';
|
|
3
|
+
import { fromEntityUrn, toEntityUrn, validateArray } from './validation.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Supported mutate entity identifiers.
|
|
7
|
+
*/
|
|
8
|
+
export const SUPPORTED_MUTATE_ENTITIES = ['campaign', 'campaign_group', 'creative'];
|
|
9
|
+
/**
|
|
10
|
+
* Supported mutate action identifiers.
|
|
11
|
+
*/
|
|
12
|
+
export const SUPPORTED_MUTATE_ACTIONS = ['create', 'update', 'pause', 'enable', 'archive'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Default status applied to created entities. LinkedIn documents DRAFT as the
|
|
16
|
+
* safe non-serving state for newly created campaigns, campaign groups, and
|
|
17
|
+
* creatives; pass an explicit status (intendedStatus for creatives) to override.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_CREATE_STATUS = 'DRAFT';
|
|
20
|
+
|
|
21
|
+
const ENTITY_COLLECTIONS = {
|
|
22
|
+
campaign: 'adCampaigns',
|
|
23
|
+
campaign_group: 'adCampaignGroups',
|
|
24
|
+
creative: 'creatives'
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const STATUS_FIELD_BY_ENTITY = {
|
|
28
|
+
campaign: 'status',
|
|
29
|
+
campaign_group: 'status',
|
|
30
|
+
creative: 'intendedStatus'
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const STATUS_BY_ACTION = {
|
|
34
|
+
pause: 'PAUSED',
|
|
35
|
+
enable: 'ACTIVE',
|
|
36
|
+
archive: 'ARCHIVED'
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function validateOperationShape(op, index) {
|
|
40
|
+
if (!op || typeof op !== 'object' || Array.isArray(op)) {
|
|
41
|
+
return { index, message: 'Operation must be an object' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!op.entity) {
|
|
45
|
+
return { index, message: 'Missing required field: entity' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!SUPPORTED_MUTATE_ENTITIES.includes(op.entity)) {
|
|
49
|
+
return {
|
|
50
|
+
index,
|
|
51
|
+
message: `Unsupported entity: ${op.entity}. Supported: ${SUPPORTED_MUTATE_ENTITIES.join(', ')}`
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!op.action) {
|
|
56
|
+
return { index, message: 'Missing required field: action' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!SUPPORTED_MUTATE_ACTIONS.includes(op.action)) {
|
|
60
|
+
return {
|
|
61
|
+
index,
|
|
62
|
+
message: `Unsupported action: ${op.action}. Supported: ${SUPPORTED_MUTATE_ACTIONS.join(', ')}`
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if ((op.action === 'create' || op.action === 'update') && (!op.params || typeof op.params !== 'object' || Array.isArray(op.params))) {
|
|
67
|
+
return {
|
|
68
|
+
index,
|
|
69
|
+
message: `${op.action} requires params object`
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (op.action !== 'create' && !op.id) {
|
|
74
|
+
return {
|
|
75
|
+
index,
|
|
76
|
+
message: `${op.action} requires id`
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (op.entity === 'creative' && op.action === 'create' && !op.params?.campaign) {
|
|
81
|
+
return {
|
|
82
|
+
index,
|
|
83
|
+
message: 'creative create requires params.campaign (campaign ID or urn:li:sponsoredCampaign URN)'
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validate mutate operation array shape and action/entity compatibility.
|
|
92
|
+
* @param {unknown} operations
|
|
93
|
+
* @returns {Array<{ index: number, message: string }>}
|
|
94
|
+
*/
|
|
95
|
+
export function validateOperations(operations) {
|
|
96
|
+
validateArray(operations, 'operations');
|
|
97
|
+
|
|
98
|
+
const errors = [];
|
|
99
|
+
for (let index = 0; index < operations.length; index += 1) {
|
|
100
|
+
const issue = validateOperationShape(operations[index], index);
|
|
101
|
+
if (issue) {
|
|
102
|
+
errors.push(issue);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return errors;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Creatives are keyed by URN in resource paths and must be percent-encoded;
|
|
110
|
+
// campaigns and campaign groups are keyed by their numeric ID.
|
|
111
|
+
function encodeEntityKey(entity, id) {
|
|
112
|
+
if (entity === 'creative') {
|
|
113
|
+
return encodeRestliValue(toEntityUrn('creative', id));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return encodeRestliValue(fromEntityUrn(id));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildCreateBody(operation, accountId) {
|
|
120
|
+
const statusField = STATUS_FIELD_BY_ENTITY[operation.entity];
|
|
121
|
+
const body = {
|
|
122
|
+
[statusField]: DEFAULT_CREATE_STATUS,
|
|
123
|
+
...operation.params
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (operation.entity === 'creative') {
|
|
127
|
+
body.campaign = toEntityUrn('campaign', operation.params.campaign);
|
|
128
|
+
return body;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!body.account) {
|
|
132
|
+
body.account = toEntityUrn('account', accountId);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return body;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Build a LinkedIn Marketing API request from a single mutate operation.
|
|
140
|
+
* Creates POST to the entity collection under the account; update and status
|
|
141
|
+
* changes POST to the entity item with X-RestLi-Method: PARTIAL_UPDATE and a
|
|
142
|
+
* { patch: { $set: {...} } } body.
|
|
143
|
+
* @param {{ entity: string, action: string, id?: string, params?: Record<string, unknown> }} operation
|
|
144
|
+
* @param {string} accountId
|
|
145
|
+
* @returns {{ method: string, path: string, headers: Record<string, string>, body: Record<string, unknown> }}
|
|
146
|
+
*/
|
|
147
|
+
export function buildApiRequest(operation, accountId) {
|
|
148
|
+
const collection = ENTITY_COLLECTIONS[operation.entity];
|
|
149
|
+
|
|
150
|
+
if (operation.action === 'create') {
|
|
151
|
+
return {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
path: `/adAccounts/${accountId}/${collection}`,
|
|
154
|
+
headers: {},
|
|
155
|
+
body: buildCreateBody(operation, accountId)
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const statusField = STATUS_FIELD_BY_ENTITY[operation.entity];
|
|
160
|
+
const actionStatus = STATUS_BY_ACTION[operation.action];
|
|
161
|
+
const setPayload = actionStatus
|
|
162
|
+
? { [statusField]: actionStatus, ...(operation.params || {}) }
|
|
163
|
+
: { ...(operation.params || {}) };
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
method: 'POST',
|
|
167
|
+
path: `/adAccounts/${accountId}/${collection}/${encodeEntityKey(operation.entity, operation.id)}`,
|
|
168
|
+
headers: {
|
|
169
|
+
'X-RestLi-Method': 'PARTIAL_UPDATE'
|
|
170
|
+
},
|
|
171
|
+
body: {
|
|
172
|
+
patch: {
|
|
173
|
+
$set: setPayload
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Build a human-readable preview of the exact API requests derived from
|
|
181
|
+
* operations, including the versioned headers that would be sent
|
|
182
|
+
* (Authorization is omitted).
|
|
183
|
+
* @param {Array<{ entity: string, action: string, id?: string, params?: Record<string, unknown> }>} operations
|
|
184
|
+
* @param {string} accountId
|
|
185
|
+
* @returns {{ requests: Array<Record<string, unknown>> }}
|
|
186
|
+
*/
|
|
187
|
+
export function buildRequestPreview(operations, accountId) {
|
|
188
|
+
const sharedHeaders = {
|
|
189
|
+
'LinkedIn-Version': getApiVersion(),
|
|
190
|
+
'X-Restli-Protocol-Version': RESTLI_PROTOCOL_VERSION,
|
|
191
|
+
'Content-Type': 'application/json'
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
requests: operations.map((operation, index) => {
|
|
196
|
+
const request = buildApiRequest(operation, accountId);
|
|
197
|
+
return {
|
|
198
|
+
index,
|
|
199
|
+
entity: operation.entity,
|
|
200
|
+
action: operation.action,
|
|
201
|
+
method: request.method,
|
|
202
|
+
path: request.path,
|
|
203
|
+
headers: {
|
|
204
|
+
...sharedHeaders,
|
|
205
|
+
...request.headers
|
|
206
|
+
},
|
|
207
|
+
body: request.body
|
|
208
|
+
};
|
|
209
|
+
})
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
let SdkMcpError = null;
|
|
2
|
+
let SdkErrorCode = null;
|
|
3
|
+
|
|
4
|
+
try {
|
|
5
|
+
const sdkTypes = await import('@modelcontextprotocol/sdk/types.js');
|
|
6
|
+
SdkMcpError = sdkTypes.McpError;
|
|
7
|
+
SdkErrorCode = sdkTypes.ErrorCode;
|
|
8
|
+
} catch {
|
|
9
|
+
// SDK may be absent in minimal test environments.
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Format a successful tool result payload in MCP text content structure.
|
|
14
|
+
* @param {{ summary: string, data: any, metadata?: Record<string, unknown> }} payload
|
|
15
|
+
* @returns {import('@modelcontextprotocol/sdk/types.js').CallToolResult}
|
|
16
|
+
*/
|
|
17
|
+
export function formatSuccess({ summary, data, metadata = {} }) {
|
|
18
|
+
return {
|
|
19
|
+
content: [{
|
|
20
|
+
type: 'text',
|
|
21
|
+
text: JSON.stringify({
|
|
22
|
+
success: true,
|
|
23
|
+
summary,
|
|
24
|
+
data,
|
|
25
|
+
metadata: {
|
|
26
|
+
rowCount: Array.isArray(data) ? data.length : undefined,
|
|
27
|
+
warnings: [],
|
|
28
|
+
...metadata
|
|
29
|
+
}
|
|
30
|
+
}, null, 2)
|
|
31
|
+
}]
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createMcpStyleError(code, message) {
|
|
36
|
+
if (SdkMcpError && SdkErrorCode && SdkErrorCode[code]) {
|
|
37
|
+
return new SdkMcpError(SdkErrorCode[code], message);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const error = new Error(message);
|
|
41
|
+
error.code = code;
|
|
42
|
+
return error;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Convert thrown errors into MCP-compatible typed errors.
|
|
47
|
+
* @param {unknown} error
|
|
48
|
+
* @throws {Error}
|
|
49
|
+
*/
|
|
50
|
+
export function formatError(error) {
|
|
51
|
+
const message = error?.message || String(error) || 'Unknown error';
|
|
52
|
+
const explicitCode = error?.mcpCode === 'InvalidParams' || error?.code === 'InvalidParams'
|
|
53
|
+
? 'InvalidParams'
|
|
54
|
+
: error?.mcpCode === 'InternalError' || error?.code === 'InternalError'
|
|
55
|
+
? 'InternalError'
|
|
56
|
+
: null;
|
|
57
|
+
|
|
58
|
+
if (explicitCode) {
|
|
59
|
+
throw createMcpStyleError(explicitCode, message);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
throw createMcpStyleError('InternalError', message);
|
|
63
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { invalidParamsError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
class RestliRaw {
|
|
4
|
+
constructor(value) {
|
|
5
|
+
this.value = String(value);
|
|
6
|
+
this.__restliRaw = true;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Wrap a pre-built Rest.li expression so buildQueryString inserts it verbatim.
|
|
12
|
+
* Use for structured params like dateRange/search whose parens, colons, and
|
|
13
|
+
* commas must stay literal in the query string.
|
|
14
|
+
* @param {string} value
|
|
15
|
+
* @returns {RestliRaw}
|
|
16
|
+
*/
|
|
17
|
+
export function rawParam(value) {
|
|
18
|
+
return new RestliRaw(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check whether a value was produced by rawParam(). Duck-typed rather than
|
|
23
|
+
* instanceof so values survive module re-imports (e.g. cache-busted test loads).
|
|
24
|
+
*/
|
|
25
|
+
export function isRawParam(value) {
|
|
26
|
+
return Boolean(value)
|
|
27
|
+
&& typeof value === 'object'
|
|
28
|
+
&& value.__restliRaw === true
|
|
29
|
+
&& typeof value.value === 'string';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Percent-encode a single Rest.li value. Extends encodeURIComponent to also
|
|
34
|
+
* encode characters Rest.li treats as structural but JS leaves bare: ! ' ( ) *
|
|
35
|
+
* URNs become e.g. urn%3Ali%3AsponsoredCampaign%3A123.
|
|
36
|
+
* @param {unknown} value
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function encodeRestliValue(value) {
|
|
40
|
+
return encodeURIComponent(String(value)).replace(
|
|
41
|
+
/[!'()*]/g,
|
|
42
|
+
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build a Rest.li List(...) expression with each item percent-encoded and
|
|
48
|
+
* item separators kept as literal commas.
|
|
49
|
+
* @param {unknown[] | unknown} values
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function restliList(values) {
|
|
53
|
+
const items = Array.isArray(values) ? values : [values];
|
|
54
|
+
return `List(${items.map(encodeRestliValue).join(',')})`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseIsoDate(value, label) {
|
|
58
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value ?? '').trim());
|
|
59
|
+
if (!match) {
|
|
60
|
+
throw invalidParamsError(`Invalid ${label} date format: ${value}. Expected YYYY-MM-DD`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const year = Number(match[1]);
|
|
64
|
+
const month = Number(match[2]);
|
|
65
|
+
const day = Number(match[3]);
|
|
66
|
+
|
|
67
|
+
if (month < 1 || month > 12 || day < 1 || day > 31) {
|
|
68
|
+
throw invalidParamsError(`Invalid ${label} date value: ${value}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { year, month, day };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Convert YYYY-MM-DD to a Rest.li date expression: (year:2026,month:6,day:1).
|
|
76
|
+
* Month and day are emitted without leading zeros as LinkedIn expects.
|
|
77
|
+
* @param {string} value
|
|
78
|
+
* @param {string} [label]
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
export function restliDate(value, label = 'date') {
|
|
82
|
+
const { year, month, day } = parseIsoDate(value, label);
|
|
83
|
+
return `(year:${year},month:${month},day:${day})`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build the adAnalytics dateRange expression from ISO dates. End is optional
|
|
88
|
+
* (LinkedIn treats a missing end as "through today").
|
|
89
|
+
* @param {string} start
|
|
90
|
+
* @param {string} [end]
|
|
91
|
+
* @returns {string}
|
|
92
|
+
*/
|
|
93
|
+
export function restliDateRange(start, end) {
|
|
94
|
+
const parts = [`start:${restliDate(start, 'start')}`];
|
|
95
|
+
if (end !== undefined && end !== null && end !== '') {
|
|
96
|
+
parts.push(`end:${restliDate(end, 'end')}`);
|
|
97
|
+
}
|
|
98
|
+
return `(${parts.join(',')})`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build a finder search expression: (status:(values:List(ACTIVE,PAUSED)),...).
|
|
103
|
+
* Fields with empty value arrays are omitted; returns null when no clause
|
|
104
|
+
* remains so callers can skip the param entirely.
|
|
105
|
+
* @param {Record<string, unknown[]>} filters
|
|
106
|
+
* @returns {string | null}
|
|
107
|
+
*/
|
|
108
|
+
export function restliSearchFilter(filters = {}) {
|
|
109
|
+
const clauses = Object.entries(filters)
|
|
110
|
+
.filter(([, values]) => Array.isArray(values) && values.length > 0)
|
|
111
|
+
.map(([field, values]) => `${field}:(values:${restliList(values)})`);
|
|
112
|
+
|
|
113
|
+
if (clauses.length === 0) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return `(${clauses.join(',')})`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Build a Rest.li 2.0-safe query string. Never uses URLSearchParams because
|
|
122
|
+
* that would percent-encode the structural parens/colons/commas of List(...)
|
|
123
|
+
* and dateRange expressions. Handling by value type:
|
|
124
|
+
* - rawParam(...) values are inserted verbatim
|
|
125
|
+
* - arrays become List(...) with percent-encoded items
|
|
126
|
+
* - everything else is percent-encoded as a single value
|
|
127
|
+
* Null/undefined/empty-string values are skipped.
|
|
128
|
+
* @param {Record<string, unknown>} [params]
|
|
129
|
+
* @returns {string}
|
|
130
|
+
*/
|
|
131
|
+
export function buildQueryString(params = {}) {
|
|
132
|
+
const parts = [];
|
|
133
|
+
|
|
134
|
+
for (const [key, value] of Object.entries(params)) {
|
|
135
|
+
if (value === undefined || value === null || value === '') {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (isRawParam(value)) {
|
|
140
|
+
parts.push(`${key}=${value.value}`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (Array.isArray(value)) {
|
|
145
|
+
parts.push(`${key}=${restliList(value)}`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
parts.push(`${key}=${encodeRestliValue(value)}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return parts.join('&');
|
|
153
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { invalidParamsError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validate that required fields are present on the params object.
|
|
5
|
+
*/
|
|
6
|
+
export function validateRequired(params, fields) {
|
|
7
|
+
const missing = fields.filter((field) => {
|
|
8
|
+
const value = params[field];
|
|
9
|
+
return value === undefined || value === null || value === '';
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
if (missing.length > 0) {
|
|
13
|
+
throw invalidParamsError(`Missing required parameter${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validate an enum value against an allowed set.
|
|
19
|
+
*/
|
|
20
|
+
export function validateEnum(value, allowed, paramName = 'value') {
|
|
21
|
+
if (!allowed.includes(value)) {
|
|
22
|
+
throw invalidParamsError(`Invalid ${paramName}: ${value}. Allowed values: ${allowed.join(', ')}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validate that a value is a non-empty array.
|
|
28
|
+
*/
|
|
29
|
+
export function validateArray(value, paramName = 'value') {
|
|
30
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
31
|
+
throw invalidParamsError(`${paramName} must be a non-empty array`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* URN prefixes for the sponsored entity types this server works with.
|
|
37
|
+
*/
|
|
38
|
+
export const ENTITY_URN_PREFIXES = {
|
|
39
|
+
account: 'urn:li:sponsoredAccount:',
|
|
40
|
+
campaign: 'urn:li:sponsoredCampaign:',
|
|
41
|
+
campaign_group: 'urn:li:sponsoredCampaignGroup:',
|
|
42
|
+
creative: 'urn:li:sponsoredCreative:'
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Normalize ad account IDs by stripping an optional sponsoredAccount URN prefix.
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeAccountId(accountId) {
|
|
49
|
+
const raw = String(accountId || '').trim();
|
|
50
|
+
if (!raw) {
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return raw.startsWith(ENTITY_URN_PREFIXES.account)
|
|
55
|
+
? raw.slice(ENTITY_URN_PREFIXES.account.length)
|
|
56
|
+
: raw;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve account ID from params or environment, normalized to the plain numeric form.
|
|
61
|
+
*/
|
|
62
|
+
export function getAccountId(params = {}) {
|
|
63
|
+
const accountId = params.account_id || process.env.LINKEDIN_ADS_ACCOUNT_ID;
|
|
64
|
+
if (!accountId) {
|
|
65
|
+
throw invalidParamsError('account_id parameter or LINKEDIN_ADS_ACCOUNT_ID environment variable required');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return normalizeAccountId(accountId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Convert a plain ID to a fully-qualified sponsored entity URN.
|
|
73
|
+
* IDs that already look like URNs are passed through untouched.
|
|
74
|
+
* @param {'account' | 'campaign' | 'campaign_group' | 'creative'} entityType
|
|
75
|
+
* @param {string | number} id
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
export function toEntityUrn(entityType, id) {
|
|
79
|
+
const prefix = ENTITY_URN_PREFIXES[entityType];
|
|
80
|
+
if (!prefix) {
|
|
81
|
+
throw invalidParamsError(`Unknown entity type for URN: ${entityType}. Allowed: ${Object.keys(ENTITY_URN_PREFIXES).join(', ')}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const raw = String(id ?? '').trim();
|
|
85
|
+
if (!raw) {
|
|
86
|
+
throw invalidParamsError(`Missing id for ${entityType} URN`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return raw.startsWith('urn:li:') ? raw : `${prefix}${raw}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Extract the trailing entity ID from a URN, or return the value unchanged
|
|
94
|
+
* when it is already a plain ID.
|
|
95
|
+
*/
|
|
96
|
+
export function fromEntityUrn(value) {
|
|
97
|
+
const raw = String(value ?? '').trim();
|
|
98
|
+
if (!raw.startsWith('urn:li:')) {
|
|
99
|
+
return raw;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const segments = raw.split(':');
|
|
103
|
+
return segments[segments.length - 1];
|
|
104
|
+
}
|