From 3653ae0f23c5c518b4fe2df99187c3e80988dcdf Mon Sep 17 00:00:00 2001 From: mrkvon Date: Thu, 16 Jul 2026 13:22:28 +0200 Subject: [PATCH 1/2] Create resource with PUT instead of POST request method PUT will guarantee the URI of the resource. Closes #16 --- .../src/requester/requests/createDataResource.ts | 5 ++--- .../src/requester/requests/uploadResource.ts | 10 +++------- packages/connected-solid/test/Integration.test.ts | 2 +- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/connected-solid/src/requester/requests/createDataResource.ts b/packages/connected-solid/src/requester/requests/createDataResource.ts index 090e8f36..63aaa950 100644 --- a/packages/connected-solid/src/requester/requests/createDataResource.ts +++ b/packages/connected-solid/src/requester/requests/createDataResource.ts @@ -169,13 +169,12 @@ export async function createDataResource( const parentUri = getParentUri(resource.uri)!; const headers: RequestInit["headers"] = { "content-type": "text/turtle", - slug: getSlug(resource.uri), }; if (resource.type === "SolidContainer") { headers.link = '; rel="type"'; } - const response = await fetch(parentUri, { - method: "post", + const response = await fetch(resource.uri, { + method: "PUT", headers, }); diff --git a/packages/connected-solid/src/requester/requests/uploadResource.ts b/packages/connected-solid/src/requester/requests/uploadResource.ts index 2f976255..6bb2d78d 100644 --- a/packages/connected-solid/src/requester/requests/uploadResource.ts +++ b/packages/connected-solid/src/requester/requests/uploadResource.ts @@ -65,13 +65,9 @@ export async function uploadResource( } } // Create the document - const parentUri = getParentUri(resource.uri)!; - const response = await fetch(parentUri, { - method: "post", - headers: { - "content-type": mimeType, - slug: getSlug(resource.uri), - }, + const response = await fetch(resource.uri, { + method: "PUT", + headers: { "content-type": mimeType }, body: blob, }); diff --git a/packages/connected-solid/test/Integration.test.ts b/packages/connected-solid/test/Integration.test.ts index 3af51168..f0fa6410 100644 --- a/packages/connected-solid/test/Integration.test.ts +++ b/packages/connected-solid/test/Integration.test.ts @@ -1069,7 +1069,7 @@ describe("Integration", () => { .some((child) => child.uri === SAMPLE_CONTAINER_URI), ).toBe(true); const postRequest = s.fetchMock.mock.calls.find( - (call) => call[1]?.method?.toLowerCase() === "post", + (call) => call[1]?.method?.toLowerCase() === "put", ); expect(postRequest?.[1]?.headers).toHaveProperty( "link", From 58f30dff5e0db6acf3e061750bbe350e4fe9e0e8 Mon Sep 17 00:00:00 2001 From: mrkvon Date: Thu, 16 Jul 2026 21:01:31 +0200 Subject: [PATCH 2/2] Use HTTP Semantics to ensure existing resource is not overwritten `If-None-Match: *` header ensures resource is not overwritten. We then use HTTP status codes 200, 201, 412 to figure out what actually happened. https://solidproject.org/TR/protocol#conditional-update https://www.rfc-editor.org/info/rfc9110/#section-13.1.2 --- .../requester/requests/createDataResource.ts | 63 +++++++++---------- .../src/requester/requests/uploadResource.ts | 46 ++++++++------ .../connected-solid/test/Integration.test.ts | 35 ++++------- .../src/results/error/ErrorResult.ts | 4 +- packages/connected/test/ErrorResult.test.ts | 12 ++-- 5 files changed, 76 insertions(+), 84 deletions(-) diff --git a/packages/connected-solid/src/requester/requests/createDataResource.ts b/packages/connected-solid/src/requester/requests/createDataResource.ts index 63aaa950..e031d513 100644 --- a/packages/connected-solid/src/requester/requests/createDataResource.ts +++ b/packages/connected-solid/src/requester/requests/createDataResource.ts @@ -5,8 +5,6 @@ import { UnexpectedResourceError } from "@ldo/connected"; import type { HttpErrorResultType } from "../results/error/HttpErrorResult"; import { HttpErrorResult } from "../results/error/HttpErrorResult"; import { CreateSuccess } from "../results/success/CreateSuccess"; -import type { DeleteResultError } from "./deleteResource"; -import { deleteResource } from "./deleteResource"; import type { ReadContainerResult, ReadLeafResult, @@ -16,11 +14,8 @@ import { readResource } from "./readResource"; import type { DatasetRequestOptions } from "./requestOptions"; import type { SolidLeaf } from "../../resources/SolidLeaf"; import type { SolidContainer } from "../../resources/SolidContainer"; -import { - addResourceRdfToContainer, - getParentUri, - getSlug, -} from "../../util/rdfUtils"; +import { addResourceRdfToContainer } from "../../util/rdfUtils"; +import { NoncompliantPodError } from "../results/error/NoncompliantPodError"; /** * All possible return values when creating and overwriting a container @@ -56,8 +51,7 @@ export type LeafCreateIfAbsentResult = * All possible errors returned by creating and overwriting a resource */ export type CreateAndOverwriteResultErrors = - | DeleteResultError - | CreateErrors; + CreateErrors; /** * All possible errors returned by creating a resource if absent @@ -91,27 +85,27 @@ export function createDataResource( options?: DatasetRequestOptions, ): Promise; export function createDataResource( - resouce: SolidLeaf, + resource: SolidLeaf, overwrite: true, options?: DatasetRequestOptions, ): Promise; export function createDataResource( - resouce: SolidContainer, + resource: SolidContainer, overwrite?: false, options?: DatasetRequestOptions, ): Promise; export function createDataResource( - resouce: SolidLeaf, + resource: SolidLeaf, overwrite?: false, options?: DatasetRequestOptions, ): Promise; export function createDataResource( - resouce: SolidContainer, + resource: SolidContainer, overwrite?: boolean, options?: DatasetRequestOptions, ): Promise; export function createDataResource( - resouce: SolidLeaf, + resource: SolidLeaf, overwrite?: boolean, options?: DatasetRequestOptions, ): Promise; @@ -147,43 +141,46 @@ export async function createDataResource( > { try { const fetch = guaranteeFetch(options?.fetch); - let didOverwrite = false; - if (overwrite) { - const deleteResult = await deleteResource(resource, options); - // Return if it wasn't deleted - if (deleteResult.isError) - return deleteResult as - | DeleteResultError - | DeleteResultError; - didOverwrite = deleteResult.resourceExisted; - } else { - // Perform a read to check if it exists - const readResult = await readResource(resource, options); - // If it does exist stop and return. - if (readResult.type !== "absentReadSuccess") { - return readResult; - } - } // Create the document - const parentUri = getParentUri(resource.uri)!; - const headers: RequestInit["headers"] = { + const headers: HeadersInit = { "content-type": "text/turtle", }; if (resource.type === "SolidContainer") { headers.link = '; rel="type"'; } + if (!overwrite) { + // https://solidproject.org/TR/protocol#conditional-update + // https://www.rfc-editor.org/info/rfc9110/#section-13.1.2 + headers["If-None-Match"] = "*"; + } const response = await fetch(resource.uri, { method: "PUT", headers, }); + // Check whether If-None-Match: "*" precondition failed. + // That means we tried to overwrite existing resource when overwriting is forbidden. + if (response.status === 412) { + const result = await readResource(resource, options); + if (result.type === "absentReadSuccess") + return new NoncompliantPodError( + resource, + `Server returned conflicting states: Response status 412 implies the resource ${resource.uri} exists, but it is absent.`, + ) as + | NoncompliantPodError + | NoncompliantPodError; + return result; + } + const httpError = HttpErrorResult.checkResponse(resource, response); if (httpError) return httpError as | HttpErrorResultType | HttpErrorResultType; + const didOverwrite = response.status !== 201; + if (options?.dataset) { addResourceRdfToContainer(resource.uri, options.dataset); } diff --git a/packages/connected-solid/src/requester/requests/uploadResource.ts b/packages/connected-solid/src/requester/requests/uploadResource.ts index 6bb2d78d..e56d7fbe 100644 --- a/packages/connected-solid/src/requester/requests/uploadResource.ts +++ b/packages/connected-solid/src/requester/requests/uploadResource.ts @@ -5,16 +5,12 @@ import type { LeafCreateAndOverwriteResult, LeafCreateIfAbsentResult, } from "./createDataResource"; -import { deleteResource } from "./deleteResource"; import { readResource } from "./readResource"; import type { DatasetRequestOptions } from "./requestOptions"; import type { SolidLeaf } from "../../resources/SolidLeaf"; import { CreateSuccess } from "../results/success/CreateSuccess"; -import { - addResourceRdfToContainer, - getParentUri, - getSlug, -} from "../../util/rdfUtils"; +import { addResourceRdfToContainer } from "../../util/rdfUtils"; +import { NoncompliantPodError } from "../results/error/NoncompliantPodError"; /** * @internal @@ -50,30 +46,40 @@ export async function uploadResource( ): Promise { try { const fetch = guaranteeFetch(options?.fetch); - let didOverwrite = false; - if (overwrite) { - const deleteResult = await deleteResource(resource, options); - // Return if it wasn't deleted - if (deleteResult.isError) return deleteResult; - didOverwrite = deleteResult.resourceExisted; - } else { - // Perform a read to check if it exists - const readResult = await readResource(resource, options); - // If it does exist stop and return. - if (readResult.type !== "absentReadSuccess") { - return readResult; - } + const headers: HeadersInit = { + "content-type": mimeType, + }; + + if (!overwrite) { + // https://solidproject.org/TR/protocol#conditional-update + // https://www.rfc-editor.org/info/rfc9110/#section-13.1.2 + headers["If-None-Match"] = "*"; } + // Create the document const response = await fetch(resource.uri, { method: "PUT", - headers: { "content-type": mimeType }, + headers, body: blob, }); + // Check whether If-None-Match: "*" precondition failed. + // That means we tried to overwrite existing resource when overwriting is forbidden. + if (response.status === 412) { + const result = await readResource(resource, options); + if (result.type === "absentReadSuccess") + return new NoncompliantPodError( + resource, + `Server returned conflicting states: Response status 412 implies the resource ${resource.uri} exists, but it is absent.`, + ); + return result; + } + const httpError = HttpErrorResult.checkResponse(resource, response); if (httpError) return httpError; + const didOverwrite = response.status !== 201; + if (options?.dataset) { addResourceRdfToContainer(resource.uri, options.dataset); } diff --git a/packages/connected-solid/test/Integration.test.ts b/packages/connected-solid/test/Integration.test.ts index f0fa6410..e1abcc91 100644 --- a/packages/connected-solid/test/Integration.test.ts +++ b/packages/connected-solid/test/Integration.test.ts @@ -520,7 +520,8 @@ describe("Integration", () => { resource.read(), ]); - expect(s.fetchMock).toHaveBeenCalledTimes(3); + // one for create, one for read + expect(s.fetchMock).toHaveBeenCalledTimes(2); expect(result.type).toBe("dataReadSuccess"); expect(result1.type).toBe("dataReadSuccess"); }); @@ -925,9 +926,6 @@ describe("Integration", () => { it("returns an error if the create fetch fails", async () => { const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); - s.fetchMock.mockImplementationOnce(async (...args) => { - return s.authFetch(...args); - }); s.fetchMock.mockResolvedValueOnce( new Response(TEST_CONTAINER_TTL, { status: 500, @@ -940,9 +938,6 @@ describe("Integration", () => { it("returns an unexpected error if some unknown error is triggered", async () => { const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); - s.fetchMock.mockImplementationOnce(async (...args) => { - return s.authFetch(...args); - }); s.fetchMock.mockImplementationOnce(async () => { throw new Error("Some Unknown"); }); @@ -961,8 +956,8 @@ describe("Integration", () => { expect(result1.type).toBe("createSuccess"); expect(result2.type).toBe("createSuccess"); - // 1 for read, 1 for delete in createAndOverwrite, 1 for create - expect(s.fetchMock).toHaveBeenCalledTimes(3); + // 1 for read, 1 for create + expect(s.fetchMock).toHaveBeenCalledTimes(2); }); it("batches the create request while waiting on a similar request", async () => { @@ -974,8 +969,8 @@ describe("Integration", () => { expect(result1.type).toBe("createSuccess"); expect(result2.type).toBe("createSuccess"); - // 1 for delete in createAndOverwrite, 1 for create - expect(s.fetchMock).toHaveBeenCalledTimes(2); + // 1 for create or update with PUT + expect(s.fetchMock).toHaveBeenCalledTimes(1); }); }); @@ -1068,10 +1063,10 @@ describe("Integration", () => { .children() .some((child) => child.uri === SAMPLE_CONTAINER_URI), ).toBe(true); - const postRequest = s.fetchMock.mock.calls.find( + const putRequest = s.fetchMock.mock.calls.find( (call) => call[1]?.method?.toLowerCase() === "put", ); - expect(postRequest?.[1]?.headers).toHaveProperty( + expect(putRequest?.[1]?.headers).toHaveProperty( "link", '; rel="type"', ); @@ -1489,9 +1484,6 @@ describe("Integration", () => { it("returns an error if the create fetch fails", async () => { const resource = solidLdoDataset.getResource(SAMPLE_BINARY_URI); - s.fetchMock.mockImplementationOnce(async (...args) => { - return s.authFetch(...args); - }); s.fetchMock.mockResolvedValueOnce( new Response(TEST_CONTAINER_TTL, { status: 500, @@ -1507,9 +1499,6 @@ describe("Integration", () => { it("returns an unexpected error if some unknown error is triggered", async () => { const resource = solidLdoDataset.getResource(SAMPLE_BINARY_URI); - s.fetchMock.mockImplementationOnce(async (...args) => { - return s.authFetch(...args); - }); s.fetchMock.mockImplementationOnce(async () => { throw new Error("Some Unknown"); }); @@ -1537,8 +1526,8 @@ describe("Integration", () => { expect(result1.type).toBe("createSuccess"); expect(result2.type).toBe("createSuccess"); - // 1 for read, 1 for delete in createAndOverwrite, 1 for create - expect(s.fetchMock).toHaveBeenCalledTimes(3); + // 1 for read, 1 for create + expect(s.fetchMock).toHaveBeenCalledTimes(2); expect(resource.getBlob()?.toString()).toBe("some text 2."); }); @@ -1557,8 +1546,8 @@ describe("Integration", () => { expect(result1.type).toBe("createSuccess"); expect(result2.type).toBe("createSuccess"); - // 1 for delete in createAndOverwrite, 1 for create - expect(s.fetchMock).toHaveBeenCalledTimes(2); + // 1 for create + expect(s.fetchMock).toHaveBeenCalledTimes(1); expect(resource.getBlob()?.toString()).toBe("some text 2."); }); }); diff --git a/packages/connected/src/results/error/ErrorResult.ts b/packages/connected/src/results/error/ErrorResult.ts index c92038d7..7fc69a50 100644 --- a/packages/connected/src/results/error/ErrorResult.ts +++ b/packages/connected/src/results/error/ErrorResult.ts @@ -19,7 +19,7 @@ export abstract class ErrorResult extends Error implements ConnectedResult { * @param message - a custom message for the error */ constructor(message?: string) { - super(message || "An unkown error was encountered."); + super(message || "An unknown error was encountered."); } } @@ -44,7 +44,7 @@ export abstract class ResourceError< * @param message - A custom message for the error */ constructor(resource: ResourceType, message?: string) { - super(message || `An unkown error for ${resource.uri}`); + super(message || `An unknown error for ${resource.uri}`); this.uri = resource.uri; this.resource = resource; } diff --git a/packages/connected/test/ErrorResult.test.ts b/packages/connected/test/ErrorResult.test.ts index f29af2b3..07f45fbb 100644 --- a/packages/connected/test/ErrorResult.test.ts +++ b/packages/connected/test/ErrorResult.test.ts @@ -18,7 +18,7 @@ describe("ErrorResult", () => { ).toBe("hello"); }); - it("returns an UnexpecteResourceError if an odd valud is provided", () => { + it("returns an UnexpectedResourceError if an odd value is provided", () => { expect(UnexpectedResourceError.fromThrown(mockResource, 5).message).toBe( "Error of type number thrown: 5", ); @@ -46,19 +46,19 @@ describe("ErrorResult", () => { readonly type = "concreteErrorResult" as const; } - it("ResourceError fallsback to a default message if none is provided", () => { + it("ResourceError falls back to a default message if none is provided", () => { expect(new ConcreteResourceError(mockResource).message).toBe( - "An unkown error for https://example.com/", + "An unknown error for https://example.com/", ); }); - it("ErrorResult fallsback to a default message if none is provided", () => { + it("ErrorResult falls back to a default message if none is provided", () => { expect(new ConcreteErrorResult().message).toBe( - "An unkown error was encountered.", + "An unknown error was encountered.", ); }); - it("InvalidUriError fallsback to a default message if none is provided", () => { + it("InvalidUriError falls back to a default message if none is provided", () => { expect(new InvalidUriError(mockResource).message).toBe( "https://example.com/ is an invalid uri.", );