Skip to content

Add "move" operation via MOVE HTTP verb#191

Merged
coddingtonbear merged 33 commits into
coddingtonbear:mainfrom
mairas:move-only
May 22, 2026
Merged

Add "move" operation via MOVE HTTP verb#191
coddingtonbear merged 33 commits into
coddingtonbear:mainfrom
mairas:move-only

Conversation

@mairas
Copy link
Copy Markdown
Contributor

@mairas mairas commented Oct 2, 2025

First, a disclaimer: I needed the Move operation for my own vault scripting and began working on the topic a couple of weeks ago. I didn't notice that Adam closed PR #177 meanwhile based on discussion with Guillaume.

Basically, I've just done the commit-wrangling requested by Adam in PR #177; there are no functional changes to any of @duquesnay's code. Despite the resolution of the original PR, I decided to publish the PR nevertheless, mainly for discussion or as an alternative solution. No ill will or passive-aggressiveness intended. :-)

- Add move operation to PATCH endpoint for files
- Use Operation: move, Target-Type: file, Target: path
- Automatically creates parent directories if needed
- Preserves all internal links using FileManager.renameFile()
- Validates paths and prevents overwrites
- Add tests for edge cases
- Add new error codes for clearer validation messages
- Use consistent returnCannedResponse pattern throughout
- Reorder validation checks for better flow
- Separate file operations from applyPatch operations early
- Maintain upstream coding style and patterns
…ile move

- Add path traversal protection to prevent access outside vault
- Use returnCannedResponse consistently throughout handleMoveOperation
- Add proper error codes to ErrorCode enum and ERROR_CODE_MESSAGES
- Validate paths early to prevent directory path destinations
- Check parent directory existence before creating
- Add comprehensive security tests for path traversal attempts
- Normalize paths to handle backslashes and multiple slashes
- Add move operation to PATCH endpoint enum
- Update Target parameter description for move operations
- Include example for file move functionality
- Documents Operation: move, Target-Type: file usage
@coddingtonbear
Copy link
Copy Markdown
Owner

Forgive me, I might have misremembered my conversation, but I wonder if you could consider adding that support via an API extension instead? That funcionality is documented here: https://github.com/coddingtonbear/obsidian-local-rest-api/wiki/Adding-your-own-API-Routes-via-an-Extension ; maybe you ran into an obstacle when trying to go that direction?

@mairas
Copy link
Copy Markdown
Contributor Author

mairas commented Oct 2, 2025

Note that I am a different person. :-D You discussed that with @duquesnay; I just took his work from PR #177 that I needed but was not being worked on (and that's fine, people have lives), so I figured I'd do the commit reshuffling you had requested.

I didn't know about the API extensions; they might be a good way to dial in new operations, but maybe Move would be such an essential operation that it would belong in the API proper?

@coddingtonbear
Copy link
Copy Markdown
Owner

coddingtonbear commented Oct 3, 2025

I really appreciate you taking the time to do this! There’s an open discussion about adding a WebDAV-style MOVE method here, and that feels like a more natural direction to me right now.

Part of the hesitation is that REST itself doesn’t really have a concept of MOVE — you can already achieve the same thing with a combination of GET, PUT, and DELETE. So while I do think it’s helpful to have a clean way to express “move,” I’m leaning toward the WebDAV-style approach over extending PATCH in this way.

@mairas
Copy link
Copy Markdown
Contributor Author

mairas commented Oct 3, 2025

No worries! Using a custom verb would definitely work, and I agree that MOVE would be more logical endpoint than PATCH. Actually, PATCH probably isn't a great method for MOVE because the original resource gets lost in the operation. In the case of the Obsidian API, GET-PUT-DELETE is not a viable alternative to a Move operation because Move also handles internal links (and backlinks, I believe). Otherwise my LLM repo organizer would've been easier to implement on the file system level.

@coddingtonbear
Copy link
Copy Markdown
Owner

You know, I'm not sure how it never occurred to me, but I honestly hadn't at all considered that one of the side-effects of having a dedicated way of moving a file would be re-writing links as appropriate. Still, definitely feeling like adding a MOVE HTTP verb sounds a lot smoother than trying to use PATCH for more than it's already used for.

In any case, would you forgive me if we moved this conversation over to #190 for the time being so both approaches can be considered side-by-side?

@mairas
Copy link
Copy Markdown
Contributor Author

mairas commented Oct 3, 2025

In any case, would you forgive me if we moved this conversation over to #190 for the time being so both approaches can be considered side-by-side?

I would. :-D

@mairas
Copy link
Copy Markdown
Contributor Author

mairas commented Oct 4, 2025

I implemented a custom MOVE operation (with the kind assistance of Claude Code). I'll provide my opinion in Discussion #190.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR implements a WebDAV-style MOVE operation for moving files within an Obsidian vault. The implementation adds a new HTTP MOVE method that accepts a destination path via a Destination header and handles various security and validation concerns.

Key Changes

  • Added new error codes for MOVE operation validation (MissingDestinationHeader, InvalidDestinationPath, PathTraversalNotAllowed, DestinationAlreadyExists, FileOperationFailed)
  • Implemented _vaultMove and vaultMove methods to handle file moving with path traversal protection
  • Added route registration for the MOVE HTTP method using Express middleware
  • Created comprehensive test coverage for the MOVE operation including edge cases
  • Updated OpenAPI documentation to include the new MOVE endpoint

Reviewed Changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/types.ts Added 5 new ErrorCode enum values for MOVE operation validation
src/requestHandler.ts Implemented MOVE operation handler with security checks and route registration; reorganized imports alphabetically
src/requestHandler.test.ts Added comprehensive test suite for MOVE operation covering success and failure scenarios; reorganized imports
src/constants.ts Added error messages for new MOVE-related error codes
docs/src/openapi.jsonnet Updated import paths and added MOVE method to OpenAPI spec
docs/src/lib/move.jsonnet Created new OpenAPI documentation for MOVE endpoint
docs/openapi.yaml Updated generated OpenAPI YAML with MOVE documentation and comment improvements

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/types.ts Outdated
Comment thread src/requestHandler.ts Outdated
Comment thread src/requestHandler.ts Outdated
Comment thread src/requestHandler.ts Outdated
Comment thread docs/openapi.yaml
@coddingtonbear
Copy link
Copy Markdown
Owner

Hey there @mairas -- it looks like you have some comments from Copilot to either fix or respond to before we can go forward on this one. Let me know if you have any questions or need a hand on those.

Regarding the docs: I think using the MOVE operation is probably the right choice. I also have read that OpenAPI 3.2 does support non-standard verbs; so we should be able to document the MOVE operation if we update to that. It may or may not appear in the Swagger docs, though, but as long as it appears in the YAML, I think I'd be OK with moving forward on things. Do you think that's something you could take on sorting out?

Copy link
Copy Markdown
Owner

@coddingtonbear coddingtonbear left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just commented above re: the next steps on this and adding this "Request Changes" just so it shows up accurately on the PR list.

@coddingtonbear
Copy link
Copy Markdown
Owner

Just checking-in @mairas; before I can go further with this one, I'm hoping you can address the above @co-pilot comments. Co-pilot is often a little bit nit-picky, so "addressing" things could just be you commenting to make a case for not doing the thing it asks. I'll keep this open until January 4th (3 months after your last interaction on the PR) in case you want to keep working on it.

I don't have a ton of time to offer, but let me know if you need a hand!

@coddingtonbear coddingtonbear changed the title Add File Move Operation to PATCH Endpoint (only the relevant commits) Add Move operation via MOVE HTTP verb Jan 10, 2026
@coddingtonbear coddingtonbear changed the title Add Move operation via MOVE HTTP verb Add "move" operation via MOVE HTTP verb Jan 10, 2026
@coddingtonbear
Copy link
Copy Markdown
Owner

I’ve looked into this a bit myself (see the move-only branch) and pushed it a little further along. At the moment, though, the main limiting factor is documentation support.

The documentation engine we currently use (Swagger-UI) only supports OpenAPI 3.1 and lower. Unfortunately, documenting atypical HTTP methods requires OpenAPI 3.2 or newer (see swagger-api/swagger-ui#10575).

I did some digging to see whether other documentation engines already support OpenAPI 3.2, and found that Redoc has a v3.0.0 branch that adds that support (see Redocly/redoc#2735). It does appear to render docs for the MOVE method correctly:

image

This looks great, and from a pure documentation-rendering perspective would be enough to move things forward. However, Redoc doesn’t currently support the kind of interactive documentation that Swagger-UI provides.

For an API like this—especially for users who may be less familiar with REST APIs—interactive docs significantly reduce confusion and support overhead. Without that interactivity, it becomes much harder for folks to get the hands-on experience they might need for understanding how a thing like this works.

Given that, my current position is that this should remain on hold until Swagger-UI lands OpenAPI 3.2 support. Without interactive docs, the support burden would be more than I can personally take on; so that’s the point at which I can confidently move this forward while keeping the project maintainable.

@RitchieMatt
Copy link
Copy Markdown

Very keen to see this feature implemented.

This is a huge pain point for me. Current "rename" work arounds are distructive to links within vaults.

I was totally unaware of the SwaggerUI documentation issues. Is this feature available yet, even if the documentation is not?

@coddingtonbear
Copy link
Copy Markdown
Owner

coddingtonbear commented Jan 13, 2026

I hear you — link breakage on rename is a real pain point, and I agree the feature would be valuable.

That said, the feature isn’t available in a released build yet. I’m intentionally holding it back until we can ship it with interactive documentation, because experience has shown that releasing API features without that support creates a lot of follow-on support and confusion. I know this sort of thing isn't obvious to non-maintainers, but answering questions and fielding (often: erroneous) bug reports already consumes a very big fraction of the time I have available to work on this project (well over 50%), and releasing a new feature without solid documentation will just make that balance worse.

If you’re comfortable running a custom build, the relevant branches are already pushed and linked above. Otherwise, this will land once Swagger-UI supports OpenAPI 3.2 and we can document it properly.

@coddingtonbear coddingtonbear added the on hold This cannot be completed until something else happens outside this project. label Jan 21, 2026
@coddingtonbear
Copy link
Copy Markdown
Owner

Good news, you all, it looks like the main blocker might be a thing of the past: swagger-api/swagger-ui#10575 (comment) via https://github.com/swagger-api/swagger-ui/releases/tag/v5.32.0 .

It might be a little while before I can find the time to give it a shot, though. That said, if any of you are able to get this endpoint documented using that functionality, that'll get us basically all of the way toward this functionality landing in the API. Let me know if you get it working!

@coddingtonbear coddingtonbear added help wanted Extra attention is needed and removed on hold This cannot be completed until something else happens outside this project. labels Mar 19, 2026
@darjeeling
Copy link
Copy Markdown

I looked into this and tested it locally today.

MOVE can be documented in the raw OpenAPI YAML, but it still does not show up in Swagger UI yet. I updated the spec from OpenAPI 3.0.2 to 3.2.0, regenerated the docs, and tested it with Swagger UI 5.32.1, but MOVE is still filtered out from the rendered operations list.

From what I found, Swagger UI PR #10721 added basic OAS 3.2 support and QUERY, but not generic custom HTTP method support / additionalOperations. So this does not seem to be fully supported upstream yet.

At this point, it looks like we either need to wait for upstream support or add a follow-up PR there for custom methods like MOVE.

@coddingtonbear @mairas

@coddingtonbear
Copy link
Copy Markdown
Owner

In v3.6.0 I moved this project to a new enough version of Swagger UI to get access to their "basic" OpenAPI 3.2.0 support; so I tried to give this another shot (see 191__swagger_move_documentation_test). Unfortunately, as @darjeeling found to be true, it still doesn't support additionalOperations quite yet.

In swagger-api/swagger-ui#10721, they note that that'll be in a separate, future PR:

Enhanced Features (moved to future PR):
The following OAS 3.2 features are excluded from this basic implementation and will be addressed in a separate enhancement PR:

  • $self field for base URI resolution
  • additionalOperations for custom HTTP methods
  • mediaTypes in Components Object
  • pathItems in Components Object
  • Tag enhancements (summary, kind, parent)
  • querystring parameter location
  • itemSchema for streaming responses

So; this is still stuck for now, unfortunately.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 5 comments.

Comment thread src/requestHandler.ts Outdated
Comment thread src/requestHandler.ts Outdated
Comment thread src/requestHandler.ts
Comment thread docs/src/openapi.jsonnet
Comment thread src/vaultOperations.ts
coddingtonbear and others added 7 commits May 20, 2026 20:50
The vault_move tool was missing from the Available Tools table in the
/mcp/ endpoint documentation. Added the entry and regenerated
docs/openapi.yaml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
decodeURIComponent throws a URIError on invalid sequences like '%E0%'.
Without a try/catch this bubbled into the generic error handler and
produced a 500. Wrap the decode step and return a new
InvalidDestinationHeader (40022) error code instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A Destination header consisting entirely of whitespace was previously
rejected with MissingDestinationHeader (400) because Node.js HTTP
strips OWS from field values before Express sees them, leaving an
empty string that the original !rawDestination guard caught.

Change the guard to rawDestination === undefined so that an explicitly
sent (but empty) Destination header is allowed through. After
normalization the empty string maps to the vault root, and the existing
!normalized branch in the newPath calculation appends the source
filename, moving the file to the root of the vault with its name
preserved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without the return, control falls through after handle() completes,
making the control flow harder to reason about and creating a risk
that a future edit adds code after the if/else that would execute
even for handled MOVE requests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n moveVaultFile

When allowOverwrite was true and destination equaled source, the
overwrite branch deleted the destination (which is the same file as
the source) before calling renameFile on the now-deleted handle,
silently destroying the file.

Add an early return when sourcePath === destinationPath, treating
a same-path move as a no-op. An integration test covers the
allowOverwrite variant that previously caused the deletion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The additionalOperations field used to document the MOVE verb is a
standard OpenAPI 3.2 Path Item field. The document was previously
declared as 3.0.2, making that field a spec violation. No schema
content changes are needed: none of the 3.0→3.1 breaking constructs
(nullable, boolean exclusiveMaximum/Minimum, format:binary/base64)
appear in this document.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.

Comment thread src/constants.ts
Comment thread src/requestHandler.ts Outdated
Comment thread src/mcpHandler.ts
coddingtonbear and others added 7 commits May 22, 2026 16:11
ErrorCode.InvalidDestinationHeader (40022) was defined in types.ts but
had no corresponding entry in the ERROR_CODE_MESSAGES Record, causing
TypeScript to emit a compile error and MOVE error responses for malformed
Destination headers to include an undefined message string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The HTTP MOVE handler trims the raw Destination header value before
normalization; the MCP vault_move tool was missing the equivalent step.
A whitespace-only destination would pass the traversal check and reach
moveVaultFile as a blank path, causing a runtime failure. Adding trim()
aligns MCP behavior with the HTTP handler.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous check (normalized.includes("..") || normalized.startsWith("/"))
was too broad: it rejected legitimate filenames containing ".." as a
substring (e.g. "notes..md") and was redundant for the leading-slash case
once normalization was in place.

The new check uses posix.resolve against a synthetic vault root and
asserts the resolved path starts with that root. This correctly handles
all traversal attempts (including multi-segment ".." sequences and
absolute paths) while allowing filenames that merely happen to contain
".." as a substring.

Applied consistently in both the HTTP MOVE handler and the MCP vault_move
tool. Tests added for both the rejection and the newly-allowed case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
posix.resolve treats its second argument as absolute when it starts with
'/', ignoring the synthetic root entirely. A destination like
/vault/notes/file.md resolves to itself, passes the startsWith check,
and reaches Obsidian's API with an absolute-looking path it does not
understand. Adding an explicit leading-slash guard before the
posix.resolve step ensures any absolute path — including those that
happen to share the synthetic-root prefix — is rejected with
PathTraversalNotAllowed (HTTP) or the equivalent MCP error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Destination header is decoded server-side with decodeURIComponent,
matching how the Target header works on PATCH endpoints. HTTP/1.1
headers are Latin-1 by default, so raw UTF-8 bytes in header values
will be misinterpreted; clients must percent-encode non-ASCII characters
(e.g. r%C3%A9sum%C3%A9.md for résumé.md). Adds the same guidance
already present in the Target header docs to the Destination header
description, and notes that Content-Location values for non-ASCII paths
will likewise be percent-encoded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The /periodic/:period/ routes require a trailing slash. All test calls
used /periodic/daily without one, producing a 404 from Express before
the handler was ever reached. This was a pre-existing bug on main.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ocation encoding

moveVaultFile now returns the path Obsidian reports after renameFile
completes (sourceFile.path), matching the pattern every other endpoint
uses — reading the canonical path from the TFile object rather than
echoing back the client-provided string. Both the REST Content-Location
header and the MCP newPath response field now reflect this value.

Also adds a percent-encoding note to the shared ContentLocationHeader
description in the OpenAPI spec, so all endpoints consistently document
that non-ASCII names appear percent-encoded in that header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 5 comments.

Comment thread src/mcpHandler.ts
Comment thread src/mcpHandler.ts Outdated
Comment thread docs/src/lib/move.jsonnet
Comment thread src/vaultOperations.ts
Comment thread src/mcpHandler.test.ts Outdated
coddingtonbear and others added 4 commits May 22, 2026 17:19
Adds an early check that rejects empty-string destinations before any
vault adapter calls are made. This prevents adapter operations on an
invalid path when a caller bypasses the normalization logic that would
normally resolve an empty destination to a vault-root move.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the caller passes an empty or whitespace-only destination, the REST
MOVE handler resolves it to the vault root by appending the source
filename (matching the trailing-slash convention). The MCP handler was
missing the !normalized guard, so it forwarded an empty string to
moveVaultFile instead. This commit adds the same guard and covers the
case with two new tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The documentation in move.jsonnet and the vault_move MCP tool
description both said the destination "must not contain '..'" — but the
actual validation resolves the path against a synthetic vault root and
rejects only paths that escape it. That means a/../b.md is accepted
(resolves within the vault) while ../../etc/passwd is rejected.

Update both the Destination header description and the 400 response
description in move.jsonnet to reflect the real rule, and regenerate
docs/openapi.yaml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The test was mocking moveVaultFile to resolve undefined, which is not a
valid return type and masked any assertion on the returned newPath.
Mock it with the real destination string and assert on newPath so the
test exercises the full round-trip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

docs/src/openapi.jsonnet:38

  • The OpenAPI version is set to 3.2.0, but the repo’s docs page (docs/index.html) is rendered via Stoplight Elements (<elements-api>), which typically expects OpenAPI 3.0/3.1. If Elements (and other tooling) doesn't recognize 3.2.0, the published docs may fail to load/validate. Consider keeping the spec at 3.0.2/3.1.0 (and using an extension for the MOVE operation) or verify the rendering/validation toolchain supports 3.2.0 before merging.

std.manifestYamlDoc(
  {
    openapi: '3.2.0',
    info: {
      title: 'Local REST API for Obsidian',
      description: importstr 'lib/descriptions/info.md',
      version: '1.0',

Comment thread src/requestHandler.test.ts
@coddingtonbear coddingtonbear merged commit 3dba89d into coddingtonbear:main May 22, 2026
@coddingtonbear
Copy link
Copy Markdown
Owner

This feature went out as part of 4.1.0 today! You can find the final documentation here: https://coddingtonbear.github.io/obsidian-local-rest-api/#/paths/vault-filename/move

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants