diff --git a/.changeset/lazy-plums-fetch.md b/.changeset/lazy-plums-fetch.md new file mode 100644 index 0000000..7613cbf --- /dev/null +++ b/.changeset/lazy-plums-fetch.md @@ -0,0 +1,5 @@ +--- +"fetchify": major +--- + +**BREAKING**: `createFetchify` clients now expose HTTP methods as lowercase properties (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`) instead of uppercase (`GET`, `POST`, ...). The underlying `fetch` request still uses the uppercase HTTP verb; only the client property names changed casing. diff --git a/openspec/changes/archive/2026-08-02-lowercase-http-methods/.openspec.yaml b/openspec/changes/archive/2026-08-02-lowercase-http-methods/.openspec.yaml new file mode 100644 index 0000000..5849c2d --- /dev/null +++ b/openspec/changes/archive/2026-08-02-lowercase-http-methods/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-01 diff --git a/openspec/changes/archive/2026-08-02-lowercase-http-methods/design.md b/openspec/changes/archive/2026-08-02-lowercase-http-methods/design.md new file mode 100644 index 0000000..6efdd35 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-lowercase-http-methods/design.md @@ -0,0 +1,23 @@ +## Context + +`src/lib/create-fetchify.ts` builds the `Fetchify` client by reducing over an `HTTP_METHODS` const tuple of uppercase verb strings (`"GET"`, `"POST"`, ...), assigning each verb directly as both the client property key and the `fetch` `method` value. The `Fetchify` interface declares one property per uppercase verb. This is a small, self-contained module with a single consumer surface (the `createFetchify` factory), re-exported unchanged from `src/native.ts`. + +## Goals / Non-Goals + +**Goals:** +- Expose client methods as lowercase properties (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`). +- Keep the actual `fetch` `method` value uppercase (HTTP verbs are conventionally uppercase on the wire; this is purely a client-API casing change). + +**Non-Goals:** +- No change to request/response behavior, `baseUrl` resolution, or `RequestInit` forwarding. +- No dual API (no keeping both cases) — this is a clean breaking rename, not an additive alias. + +## Decisions + +- **Separate "client key" from "wire method" in the method table.** Change `HTTP_METHODS` from a flat string tuple to a list of `{ key: lowercase, verb: uppercase }` pairs (or an equivalent map), so the reduce loop can assign `client[key]` while still calling `fetch` with `method: verb`. Alternative considered: lowercase the tuple and `.toUpperCase()` when calling fetch — rejected because it's less explicit and couples casing transformation logic into the request path instead of a single static table. +- **No backwards-compatible aliasing.** Per proposal, this is a **BREAKING** change; uppercase properties are removed entirely rather than kept alongside lowercase ones. Rejected keeping both: it would double the public surface and contradict the goal of a consistent, lowercase-only API. + +## Risks / Trade-offs + +- [Breaking change for any existing consumer using `client.GET`/etc.] → Mitigated by a major changeset entry (per repo convention) and updating all in-repo call sites/tests in the same change. +- [TypeScript consumers relying on the exported `Fetchify` interface shape] → Interface is updated in lockstep; no separate migration path needed since it's a source-compatible rename at the type level (consumers just update property names). diff --git a/openspec/changes/archive/2026-08-02-lowercase-http-methods/proposal.md b/openspec/changes/archive/2026-08-02-lowercase-http-methods/proposal.md new file mode 100644 index 0000000..a7971c8 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-lowercase-http-methods/proposal.md @@ -0,0 +1,25 @@ +## Why + +The `Fetchify` client currently exposes HTTP verbs as uppercase properties (`client.GET`, `client.POST`, ...). Most fetch-wrapper libraries in the ecosystem (axios, ky, ofetch) use lowercase method names, so the uppercase API is surprising and inconsistent with what consumers expect when calling `http.get(...)`, `http.post(...)`, etc. + +## What Changes + +- **BREAKING**: Rename all client method properties from uppercase (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`) to lowercase (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`). +- Update the `Fetchify` interface to declare lowercase method names. +- The underlying HTTP method string sent in the `fetch` call (`init.method`) remains uppercase (e.g. `client.get()` still issues a request with `method: "GET"`); only the client property names change casing. +- Update existing tests and any documentation/examples that reference the uppercase method names. + +## Capabilities + +### New Capabilities +(none) + +### Modified Capabilities +- `http-client`: The client object returned by `createFetchify` exposes HTTP methods as lowercase properties (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`) instead of uppercase. + +## Impact + +- `src/lib/create-fetchify.ts`: `Fetchify` interface and `HTTP_METHODS`/client-building logic. +- `src/lib/create-fetchify.test.ts`: tests referencing `client.GET`, `client.POST`, etc. +- `src/native.ts`: re-exports the same `Fetchify` type, no separate changes needed beyond the shared implementation. +- Any consumer code using `createFetchify` must update call sites from uppercase to lowercase method names — this is a breaking change requiring a major version bump per semver, and a changeset must be added. diff --git a/openspec/changes/archive/2026-08-02-lowercase-http-methods/specs/http-client/spec.md b/openspec/changes/archive/2026-08-02-lowercase-http-methods/specs/http-client/spec.md new file mode 100644 index 0000000..6615d62 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-lowercase-http-methods/specs/http-client/spec.md @@ -0,0 +1,46 @@ +## MODIFIED Requirements + +### Requirement: createFetchify factory +The package SHALL export a `createFetchify` function that accepts a `FetchifyOptions` object and returns a client object exposing one method per supported HTTP verb, using lowercase property names. `FetchifyOptions` is the same type produced by the `defineOptions` factory, so `createFetchify` SHALL accept either a plain object literal or the object returned by `defineOptions`. + +#### Scenario: Creating a client +- **WHEN** a consumer calls `createFetchify({ baseUrl: "https://api.example.com" })` +- **THEN** the call returns an object with `get`, `post`, `put`, `patch`, `delete`, `head`, and `options` methods + +#### Scenario: Creating a client with no options +- **WHEN** a consumer calls `createFetchify()` or `createFetchify({})` +- **THEN** the call returns a client object with the same set of HTTP methods, and no `baseUrl` resolution is applied to request paths + +#### Scenario: Creating a client from defineOptions +- **WHEN** a consumer calls `createFetchify(defineOptions({ baseUrl: "https://api.example.com" }))` +- **THEN** the call returns a client behaving identically to one created by passing the equivalent object literal directly to `createFetchify` + +### Requirement: Relative and absolute request paths +Each HTTP method on the returned client SHALL resolve a relative request path against the configured `baseUrl`, and SHALL use an absolute request path as-is, ignoring `baseUrl`. + +#### Scenario: Relative path with baseUrl configured +- **WHEN** a client created with `baseUrl: "https://api.example.com"` calls `get("/users")` +- **THEN** the underlying request is made to `https://api.example.com/users` + +#### Scenario: Absolute path overrides baseUrl +- **WHEN** a client created with `baseUrl: "https://api.example.com"` calls `get("https://other.example.com/users")` +- **THEN** the underlying request is made to `https://other.example.com/users`, not joined with `baseUrl` + +#### Scenario: Relative path with no baseUrl configured +- **WHEN** a client created without `baseUrl` calls `get("/users")` +- **THEN** the request fails the same way a bare `fetch("/users")` would fail in that environment, since there is no base to resolve against + +### Requirement: HTTP method wrappers +Each of `get`, `post`, `put`, `patch`, `delete`, `head`, and `options` SHALL issue a `fetch` request using its corresponding uppercase HTTP method (e.g. `get` issues a request with method `GET`), forwarding any caller-supplied request-init options (headers, body, signal, etc.) other than `method`, and SHALL return the resulting `Response`. + +#### Scenario: Method sets the correct HTTP verb +- **WHEN** a client calls `client.post("/users", { body: JSON.stringify({ name: "a" }) })` +- **THEN** the underlying request is sent with HTTP method `POST` and the given body + +#### Scenario: Caller-supplied init.method is overridden +- **WHEN** a client calls `client.get("/users", { method: "POST" })` +- **THEN** the underlying request is still sent with HTTP method `GET` + +#### Scenario: Returned value is the fetch Response +- **WHEN** any HTTP method wrapper resolves +- **THEN** it resolves to the `Response` object produced by the underlying `fetch` call, unmodified diff --git a/openspec/changes/archive/2026-08-02-lowercase-http-methods/tasks.md b/openspec/changes/archive/2026-08-02-lowercase-http-methods/tasks.md new file mode 100644 index 0000000..1cbb59e --- /dev/null +++ b/openspec/changes/archive/2026-08-02-lowercase-http-methods/tasks.md @@ -0,0 +1,15 @@ +## 1. Implementation + +- [x] 1.1 In `src/lib/create-fetchify.ts`, change the `HTTP_METHODS` table to pair each lowercase client key with its uppercase wire-method verb (e.g. `{ key: "get", verb: "GET" }`, ... for `post`/`put`/`patch`/`delete`/`head`/`options`) +- [x] 1.2 Update the `Fetchify` interface to declare lowercase method properties (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`) instead of uppercase +- [x] 1.3 Update the client-building `reduce` in `createFetchify` to assign `client[key]` while calling `request(verb, ...)`, so the `fetch` call still uses the uppercase HTTP method + +## 2. Tests + +- [x] 2.1 Update `src/lib/create-fetchify.test.ts` to reference lowercase client methods (`client.get`, `client.post`, etc.) everywhere they currently use uppercase (also updated `src/index.test.ts` and `src/native.test.ts`, which had the same end-to-end assertion) +- [x] 2.2 Update the `test.each` table of methods to assert `init.method` is still the uppercase verb while calling the lowercase client property +- [x] 2.3 Run `bun test` and confirm all tests pass + +## 3. Release Prep + +- [x] 3.1 Run `bun run changeset` and record this as a **major** bump (breaking change: client method names changed casing) diff --git a/openspec/specs/http-client/spec.md b/openspec/specs/http-client/spec.md index cb08f90..efd633a 100644 --- a/openspec/specs/http-client/spec.md +++ b/openspec/specs/http-client/spec.md @@ -7,11 +7,11 @@ Defines the `createFetchify` HTTP client factory that consumers use to make requ ## Requirements ### Requirement: createFetchify factory -The package SHALL export a `createFetchify` function that accepts a `FetchifyOptions` object and returns a client object exposing one method per supported HTTP verb. `FetchifyOptions` is the same type produced by the `defineOptions` factory, so `createFetchify` SHALL accept either a plain object literal or the object returned by `defineOptions`. +The package SHALL export a `createFetchify` function that accepts a `FetchifyOptions` object and returns a client object exposing one method per supported HTTP verb, using lowercase property names. `FetchifyOptions` is the same type produced by the `defineOptions` factory, so `createFetchify` SHALL accept either a plain object literal or the object returned by `defineOptions`. #### Scenario: Creating a client - **WHEN** a consumer calls `createFetchify({ baseUrl: "https://api.example.com" })` -- **THEN** the call returns an object with `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, and `OPTIONS` methods +- **THEN** the call returns an object with `get`, `post`, `put`, `patch`, `delete`, `head`, and `options` methods #### Scenario: Creating a client with no options - **WHEN** a consumer calls `createFetchify()` or `createFetchify({})` @@ -47,26 +47,26 @@ The package SHALL export a `createFetchify` function that accepts a `FetchifyOpt Each HTTP method on the returned client SHALL resolve a relative request path against the configured `baseUrl`, and SHALL use an absolute request path as-is, ignoring `baseUrl`. #### Scenario: Relative path with baseUrl configured -- **WHEN** a client created with `baseUrl: "https://api.example.com"` calls `GET("/users")` +- **WHEN** a client created with `baseUrl: "https://api.example.com"` calls `get("/users")` - **THEN** the underlying request is made to `https://api.example.com/users` #### Scenario: Absolute path overrides baseUrl -- **WHEN** a client created with `baseUrl: "https://api.example.com"` calls `GET("https://other.example.com/users")` +- **WHEN** a client created with `baseUrl: "https://api.example.com"` calls `get("https://other.example.com/users")` - **THEN** the underlying request is made to `https://other.example.com/users`, not joined with `baseUrl` #### Scenario: Relative path with no baseUrl configured -- **WHEN** a client created without `baseUrl` calls `GET("/users")` +- **WHEN** a client created without `baseUrl` calls `get("/users")` - **THEN** the request fails the same way a bare `fetch("/users")` would fail in that environment, since there is no base to resolve against ### Requirement: HTTP method wrappers -Each of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, and `OPTIONS` SHALL issue a `fetch` request using its corresponding HTTP method, forwarding any caller-supplied request-init options (headers, body, signal, etc.) other than `method`, and SHALL return the resulting `Response`. +Each of `get`, `post`, `put`, `patch`, `delete`, `head`, and `options` SHALL issue a `fetch` request using its corresponding uppercase HTTP method (e.g. `get` issues a request with method `GET`), forwarding any caller-supplied request-init options (headers, body, signal, etc.) other than `method`, and SHALL return the resulting `Response`. #### Scenario: Method sets the correct HTTP verb -- **WHEN** a client calls `client.POST("/users", { body: JSON.stringify({ name: "a" }) })` +- **WHEN** a client calls `client.post("/users", { body: JSON.stringify({ name: "a" }) })` - **THEN** the underlying request is sent with HTTP method `POST` and the given body #### Scenario: Caller-supplied init.method is overridden -- **WHEN** a client calls `client.GET("/users", { method: "POST" })` +- **WHEN** a client calls `client.get("/users", { method: "POST" })` - **THEN** the underlying request is still sent with HTTP method `GET` #### Scenario: Returned value is the fetch Response diff --git a/src/index.test.ts b/src/index.test.ts index e532abf..eab7281 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -17,5 +17,5 @@ test("web entry: defineOptions works end-to-end with createFetchify", () => { const options = defineOptions({ baseUrl: "https://api.example.com" }); const client = createFetchify(options); - expect(typeof client.GET).toBe("function"); + expect(typeof client.get).toBe("function"); }); diff --git a/src/lib/create-fetchify.test.ts b/src/lib/create-fetchify.test.ts index 1396a1d..42260d8 100644 --- a/src/lib/create-fetchify.test.ts +++ b/src/lib/create-fetchify.test.ts @@ -17,43 +17,43 @@ afterEach(() => { test("createFetchify returns a client with all HTTP methods", () => { const client = createFetchify({ baseUrl: "https://api.example.com" }); - expect(typeof client.GET).toBe("function"); - expect(typeof client.POST).toBe("function"); - expect(typeof client.PUT).toBe("function"); - expect(typeof client.PATCH).toBe("function"); - expect(typeof client.DELETE).toBe("function"); - expect(typeof client.HEAD).toBe("function"); - expect(typeof client.OPTIONS).toBe("function"); + expect(typeof client.get).toBe("function"); + expect(typeof client.post).toBe("function"); + expect(typeof client.put).toBe("function"); + expect(typeof client.patch).toBe("function"); + expect(typeof client.delete).toBe("function"); + expect(typeof client.head).toBe("function"); + expect(typeof client.options).toBe("function"); }); test("createFetchify works with no options", () => { const client = createFetchify(); - expect(typeof client.GET).toBe("function"); + expect(typeof client.get).toBe("function"); }); test.each([ - ["GET"], - ["POST"], - ["PUT"], - ["PATCH"], - ["DELETE"], - ["HEAD"], - ["OPTIONS"], -] as const)("%s sends the correct HTTP method", async (method) => { + ["get", "GET"], + ["post", "POST"], + ["put", "PUT"], + ["patch", "PATCH"], + ["delete", "DELETE"], + ["head", "HEAD"], + ["options", "OPTIONS"], +] as const)("%s sends the correct HTTP method", async (key, verb) => { const client = createFetchify({ baseUrl: "https://api.example.com" }); - await client[method]("/users"); + await client[key]("/users"); expect(fetchMock).toHaveBeenCalledTimes(1); const [, init] = fetchMock.mock.calls[0] as [unknown, RequestInit]; - expect(init.method).toBe(method); + expect(init.method).toBe(verb); }); test("resolves a relative path against baseUrl", async () => { const client = createFetchify({ baseUrl: "https://api.example.com" }); - await client.GET("/users"); + await client.get("/users"); const [url] = fetchMock.mock.calls[0] as [URL, RequestInit]; expect(url.toString()).toBe("https://api.example.com/users"); @@ -64,7 +64,7 @@ test("accepts baseUrl as a URL instance", async () => { baseUrl: new URL("https://api.example.com"), }); - await client.GET("/users"); + await client.get("/users"); const [url] = fetchMock.mock.calls[0] as [URL, RequestInit]; expect(url.toString()).toBe("https://api.example.com/users"); @@ -73,7 +73,7 @@ test("accepts baseUrl as a URL instance", async () => { test("an absolute path overrides baseUrl", async () => { const client = createFetchify({ baseUrl: "https://api.example.com" }); - await client.GET("https://other.example.com/users"); + await client.get("https://other.example.com/users"); const [url] = fetchMock.mock.calls[0] as [URL, RequestInit]; expect(url.toString()).toBe("https://other.example.com/users"); @@ -82,7 +82,7 @@ test("an absolute path overrides baseUrl", async () => { test("caller-supplied init.method is overridden by the client method", async () => { const client = createFetchify({ baseUrl: "https://api.example.com" }); - await client.GET("/users", { method: "POST" }); + await client.get("/users", { method: "POST" }); const [, init] = fetchMock.mock.calls[0] as [unknown, RequestInit]; expect(init.method).toBe("GET"); @@ -91,7 +91,7 @@ test("caller-supplied init.method is overridden by the client method", async () test("returns the fetch Response unmodified", async () => { const client = createFetchify({ baseUrl: "https://api.example.com" }); - const response = await client.GET("/users"); + const response = await client.get("/users"); expect(response.status).toBe(204); }); @@ -101,7 +101,7 @@ test("accepts options built with defineOptions", async () => { defineOptions({ baseUrl: "https://api.example.com" }), ); - await client.GET("/users"); + await client.get("/users"); const [url] = fetchMock.mock.calls[0] as [URL, RequestInit]; expect(url.toString()).toBe("https://api.example.com/users"); diff --git a/src/lib/create-fetchify.ts b/src/lib/create-fetchify.ts index e7e9390..2e12eab 100644 --- a/src/lib/create-fetchify.ts +++ b/src/lib/create-fetchify.ts @@ -2,23 +2,23 @@ import type { FetchifyOptions } from "./define-options"; import type { FetchifyMethod } from "../types/fetchify-method"; export interface Fetchify { - GET: FetchifyMethod; - POST: FetchifyMethod; - PUT: FetchifyMethod; - PATCH: FetchifyMethod; - DELETE: FetchifyMethod; - HEAD: FetchifyMethod; - OPTIONS: FetchifyMethod; + get: FetchifyMethod; + post: FetchifyMethod; + put: FetchifyMethod; + patch: FetchifyMethod; + delete: FetchifyMethod; + head: FetchifyMethod; + options: FetchifyMethod; } const HTTP_METHODS = [ - "GET", - "POST", - "PUT", - "PATCH", - "DELETE", - "HEAD", - "OPTIONS", + { key: "get", verb: "GET" }, + { key: "post", verb: "POST" }, + { key: "put", verb: "PUT" }, + { key: "patch", verb: "PATCH" }, + { key: "delete", verb: "DELETE" }, + { key: "head", verb: "HEAD" }, + { key: "options", verb: "OPTIONS" }, ] as const; export function createFetchify(options: FetchifyOptions = {}): Fetchify { @@ -26,17 +26,17 @@ export function createFetchify(options: FetchifyOptions = {}): Fetchify { options.baseUrl === undefined ? undefined : new URL(options.baseUrl); const request = ( - method: (typeof HTTP_METHODS)[number], + verb: (typeof HTTP_METHODS)[number]["verb"], path: string, init?: RequestInit, ): Promise => { const url = baseUrl ? new URL(path, baseUrl) : path; - return fetch(url, { ...init, method }); + return fetch(url, { ...init, method: verb }); }; - return HTTP_METHODS.reduce((client, method) => { - client[method] = (path: string, init?: RequestInit) => - request(method, path, init); + return HTTP_METHODS.reduce((client, { key, verb }) => { + client[key] = (path: string, init?: RequestInit) => + request(verb, path, init); return client; }, {} as Fetchify); } diff --git a/src/native.test.ts b/src/native.test.ts index c335636..280106c 100644 --- a/src/native.test.ts +++ b/src/native.test.ts @@ -17,5 +17,5 @@ test("native entry: defineOptions works end-to-end with createFetchify", () => { const options = defineOptions({ baseUrl: "https://api.example.com" }); const client = createFetchify(options); - expect(typeof client.GET).toBe("function"); + expect(typeof client.get).toBe("function"); });