@attlaz/client 1.111.0 → 1.113.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.
@@ -33,7 +33,7 @@ export class HttpClient {
33
33
  clientError.response = {
34
34
  status: rawResponse.status,
35
35
  statusText,
36
- data: await rawResponse.json(),
36
+ data: JSON.parse(Buffer.from(await rawResponse.arrayBuffer()).toString('utf8')),
37
37
  };
38
38
  }
39
39
  catch {
@@ -45,13 +45,11 @@ export class HttpClient {
45
45
  rawResponse.headers.forEach((value, header) => {
46
46
  httpResponse.headers[header] = value;
47
47
  });
48
- const contentType = rawResponse.headers.get('content-type');
49
- if (contentType !== null && contentType.includes('application/json')) {
50
- httpResponse.body = await rawResponse.json();
51
- }
52
- else {
53
- httpResponse.body = await rawResponse.text();
54
- }
48
+ // Bytes, never an interpretation. The caller already knows what it asked for — an
49
+ // endpoint supplies a parser, a blob read wants the bytes — so sniffing the content type
50
+ // here would be a second, weaker answer to a question already answered. It was also
51
+ // lossy: the old `text()` fallback UTF-8-decoded binary, turning 10 bytes into 16.
52
+ httpResponse.body = Buffer.from(await rawResponse.arrayBuffer());
55
53
  return httpResponse;
56
54
  }
57
55
  catch (error) {
@@ -1,8 +1,19 @@
1
1
  import { Headers } from './Data/Headers.js';
2
+ /**
3
+ * A response as it came off the wire. `body` is always bytes — the transport does not interpret,
4
+ * because the caller already knows what it asked for. Use `getJson()` or `getBytes()` to say which.
5
+ */
2
6
  export declare class HttpClientResponse {
3
7
  status: number;
4
8
  statusText: string;
5
- body: any | null;
9
+ body: Buffer | null;
6
10
  headers: Headers;
7
11
  constructor(status: number, statusText: string);
12
+ /** The raw bytes, empty when the response had no body. */
13
+ getBytes(): Buffer;
14
+ /**
15
+ * The body parsed as JSON, or null when there was none — a 204 and a DELETE that answers empty
16
+ * are normal, and `JSON.parse('')` throws.
17
+ */
18
+ getJson<T>(): T | null;
8
19
  }
@@ -1,3 +1,7 @@
1
+ /**
2
+ * A response as it came off the wire. `body` is always bytes — the transport does not interpret,
3
+ * because the caller already knows what it asked for. Use `getJson()` or `getBytes()` to say which.
4
+ */
1
5
  export class HttpClientResponse {
2
6
  status;
3
7
  statusText;
@@ -7,4 +11,19 @@ export class HttpClientResponse {
7
11
  this.status = status;
8
12
  this.statusText = statusText;
9
13
  }
14
+ /** The raw bytes, empty when the response had no body. */
15
+ getBytes() {
16
+ return this.body ?? Buffer.alloc(0);
17
+ }
18
+ /**
19
+ * The body parsed as JSON, or null when there was none — a 204 and a DELETE that answers empty
20
+ * are normal, and `JSON.parse('')` throws.
21
+ */
22
+ getJson() {
23
+ const bytes = this.getBytes();
24
+ if (bytes.length === 0) {
25
+ return null;
26
+ }
27
+ return JSON.parse(bytes.toString('utf8'));
28
+ }
10
29
  }
@@ -32,6 +32,7 @@ export declare class DirectTransport implements ITransport {
32
32
  private parseErrorHandler;
33
33
  constructor(defaultBaseUrl: string, sessionPayload: string, sessionSignature: string, routes?: DirectTransportRoute[]);
34
34
  request<T>(action: string, parameters?: Parameters, method?: string, _signWithOauthToken?: boolean): Promise<T>;
35
+ requestBytes(action: string, parameters?: Parameters, method?: string, _signWithOauthToken?: boolean, headers?: Record<string, string>): Promise<Buffer>;
35
36
  private resolveClient;
36
37
  isDebugEnabled(): boolean;
37
38
  setParseErrorHandler(handler: ParseErrorHandler | null): void;
@@ -44,6 +44,10 @@ export class DirectTransport {
44
44
  // Always call without OAuth signing — authentication is via the signed session headers
45
45
  return await client.request(action, parameters, method, false);
46
46
  }
47
+ async requestBytes(action, parameters = null, method = 'GET', _signWithOauthToken = true, headers = {}) {
48
+ const client = this.resolveClient(action);
49
+ return await client.requestBytes(action, parameters, method, false, headers);
50
+ }
47
51
  resolveClient(action) {
48
52
  for (const route of this.sortedRoutes) {
49
53
  if (action.startsWith(route.prefix)) {
@@ -7,6 +7,8 @@ import { Parameters } from '../Data/Parameters.js';
7
7
  export type ParseErrorHandler = (message: string, context: Record<string, unknown>) => void;
8
8
  export interface ITransport {
9
9
  request: <T>(action: string, parameters: Parameters, method: string, signWithOauthToken: boolean) => Promise<T>;
10
+ /** The response bytes, undecoded — for resources a JSON round trip would inflate or destroy. */
11
+ requestBytes: (action: string, parameters: Parameters, method: string, signWithOauthToken: boolean, headers?: Record<string, string>) => Promise<Buffer>;
10
12
  isDebugEnabled: () => boolean;
11
13
  reportParseError: (message: string, context: Record<string, unknown>) => void;
12
14
  setParseErrorHandler: (handler: ParseErrorHandler | null) => void;
@@ -48,6 +48,11 @@ export declare class OAuthClient implements ITransport {
48
48
  */
49
49
  private requestToken;
50
50
  isTokenExpired(): boolean;
51
+ /**
52
+ * Like `request`, but asks for and returns the raw bytes. For a resource that has a binary
53
+ * representation — a stored item — where decoding would destroy it.
54
+ */
55
+ requestBytes(action: string, parameters?: Parameters, method?: string, signWithOauthToken?: boolean, headers?: Record<string, string>): Promise<Buffer>;
51
56
  request<T>(action: string, parameters?: Parameters, method?: string, signWithOauthToken?: boolean): Promise<T>;
52
57
  isAuthenticated(): boolean;
53
58
  getToken(): OAuthClientToken | null;
@@ -219,6 +219,25 @@ export class OAuthClient {
219
219
  }
220
220
  return OAuthClientToken.isExpired(this.oauthClientToken);
221
221
  }
222
+ /**
223
+ * Like `request`, but asks for and returns the raw bytes. For a resource that has a binary
224
+ * representation — a stored item — where decoding would destroy it.
225
+ */
226
+ async requestBytes(action, parameters = null, method = 'GET', signWithOauthToken = true, headers = {}) {
227
+ if (signWithOauthToken) {
228
+ await this.ensureAccessToken();
229
+ if (this.oauthClientToken === null) {
230
+ throw new ClientError('Unable to perform request, access token not provided', HttpStatus.HTTP_UNAUTHORIZED);
231
+ }
232
+ }
233
+ const requestData = this.createRequestData(action, parameters, method, signWithOauthToken);
234
+ requestData.setHeader('Accept', 'application/octet-stream');
235
+ for (const [name, value] of Object.entries(headers)) {
236
+ requestData.setHeader(name, value);
237
+ }
238
+ const response = await HttpClient.request(requestData, this.options.timeoutMs);
239
+ return response.getBytes();
240
+ }
222
241
  async request(action, parameters = null, method = 'GET', signWithOauthToken = true) {
223
242
  if (signWithOauthToken) {
224
243
  // Token-first, credentials-as-fallback (mirrors the PHP client): use a valid token as-is,
@@ -235,7 +254,7 @@ export class OAuthClient {
235
254
  }
236
255
  try {
237
256
  const response = await HttpClient.request(requestData, this.options.timeoutMs);
238
- return response.body;
257
+ return response.getJson();
239
258
  }
240
259
  catch (error) {
241
260
  if (!(error instanceof ClientError)) {
@@ -367,20 +386,33 @@ export class OAuthClient {
367
386
  const url = this.getApiEndpointUrl(action);
368
387
  let requestData = new HttpClientRequest(url, method);
369
388
  requestData.headers = this.getDefaultHeaders();
389
+ // Stated explicitly, even though JSON is what the API returns by default today. The server
390
+ // plans to make bytes the default once no request arrives without an Accept, and a silent
391
+ // client is indistinguishable from one that did not care — which is what makes that flip
392
+ // unsafe. An endpoint wanting another representation overrides this afterwards.
393
+ requestData.setHeader('Accept', 'application/json');
370
394
  if (this.version !== null) {
371
395
  requestData.setHeader('Attlaz-API-Version', this.version);
372
396
  }
373
397
  if ((method === 'POST' || method === 'DELETE' || method === 'PUT' || method === 'PATCH') && parameters !== null && parameters !== undefined) {
374
- if (typeof parameters === 'object') {
398
+ // A Buffer is the payload, not something to serialise. Stringifying one yields
399
+ // {"type":"Buffer","data":[…]} — larger than the bytes and not what any reader expects.
400
+ if (Buffer.isBuffer(parameters)) {
401
+ requestData.body = parameters;
402
+ requestData.setHeader('Content-Type', 'application/octet-stream');
403
+ }
404
+ else if (typeof parameters === 'object') {
375
405
  requestData.body = JsonSerializable.stringify(parameters);
406
+ requestData.setJsonHeader();
376
407
  }
377
408
  else if (typeof parameters === 'string') {
378
409
  requestData.body = parameters;
410
+ requestData.setJsonHeader();
379
411
  }
380
412
  else {
381
413
  console.error('Unknown parameter type: ' + typeof parameters);
414
+ requestData.setJsonHeader();
382
415
  }
383
- requestData.setJsonHeader();
384
416
  }
385
417
  if (method === 'GET' && parameters !== null && parameters !== undefined) {
386
418
  const params = parameters;
@@ -15,6 +15,13 @@ export declare abstract class Endpoint {
15
15
  private prepareParameters;
16
16
  private formatParameters;
17
17
  private formatKey;
18
+ /** Raw response bytes for this action — see `ITransport.requestBytes`. */
19
+ requestBytes(action: string, parameters?: Parameters, method?: string, signWithOauthToken?: boolean, headers?: Record<string, string>): Promise<Buffer>;
20
+ /**
21
+ * Send raw bytes as the request body. Metadata rides headers because the body IS the value —
22
+ * there is no envelope left to put it in.
23
+ */
24
+ requestBinary(action: string, value: Buffer, headers?: Record<string, string>): Promise<void>;
18
25
  requestCollection<T>(action: string | QueryString, parser: (input: any) => T, parameters?: Parameters, method?: string, signWithOauthToken?: boolean): Promise<CollectionResult<T>>;
19
26
  requestObject<T>(action: string | QueryString, parameters: Parameters | undefined, parser: (input: Record<string, any>) => T, method?: string, signWithOauthToken?: boolean): Promise<ObjectResult<T>>;
20
27
  private toApiError;
@@ -79,6 +79,17 @@ export class Endpoint {
79
79
  }
80
80
  return formattedKey;
81
81
  }
82
+ /** Raw response bytes for this action — see `ITransport.requestBytes`. */
83
+ async requestBytes(action, parameters = null, method = 'GET', signWithOauthToken = true, headers = {}) {
84
+ return await this.httpClient.requestBytes(action, parameters, method, signWithOauthToken, headers);
85
+ }
86
+ /**
87
+ * Send raw bytes as the request body. Metadata rides headers because the body IS the value —
88
+ * there is no envelope left to put it in.
89
+ */
90
+ async requestBinary(action, value, headers = {}) {
91
+ await this.httpClient.requestBytes(action, value, 'POST', true, headers);
92
+ }
82
93
  async requestCollection(action, parser, parameters = null, method = 'GET', signWithOauthToken = true) {
83
94
  parameters = this.prepareParameters(parameters);
84
95
  if (action instanceof QueryString) {
@@ -15,6 +15,25 @@ export declare class StorageEndpoint extends Endpoint {
15
15
  clearPool(projectEnvironmentId: string, storageType: StorageType, bucketKey: string): Promise<boolean>;
16
16
  getItem(projectEnvironmentId: string, storageType: StorageType, bucketKey: string, storageItemKey: string): Promise<StorageItem | null>;
17
17
  getBucketItem(storageBucketId: string, storageItemKey: string): Promise<StorageItem | null>;
18
+ /**
19
+ * The item's bytes, without decoding. Use for anything binary — an image, an archive — where the
20
+ * JSON envelope would either inflate it (base64) or destroy it (a UTF-8 decode replaces every
21
+ * invalid sequence, so bytes do not survive the round trip).
22
+ */
23
+ /**
24
+ * Whether an item exists, without transferring it. Backed by HEAD, so a content-addressed check
25
+ * ("do I already have these bytes?") costs headers rather than the whole object.
26
+ */
27
+ hasItem(projectEnvironmentId: string, storageType: StorageType, bucketKey: string, storageItemKey: string): Promise<boolean>;
28
+ getItemBytes(projectEnvironmentId: string, storageType: StorageType, bucketKey: string, storageItemKey: string): Promise<Buffer>;
29
+ /**
30
+ * Store raw bytes. Metadata travels as headers, since the body is the value.
31
+ *
32
+ * Pass `contentType` for anything that will be served to a browser — the CDN reads it back as the
33
+ * response `Content-Type`, and without it an image is delivered as `application/octet-stream`,
34
+ * which downloads rather than renders.
35
+ */
36
+ setItemBytes(projectEnvironmentId: string, storageType: StorageType, bucketKey: string, storageItemKey: string, value: Buffer, expiration?: Date | null, contentType?: string | null): Promise<void>;
18
37
  setItem(projectEnvironmentId: string, storageType: StorageType, bucketKey: string, storageItem: StorageItem): Promise<StorageItem>;
19
38
  deleteItem(projectEnvironmentId: string, storageType: StorageType, bucketKey: string, storageItemKey: string): Promise<boolean>;
20
39
  }
@@ -3,6 +3,8 @@ import { StorageInformation } from '../Model/Storage/StorageInformation.js';
3
3
  import { StorageItem } from '../Model/Storage/StorageItem.js';
4
4
  import { StorageItemInformation } from '../Model/Storage/StorageItemInformation.js';
5
5
  import { QueryString } from '../Http/Data/QueryString.js';
6
+ import { ClientError } from '../Http/ClientError.js';
7
+ import { HttpStatus } from '../Http/HttpStatus.js';
6
8
  import { Endpoint } from './Endpoint.js';
7
9
  export class StorageEndpoint extends Endpoint {
8
10
  async getInformation(projectEnvironmentId, storageType) {
@@ -93,6 +95,50 @@ export class StorageEndpoint extends Endpoint {
93
95
  throw ex;
94
96
  }
95
97
  }
98
+ /**
99
+ * The item's bytes, without decoding. Use for anything binary — an image, an archive — where the
100
+ * JSON envelope would either inflate it (base64) or destroy it (a UTF-8 decode replaces every
101
+ * invalid sequence, so bytes do not survive the round trip).
102
+ */
103
+ /**
104
+ * Whether an item exists, without transferring it. Backed by HEAD, so a content-addressed check
105
+ * ("do I already have these bytes?") costs headers rather than the whole object.
106
+ */
107
+ async hasItem(projectEnvironmentId, storageType, bucketKey, storageItemKey) {
108
+ const cmd = path('/projectenvironments/:projectEnvironmentId/storage/:storageType/:bucketKey/items/:key', { projectEnvironmentId, storageType, bucketKey, key: storageItemKey });
109
+ try {
110
+ await this.requestBytes(cmd, null, 'HEAD');
111
+ return true;
112
+ }
113
+ catch (error) {
114
+ if (ClientError.statusOf(error) === HttpStatus.HTTP_NOTFOUND) {
115
+ return false;
116
+ }
117
+ throw error;
118
+ }
119
+ }
120
+ async getItemBytes(projectEnvironmentId, storageType, bucketKey, storageItemKey) {
121
+ const cmd = path('/projectenvironments/:projectEnvironmentId/storage/:storageType/:bucketKey/items/:key', { projectEnvironmentId, storageType, bucketKey, key: storageItemKey });
122
+ return await this.requestBytes(cmd);
123
+ }
124
+ /**
125
+ * Store raw bytes. Metadata travels as headers, since the body is the value.
126
+ *
127
+ * Pass `contentType` for anything that will be served to a browser — the CDN reads it back as the
128
+ * response `Content-Type`, and without it an image is delivered as `application/octet-stream`,
129
+ * which downloads rather than renders.
130
+ */
131
+ async setItemBytes(projectEnvironmentId, storageType, bucketKey, storageItemKey, value, expiration = null, contentType = null) {
132
+ const cmd = path('/projectenvironments/:projectEnvironmentId/storage/:storageType/:bucketKey/items/:key', { projectEnvironmentId, storageType, bucketKey, key: storageItemKey });
133
+ const headers = {};
134
+ if (expiration !== null) {
135
+ headers['X-Attlaz-Expiration'] = expiration.toISOString();
136
+ }
137
+ if (contentType !== null) {
138
+ headers['X-Attlaz-Content-Type'] = contentType;
139
+ }
140
+ await this.requestBinary(cmd, value, headers);
141
+ }
96
142
  async setItem(projectEnvironmentId, storageType, bucketKey, storageItem) {
97
143
  const cmd = path('/projectenvironments/:projectEnvironmentId/storage/:storageType/:bucketKey/items/:key', { projectEnvironmentId, storageType, bucketKey, key: storageItem.key });
98
144
  try {
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "1.110.0";
1
+ export declare const VERSION = "1.112.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "1.110.0";
1
+ export const VERSION = "1.112.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@attlaz/client",
3
- "version": "1.111.0",
3
+ "version": "1.113.0",
4
4
  "description": "Javascript Client to access Attlaz API",
5
5
  "types": "./dist/index.d.ts",
6
6
  "main": "./dist/index.js",