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/tame-otters-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"fetchify": minor
---

Add `defineOptions` factory for authoring a typed `FetchifyOptions` object independently of `createFetchify`, e.g. `createFetchify(defineOptions({ baseUrl: "https://api.example.com" }))`. **BREAKING**: the `CreateFetchifyOptions` type has been renamed to `FetchifyOptions`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
## Context

`createFetchify` currently accepts a plain object literal typed as `CreateFetchifyOptions` (`src/lib/create-fetchify.ts`). The package is unreleased (`version: 0.0.0`, `private: true`), so there are no external consumers to preserve compatibility for. The request is to support a `defineOptions`-style entry point, mirroring the `defineConfig` pattern used by tools like Vite and Vue, so consumers can build a fetchify options object independently of the call that creates the client:

```ts
const options = defineOptions({ baseUrl: new URL("https://api.packetify.app") });
const http = createFetchify(options);
```

## Goals / Non-Goals

**Goals:**
- Provide a `defineOptions` function that returns a typed `FetchifyOptions` object, giving editors full type inference/autocomplete at the point options are authored, independent of `createFetchify`.
- Keep `createFetchify`'s runtime behavior unchanged: it still accepts a `FetchifyOptions`-shaped object, whether produced by `defineOptions` or written as a literal.
- Make `defineOptions` available from both entry points (`fetchify`, `fetchify/native`), consistent with `createFetchify`.

**Non-Goals:**
- No new option fields beyond the existing `baseUrl`. This change only introduces the `defineOptions` entry point and aligns naming; it does not expand `FetchifyOptions`' shape.
- No runtime validation/parsing beyond what `createFetchify` already does (e.g. no schema validation library). `defineOptions` does not normalize `baseUrl` to a `URL` eagerly — that normalization still happens inside `createFetchify`, as today.
- No support for merging/extending multiple option objects (e.g. `defineOptions(a, b)`).

## Decisions

**`defineOptions` is an identity function at runtime.**
`defineOptions(options: FetchifyOptions): FetchifyOptions` simply returns its argument unchanged. Its value is purely at the type level: it gives consumers a named call to hang a typed object literal off, the same way `defineConfig` does in other tools, without requiring them to import and annotate `FetchifyOptions` by hand. Keeping it a pure identity function means there is no behavioral difference between `createFetchify({ baseUrl: ... })` and `createFetchify(defineOptions({ baseUrl: ... }))` — `defineOptions` is opt-in sugar, not a new code path `createFetchify` has to special-case.
- Alternative considered: have `defineOptions` normalize `baseUrl` to a `URL` up front. Rejected for now — it would create two normalization sites (here and in `createFetchify`) and isn't required by the requested usage; can be revisited if `defineOptions` grows validation responsibilities.

**Rename `CreateFetchifyOptions` to `FetchifyOptions`.**
The options type is shared by both `defineOptions` and `createFetchify` now, so `CreateFetchifyOptions` (named after one specific consumer of the type) is no longer accurate. Renaming to `FetchifyOptions` reflects that it's the package's general options shape.
- Alternative considered: keep `CreateFetchifyOptions` as the canonical name and have `defineOptions` reference it. Rejected — the name would be misleading for a type used outside `createFetchify`, and since the package has no released consumers yet, there's no compatibility cost to renaming now rather than carrying the confusing name forward.

**Location: new `src/lib/define-options.ts`.**
Mirrors the existing one-file-per-export layout (`src/lib/create-fetchify.ts`). `FetchifyOptions` is defined in `define-options.ts` and re-exported from `create-fetchify.ts` (or vice versa) to avoid a circular/duplicate type definition — `create-fetchify.ts` imports `FetchifyOptions` from `define-options.ts` since the type conceptually belongs to the options module.

## Risks / Trade-offs

- [Type rename breaks any in-flight branches/PRs importing `CreateFetchifyOptions`] → Acceptable: package is unreleased (`0.0.0`, `private: true`); communicated via the proposal's **BREAKING** note and a changeset entry.
- [`defineOptions` being a no-op identity function may read as pointless to some consumers] → Mitigated by documenting it as the type-inference entry point (same rationale as `defineConfig` in other tools); leaves room to add validation later without a signature change.

## Migration Plan

Not applicable — the package has no published releases depending on `CreateFetchifyOptions`. The rename and new export ship together in the same release, recorded by one changeset.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## Why

Today a consumer configures `createFetchify` by passing an object literal directly, e.g. `createFetchify({ baseUrl: "..." })`. There is no dedicated way to author, name, or reuse a fetchify configuration independently of the call that creates the client, and no explicit type-inference entry point for editors/IDEs the way ecosystem tools like Vite's `defineConfig` provide. Introducing a `defineOptions` helper gives consumers a typed, standalone way to build a `FetchifyOptions` object before handing it to `createFetchify`, matching the requested usage:

```ts
const options = defineOptions({ baseUrl: new URL("https://api.packetify.app") });
const http = createFetchify(options);
const response = http.GET("/users");
```

## What Changes

- Add a `defineOptions(options: FetchifyOptions): FetchifyOptions` function that returns a typed, validated fetchify options object for later use with `createFetchify`.
- Rename the existing `CreateFetchifyOptions` type to `FetchifyOptions`, the shared shape used by both `defineOptions` and `createFetchify`. **BREAKING**: consumers importing the `CreateFetchifyOptions` type name must switch to `FetchifyOptions`.
- `createFetchify` continues to accept an options object matching `FetchifyOptions`, whether constructed via `defineOptions` or passed as a plain object literal (no behavior change to `createFetchify` itself beyond the type rename).
- Export `defineOptions` and `FetchifyOptions` from both the web entry point (`fetchify`) and the React Native entry point (`fetchify/native`), mirroring `createFetchify`'s availability.

## Capabilities

### New Capabilities
- `fetchify-options`: The `defineOptions` factory function that produces a typed `FetchifyOptions` object for use with `createFetchify`.

### Modified Capabilities
- `http-client`: `createFetchify`'s options parameter type is renamed from `CreateFetchifyOptions` to `FetchifyOptions`, and its options object is documented as accepting the result of `defineOptions`.

## Impact

- `src/lib/create-fetchify.ts`: rename `CreateFetchifyOptions` to `FetchifyOptions`; `createFetchify` continues to accept `FetchifyOptions`.
- New `src/lib/define-options.ts` (or similar) implementing `defineOptions`.
- `src/index.ts` and `src/native.ts`: export `defineOptions` and `FetchifyOptions`.
- `openspec/specs/http-client/spec.md`: update the requirement referencing `CreateFetchifyOptions`.
- New `openspec/specs/fetchify-options/spec.md`.
- Tests for `defineOptions` and updated tests referencing the renamed type.
- A changeset recording the breaking type rename and the new `defineOptions` export.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
## ADDED Requirements

### Requirement: defineOptions factory
The package SHALL export a `defineOptions` function that accepts a `FetchifyOptions` object and returns it unchanged, giving consumers a typed entry point for authoring fetchify options independently of `createFetchify`.

#### Scenario: Defining options with a baseUrl
- **WHEN** a consumer calls `defineOptions({ baseUrl: new URL("https://api.example.com") })`
- **THEN** the call returns a `FetchifyOptions` object equal to the argument passed in

#### Scenario: Defined options are usable with createFetchify
- **WHEN** a consumer passes the object returned by `defineOptions` directly to `createFetchify`
- **THEN** `createFetchify` behaves exactly as if the same object literal had been passed to it directly

### Requirement: FetchifyOptions shape
`FetchifyOptions` SHALL describe the same option fields accepted by `createFetchify`, currently an optional `baseUrl` accepting either a `string` or a `URL` instance.

#### Scenario: baseUrl as a string
- **WHEN** a consumer calls `defineOptions({ baseUrl: "https://api.example.com" })`
- **THEN** the call type-checks and returns the object unchanged

#### Scenario: baseUrl as a URL instance
- **WHEN** a consumer calls `defineOptions({ baseUrl: new URL("https://api.example.com") })`
- **THEN** the call type-checks and returns the object unchanged

#### Scenario: No options
- **WHEN** a consumer calls `defineOptions({})` or `defineOptions()`
- **THEN** the call returns an empty (or undefined-defaulted) `FetchifyOptions` object without error

### Requirement: Available from both the web and native entry points
`defineOptions` and the `FetchifyOptions` type SHALL be available, with identical behavior, from both the package's web entry point (`fetchify`) and its React Native entry point (`fetchify/native`).

#### Scenario: Importing from the web entry
- **WHEN** a consumer imports `defineOptions` from `"fetchify"`
- **THEN** the imported function behaves as described by this capability's other requirements

#### Scenario: Importing from the native entry
- **WHEN** a consumer imports `defineOptions` from `"fetchify/native"`
- **THEN** the imported function behaves identically to the one imported from `"fetchify"`
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
## 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. `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`
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
## 1. Options module

- [x] 1.1 Create `src/lib/define-options.ts` exporting the `FetchifyOptions` type (moved/renamed from `CreateFetchifyOptions`) and the `defineOptions(options: FetchifyOptions = {}): FetchifyOptions` identity function
- [x] 1.2 Create `src/lib/define-options.test.ts` covering: returns the input unchanged, works with a `string` `baseUrl`, works with a `URL` `baseUrl`, works with no arguments

## 2. Update createFetchify to use FetchifyOptions

- [x] 2.1 In `src/lib/create-fetchify.ts`, remove the local `CreateFetchifyOptions` type and import `FetchifyOptions` from `./define-options`
- [x] 2.2 Update `createFetchify`'s signature to accept `FetchifyOptions`
- [x] 2.3 Update `src/lib/create-fetchify.test.ts` (and any other references) from `CreateFetchifyOptions` to `FetchifyOptions`, and add a test that `createFetchify(defineOptions({ baseUrl: ... }))` behaves the same as passing the object literal directly

## 3. Entry points

- [x] 3.1 Update `src/index.ts` to export `defineOptions` and the `FetchifyOptions` type (in place of `CreateFetchifyOptions`)
- [x] 3.2 Update `src/native.ts` to export `defineOptions` and the `FetchifyOptions` type (in place of `CreateFetchifyOptions`)
- [x] 3.3 Update `src/index.test.ts` and `src/native.test.ts` to cover `defineOptions` being exported and usable end-to-end with `createFetchify`

## 4. Verification and release

- [x] 4.1 Run `bun test` and confirm all tests pass
- [x] 4.2 Run `bun run build` and confirm `dist/lib/define-options.d.ts` (or equivalent) is generated with no type errors
- [x] 4.3 Run `bun run changeset` to record the new `defineOptions` export and the breaking `CreateFetchifyOptions` → `FetchifyOptions` rename
44 changes: 44 additions & 0 deletions openspec/specs/fetchify-options/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# fetchify-options Specification

## Purpose

Defines the `defineOptions` factory and the `FetchifyOptions` type that consumers use to author fetchify configuration independently of `createFetchify`, available from both the web and React Native entry points.

## Requirements

### Requirement: defineOptions factory
The package SHALL export a `defineOptions` function that accepts a `FetchifyOptions` object and returns it unchanged, giving consumers a typed entry point for authoring fetchify options independently of `createFetchify`.

#### Scenario: Defining options with a baseUrl
- **WHEN** a consumer calls `defineOptions({ baseUrl: new URL("https://api.example.com") })`
- **THEN** the call returns a `FetchifyOptions` object equal to the argument passed in

#### Scenario: Defined options are usable with createFetchify
- **WHEN** a consumer passes the object returned by `defineOptions` directly to `createFetchify`
- **THEN** `createFetchify` behaves exactly as if the same object literal had been passed to it directly

### Requirement: FetchifyOptions shape
`FetchifyOptions` SHALL describe the same option fields accepted by `createFetchify`, currently an optional `baseUrl` accepting either a `string` or a `URL` instance.

#### Scenario: baseUrl as a string
- **WHEN** a consumer calls `defineOptions({ baseUrl: "https://api.example.com" })`
- **THEN** the call type-checks and returns the object unchanged

#### Scenario: baseUrl as a URL instance
- **WHEN** a consumer calls `defineOptions({ baseUrl: new URL("https://api.example.com") })`
- **THEN** the call type-checks and returns the object unchanged

#### Scenario: No options
- **WHEN** a consumer calls `defineOptions({})` or `defineOptions()`
- **THEN** the call returns an empty (or undefined-defaulted) `FetchifyOptions` object without error

### Requirement: Available from both the web and native entry points
`defineOptions` and the `FetchifyOptions` type SHALL be available, with identical behavior, from both the package's web entry point (`fetchify`) and its React Native entry point (`fetchify/native`).

#### Scenario: Importing from the web entry
- **WHEN** a consumer imports `defineOptions` from `"fetchify"`
- **THEN** the imported function behaves as described by this capability's other requirements

#### Scenario: Importing from the native entry
- **WHEN** a consumer imports `defineOptions` from `"fetchify/native"`
- **THEN** the imported function behaves identically to the one imported from `"fetchify"`
6 changes: 5 additions & 1 deletion openspec/specs/http-client/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ 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 an options object and returns a client object exposing one method per supported HTTP verb.
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`.

#### Scenario: Creating a client
- **WHEN** a consumer calls `createFetchify({ baseUrl: "https://api.example.com" })`
Expand All @@ -17,6 +17,10 @@ The package SHALL export a `createFetchify` function that accepts an options obj
- **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: Available from both the web and native entry points
`createFetchify` SHALL be available, with identical behavior, from both the package's web entry point (`fetchify`) and its React Native entry point (`fetchify/native`).

Expand Down
13 changes: 12 additions & 1 deletion src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import { createFetchify, VERSION } from "./index";
import { createFetchify, defineOptions, VERSION } from "./index";

test("web entry exports VERSION", () => {
expect(VERSION).toBeDefined();
Expand All @@ -8,3 +8,14 @@ test("web entry exports VERSION", () => {
test("web entry exports createFetchify", () => {
expect(typeof createFetchify).toBe("function");
});

test("web entry exports defineOptions", () => {
expect(typeof defineOptions).toBe("function");
});

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");
});
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export const VERSION = "0.0.0";

export { createFetchify } from "./lib/create-fetchify";
export type { CreateFetchifyOptions, Fetchify } from "./lib/create-fetchify";
export type { Fetchify } from "./lib/create-fetchify";
export { defineOptions } from "./lib/define-options";
export type { FetchifyOptions } from "./lib/define-options";
export type { FetchifyMethod } from "./types/fetchify-method";
12 changes: 12 additions & 0 deletions src/lib/create-fetchify.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, expect, mock, test } from "bun:test";
import { createFetchify } from "./create-fetchify";
import { defineOptions } from "./define-options";

const originalFetch = globalThis.fetch;
let fetchMock: ReturnType<typeof mock>;
Expand Down Expand Up @@ -94,3 +95,14 @@ test("returns the fetch Response unmodified", async () => {

expect(response.status).toBe(204);
});

test("accepts options built with defineOptions", async () => {
const client = createFetchify(
defineOptions({ baseUrl: "https://api.example.com" }),
);

await client.GET("/users");

const [url] = fetchMock.mock.calls[0] as [URL, RequestInit];
expect(url.toString()).toBe("https://api.example.com/users");
});
7 changes: 2 additions & 5 deletions src/lib/create-fetchify.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import type { FetchifyOptions } from "./define-options";
import type { FetchifyMethod } from "../types/fetchify-method";

export interface CreateFetchifyOptions {
baseUrl?: string | URL;
}

export interface Fetchify {
GET: FetchifyMethod;
POST: FetchifyMethod;
Expand All @@ -24,7 +21,7 @@ const HTTP_METHODS = [
"OPTIONS",
] as const;

export function createFetchify(options: CreateFetchifyOptions = {}): Fetchify {
export function createFetchify(options: FetchifyOptions = {}): Fetchify {
const baseUrl =
options.baseUrl === undefined ? undefined : new URL(options.baseUrl);

Expand Down
27 changes: 27 additions & 0 deletions src/lib/define-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { expect, test } from "bun:test";
import { defineOptions } from "./define-options";

test("returns the input unchanged", () => {
const input = { baseUrl: "https://api.example.com" };

expect(defineOptions(input)).toBe(input);
});

test("works with a string baseUrl", () => {
const options = defineOptions({ baseUrl: "https://api.example.com" });

expect(options.baseUrl).toBe("https://api.example.com");
});

test("works with a URL baseUrl", () => {
const baseUrl = new URL("https://api.example.com");
const options = defineOptions({ baseUrl });

expect(options.baseUrl).toBe(baseUrl);
});

test("works with no arguments", () => {
const options = defineOptions();

expect(options).toEqual({});
});
Loading
Loading