Skip to content
Open
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
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,59 @@ Options:
| Schema engine | custom (zero dependencies) |
| Diff engine | custom recursive differ |

## Environment Variables

| Variable | Default | Description |
| -------- | ------- | ----------- |
| `STAGING_TOKEN` | — | Auth token interpolated into `${STAGING_TOKEN}` in your config |
| `PROD_TOKEN` | — | Auth token interpolated into `${PROD_TOKEN}` in your config |
| `APIDRIFT_TIMEOUT_MS` | `10000` | HTTP request timeout in milliseconds. Increase for slow APIs. |

**Bash / macOS / Linux:**
```bash
export APIDRIFT_TIMEOUT_MS=30000
apidrift snapshot --tag v1.0 --env staging
```

**PowerShell (Windows):**
```powershell
$env:APIDRIFT_TIMEOUT_MS = "30000"
apidrift snapshot --tag v1.0 --env staging
```

---

## Troubleshooting

### Requests time out on slow APIs

If you see `ECONNABORTED` or `ETIMEDOUT` errors, your API is responding slower than the default 10-second timeout. Set `APIDRIFT_TIMEOUT_MS` to a larger value:

```bash
APIDRIFT_TIMEOUT_MS=60000 apidrift snapshot --tag v1.0 --env staging
```

### `Authorization` header shows `Bearer ` (empty token)

This means your `${STAGING_TOKEN}` or `${PROD_TOKEN}` env var is not set. Verify:

```bash
# Bash / macOS / Linux
echo $STAGING_TOKEN

# PowerShell (Windows)
echo $env:STAGING_TOKEN
```

If empty, create a `.env` file in your project directory:

```bash
STAGING_TOKEN=your_actual_token_here
PROD_TOKEN=your_actual_token_here
```

Make sure `.env` is listed in your `.gitignore` so tokens are never committed.

---

## License
Expand Down
15 changes: 15 additions & 0 deletions src/commands/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ export async function runInit() {
initial: false,
});
if (!overwrite) {
console.log(chalk.yellow("ℹ Keeping existing apidrift.config.json"));
console.log("");
console.log(" Your current config was not modified. Next steps:");
console.log(
` 1. Make sure you have a ${chalk.cyan(".env")} file or environment variables set for your tokens:`
);
console.log(` ${chalk.gray("STAGING_TOKEN=your_token_here")}`);
console.log(` ${chalk.gray("PROD_TOKEN=your_token_here")}`);
console.log(
` 2. Add ${chalk.cyan(".env")} to your ${chalk.cyan(".gitignore")} to keep tokens out of Git`
);
console.log(
` 3. Run: ${chalk.cyan("apidrift snapshot --tag v1.0 --env staging")}`
);
console.log("");
return;
}
}
Expand Down
15 changes: 14 additions & 1 deletion src/core/fetcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ const RETRY_DEFAULTS = {
maxDelay: 8000,
};

/**
* HTTP request timeout in milliseconds.
* Override via the APIDRIFT_TIMEOUT_MS environment variable.
* Defaults to 10000 (10 seconds) if unset or if the value is not a positive integer.
*/
const DEFAULT_TIMEOUT_MS = 10000;
const TIMEOUT_MS = (() => {
const raw = process.env.APIDRIFT_TIMEOUT_MS;
if (!raw) return DEFAULT_TIMEOUT_MS;
const parsed = Number(raw);
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
})();

let didWarnEmptyAuth = false;

function isRetryable(err) {
Expand All @@ -29,7 +42,7 @@ async function fetchWithRetry(
config = RETRY_DEFAULTS,
) {
try {
return await axios({ ...options, url, timeout: 10000 });
return await axios({ ...options, url, timeout: TIMEOUT_MS });
} catch (err) {
if (attempt < config.retries && isRetryable(err)) {
const delay = Math.min(
Expand Down
62 changes: 59 additions & 3 deletions src/storage/snapshotStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,69 @@ const SNAP_DIR = path.join(os.homedir(), ".apidrift", "snapshots");

if (!fs.existsSync(SNAP_DIR)) fs.mkdirSync(SNAP_DIR, { recursive: true });

/**
* Sanitize a snapshot tag so it is safe to use as a filename on all platforms.
*
* Rules:
* - Only A-Z, a-z, 0-9, `.`, `_`, and `-` are allowed.
* - Every other character (including `:`, `/`, `\`, and space) is replaced with `_`.
Comment on lines +11 to +15
* - Leading/trailing dots and underscores are stripped to prevent hidden-file
* names and confusing output.
* - The empty string is rejected.
*
* Tags that are already safe (e.g. `v1.0.13`, `prod-users`) pass through unchanged.
*
* @param {string} tag - Raw tag supplied by the user.
* @returns {string} Sanitized tag string.
* @throws {Error} If the resulting sanitized name is empty.
*/
export function sanitizeTag(tag) {
// Replace every character that is NOT in the safe set with '_'
let sanitized = String(tag)
.replace(/[^A-Za-z0-9._-]/g, "_")
// Collapse consecutive underscores for readability (optional, keeps names clean)
.replace(/_+/g, "_")
// Remove leading/trailing underscores and dots
.replace(/^[._]+|[._]+$/g, "");
Comment on lines +26 to +33

if (!sanitized) {
throw new Error(
`Invalid snapshot tag "${tag}": tag must contain at least one alphanumeric character.`
);
}

// Windows reserved device names check: CON, PRN, AUX, NUL, COM1-9, LPT1-9
if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(sanitized)) {
sanitized = `_${sanitized}`;
}

return sanitized;
}

export function saveSnapshot(tag, data) {
const file = path.join(SNAP_DIR, `${tag}.json`);
const safe = sanitizeTag(tag);
const file = path.join(SNAP_DIR, `${safe}.json`);
if (fs.existsSync(file)) {
try {
const existing = JSON.parse(fs.readFileSync(file, "utf-8"));
if (existing && existing.tag !== tag) {
throw new Error(
`Snapshot file collision: The file for tag "${tag}" already exists and contains a different tag "${existing.tag}".`
);
}
} catch (err) {
if (err.message.includes("Snapshot file collision")) {
throw err;
}
}
}
fs.writeFileSync(file, JSON.stringify(data, null, 2));
Comment on lines 49 to 66
return file;
}

export function loadSnapshot(tag) {
const file = path.join(SNAP_DIR, `${tag}.json`);
const safe = sanitizeTag(tag);
const file = path.join(SNAP_DIR, `${safe}.json`);
if (!fs.existsSync(file)) {
console.error(`Snapshot "${tag}" not found. Run: apidrift list`);
process.exit(1);
Expand All @@ -26,5 +81,6 @@ export function listSnapshots() {
return fs
.readdirSync(SNAP_DIR)
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(".json", ""));
.map((f) => f.replace(/\.json$/, ""))
.sort();
}
161 changes: 161 additions & 0 deletions tests/storage/snapshotStore.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import fs from "fs";
import { jest } from "@jest/globals";
import {
sanitizeTag,
saveSnapshot,
loadSnapshot,
listSnapshots,
} from "../../src/storage/snapshotStore.js";

describe("snapshotStore", () => {
let existsSpy;
let writeSpy;
let readSpy;
let readdirSpy;

beforeEach(() => {
jest.clearAllMocks();
existsSpy = jest.spyOn(fs, "existsSync").mockImplementation(() => false);
writeSpy = jest.spyOn(fs, "writeFileSync").mockImplementation(() => {});
readSpy = jest.spyOn(fs, "readFileSync").mockImplementation(() => "");
readdirSpy = jest.spyOn(fs, "readdirSync").mockImplementation(() => []);
});

afterEach(() => {
existsSpy.mockRestore();
writeSpy.mockRestore();
readSpy.mockRestore();
readdirSpy.mockRestore();
});

describe("sanitizeTag", () => {
test("passes safe tags through unchanged", () => {
expect(sanitizeTag("v1.0.13")).toBe("v1.0.13");
expect(sanitizeTag("prod-users")).toBe("prod-users");
expect(sanitizeTag("my_tag")).toBe("my_tag");
});

test("replaces invalid characters with underscores", () => {
expect(sanitizeTag("v1.0:beta")).toBe("v1.0_beta");
expect(sanitizeTag("my tag")).toBe("my_tag");
expect(sanitizeTag("tag/sub")).toBe("tag_sub");
expect(sanitizeTag("tag\\sub")).toBe("tag_sub");
});

test("collapses consecutive underscores", () => {
expect(sanitizeTag("my__tag")).toBe("my_tag");
expect(sanitizeTag("my:::tag")).toBe("my_tag");
});

test("removes leading/trailing underscores and dots", () => {
expect(sanitizeTag("_my_tag_")).toBe("my_tag");
expect(sanitizeTag(".my_tag.")).toBe("my_tag");
expect(sanitizeTag("_.my_tag._")).toBe("my_tag");
});

test("throws error for empty or invalid tags", () => {
expect(() => sanitizeTag("")).toThrow();
expect(() => sanitizeTag("/")).toThrow();
expect(() => sanitizeTag(":::")).toThrow();
});

test("prefixes Windows reserved device names case-insensitively", () => {
expect(sanitizeTag("CON")).toBe("_CON");
expect(sanitizeTag("con")).toBe("_con");
expect(sanitizeTag("PRN")).toBe("_PRN");
expect(sanitizeTag("AUX")).toBe("_AUX");
expect(sanitizeTag("NUL")).toBe("_NUL");
expect(sanitizeTag("COM1")).toBe("_COM1");
expect(sanitizeTag("com9")).toBe("_com9");
expect(sanitizeTag("LPT1")).toBe("_LPT1");
expect(sanitizeTag("lpt9")).toBe("_lpt9");
});

test("does not prefix Windows reserved device names if they are part of a larger name", () => {
expect(sanitizeTag("CONTENT")).toBe("CONTENT");
expect(sanitizeTag("COM10")).toBe("COM10");
expect(sanitizeTag("LPT")).toBe("LPT");
});
});

describe("saveSnapshot", () => {
test("writes successfully when file does not exist", () => {
existsSpy.mockReturnValue(false);
const data = { tag: "v1", env: "test" };

saveSnapshot("v1", data);

expect(writeSpy).toHaveBeenCalledWith(
expect.any(String),
JSON.stringify(data, null, 2)
);
});

test("writes successfully when file exists and tag is the same", () => {
existsSpy.mockReturnValue(true);
readSpy.mockReturnValue(JSON.stringify({ tag: "v1", env: "old" }));
const data = { tag: "v1", env: "test" };

saveSnapshot("v1", data);

expect(writeSpy).toHaveBeenCalledWith(
expect.any(String),
JSON.stringify(data, null, 2)
);
});

test("throws collision error when file exists and tag is different", () => {
existsSpy.mockReturnValue(true);
readSpy.mockReturnValue(JSON.stringify({ tag: "v1-old", env: "old" }));
const data = { tag: "v1-new", env: "test" };

expect(() => saveSnapshot("v1-new", data)).toThrow(
/Snapshot file collision/
);
expect(writeSpy).not.toHaveBeenCalled();
});
});

describe("loadSnapshot", () => {
test("loads successfully when file exists", () => {
existsSpy.mockReturnValue(true);
const mockData = { tag: "v1", data: "test" };
readSpy.mockReturnValue(JSON.stringify(mockData));

const result = loadSnapshot("v1");

expect(result).toEqual(mockData);
});

test("logs error and exits process when file does not exist", () => {
existsSpy.mockReturnValue(false);
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
const processExitSpy = jest.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});

expect(() => loadSnapshot("v1")).toThrow("process.exit");

expect(consoleErrorSpy).toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(1);

consoleErrorSpy.mockRestore();
processExitSpy.mockRestore();
});
});

describe("listSnapshots", () => {
test("returns alphabetically sorted list of snapshot tags with .json removed", () => {
readdirSpy.mockReturnValue([
"c.json",
"a.json",
"b.json.json",
"README.md", // Should be ignored
]);

const result = listSnapshots();

expect(result).toEqual(["a", "b.json", "c"]);
});
});
});