@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 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 declare function buildListURL(params: {
35
+ export type BuildListURLParams = {
34
36
  pageToken?: string;
35
37
  syncToken?: string;
36
38
  pageSize?: number;
37
- }): string;
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
- async function parseGoogleErrorBody(response) {
16
- try {
17
- const body = await response.json();
18
- return {
19
- message: body.error?.message ?? `${response.status} ${response.statusText}`,
20
- status: body.error?.status
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
- async function googlePeopleWriteError(response) {
46
- const { message, status } = await parseGoogleErrorBody(response);
47
- return new GoogleContactsError(`Google People API error: ${message}`, {
48
- httpStatus: response.status,
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 tagging.
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
- const response = await fetchFn(url, {
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 — see
138
- // googlePeopleAPIError's doc.
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 response = await fetchFn(`${CONTACTS_BASE_URL}/people:createContact?personFields=${PERSON_FIELDS}`, {
127
+ const created = await peopleAPIClient(options.accessToken, options.fetch).send(`${CONTACTS_BASE_URL}/people:createContact?personFields=${PERSON_FIELDS}`, {
157
128
  method: 'POST',
158
- headers: {
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 response = await fetchFn(url, {
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
- headers: {
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) throw await googlePeopleWriteError(response);
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 { dateTimeSchema, urlSchema } from '@kubun/scalars';
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
- SourceService: {
57
- type: 'string',
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
- const response = await fetchFn(url, {
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
- try {
98
- const record = await getPerson({
99
- accessToken: config.credential.accessToken,
100
- sourceID,
101
- fetch: fetchFn
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
- sourceFetchHLC
91
+ fetch: fetchFn
92
+ });
93
+ return {
94
+ record
114
95
  };
115
96
  }
116
- throw err;
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
- try {
126
- const { record, etag } = await getPersonWithETag({
127
- accessToken: config.credential.accessToken,
128
- sourceID,
129
- fetch: fetchFn
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
- sourceFetchHLC
112
+ fetch: fetchFn
113
+ });
114
+ return {
115
+ record,
116
+ providerETag: etag
145
117
  };
146
118
  }
147
- throw err;
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.1",
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.2",
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.1",
23
- "@kubun/provider-google": "^0.17.0",
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": {