@graffiti-garden/api 1.0.2 → 1.0.4

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/src/1-api.ts CHANGED
@@ -6,6 +6,9 @@ import type {
6
6
  GraffitiPostObject,
7
7
  GraffitiObjectStream,
8
8
  GraffitiObjectStreamContinue,
9
+ GraffitiMedia,
10
+ GraffitiPostMedia,
11
+ GraffitiMediaAccept,
9
12
  } from "./2-types";
10
13
  import type { JSONSchema } from "json-schema-to-ts";
11
14
 
@@ -150,6 +153,8 @@ export abstract class Graffiti {
150
153
  *
151
154
  * @throws {@link GraffitiErrorSchemaMismatch} if the retrieved object does not match the provided schema.
152
155
  *
156
+ * @throws {@link GraffitiErrorInvalidSchema} If an invalid schema is provided.
157
+ *
153
158
  * @group 1 - Single-Object Methods
154
159
  */
155
160
  abstract get<Schema extends JSONSchema>(
@@ -194,7 +199,7 @@ export abstract class Graffiti {
194
199
  * {@link GraffitiObjectBase.actor | `actor`}.
195
200
  */
196
201
  session: GraffitiSession,
197
- ): Promise<void>;
202
+ ): Promise<GraffitiObjectBase>;
198
203
 
199
204
  /**
200
205
  * Discovers objects created by any actor that are contained
@@ -223,6 +228,9 @@ export abstract class Graffiti {
223
228
  * Since different implementations may fetch data from multiple sources there is
224
229
  * no guarentee on the order that objects are returned in.
225
230
  *
231
+ * @throws {@link GraffitiErrorInvalidSchema} if an invalid schema is provided.
232
+ * Discovery is lazy and will not throw until the iterator is consumed.
233
+ *
226
234
  * @returns Returns a stream of objects that match the given {@link GraffitiObjectBase.channels | `channels`}
227
235
  * and [JSON Schema](https://json-schema.org).
228
236
  *
@@ -263,7 +271,11 @@ export abstract class Graffiti {
263
271
  * instead, which is returned along with the `cursor` at the
264
272
  * end of the original stream.
265
273
  *
266
- * @throws {@link GraffitiErrorForbidden} if the {@link GraffitiObjectBase.actor | `actor`}
274
+ * @throws {@link GraffitiErrorNotFound} upon iteration
275
+ * if the cursor is invalid or expired.
276
+ *
277
+ * @throws {@link GraffitiErrorForbidden} upon iteration
278
+ * if the {@link GraffitiObjectBase.actor | `actor`}
267
279
  * provided in the `session` is not the same as the `actor`
268
280
  * that initiated the original stream.
269
281
  *
@@ -286,24 +298,12 @@ export abstract class Graffiti {
286
298
  * @group 3 - Media Methods
287
299
  */
288
300
  abstract postMedia(
289
- media: {
290
- /**
291
- * The binary data of the media to be uploaded,
292
- * along with its [media type](https://www.iana.org/assignments/media-types/media-types.xhtml),
293
- * formatted as a [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob).
294
- */
295
- data: Blob;
296
- /**
297
- * An optional list, identical in function to an object's
298
- * {@link GraffitiObjectBase.allowed | `allowed`} property,
299
- * that specifies the {@link GraffitiObjectBase.actor | `actor`}s
300
- * who are allowed to access the media. If the list is `undefined`
301
- * or `null`, anyone with the URL can access the media. If the list
302
- * is empty, only the {@link GraffitiObjectBase.actor | `actor`}
303
- * who {@link postMedia | `post`ed} the media can access it.
304
- */
305
- allowed?: string[] | null;
306
- },
301
+ /**
302
+ * The media data to upload, and optionally
303
+ * an {@link GraffitiObjectBase.allowed | `allowed`}
304
+ * list of actors that can view it.
305
+ */
306
+ media: GraffitiPostMedia,
307
307
  /**
308
308
  * An implementation-specific object with information to authenticate the
309
309
  * {@link GraffitiObjectBase.actor | `actor`}.
@@ -326,7 +326,7 @@ export abstract class Graffiti {
326
326
  /**
327
327
  * A globally unique identifier and locator for the media.
328
328
  */
329
- mediaUrl: string,
329
+ url: string,
330
330
  /**
331
331
  * An implementation-specific object with information to authenticate the
332
332
  * {@link GraffitiObjectBase.actor | `actor`}.
@@ -355,29 +355,15 @@ export abstract class Graffiti {
355
355
  */
356
356
  mediaUrl: string,
357
357
  /**
358
- * A set of requirements the retrieved media must meet.
358
+ * A specification for what types and sizes of media are acceptable.
359
359
  */
360
- requirements: {
361
- /**
362
- * A list of acceptable media types for the retrieved media,
363
- * formatted as like an [HTTP Accept header](https://httpwg.org/specs/rfc9110.html#field.accept)
364
- */
365
- accept?: string;
366
- /**
367
- * The maximum acceptable size, in bytes, of the media.
368
- */
369
- maxBytes?: number;
370
- },
360
+ accept: GraffitiMediaAccept,
371
361
  /**
372
362
  * An implementation-specific object with information to authenticate the
373
363
  * {@link GraffitiObjectBase.actor | `actor`}.
374
364
  */
375
365
  session?: GraffitiSession | null,
376
- ): Promise<{
377
- data: Blob;
378
- actor: string;
379
- allowed?: string[] | null;
380
- }>;
366
+ ): Promise<GraffitiMedia>;
381
367
 
382
368
  /**
383
369
  * Begins the login process. Depending on the implementation, this may
package/src/2-types.ts CHANGED
@@ -350,7 +350,7 @@ export type GraffitiObjectStreamContinue<Schema extends JSONSchema> =
350
350
  export type GraffitiLoginEvent = CustomEvent<
351
351
  | {
352
352
  error: Error;
353
- session?: undefined;
353
+ session?: GraffitiSession;
354
354
  }
355
355
  | {
356
356
  error?: undefined;
@@ -396,3 +396,43 @@ export type GraffitiSessionInitializedEvent = CustomEvent<
396
396
  | null
397
397
  | undefined
398
398
  >;
399
+
400
+ export type GraffitiMedia = {
401
+ /**
402
+ * The binary data of the media to be uploaded,
403
+ * along with its [media type](https://www.iana.org/assignments/media-types/media-types.xhtml),
404
+ * formatted as a [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob).
405
+ */
406
+ data: Blob;
407
+ /**
408
+ * The {@link GraffitiObjectBase.actor | `actor`} that
409
+ * {@link Graffiti.postMedia | `post`ed} the media.
410
+ */
411
+ actor: string;
412
+ /**
413
+ * An optional list, identical in function to an object's
414
+ * {@link GraffitiObjectBase.allowed | `allowed`} property,
415
+ * that specifies the {@link GraffitiObjectBase.actor | `actor`}s
416
+ * who are allowed to access the media. If the list is `undefined`
417
+ * or `null`, anyone with the URL can access the media. If the list
418
+ * is empty, only the {@link GraffitiObjectBase.actor | `actor`}
419
+ * who {@link Graffiti.postMedia | `post`ed} the media can access it.
420
+ */
421
+ allowed?: string[] | null;
422
+ };
423
+
424
+ export type GraffitiPostMedia = Pick<GraffitiMedia, "data" | "allowed">;
425
+
426
+ export type GraffitiMediaAccept = {
427
+ /**
428
+ * A list of acceptable media types for the retrieved media.
429
+ * Each type in the list may be of the form `<type>/<subtype>`,
430
+ * `<type>/*`, or `&#42;/*`, just as types are formatted in
431
+ * an [HTTP Accept header](https://httpwg.org/specs/rfc9110.html#field.accept).
432
+ */
433
+ types?: string[];
434
+ /**
435
+ * The maximum acceptable size, in bytes, of the media.
436
+ */
437
+ maxBytes?: number;
438
+ };
@@ -1,9 +1,5 @@
1
- import type { JSONSchema } from "json-schema-to-ts";
2
- import type { Ajv } from "ajv";
3
- import { GraffitiErrorInvalidSchema } from "./3-errors";
4
1
  import type {
5
2
  GraffitiObjectBase,
6
- GraffitiObject,
7
3
  GraffitiObjectUrl,
8
4
  GraffitiSession,
9
5
  } from "./2-types";
@@ -12,25 +8,6 @@ export function unpackObjectUrl(url: string | GraffitiObjectUrl) {
12
8
  return typeof url === "string" ? url : url.url;
13
9
  }
14
10
 
15
- export function compileGraffitiObjectSchema<Schema extends JSONSchema>(
16
- ajv: Ajv,
17
- schema: Schema,
18
- ) {
19
- try {
20
- // Force the validation guard because
21
- // it is too big for the type checker.
22
- // Fortunately json-schema-to-ts is
23
- // well tested against ajv.
24
- return ajv.compile(schema) as (
25
- data: GraffitiObjectBase,
26
- ) => data is GraffitiObject<Schema>;
27
- } catch (error) {
28
- throw new GraffitiErrorInvalidSchema(
29
- error instanceof Error ? error.message : undefined,
30
- );
31
- }
32
- }
33
-
34
11
  export function isActorAllowedGraffitiObject(
35
12
  object: GraffitiObjectBase,
36
13
  session?: GraffitiSession | null,
@@ -50,16 +27,43 @@ export function isActorAllowedGraffitiObject(
50
27
  export function maskGraffitiObject(
51
28
  object: GraffitiObjectBase,
52
29
  channels: string[],
53
- session?: GraffitiSession | null,
54
- ): void {
55
- // If the actor is not the creator, mask the object.
56
- if (object.actor !== session?.actor) {
57
- // If there is an allowed list, mask it to only include the actor
58
- // (This assumes the actor is already allowed to access the object)
59
- object.allowed = object.allowed && session ? [session.actor] : undefined;
60
- // Mask the channels to only include the channels that are being queried
61
- object.channels = object.channels.filter((channel) =>
62
- channels.includes(channel),
30
+ actor?: string | null,
31
+ ): GraffitiObjectBase {
32
+ // If the actor is the creator, return the object as is
33
+ if (actor === object.actor) return object;
34
+
35
+ // If there is an allowed list, mask it to only include the actor
36
+ // (This assumes the actor is already allowed to access the object)
37
+ const allowedMasked = object.allowed && actor ? [actor] : undefined;
38
+ // Mask the channels to only include the channels that are being queried
39
+ const channelsMasked = object.channels.filter((c) => channels.includes(c));
40
+
41
+ return {
42
+ ...object,
43
+ allowed: allowedMasked,
44
+ channels: channelsMasked,
45
+ };
46
+ }
47
+
48
+ export function isMediaAcceptable(
49
+ mediaType: string,
50
+ acceptableMediaTypes: string[],
51
+ ): boolean {
52
+ const [type, subtype] = mediaType.toLowerCase().split(";")[0].split("/");
53
+
54
+ if (!type || !subtype) return false;
55
+
56
+ return acceptableMediaTypes.some((acceptable) => {
57
+ const [accType, accSubtype] = acceptable
58
+ .toLowerCase()
59
+ .split(";")[0]
60
+ .split("/");
61
+
62
+ if (!accType || !accSubtype) return false;
63
+
64
+ return (
65
+ (accType === type || accType === "*") &&
66
+ (accSubtype === subtype || accSubtype === "*")
63
67
  );
64
- }
68
+ });
65
69
  }
package/tests/crud.ts CHANGED
@@ -34,6 +34,12 @@ export const graffitiCRUDTests = (
34
34
  session2 = await useSession2();
35
35
  });
36
36
 
37
+ it("get nonexistant object", async () => {
38
+ await expect(graffiti.get(randomString(), {})).rejects.toThrow(
39
+ GraffitiErrorNotFound,
40
+ );
41
+ });
42
+
37
43
  it("post, get, delete", async () => {
38
44
  const value = {
39
45
  something: "hello, world~ c:",
@@ -56,7 +62,12 @@ export const graffitiCRUDTests = (
56
62
  expect(gotten.actor).toEqual(previous.actor);
57
63
 
58
64
  // Delete it
59
- await graffiti.delete(gotten, session);
65
+ const deleted = await graffiti.delete(gotten, session);
66
+ expect(deleted.value).toEqual(value);
67
+ expect(deleted.channels).toEqual(channels);
68
+ expect(deleted.allowed).toBeUndefined();
69
+ expect(deleted.actor).toEqual(session.actor);
70
+ expect(deleted.url).toEqual(previous.url);
60
71
 
61
72
  // Get is not found
62
73
  await expect(graffiti.get(gotten, {})).rejects.toBeInstanceOf(
package/tests/discover.ts CHANGED
@@ -1,10 +1,14 @@
1
1
  import { it, expect, describe, assert, beforeAll } from "vitest";
2
2
  import type {
3
3
  Graffiti,
4
- GraffitiObjectBase,
5
4
  GraffitiSession,
6
5
  JSONSchema,
7
6
  } from "@graffiti-garden/api";
7
+ import {
8
+ GraffitiErrorForbidden,
9
+ GraffitiErrorInvalidSchema,
10
+ GraffitiErrorNotFound,
11
+ } from "@graffiti-garden/api";
8
12
  import {
9
13
  randomString,
10
14
  nextStreamValue,
@@ -103,6 +107,19 @@ export const graffitiDiscoverTests = (
103
107
  expect(value.actor).toEqual(session1.actor);
104
108
  });
105
109
 
110
+ it("discover bad schema", async () => {
111
+ const iterator = graffiti.discover([], {
112
+ properties: {
113
+ value: {
114
+ //@ts-ignore
115
+ type: "asdf",
116
+ },
117
+ },
118
+ });
119
+
120
+ await expect(iterator.next()).rejects.toThrow(GraffitiErrorInvalidSchema);
121
+ });
122
+
106
123
  it("discover for actor", async () => {
107
124
  const object1 = randomPostObject();
108
125
  const posted1 = await graffiti.post<{}>(object1, session1);
@@ -396,12 +413,47 @@ export const graffitiDiscoverTests = (
396
413
  assert(!value.done && !value.value.error, "value is done");
397
414
  assert(value.value.tombstone, "value is not tombstone");
398
415
  expect(value.value.object.url).toEqual(posted.url);
399
- await expect(tombIterator.next()).resolves.toHaveProperty(
416
+ const returnValue2 = await tombIterator.next();
417
+ assert(returnValue2.done, "value2 is not done");
418
+
419
+ // Post another object
420
+ const posted2 = await graffiti.post<{}>(object, session);
421
+ const doubleContinueIterator = continueStream<{}>(
422
+ graffiti,
423
+ returnValue2.value,
424
+ continueType,
425
+ );
426
+ const value2 = await doubleContinueIterator.next();
427
+ assert(!value2.done && !value2.value.error, "value2 is done");
428
+ assert(!value2.value.tombstone, "value2 is tombstone");
429
+ expect(value2.value.object.url).toEqual(posted2.url);
430
+ await expect(doubleContinueIterator.next()).resolves.toHaveProperty(
400
431
  "done",
401
432
  true,
402
433
  );
403
434
  });
435
+
436
+ it("continue with wrong actor", async () => {
437
+ const iterator = graffiti.discover<{}>([], {}, session1);
438
+ const result = await iterator.next();
439
+ assert(result.done, "iterator is not done");
440
+
441
+ const continuation = continueStream<{}>(
442
+ graffiti,
443
+ result.value,
444
+ continueType,
445
+ session2,
446
+ );
447
+ await expect(continuation.next()).rejects.toThrow(
448
+ GraffitiErrorForbidden,
449
+ );
450
+ });
404
451
  });
405
452
  }
453
+
454
+ it("lookup non-existant cursor", async () => {
455
+ const iterator = graffiti.continueDiscover(randomString());
456
+ await expect(iterator.next()).rejects.toThrow(GraffitiErrorNotFound);
457
+ });
406
458
  });
407
459
  };
package/tests/media.ts CHANGED
@@ -58,7 +58,7 @@ export const graffitiMediaTests = (
58
58
  const mediaUrl = await graffiti.postMedia({ data }, session);
59
59
 
60
60
  const media = await graffiti.getMedia(mediaUrl, {
61
- accept: "text/*",
61
+ types: ["application/json", "text/*"],
62
62
  });
63
63
  expect(await media.data.text()).toEqual(text);
64
64
  expect(media.data.type).toEqual("text/plain");
@@ -73,7 +73,7 @@ export const graffitiMediaTests = (
73
73
 
74
74
  await expect(
75
75
  graffiti.getMedia(mediaUrl, {
76
- accept: "image/*",
76
+ types: ["image/*"],
77
77
  }),
78
78
  ).rejects.toThrow(GraffitiErrorNotAcceptable);
79
79
  });