From ecab24bf6ee0f7d1a1ce10e577e6bac2f9319680 Mon Sep 17 00:00:00 2001 From: Kyle Hensel Date: Thu, 18 Sep 2025 21:02:37 +1000 Subject: [PATCH] v4: several breaking changes for changeset APIs to be consistent with... ...the OSM API's new json endpoints. --- CHANGELOG.md | 10 +++ examples/uploadChangeset.md | 13 ++-- src/__tests__/__snapshots__/e2e.test.ts.snap | 41 +++++------- src/api/_rawResponse.d.ts | 21 +----- .../__tests__/uploadChangeset.test.ts | 57 +++++++++++++++- src/api/changesets/createChangesetComment.ts | 6 +- src/api/changesets/getChangesets.ts | 25 ++----- src/api/changesets/uploadChangeset.ts | 65 ++++++++++++++----- src/api/dwg/hideChangesetComment.ts | 6 +- src/types/changesets.ts | 38 +++++------ 10 files changed, 164 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcc949d..cd4c858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## 4.0.0 (----------) + +- 💥 BREAKING CHANGE: `uploadChangeset` now returns a object instead of a single changeset ID. This is because: + 1. the function supports chunking uploads into multiple changesets if it exceeds the limit of 10,000 features per changeset. + 2. For each feature that was created, the response now includes a mapping between the temporary ID used by the uploader, and the permanent ID allocated by the server. +- 💥 BREAKING CHANGE: The type defintions for `Changeset` have been changed to mark several properties as optional. (see [#14](https://github.com/osmlab/osm-api-js/issues/14)) +- 💥 BREAKING CHANGE: `Changeset.created_at`, `Changeset.closed_at`, and `ChangesetComment.date` are now a `string`, not a `Date`. This makes it more consistent with the XML format, and easier to serialise to JSON. +- 💥 BREAKING CHANGE: `ChangesetComment.uid` is now a `number`, not a `string`. This matches the behaviour of OSM's new json API. +- 💥 BREAKING CHANGE: `Changeset.discussion` has been renamed to `Changeset.comments`. This matches the behaviour of OSM's new json API. + ## 3.3.0 (2025-09-18) - [uploadChangeset] add an `onProgress` callback, so that apps can show a progress bar while uploading diff --git a/examples/uploadChangeset.md b/examples/uploadChangeset.md index 257bed6..d912cfa 100644 --- a/examples/uploadChangeset.md +++ b/examples/uploadChangeset.md @@ -26,12 +26,17 @@ await uploadChangeset( Response: -```json -12345 +```jsonc +{ + // 12345 is the changeset number + "12345": { + // the contents of this object is the diff result. + // - for created features, this object allows you to map the temporary ID used by the uploader, to the permananet ID that the server allocated to this feature. + // - for updated & deleted features, it includes the new version number + }, +} ``` -(changeset number) - ## Detailed Examples ### Updating existing features diff --git a/src/__tests__/__snapshots__/e2e.test.ts.snap b/src/__tests__/__snapshots__/e2e.test.ts.snap index d46bb21..c0f7338 100644 --- a/src/__tests__/__snapshots__/e2e.test.ts.snap +++ b/src/__tests__/__snapshots__/e2e.test.ts.snap @@ -109,10 +109,9 @@ exports[`end to end tests > getCapabilities 1`] = ` exports[`end to end tests > getChangeset (no discussion) 1`] = ` { "changes_count": 10, - "closed_at": 2021-12-16T09:29:15.000Z, + "closed_at": "2021-12-16T09:29:15Z", "comments_count": 2, - "created_at": 2021-12-16T09:29:14.000Z, - "discussion": undefined, + "created_at": "2021-12-16T09:29:14Z", "id": 227200, "max_lat": -36.8801278, "max_lon": 174.7400986, @@ -136,27 +135,27 @@ exports[`end to end tests > getChangeset (no discussion) 1`] = ` exports[`end to end tests > getChangeset (with discussion) 1`] = ` { "changes_count": 10, - "closed_at": 2021-12-16T09:29:15.000Z, - "comments_count": 2, - "created_at": 2021-12-16T09:29:14.000Z, - "discussion": [ + "closed_at": "2021-12-16T09:29:15Z", + "comments": [ { - "date": 2024-06-30T08:59:15.000Z, + "date": "2024-06-30T08:59:15Z", "id": 736, "text": "­", - "uid": "12248", + "uid": 12248, "user": "kylenz_testing", "visible": true, }, { - "date": 2024-06-30T08:59:55.000Z, + "date": "2024-06-30T08:59:55Z", "id": 737, "text": "sup bro", - "uid": "12248", + "uid": 12248, "user": "kylenz_testing", "visible": true, }, ], + "comments_count": 2, + "created_at": "2021-12-16T09:29:14Z", "id": 227200, "max_lat": -36.8801278, "max_lon": 174.7400986, @@ -533,10 +532,9 @@ exports[`end to end tests > listChangesets 1`] = ` [ { "changes_count": 2, - "closed_at": 2022-09-10T11:47:14.000Z, + "closed_at": "2022-09-10T11:47:14Z", "comments_count": 0, - "created_at": 2022-09-10T11:47:13.000Z, - "discussion": undefined, + "created_at": "2022-09-10T11:47:13Z", "id": 243638, "max_lat": -36.8804862, "max_lon": 174.739748, @@ -553,10 +551,9 @@ exports[`end to end tests > listChangesets 1`] = ` }, { "changes_count": 10, - "closed_at": 2022-09-10T11:30:13.000Z, + "closed_at": "2022-09-10T11:30:13Z", "comments_count": 0, - "created_at": 2022-09-10T11:30:12.000Z, - "discussion": undefined, + "created_at": "2022-09-10T11:30:12Z", "id": 243637, "max_lat": -36.8804809, "max_lon": 174.7397571, @@ -576,10 +573,9 @@ exports[`end to end tests > listChangesets 1`] = ` }, { "changes_count": 10, - "closed_at": 2021-12-16T09:29:15.000Z, + "closed_at": "2021-12-16T09:29:15Z", "comments_count": 2, - "created_at": 2021-12-16T09:29:14.000Z, - "discussion": undefined, + "created_at": "2021-12-16T09:29:14Z", "id": 227200, "max_lat": -36.8801278, "max_lon": 174.7400986, @@ -600,10 +596,9 @@ exports[`end to end tests > listChangesets 1`] = ` }, { "changes_count": 5, - "closed_at": 2021-12-16T08:43:07.000Z, + "closed_at": "2021-12-16T08:43:07Z", "comments_count": 0, - "created_at": 2021-12-16T08:43:06.000Z, - "discussion": undefined, + "created_at": "2021-12-16T08:43:06Z", "id": 227184, "max_lat": -36.8804809, "max_lon": 174.7397571, diff --git a/src/api/_rawResponse.d.ts b/src/api/_rawResponse.d.ts index 7d13d30..d61abc1 100644 --- a/src/api/_rawResponse.d.ts +++ b/src/api/_rawResponse.d.ts @@ -1,24 +1,5 @@ import type { Feature, FeatureCollection, Point } from "geojson"; -import type { - Changeset, - ChangesetComment, - OsmFeatureType, - OsmNote, -} from "../types"; - -/** @internal */ -export type RawChangeset = Omit< - Changeset, - "discussion" | "created_at" | "closed_at" -> & { - created_at: string; - closed_at?: string; - comments?: (Omit & { - /** ISO Date */ - date: string; - uid: number; - })[]; -}; +import type { OsmFeatureType, OsmNote } from "../types"; /** @internal */ export type RawNotesSearch = FeatureCollection< diff --git a/src/api/changesets/__tests__/uploadChangeset.test.ts b/src/api/changesets/__tests__/uploadChangeset.test.ts index 31e83b5..f5bd5b1 100644 --- a/src/api/changesets/__tests__/uploadChangeset.test.ts +++ b/src/api/changesets/__tests__/uploadChangeset.test.ts @@ -236,7 +236,7 @@ describe("uploadChangeset", () => { { comment: "added a building" }, { create: [], modify: [], delete: [] } ); - expect(output).toBe(1); + expect(output).toStrictEqual({ 1: { diffResult: {} } }); expect(osmFetch).toHaveBeenCalledTimes(3); expect(osmFetch).toHaveBeenNthCalledWith( @@ -277,7 +277,56 @@ describe("uploadChangeset", () => { ); } - expect(output).toBe(1); + expect(output).toStrictEqual({ + // create nodes first. + 1: { + diffResult: { + node: { + "-10": { newId: 2, newVersion: 1 }, + "-11": { newId: 1, newVersion: 1 }, + "-2": { newId: 5, newVersion: 1 }, + "-7": { newId: 6, newVersion: 1 }, + "-8": { newId: 4, newVersion: 1 }, + "-9": { newId: 3, newVersion: 1 }, + }, + }, + }, + // create nodes then ways then relations next + 2: { + diffResult: { + node: { + "-100": { newId: 10, newVersion: 1 }, + "-4": { newId: 9, newVersion: 1 }, + "-5": { newId: 8, newVersion: 1 }, + "-6": { newId: 7, newVersion: 1 }, + }, + relation: { "-300000": { newId: 1, newVersion: 1 } }, + way: { "-3": { newId: 1, newVersion: 1 } }, + }, + }, + // modify and delete next (any order) + 3: { + diffResult: { + node: { + 1: { newId: 1, newVersion: 2 }, + 2: { newId: 2, newVersion: 3 }, + }, + relation: { + 2: { newId: 2, newVersion: 22 }, + 3: { newId: 3, newVersion: 2 }, + 4: { newId: 4, newVersion: 2 }, + }, + way: { 2: { newId: 2, newVersion: 2 } }, + }, + }, + // delete last + 4: { + diffResult: { + relation: { 1: { newId: 1, newVersion: 11 } }, + way: { 1: { newId: 1, newVersion: 3 } }, + }, + }, + }); }); it("splits changesets into chunks and suports a custom tag function", async () => { @@ -311,7 +360,9 @@ describe("uploadChangeset", () => { ); } - expect(output).toBe(1); + // don't need to assert the output, since it's the same + // as the previous test case. + expect(Object.keys(output).map(Number)).toStrictEqual([1, 2, 3, 4]); }); // end of tests diff --git a/src/api/changesets/createChangesetComment.ts b/src/api/changesets/createChangesetComment.ts index c014c74..6929f4c 100644 --- a/src/api/changesets/createChangesetComment.ts +++ b/src/api/changesets/createChangesetComment.ts @@ -1,7 +1,5 @@ import type { Changeset } from "../../types"; import { type FetchOptions, osmFetch } from "../_osmFetch"; -import type { RawChangeset } from "../_rawResponse"; -import { mapRawChangeset } from "./getChangesets"; /** Add a comment to a changeset. The changeset must be closed. */ export async function createChangesetComment( @@ -9,7 +7,7 @@ export async function createChangesetComment( commentText: string, options?: FetchOptions ): Promise { - const result = await osmFetch<{ changeset: RawChangeset }>( + const result = await osmFetch<{ changeset: Changeset }>( `/0.6/changeset/${changesetId}/comment.json`, undefined, { @@ -22,5 +20,5 @@ export async function createChangesetComment( }, } ); - return mapRawChangeset(result.changeset); + return result.changeset; } diff --git a/src/api/changesets/getChangesets.ts b/src/api/changesets/getChangesets.ts index 5457d96..078cf23 100644 --- a/src/api/changesets/getChangesets.ts +++ b/src/api/changesets/getChangesets.ts @@ -1,22 +1,5 @@ import type { BBox, Changeset } from "../../types"; import { type FetchOptions, osmFetch } from "../_osmFetch"; -import type { RawChangeset } from "../_rawResponse"; - -/** @internal */ -export const mapRawChangeset = ({ - comments, - ...raw -}: RawChangeset): Changeset => ({ - ...raw, - created_at: new Date(raw.created_at), - closed_at: raw.closed_at ? new Date(raw.closed_at) : undefined!, - - discussion: comments?.map((comment) => ({ - ...comment, - date: new Date(comment.date), - uid: `${comment.uid}`, - })), -}); // does not extend BasicFilters for historical reasons export type ListChangesetOptions = { @@ -57,7 +40,7 @@ export async function listChangesets( ): Promise { const { only, ...otherQueries } = query; - const raw = await osmFetch<{ changesets: RawChangeset[] }>( + const raw = await osmFetch<{ changesets: Changeset[] }>( "/0.6/changesets.json", { ...(only && { [only]: true }), @@ -66,7 +49,7 @@ export async function listChangesets( options ); - return raw.changesets.map(mapRawChangeset); + return raw.changesets; } /** get a single changeset */ @@ -76,11 +59,11 @@ export async function getChangeset( includeDiscussion = true, options?: FetchOptions ): Promise { - const raw = await osmFetch<{ changeset: RawChangeset }>( + const raw = await osmFetch<{ changeset: Changeset }>( `/0.6/changeset/${id}.json`, includeDiscussion ? { include_discussion: 1 } : {}, options ); - return mapRawChangeset(raw.changeset); + return raw.changeset; } diff --git a/src/api/changesets/uploadChangeset.ts b/src/api/changesets/uploadChangeset.ts index b3452e6..ca5053a 100644 --- a/src/api/changesets/uploadChangeset.ts +++ b/src/api/changesets/uploadChangeset.ts @@ -1,6 +1,7 @@ -import type { OsmChange, Tags } from "../../types"; +import type { OsmChange, OsmFeatureType, Tags } from "../../types"; import { type FetchOptions, osmFetch } from "../_osmFetch"; import { version } from "../../../package.json"; +import type { RawUploadResponse } from "../_rawResponse"; import { createChangesetMetaXml, createOsmChangeXml, @@ -19,6 +20,20 @@ export interface UploadChunkInfo { export type UploadPhase = "upload" | "merge_conflicts"; +/** Can include multiple changeset IDs if the upload was chunked. */ +export interface UploadResult { + [changesetId: number]: { + diffResult: { + [Type in OsmFeatureType]?: { + [oldId: number]: { + newId: number; + newVersion: number; + }; + }; + }; + }; +} + /** @internal */ export function compress(input: string) { // check if it's supported @@ -61,7 +76,7 @@ export async function uploadChangeset( tags: Tags, diff: OsmChange, options?: FetchOptions & UploadOptions -): Promise { +): Promise { const { onChunk, onProgress, @@ -71,10 +86,10 @@ export async function uploadChangeset( } = options || {}; const chunks = chunkOsmChange(diff); - const csIds: number[] = []; - const featureCount = getOsmChangeSize(diff); + const result: UploadResult = {}; + for (const [index, chunk] of chunks.entries()) { let tagsForChunk = tags; @@ -114,23 +129,41 @@ export async function uploadChangeset( const compressed = !disableCompression && (await compress(osmChangeXml)); - await osmFetch(`/0.6/changeset/${csId}/upload`, undefined, { - ...fetchOptions, - method: "POST", - body: compressed || osmChangeXml, - headers: { - ...fetchOptions.headers, - ...(compressed && { "Content-Encoding": "gzip" }), - "content-type": "application/xml; charset=utf-8", - }, - }); + const idMap = await osmFetch( + `/0.6/changeset/${csId}/upload`, + undefined, + { + ...fetchOptions, + method: "POST", + body: compressed || osmChangeXml, + headers: { + ...fetchOptions.headers, + ...(compressed && { "Content-Encoding": "gzip" }), + "content-type": "application/xml; charset=utf-8", + }, + } + ); + + // convert the XML format into a more concise JSON format + result[csId] = { diffResult: {} }; + for (const _type in idMap.diffResult[0]) { + if (_type === "$") continue; + const type = _type; + const items = idMap.diffResult[0][type] || []; + for (const item of items) { + result[csId].diffResult[type] ||= {}; + result[csId].diffResult[type][item.$.old_id] = { + newId: +item.$.new_id, + newVersion: +item.$.new_version, + }; + } + } await osmFetch(`/0.6/changeset/${csId}/close`, undefined, { ...fetchOptions, method: "PUT", }); - csIds.push(csId); } - return csIds[0]; // TODO:(semver breaking) return an array of IDs + return result; } diff --git a/src/api/dwg/hideChangesetComment.ts b/src/api/dwg/hideChangesetComment.ts index e31a67c..446cf2f 100644 --- a/src/api/dwg/hideChangesetComment.ts +++ b/src/api/dwg/hideChangesetComment.ts @@ -1,7 +1,5 @@ import type { Changeset } from "../../types"; import { type FetchOptions, osmFetch } from "../_osmFetch"; -import type { RawChangeset } from "../_rawResponse"; -import { mapRawChangeset } from "../changesets"; /** DWG only */ export async function hideChangesetComment( @@ -9,10 +7,10 @@ export async function hideChangesetComment( action?: "hide" | "unhide", options?: FetchOptions ): Promise { - const result = await osmFetch<{ changeset: RawChangeset }>( + const result = await osmFetch<{ changeset: Changeset }>( `/0.6/changeset_comments/${changesetCommentId}/visibility.json`, {}, { ...options, method: action === "unhide" ? "POST" : "DELETE" } ); - return mapRawChangeset(result.changeset); + return result.changeset; } diff --git a/src/types/changesets.ts b/src/types/changesets.ts index 4664ef2..69e50d3 100644 --- a/src/types/changesets.ts +++ b/src/types/changesets.ts @@ -5,40 +5,32 @@ export type ChangesetComment = { id: number; visible: boolean; user: string; - // TODO:(semver breaking) change to number - uid: string; - // TODO:(semver breaking) change to string - date: Date; + uid: number; + date: string; text: string; }; export type Changeset = { id: number; - // TODO:(semver breaking) change to string - created_at: Date; + created_at: string; open: boolean; comments_count: number; changes_count: number; - /** property only exists if `open=false` */ - // TODO:(semver breaking) mark as optional - closed_at: Date; - /** property only exists if `open=false` */ - // TODO:(semver breaking) mark as optional - min_lat: number; - /** property only exists if `open=false` */ - // TODO:(semver breaking) mark as optional - min_lon: number; - /** property only exists if `open=false` */ - // TODO:(semver breaking) mark as optional - max_lat: number; - /** property only exists if `open=false` */ - // TODO:(semver breaking) mark as optional - max_lon: number; + /** This property is missing if `open=true` */ + closed_at?: string; + /** This property is missing if `open=true`, or if none of the edited features have geometry data (see #14) */ + min_lat?: number; + /** This property is missing if `open=true`, or if none of the edited features have geometry data (see #14) */ + min_lon?: number; + /** This property is missing if `open=true`, or if none of the edited features have geometry data (see #14) */ + max_lat?: number; + /** This property is missing if `open=true`, or if none of the edited features have geometry data (see #14) */ + max_lon?: number; uid: number; user: string; tags: Tags; - /** the `discussion` attribute is only included in the `getChangeset` API */ - discussion?: ChangesetComment[]; + /** the `comments` attribute is only included in the `getChangeset` API */ + comments?: ChangesetComment[]; }; export type OsmChange = {