diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e81995f58..d94ef96e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ The following changes are pending, and will be applied on the next major release The following changes have been implemented but not released yet: +### Bugfixes + +- `Buffer` type: As discussed in microsoft/TypeScript#53668 the + @types/node definition of a buffer is looser than the DOM one (the latter being TS' default), and hence we now + use that in order to be compatible with web buffer types and node + buffer types. + ## [1.28.0] - 2023-05-09 ### New feature diff --git a/e2e/node/resource.test.ts b/e2e/node/resource.test.ts index e33705196d..360eeaabe3 100644 --- a/e2e/node/resource.test.ts +++ b/e2e/node/resource.test.ts @@ -39,6 +39,7 @@ import { getPodRoot, createFetch, } from "@inrupt/internal-test-env"; +import { Buffer as NodeBuffer, Blob } from "buffer"; import { getSolidDataset, setThing, @@ -151,6 +152,46 @@ describe("Authenticated end-to-end", () => { await deleteFile(fileUrl, fetchOptions); }); + it("can create, delete, and differentiate between RDF and non-RDF Resources using a node Buffer", async () => { + const fileUrl = `${sessionResource}.txt`; + + const sessionFile = await overwriteFile( + fileUrl, + NodeBuffer.from("test"), + fetchOptions + ); + const sessionDataset = await getSolidDataset(sessionResource, fetchOptions); + + expect(isRawData(sessionDataset)).toBe(false); + expect(isRawData(sessionFile)).toBe(true); + + await deleteFile(fileUrl, fetchOptions); + }); + + // Blob isn't available in Node 14 + it("can create, delete, and differentiate between RDF and non-RDF Resources using a Blob", async () => { + const fileUrl = `${sessionResource}.txt`; + + const sessionFile = await overwriteFile( + fileUrl, + // We need to type cast because the buffer definition + // of Blob does not have the prototype property expected + // by the lib.dom.ts + new Blob(["test"]) as unknown as globalThis.Blob, + fetchOptions + ); + const sessionDataset = await getSolidDataset(sessionResource, fetchOptions); + + // Eslint isn't detecting the fact that this is inside an it statement + // because of the conditional. + // eslint-disable-next-line jest/no-standalone-expect + expect(isRawData(sessionDataset)).toBe(false); + // eslint-disable-next-line jest/no-standalone-expect + expect(isRawData(sessionFile)).toBe(true); + + await deleteFile(fileUrl, fetchOptions); + }); + it("can create and remove Containers", async () => { const containerUrl = `${pod}solid-client-tests/node/container-test/container1-${session.info.sessionId}/`; const containerContainerUrl = `${pod}solid-client-tests/node/container-test/`; diff --git a/package-lock.json b/package-lock.json index f6897421a1..4727d8a70e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@inrupt/universal-fetch": "^1.0.1", "@rdfjs/dataset": "^1.1.0", "@types/rdfjs__dataset": "^1.0.4", + "buffer": "^6.0.3", "http-link-header": "^1.1.0", "jsonld-context-parser": "^2.3.0", "jsonld-streaming-parser": "^3.2.0", diff --git a/package.json b/package.json index fad70a8bf4..3d794e8ddf 100644 --- a/package.json +++ b/package.json @@ -203,6 +203,7 @@ "@inrupt/universal-fetch": "^1.0.1", "@rdfjs/dataset": "^1.1.0", "@types/rdfjs__dataset": "^1.0.4", + "buffer": "^6.0.3", "http-link-header": "^1.1.0", "jsonld-context-parser": "^2.3.0", "jsonld-streaming-parser": "^3.2.0", diff --git a/src/resource/file.test.ts b/src/resource/file.test.ts index 047ea2c0f8..66cd098d0e 100644 --- a/src/resource/file.test.ts +++ b/src/resource/file.test.ts @@ -20,8 +20,7 @@ // import { jest, describe, it, expect } from "@jest/globals"; -import type { Mock } from "jest-mock"; - +import { Buffer as NodeBuffer } from "buffer"; import { Headers, Response } from "@inrupt/universal-fetch"; import { @@ -364,6 +363,8 @@ describe("Non-RDF data deletion", () => { describe("Write non-RDF data into a folder", () => { const mockBlob = new Blob(["mock blob data"], { type: "binary" }); + const mockBuffer = Buffer.from("mock blob data"); + const mockNodeBuffer = NodeBuffer.from("mock blob data"); function setMockOnFetch( fetch: jest.Mocked, saveResponse = new Response(undefined, { @@ -376,80 +377,159 @@ describe("Write non-RDF data into a folder", () => { return fetch; } - it("should default to the included fetcher if no other is available", async () => { - const fetcher = jest.requireMock("../fetcher") as { - fetch: jest.Mocked; - }; + describe.each([ + ["blob", mockBlob], + ["buffer", mockBuffer], + ["nodeBuffer", mockNodeBuffer], + ])("support for %s raw data source", (_, data) => { + it("should default to the included fetcher if no other is available", async () => { + const fetcher = jest.requireMock("../fetcher") as { + fetch: jest.Mocked; + }; - fetcher.fetch = setMockOnFetch(fetcher.fetch); + fetcher.fetch = setMockOnFetch(fetcher.fetch); - await saveFileInContainer("https://some.url", mockBlob); + await saveFileInContainer("https://some.url", data); - expect(fetcher.fetch).toHaveBeenCalled(); - }); + expect(fetcher.fetch).toHaveBeenCalled(); + }); - it("should POST to a remote resource using the included fetcher, and return the saved file", async () => { - const fetcher = jest.requireMock("../fetcher") as { - fetch: jest.Mocked; - }; + it("should POST to a remote resource using the included fetcher, and return the saved file", async () => { + const fetcher = jest.requireMock("../fetcher") as { + fetch: jest.Mocked; + }; + + fetcher.fetch = setMockOnFetch(fetcher.fetch); + + const savedFile = await saveFileInContainer("https://some.url", data); + + const mockCall = fetcher.fetch.mock.calls[0]; + expect(mockCall[0]).toBe("https://some.url"); + expect(mockCall[1]?.headers).toEqual({ + "Content-Type": + mockBlob === data ? "binary" : "application/octet-stream", + }); + expect(mockCall[1]?.method).toBe("POST"); + expect(mockCall[1]?.body).toEqual(data); + if (mockBlob === data) { + // eslint-disable-next-line jest/no-conditional-expect + expect(savedFile).toBeInstanceOf(Blob); + } + expect(savedFile!.internal_resourceInfo).toEqual({ + contentType: mockBlob === data ? "binary" : "application/octet-stream", + sourceIri: "https://some.url/someFileName", + isRawData: true, + }); + }); - fetcher.fetch = setMockOnFetch(fetcher.fetch); + it("should use the provided fetcher if available", async () => { + const mockFetch = setMockOnFetch(jest.fn()); - const savedFile = await saveFileInContainer("https://some.url", mockBlob); + await saveFileInContainer("https://some.url", data, { + fetch: mockFetch, + }); - const mockCall = fetcher.fetch.mock.calls[0]; - expect(mockCall[0]).toBe("https://some.url"); - expect(mockCall[1]?.headers).toEqual({ - "Content-Type": "binary", + expect(mockFetch).toHaveBeenCalled(); }); - expect(mockCall[1]?.method).toBe("POST"); - expect(mockCall[1]?.body).toEqual(mockBlob); - expect(savedFile).toBeInstanceOf(Blob); - expect(savedFile!.internal_resourceInfo).toEqual({ - contentType: "binary", - sourceIri: "https://some.url/someFileName", - isRawData: true, - }); - }); - it("should use the provided fetcher if available", async () => { - const mockFetch = setMockOnFetch(jest.fn()); + it("should POST a remote resource using the provided fetcher", async () => { + const mockFetch = setMockOnFetch(jest.fn()); - await saveFileInContainer("https://some.url", mockBlob, { - fetch: mockFetch, + await saveFileInContainer("https://some.url", data, { + fetch: mockFetch, + }); + + const mockCall = mockFetch.mock.calls[0]; + expect(mockCall[0]).toBe("https://some.url"); + expect(mockCall[1]?.headers).toEqual({ + "Content-Type": + mockBlob === data ? "binary" : "application/octet-stream", + }); + expect(mockCall[1]?.body).toEqual(data); }); - expect(mockFetch).toHaveBeenCalled(); - }); + it("should pass the suggested slug through", async () => { + const mockFetch = setMockOnFetch(jest.fn()); - it("should POST a remote resource using the provided fetcher", async () => { - const mockFetch = setMockOnFetch(jest.fn()); + await saveFileInContainer("https://some.url", data, { + fetch: mockFetch, + slug: "someFileName", + }); + + const mockCall = mockFetch.mock.calls[0]; + expect(mockCall[0]).toBe("https://some.url"); + expect(mockCall[1]?.headers).toEqual({ + "Content-Type": + mockBlob === data ? "binary" : "application/octet-stream", + Slug: "someFileName", + }); + expect(mockCall[1]?.body).toEqual(data); + }); - await saveFileInContainer("https://some.url", mockBlob, { - fetch: mockFetch, + it("throws when a reserved header is passed", async () => { + const mockFetch = setMockOnFetch(jest.fn()); + + await expect( + saveFileInContainer("https://some.url", data, { + fetch: mockFetch, + init: { + headers: { + Slug: "someFileName", + }, + }, + }) + ).rejects.toThrow(/reserved header/); }); - const mockCall = mockFetch.mock.calls[0]; - expect(mockCall[0]).toBe("https://some.url"); - expect(mockCall[1]?.headers).toEqual({ "Content-Type": "binary" }); - expect(mockCall[1]?.body).toEqual(mockBlob); - }); + it("throws when saving failed", async () => { + const mockFetch = setMockOnFetch( + jest.fn(), + new Response(undefined, { status: 403, statusText: "Forbidden" }) + ); - it("should pass the suggested slug through", async () => { - const mockFetch = setMockOnFetch(jest.fn()); + await expect( + saveFileInContainer("https://some.url", data, { + fetch: mockFetch, + }) + ).rejects.toThrow( + "Saving the file in [https://some.url] failed: [403] [Forbidden]" + ); + }); - await saveFileInContainer("https://some.url", mockBlob, { - fetch: mockFetch, - slug: "someFileName", + it("throws when the server did not return the location of the newly-saved file", async () => { + const mockFetch = setMockOnFetch( + jest.fn(), + new Response(undefined, { status: 201, statusText: "Created" }) + ); + + await expect( + saveFileInContainer("https://some.url", data, { + fetch: mockFetch, + }) + ).rejects.toThrow( + "Could not determine the location of the newly saved file." + ); }); - const mockCall = mockFetch.mock.calls[0]; - expect(mockCall[0]).toBe("https://some.url"); - expect(mockCall[1]?.headers).toEqual({ - "Content-Type": "binary", - Slug: "someFileName", + it("includes the status code, status message and response body when a request failed", async () => { + const mockFetch = setMockOnFetch( + jest.fn(), + new Response("Teapots don't make coffee", { + status: 418, + statusText: "I'm a teapot!", + }) + ); + + await expect( + saveFileInContainer("https://arbitrary.url", data, { + fetch: mockFetch, + }) + ).rejects.toMatchObject({ + statusCode: 418, + statusText: "I'm a teapot!", + message: expect.stringMatching("Teapots don't make coffee"), + }); }); - expect(mockCall[1]?.body).toEqual(mockBlob); }); it("sets the correct Content Type on the returned file, if available", async () => { @@ -459,7 +539,9 @@ describe("Write non-RDF data into a folder", () => { fetcher.fetch = setMockOnFetch(fetcher.fetch); - const mockTextBlob = new Blob(["mock blob data"], { type: "text/plain" }); + const mockTextBlob = new Blob(["mock blob data"], { + type: "text/plain", + }); const savedFile = await saveFileInContainer( "https://some.url", mockTextBlob @@ -476,7 +558,9 @@ describe("Write non-RDF data into a folder", () => { fetcher.fetch = setMockOnFetch(fetcher.fetch); - const mockTextBlob = new Blob(["mock blob data"], { type: "text/plain" }); + const mockTextBlob = new Blob(["mock blob data"], { + type: "text/plain", + }); const savedFile = await saveFileInContainer( "https://some.url", mockTextBlob, @@ -507,208 +591,159 @@ describe("Write non-RDF data into a folder", () => { "application/octet-stream" ); }); - - it("throws when a reserved header is passed", async () => { - const mockFetch = setMockOnFetch(jest.fn()); - - await expect( - saveFileInContainer("https://some.url", mockBlob, { - fetch: mockFetch, - init: { - headers: { - Slug: "someFileName", - }, - }, - }) - ).rejects.toThrow(/reserved header/); - }); - - it("throws when saving failed", async () => { - const mockFetch = setMockOnFetch( - jest.fn(), - new Response(undefined, { status: 403, statusText: "Forbidden" }) - ); - - await expect( - saveFileInContainer("https://some.url", mockBlob, { - fetch: mockFetch, - }) - ).rejects.toThrow( - "Saving the file in [https://some.url] failed: [403] [Forbidden]" - ); - }); - - it("throws when the server did not return the location of the newly-saved file", async () => { - const mockFetch = setMockOnFetch( - jest.fn(), - new Response(undefined, { status: 201, statusText: "Created" }) - ); - - await expect( - saveFileInContainer("https://some.url", mockBlob, { - fetch: mockFetch, - }) - ).rejects.toThrow( - "Could not determine the location of the newly saved file." - ); - }); - - it("includes the status code, status message and response body when a request failed", async () => { - const mockFetch = setMockOnFetch( - jest.fn(), - new Response("Teapots don't make coffee", { - status: 418, - statusText: "I'm a teapot!", - }) - ); - - await expect( - saveFileInContainer("https://arbitrary.url", mockBlob, { - fetch: mockFetch, - }) - ).rejects.toMatchObject({ - statusCode: 418, - statusText: "I'm a teapot!", - message: expect.stringMatching("Teapots don't make coffee"), - }); - }); }); describe("Write non-RDF data directly into a resource (potentially erasing previous value)", () => { const mockBlob = new Blob(["mock blob data"], { type: "binary" }); + const mockBuffer = Buffer.from("mock blob data"); + const mockNodeBuffer = NodeBuffer.from("mock blob data"); + + describe.each([ + ["blob", mockBlob], + ["buffer", mockBuffer], + ["nodeBuffer", mockNodeBuffer], + ])("support for %s raw data source", (_, data) => { + it("should default to the included fetcher if no other fetcher is available", async () => { + const fetcher = jest.requireMock("../fetcher") as { + fetch: jest.Mocked; + }; + + fetcher.fetch.mockReturnValue( + Promise.resolve( + new Response(undefined, { status: 201, statusText: "Created" }) + ) + ); - it("should default to the included fetcher if no other fetcher is available", async () => { - const fetcher = jest.requireMock("../fetcher") as { - fetch: jest.Mocked; - }; - - fetcher.fetch.mockReturnValue( - Promise.resolve( - new Response(undefined, { status: 201, statusText: "Created" }) - ) - ); - - await overwriteFile("https://some.url", mockBlob); - - expect(fetcher.fetch).toHaveBeenCalled(); - }); - - it("should PUT to a remote resource when using the included fetcher, and return the saved file", async () => { - const fetcher = jest.requireMock("../fetcher") as { - fetch: jest.Mocked; - }; - - fetcher.fetch.mockReturnValue( - Promise.resolve( - new Response(undefined, { - status: 201, - statusText: "Created", - url: "https://some.url", - } as ResponseInit) - ) - ); - - const savedFile = await overwriteFile("https://some.url", mockBlob); + await overwriteFile("https://some.url", data); - const mockCall = fetcher.fetch.mock.calls[0]; - expect(mockCall[0]).toBe("https://some.url"); - expect(mockCall[1]?.headers).toEqual({ - "Content-Type": "binary", + expect(fetcher.fetch).toHaveBeenCalled(); }); - expect(mockCall[1]?.method).toBe("PUT"); - expect(mockCall[1]?.body).toEqual(mockBlob); - expect(savedFile).toBeInstanceOf(Blob); - expect(savedFile.internal_resourceInfo).toEqual({ - contentType: undefined, - sourceIri: "https://some.url", - isRawData: true, - linkedResources: {}, - }); - }); + it("should PUT to a remote resource when using the included fetcher, and return the saved file", async () => { + const fetcher = jest.requireMock("../fetcher") as { + fetch: jest.Mocked; + }; - it("should use the provided fetcher", async () => { - const mockFetch = jest - .fn() - .mockReturnValue( + fetcher.fetch.mockReturnValue( Promise.resolve( - new Response(undefined, { status: 201, statusText: "Created" }) + new Response(undefined, { + status: 201, + statusText: "Created", + url: "https://some.url", + } as ResponseInit) ) ); - const response = await overwriteFile("https://some.url", mockBlob, { - fetch: mockFetch, + const savedFile = await overwriteFile("https://some.url", data); + + const mockCall = fetcher.fetch.mock.calls[0]; + expect(mockCall[0]).toBe("https://some.url"); + expect(mockCall[1]?.headers).toEqual({ + "Content-Type": + mockBlob === data ? "binary" : "application/octet-stream", + }); + expect(mockCall[1]?.method).toBe("PUT"); + expect(mockCall[1]?.body).toEqual(data); + if (mockBlob === data) { + // eslint-disable-next-line jest/no-conditional-expect + expect(savedFile).toBeInstanceOf(Blob); + } + expect(savedFile.internal_resourceInfo).toEqual({ + contentType: undefined, + sourceIri: "https://some.url", + isRawData: true, + linkedResources: {}, + }); }); - expect(mockFetch).toHaveBeenCalled(); - }); - - it("should PUT a remote resource using the provided fetcher, and return the saved file", async () => { - const mockFetch = jest.fn().mockReturnValue( - Promise.resolve( - new Response(undefined, { - status: 201, - statusText: "Created", - url: "https://some.url", - } as ResponseInit) - ) - ); + it("should use the provided fetcher", async () => { + const mockFetch = jest + .fn() + .mockReturnValue( + Promise.resolve( + new Response(undefined, { status: 201, statusText: "Created" }) + ) + ); - const savedFile = await overwriteFile("https://some.url", mockBlob, { - fetch: mockFetch, - }); - - const mockCall = mockFetch.mock.calls[0]; - expect(mockCall[0]).toBe("https://some.url"); - expect(mockCall[1]?.headers).toEqual({ "Content-Type": "binary" }); - expect(mockCall[1]?.method).toBe("PUT"); - expect(mockCall[1]?.body).toEqual(mockBlob); + await overwriteFile("https://some.url", data, { + fetch: mockFetch, + }); - expect(savedFile).toBeInstanceOf(Blob); - expect(savedFile.internal_resourceInfo).toEqual({ - contentType: undefined, - sourceIri: "https://some.url", - isRawData: true, - linkedResources: {}, + expect(mockFetch).toHaveBeenCalled(); }); - }); - it("throws when saving failed", async () => { - const mockFetch = jest - .fn() - .mockReturnValue( + it("should PUT a remote resource using the provided fetcher, and return the saved file", async () => { + const mockFetch = jest.fn().mockReturnValue( Promise.resolve( - new Response(undefined, { status: 403, statusText: "Forbidden" }) + new Response(undefined, { + status: 201, + statusText: "Created", + url: "https://some.url", + } as ResponseInit) ) ); - await expect( - overwriteFile("https://some.url", mockBlob, { + const savedFile = await overwriteFile("https://some.url", data, { fetch: mockFetch, - }) - ).rejects.toThrow( - "Overwriting the file at [https://some.url] failed: [403] [Forbidden]" - ); - }); + }); + + const mockCall = mockFetch.mock.calls[0]; + expect(mockCall[0]).toBe("https://some.url"); + expect(mockCall[1]?.headers).toEqual({ + "Content-Type": + mockBlob === data ? "binary" : "application/octet-stream", + }); + expect(mockCall[1]?.method).toBe("PUT"); + expect(mockCall[1]?.body).toEqual(data); + if (mockBlob === data) { + // eslint-disable-next-line jest/no-conditional-expect + expect(savedFile).toBeInstanceOf(Blob); + } + expect(savedFile.internal_resourceInfo).toEqual({ + contentType: undefined, + sourceIri: "https://some.url", + isRawData: true, + linkedResources: {}, + }); + }); - it("includes the status code, status message and response body when a request failed", async () => { - const mockFetch = jest.fn().mockReturnValue( - Promise.resolve( - new Response("Teapots don't make coffee", { - status: 418, - statusText: "I'm a teapot!", + it("throws when saving failed", async () => { + const mockFetch = jest + .fn() + .mockReturnValue( + Promise.resolve( + new Response(undefined, { status: 403, statusText: "Forbidden" }) + ) + ); + + await expect( + overwriteFile("https://some.url", data, { + fetch: mockFetch, }) - ) - ); + ).rejects.toThrow( + "Overwriting the file at [https://some.url] failed: [403] [Forbidden]" + ); + }); - await expect( - overwriteFile("https://arbitrary.url", mockBlob, { - fetch: mockFetch, - }) - ).rejects.toMatchObject({ - statusCode: 418, - statusText: "I'm a teapot!", - message: expect.stringContaining("Teapots don't make coffee"), + it("includes the status code, status message and response body when a request failed", async () => { + const mockFetch = jest.fn().mockReturnValue( + Promise.resolve( + new Response("Teapots don't make coffee", { + status: 418, + statusText: "I'm a teapot!", + }) + ) + ); + + await expect( + overwriteFile("https://arbitrary.url", data, { + fetch: mockFetch, + }) + ).rejects.toMatchObject({ + statusCode: 418, + statusText: "I'm a teapot!", + message: expect.stringContaining("Teapots don't make coffee"), + }); }); }); }); diff --git a/src/resource/file.ts b/src/resource/file.ts index 9ca553ffeb..e4afbdb007 100644 --- a/src/resource/file.ts +++ b/src/resource/file.ts @@ -19,6 +19,7 @@ // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // +import type { Buffer } from "buffer"; import { fetch } from "../fetcher"; import { File,