@kubun/connector-google-contacts 0.17.1 → 0.17.3
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/lib/api.d.ts +5 -2
- package/lib/api.js +27 -64
- package/lib/index.d.ts +1 -1
- package/lib/model.d.ts +5 -5
- package/lib/model.js +4 -17
- package/lib/provider.js +27 -56
- package/package.json +4 -4
package/lib/api.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type EntityRecord } from '@kubun/connector';
|
|
2
|
+
import { type GoogleAPIClient } from '@kubun/provider-google';
|
|
2
3
|
export type GooglePersonResource = {
|
|
3
4
|
resourceName: string;
|
|
4
5
|
etag?: string;
|
|
@@ -24,17 +25,19 @@ export type GooglePersonResource = {
|
|
|
24
25
|
deleted?: boolean;
|
|
25
26
|
};
|
|
26
27
|
};
|
|
28
|
+
export declare function peopleAPIClient(accessToken: string, fetch?: typeof globalThis.fetch): GoogleAPIClient;
|
|
27
29
|
export type GoogleConnectionsResponse = {
|
|
28
30
|
connections?: Array<GooglePersonResource>;
|
|
29
31
|
nextPageToken?: string;
|
|
30
32
|
nextSyncToken?: string;
|
|
31
33
|
totalPeople?: number;
|
|
32
34
|
};
|
|
33
|
-
export
|
|
35
|
+
export type BuildListURLParams = {
|
|
34
36
|
pageToken?: string;
|
|
35
37
|
syncToken?: string;
|
|
36
38
|
pageSize?: number;
|
|
37
|
-
}
|
|
39
|
+
};
|
|
40
|
+
export declare function buildListURL(params: BuildListURLParams): string;
|
|
38
41
|
export declare function mapPersonToEntity(person: GooglePersonResource): EntityRecord;
|
|
39
42
|
export declare function buildPersonFromParams(params: Record<string, unknown>): Partial<GooglePersonResource>;
|
|
40
43
|
export declare function getPerson(config: {
|
package/lib/api.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { classifyHTTPStatus, connectorHTTPError } from '@kubun/connector';
|
|
2
|
+
import { createGoogleAPIClient } from '@kubun/provider-google';
|
|
2
3
|
import { ContactEtagRequired, GoogleContactsError } from './errors.js';
|
|
3
4
|
const PERSON_FIELDS = 'names,emailAddresses,phoneNumbers,organizations,photos';
|
|
4
5
|
const CONTACTS_BASE_URL = 'https://people.googleapis.com/v1';
|
|
@@ -12,41 +13,22 @@ function assertValidSourceID(sourceID) {
|
|
|
12
13
|
throw new Error(`Invalid contact sourceID: ${JSON.stringify(sourceID)}`);
|
|
13
14
|
}
|
|
14
15
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} catch {
|
|
23
|
-
return {
|
|
24
|
-
message: `${response.status} ${response.statusText}`
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
async function parseGoogleError(response) {
|
|
29
|
-
return (await parseGoogleErrorBody(response)).message;
|
|
30
|
-
}
|
|
31
|
-
// Build the error thrown by a People API call, tagged with the HTTP status and
|
|
32
|
-
// its outcome classification (`kind`, see `classifyHTTPStatus`) — a definite
|
|
33
|
-
// rejection dead-letters a write instead of re-driving it. The status is also
|
|
34
|
-
// the seam fetchEntity/reconcile (provider.ts) use to turn a 404 into a
|
|
35
|
-
// `present: false` result (deletion detected) instead of a throw: a Google
|
|
36
|
-
// person resource has no `metadata.deleted` flag on a direct get — the 404
|
|
37
|
-
// itself IS the deletion signal.
|
|
38
|
-
function googlePeopleAPIError(status, detail) {
|
|
39
|
-
return connectorHTTPError(`Google People API error: ${detail}`, status);
|
|
16
|
+
export function peopleAPIClient(accessToken, fetch) {
|
|
17
|
+
return createGoogleAPIClient({
|
|
18
|
+
api: 'Google People API',
|
|
19
|
+
accessToken,
|
|
20
|
+
fetch,
|
|
21
|
+
toError: ({ httpStatus, message })=>connectorHTTPError(message, httpStatus)
|
|
22
|
+
});
|
|
40
23
|
}
|
|
41
24
|
// A write answered with FAILED_PRECONDITION (a 400) was refused because the
|
|
42
25
|
// contact changed at Google since the etag it was conditioned on — the People
|
|
43
26
|
// API's form of a 412. Like a 412 it is outcome-unknown, never a rejection:
|
|
44
27
|
// recovery reconciles against the contact's current state.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
kind: status === 'FAILED_PRECONDITION' ? 'unknown' : classifyHTTPStatus(response.status)
|
|
28
|
+
function googlePeopleWriteError(failure) {
|
|
29
|
+
return new GoogleContactsError(failure.message, {
|
|
30
|
+
httpStatus: failure.httpStatus,
|
|
31
|
+
kind: failure.googleStatus === 'FAILED_PRECONDITION' ? 'unknown' : classifyHTTPStatus(failure.httpStatus)
|
|
50
32
|
});
|
|
51
33
|
}
|
|
52
34
|
export function buildListURL(params) {
|
|
@@ -117,25 +99,15 @@ export function buildPersonFromParams(params) {
|
|
|
117
99
|
}
|
|
118
100
|
// Raw people.get, shared by getPerson/getPersonWithETag (fetchEntity/
|
|
119
101
|
// reconcile's targeted read) — a single choke point for sourceID validation
|
|
120
|
-
// and 404
|
|
102
|
+
// and 404 classification.
|
|
121
103
|
async function fetchPersonResource(config) {
|
|
122
104
|
assertValidSourceID(config.sourceID);
|
|
123
|
-
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
124
105
|
const url = `${CONTACTS_BASE_URL}/${config.sourceID}?personFields=${PERSON_FIELDS}`;
|
|
125
|
-
|
|
126
|
-
headers: {
|
|
127
|
-
Authorization: `Bearer ${config.accessToken}`
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
if (!response.ok) {
|
|
131
|
-
const errorMessage = await parseGoogleError(response);
|
|
132
|
-
throw googlePeopleAPIError(response.status, errorMessage);
|
|
133
|
-
}
|
|
134
|
-
return await response.json();
|
|
106
|
+
return peopleAPIClient(config.accessToken, config.fetch).get(url);
|
|
135
107
|
}
|
|
136
108
|
// Targeted single-contact fetch (anti-entropy repair verification / write-back
|
|
137
|
-
// recovery's fetchEntity). A 404 here is the deletion signal
|
|
138
|
-
//
|
|
109
|
+
// recovery's fetchEntity). A 404 here is the deletion signal: fetchOrAbsent
|
|
110
|
+
// turns it into a `present: false` result instead of a throw.
|
|
139
111
|
export async function getPerson(config) {
|
|
140
112
|
const data = await fetchPersonResource(config);
|
|
141
113
|
return mapPersonToEntity(data);
|
|
@@ -151,21 +123,11 @@ export async function getPersonWithETag(config) {
|
|
|
151
123
|
};
|
|
152
124
|
}
|
|
153
125
|
export async function createContact(options) {
|
|
154
|
-
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
155
126
|
const person = buildPersonFromParams(options.params);
|
|
156
|
-
const
|
|
127
|
+
const created = await peopleAPIClient(options.accessToken, options.fetch).send(`${CONTACTS_BASE_URL}/people:createContact?personFields=${PERSON_FIELDS}`, {
|
|
157
128
|
method: 'POST',
|
|
158
|
-
|
|
159
|
-
Authorization: `Bearer ${options.accessToken}`,
|
|
160
|
-
'Content-Type': 'application/json'
|
|
161
|
-
},
|
|
162
|
-
body: JSON.stringify(person)
|
|
129
|
+
body: person
|
|
163
130
|
});
|
|
164
|
-
if (!response.ok) {
|
|
165
|
-
const errorMessage = await parseGoogleError(response);
|
|
166
|
-
throw googlePeopleAPIError(response.status, errorMessage);
|
|
167
|
-
}
|
|
168
|
-
const created = await response.json();
|
|
169
131
|
return mapPersonToEntity(created);
|
|
170
132
|
}
|
|
171
133
|
function contactEtagRequired() {
|
|
@@ -177,7 +139,6 @@ export async function updateContact(options) {
|
|
|
177
139
|
// the only way to get one is a fresh read, which would overwrite a change
|
|
178
140
|
// made at Google since the edit: refuse before any request instead.
|
|
179
141
|
if (options.etag == null || options.etag === '') throw contactEtagRequired();
|
|
180
|
-
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
181
142
|
const person = buildPersonFromParams(options.params);
|
|
182
143
|
person.etag = options.etag;
|
|
183
144
|
const updatePersonFields = [];
|
|
@@ -186,15 +147,17 @@ export async function updateContact(options) {
|
|
|
186
147
|
if (person.phoneNumbers != null) updatePersonFields.push('phoneNumbers');
|
|
187
148
|
if (person.organizations != null) updatePersonFields.push('organizations');
|
|
188
149
|
const url = `${CONTACTS_BASE_URL}/${options.sourceID}:updateContact?updatePersonFields=${updatePersonFields.join(',')}&personFields=${PERSON_FIELDS}`;
|
|
189
|
-
const
|
|
150
|
+
const client = createGoogleAPIClient({
|
|
151
|
+
api: 'Google People API',
|
|
152
|
+
accessToken: options.accessToken,
|
|
153
|
+
fetch: options.fetch,
|
|
154
|
+
toError: googlePeopleWriteError
|
|
155
|
+
});
|
|
156
|
+
const response = await client.request(url, {
|
|
190
157
|
method: 'PATCH',
|
|
191
|
-
|
|
192
|
-
Authorization: `Bearer ${options.accessToken}`,
|
|
193
|
-
'Content-Type': 'application/json'
|
|
194
|
-
},
|
|
195
|
-
body: JSON.stringify(person)
|
|
158
|
+
body: person
|
|
196
159
|
});
|
|
197
|
-
if (!response.ok)
|
|
160
|
+
if (!response.ok) return client.fail(response);
|
|
198
161
|
const updated = await response.json();
|
|
199
162
|
return mapPersonToEntity(updated);
|
|
200
163
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { createGoogleContactsActionHandler, type GoogleContactsActionHandlerConfig, } from './actions.js';
|
|
2
|
-
export { buildListURL, buildPersonFromParams, type CreateContactParams, createContact, type GoogleConnectionsResponse, type GooglePersonResource, mapPersonToEntity, type UpdateContactParams, updateContact, } from './api.js';
|
|
2
|
+
export { type BuildListURLParams, buildListURL, buildPersonFromParams, type CreateContactParams, createContact, type GoogleConnectionsResponse, type GooglePersonResource, mapPersonToEntity, type UpdateContactParams, updateContact, } from './api.js';
|
|
3
3
|
export { type CreateGoogleContactsConnectorOptions, createGoogleContactsConnector, } from './connector.js';
|
|
4
4
|
export { ContactEtagRequired, GoogleContactsError, type GoogleContactsErrorCode, type GoogleContactsErrorOptions, } from './errors.js';
|
|
5
5
|
export { canonicalizeContactIntent } from './intent.js';
|
package/lib/model.d.ts
CHANGED
|
@@ -42,6 +42,11 @@ export declare const googleContactDefinition: {
|
|
|
42
42
|
readonly required: ["sourceService", "sourceID", "lastSyncedAt"];
|
|
43
43
|
readonly additionalProperties: false;
|
|
44
44
|
readonly definitions: {
|
|
45
|
+
readonly PhotoURL: {
|
|
46
|
+
readonly type: "string";
|
|
47
|
+
readonly format: "uri";
|
|
48
|
+
readonly title: "URL";
|
|
49
|
+
};
|
|
45
50
|
readonly SourceService: {
|
|
46
51
|
readonly type: "string";
|
|
47
52
|
readonly title: "SourceService";
|
|
@@ -59,11 +64,6 @@ export declare const googleContactDefinition: {
|
|
|
59
64
|
readonly format: "uri";
|
|
60
65
|
readonly title: "URL";
|
|
61
66
|
};
|
|
62
|
-
readonly PhotoURL: {
|
|
63
|
-
readonly type: "string";
|
|
64
|
-
readonly format: "uri";
|
|
65
|
-
readonly title: "URL";
|
|
66
|
-
};
|
|
67
67
|
readonly DateTime: import("json-schema-typed").JSONSchema.String;
|
|
68
68
|
};
|
|
69
69
|
};
|
package/lib/model.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { connectorClusters, connectorModelIDs } from '@kubun/connector';
|
|
1
|
+
import { connectorClusters, connectorModelIDs, externalEntityDefinition } from '@kubun/connector';
|
|
2
2
|
import { modelIDs, modelsClusters } from '@kubun/models';
|
|
3
3
|
import { GraphBuilder } from '@kubun/protocol';
|
|
4
|
-
import {
|
|
4
|
+
import { urlSchema } from '@kubun/scalars';
|
|
5
5
|
export const googleContactDefinition = {
|
|
6
6
|
name: 'GoogleContact',
|
|
7
7
|
behavior: 'default',
|
|
@@ -53,21 +53,8 @@ export const googleContactDefinition = {
|
|
|
53
53
|
],
|
|
54
54
|
additionalProperties: false,
|
|
55
55
|
definitions: {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
title: 'SourceService',
|
|
59
|
-
minLength: 1,
|
|
60
|
-
maxLength: 50
|
|
61
|
-
},
|
|
62
|
-
SourceID: {
|
|
63
|
-
type: 'string',
|
|
64
|
-
title: 'SourceID',
|
|
65
|
-
minLength: 1,
|
|
66
|
-
maxLength: 500
|
|
67
|
-
},
|
|
68
|
-
SourceURL: urlSchema,
|
|
69
|
-
PhotoURL: urlSchema,
|
|
70
|
-
DateTime: dateTimeSchema
|
|
56
|
+
...externalEntityDefinition.schema.definitions,
|
|
57
|
+
PhotoURL: urlSchema
|
|
71
58
|
}
|
|
72
59
|
},
|
|
73
60
|
fieldsMeta: {},
|
package/lib/provider.js
CHANGED
|
@@ -1,32 +1,19 @@
|
|
|
1
|
-
import { resolveModelID } from '@kubun/connector';
|
|
2
|
-
import { buildListURL, getPerson, getPersonWithETag, mapPersonToEntity } from './api.js';
|
|
1
|
+
import { fetchOrAbsent, resolveModelID } from '@kubun/connector';
|
|
2
|
+
import { buildListURL, getPerson, getPersonWithETag, mapPersonToEntity, peopleAPIClient } from './api.js';
|
|
3
3
|
import { googleContactsClusters } from './model.js';
|
|
4
4
|
const GOOGLE_CONTACT_MODEL_ID = resolveModelID(googleContactsClusters, 'GoogleContact');
|
|
5
5
|
export function createGoogleContactsProvider(config) {
|
|
6
6
|
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
7
|
+
const client = peopleAPIClient(config.credential.accessToken, fetchFn);
|
|
7
8
|
const mintFetchHLC = config.mintFetchHLC;
|
|
8
|
-
// getPerson/getPersonWithETag (api.ts) throw a plain Error tagged with
|
|
9
|
-
// `.httpStatus` (googlePeopleAPIError) — this is the seam fetchEntity/
|
|
10
|
-
// reconcile use to turn a 404 into a `present: false` result instead of a
|
|
11
|
-
// throw.
|
|
12
|
-
function isNotFound(err) {
|
|
13
|
-
return err?.httpStatus === 404;
|
|
14
|
-
}
|
|
15
9
|
async function fetchPage(params) {
|
|
16
10
|
const url = buildListURL({
|
|
17
11
|
pageToken: params.pageToken,
|
|
18
12
|
syncToken: params.syncToken
|
|
19
13
|
});
|
|
20
|
-
|
|
21
|
-
headers: {
|
|
22
|
-
Authorization: `Bearer ${config.credential.accessToken}`
|
|
23
|
-
},
|
|
14
|
+
return client.get(url, {
|
|
24
15
|
signal: params.signal
|
|
25
16
|
});
|
|
26
|
-
if (!response.ok) {
|
|
27
|
-
throw new Error(`Google People API error: ${response.status} ${response.statusText}`);
|
|
28
|
-
}
|
|
29
|
-
return await response.json();
|
|
30
17
|
}
|
|
31
18
|
async function* fetchAllEntities(params) {
|
|
32
19
|
let pageToken;
|
|
@@ -94,27 +81,20 @@ export function createGoogleContactsProvider(config) {
|
|
|
94
81
|
// (see ServerProviderFactory.mintFetchHLC's doc).
|
|
95
82
|
async function fetchEntity(_model, sourceID) {
|
|
96
83
|
const sourceFetchHLC = mintFetchHLC();
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return {
|
|
104
|
-
present: true,
|
|
105
|
-
record,
|
|
106
|
-
sourceFetchHLC
|
|
107
|
-
};
|
|
108
|
-
} catch (err) {
|
|
109
|
-
if (isNotFound(err)) {
|
|
110
|
-
return {
|
|
111
|
-
present: false,
|
|
84
|
+
return fetchOrAbsent({
|
|
85
|
+
sourceID,
|
|
86
|
+
sourceFetchHLC,
|
|
87
|
+
read: async ()=>{
|
|
88
|
+
const record = await getPerson({
|
|
89
|
+
accessToken: config.credential.accessToken,
|
|
112
90
|
sourceID,
|
|
113
|
-
|
|
91
|
+
fetch: fetchFn
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
record
|
|
114
95
|
};
|
|
115
96
|
}
|
|
116
|
-
|
|
117
|
-
}
|
|
97
|
+
});
|
|
118
98
|
}
|
|
119
99
|
// Provider-authoritative re-read for write-back recovery. Unlike
|
|
120
100
|
// fetchEntity, the present:true branch also surfaces a providerETag when the
|
|
@@ -122,30 +102,21 @@ export function createGoogleContactsProvider(config) {
|
|
|
122
102
|
// can condition on.
|
|
123
103
|
async function reconcile(_model, sourceID) {
|
|
124
104
|
const sourceFetchHLC = mintFetchHLC();
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return {
|
|
132
|
-
present: true,
|
|
133
|
-
record,
|
|
134
|
-
sourceFetchHLC,
|
|
135
|
-
...etag != null ? {
|
|
136
|
-
providerETag: etag
|
|
137
|
-
} : {}
|
|
138
|
-
};
|
|
139
|
-
} catch (err) {
|
|
140
|
-
if (isNotFound(err)) {
|
|
141
|
-
return {
|
|
142
|
-
present: false,
|
|
105
|
+
return fetchOrAbsent({
|
|
106
|
+
sourceID,
|
|
107
|
+
sourceFetchHLC,
|
|
108
|
+
read: async ()=>{
|
|
109
|
+
const { record, etag } = await getPersonWithETag({
|
|
110
|
+
accessToken: config.credential.accessToken,
|
|
143
111
|
sourceID,
|
|
144
|
-
|
|
112
|
+
fetch: fetchFn
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
record,
|
|
116
|
+
providerETag: etag
|
|
145
117
|
};
|
|
146
118
|
}
|
|
147
|
-
|
|
148
|
-
}
|
|
119
|
+
});
|
|
149
120
|
}
|
|
150
121
|
return {
|
|
151
122
|
supportedStrategies: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/connector-google-contacts",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.3",
|
|
4
4
|
"keywords": [],
|
|
5
5
|
"license": "see LICENSE.md",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
"LICENSE.md"
|
|
16
16
|
],
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@kubun/connector": "^0.17.
|
|
18
|
+
"@kubun/connector": "^0.17.3",
|
|
19
19
|
"@kubun/credential-types": "^0.17.2",
|
|
20
20
|
"@kubun/hlc": "^0.17.0",
|
|
21
21
|
"@kubun/models": "^0.17.0",
|
|
22
|
-
"@kubun/protocol": "^0.17.
|
|
23
|
-
"@kubun/provider-google": "^0.17.
|
|
22
|
+
"@kubun/protocol": "^0.17.2",
|
|
23
|
+
"@kubun/provider-google": "^0.17.1",
|
|
24
24
|
"@kubun/scalars": "^0.17.0"
|
|
25
25
|
},
|
|
26
26
|
"publishConfig": {
|