Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lazy-plums-fetch.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-01
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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)
16 changes: 8 additions & 8 deletions openspec/specs/http-client/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({})`
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
48 changes: 24 additions & 24 deletions src/lib/create-fetchify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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);
});
Expand All @@ -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");
Expand Down
38 changes: 19 additions & 19 deletions src/lib/create-fetchify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,41 @@ 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 {
const baseUrl =
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<Response> => {
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);
}
2 changes: 1 addition & 1 deletion src/native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Loading