diff --git a/.gitignore b/.gitignore index 951d3b1..1eae0cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ dist/ node_modules/ -run.ts diff --git a/README.md b/README.md index 3e1e070..ce697c0 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ **Support questions should be asked [here](https://github.com/frangeris/use-requests/discussions).** +> Type-Safe HTTP client hook helper to handle requests based on native fetch api with some magic under the hood ✨. + +![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/frangeris/use-requests/build.yml) + ## Install > [!CAUTION] @@ -31,17 +35,17 @@ Some key features: ## Usage -To begin using the API request module, you need to initialize it by specifying the base URL of your API and defining the endpoints you'll be working with. This setup ensures that all subsequent API requests are made to the correct URLs with consistent endpoint handling. +To begin using the API request module, you need to initialize it by specifying the base URL of your API and defining the endpoints you'll be working with (there is an option for `raw` request we will cover later). This setup ensures that all subsequent API requests are made to the correct URLs with consistent endpoint handling. First, you’ll need to import the necessary functions. - The `useRequests` function is the core hook for making requests. -- While `init` sets up the configuration for your API requests and can be called from anywhere in your app. +- While `init` setup the configuration for your API requests and can be called from anywhere in your app. Here's how you import them from the module: ```ts -import { init, useRequests, useOptions } from "use-requests"; +import { init, useRequests, useOptions, useRawRequests } from "use-requests"; ``` Then, you'll define your API endpoints using an [enum](https://www.typescriptlang.org/docs/handbook/enums.html). This `enum` acts as a centralized way to declare all the routes your API supports. It also helps ensure that requests are type-safe, meaning you'll get compile-time checks for correct usage: @@ -55,23 +59,25 @@ export enum Api { Each key in the enum represents a different API route. These routes can contain dynamic parameters (e.g., `:id`), which are replaced by actual values when making requests. -Now, we need to initialize the by using the `init` function. This function requires two arguments: +Now, we need to initialize by using the `init` function. This function requires two arguments: -- **Base URL**: The root URL where your API is hosted (e.g., https://api.example.io/dev). +- **Base URL**: The root URL where your API is hosted (`e.g. https://api.example.io/dev`). - **Endpoints**: The enum you defined earlier, which specifies your available API routes. ```ts init("https://api.example.io/dev", { ...Api }); ``` +In above example: + - `https://api.example.io/dev` is the base URL of the API. -- The spread operator `{ ...Endpoints }` ensures that all the endpoints defined in the `Endpoints` enum are passed to the initialization function. +- The spread operator `{ ...Api }` ensures that all the endpoints defined in the `Api` enum are passed to the initialization function. By setting up this initialization, you ensure that every request you make using the `useRequests` hook will automatically target the correct API with the predefined endpoints. ### ⚡️ Now, let's make some requests -Once the module is initialized, you can easily make requests to the defined endpoints. Here's a snippet for requests: +Once the module is initialized, you can easily make requests to the defined endpoints. Here's quick snippet: ```ts import { init, useRequests, useOptions } from "use-requests"; @@ -86,48 +92,97 @@ export enum Api { init("https://api.example.io/dev", { ...Api }); const main = async () => { - const { userById, users } = useRequests(); - const { data: usersRes } = await users.get(); - const { json, ok, body, blob, bytes, headers, status, text, statusText } = - await userById.get({ params: { id: 1 } }); + const { userById } = useRequests(); + + // the returned structure is the same as the fetch Response object + // so json, ok, body, blob, bytes, headers, status, text, statusText are available + const t = await userById.get({ params: { id: 1 } }); }; main(); ``` -The `data` method is also available, it's a wrapper around the `Response.json()` method that cast the response using the `data` property in the response, in case that property exists, it will be returned casted to the generic type used, eg: `User[]` or `User`. +> [!NOTE] +> The `data` property is also available, but there are some caveats we will discuss later. + +All HTTP methods expect an optional `RequestPath` object, which can be used to customize the request path, query strings, and other options. For example: -There is also an option available for customizing the path of the endpoint, adding another part to the URL, used for extends the endpoint, for example: +Adding another part to the URL, used for extends the endpoint: ```ts const { users } = useRequests(); await users.get({ path: "/test", query: { id: "1" } }); -// Will request to https://api.example.io/dev/users/test?id=1 +// will request to https://api.example.io/dev/users/test?id=1 ``` --- +Based on the above example, the `useRequests` hook returns an object with methods for each endpoint defined in the `Api` enum. You can then call these methods to make requests to the corresponding API routes. + ### Only registered endpoints are available -example 1 +If you try to access an endpoint that isn't defined in the `Api` enum, TypeScript will throw a compile-time error. This ensures that you only use the correct endpoints and helps prevent runtime errors. -Based on the above example, the `useRequests` hook returns an object with methods for each endpoint defined in the `Api` enum. You can then call these methods to make requests to the corresponding API routes. +example 1 ### Type-safe request handling -example 2 +The `useRequests` hook provides methods for each endpoint defined in the `Api` enum. These methods are type-safe, meaning that the parameters you pass to them are checked against the expected types defined in the `Api` enum. This ensures that you're using the correct parameters for each endpoint and helps prevent runtime errors. -Each method returned by `useRequests` is type-safe, meaning that the parameters you pass to the method are checked against the expected types defined in `Api` at compile time. This ensures that you're using the correct parameters for each endpoint and helps prevent runtime errors. +example 2 -> [!TIP] -> Only dynamic path parameters `:param` and query strings are currently supported. Support for request bodies will be added in future versions. - -The response by any methods is an instance of [fetch Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) object, which you can use to extract the data, status, headers, etc. +The response by any methods is an instance of [fetch Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) object, which you can use to extract `status, headers, json(), text()`, etc. > [!WARNING] > Any parameters defined in the endpoint definition are required when calling the method. If you omit a required parameter, TypeScript will throw a compile-time error or an exception will be thrown at runtime. +### `data` property + +The `data` property is a helper that allows you to extract the JSON response from the fetch response object, it's a wrapper that _in case that **data** property exists_ in the `json` response so will be casted to the generic type used, eg: `User[]` or `User`. + +#### Caveats + +1. The property is not populated automatically, you need to call it explicitly in a asynchronous way. +2. `data` key needs to be present on the response, otherwise, it will return `null`, + +Here's an example of how to use the `data` property, given the following JSON response for `GET /users`: + +```json +{ + "data": [ + { + "id": 1, + "name": "John Doe" + } + ] +} +``` + +```ts +const { users } = useRequests(); +const { data } = await users.get(); +console.log(await data); // [{ id: 1, name: "John Doe" }] +``` + +The `await` keyword is used to extract the JSON response from the fetch response object and cast it to the generic type `User[]` in runtime, so it will be available only when requested, otherwise and others scenarios when needs the direct manipulation of the response object, you can use the fetch response object directly. + +### Raw requests + +In some cases, you may need to make requests that don't correspond to any of the predefined endpoints. For these cases, you can use the `useRawRequests` hook, which provides a way to make raw requests to any URL. + +The `useRawRequests` hook returns an object with methods for making requests using the `fetch` API. You can use these methods to make requests to any URL, with full control over the request path, query strings, headers, and other options. + +When using raw requests, global shared options are not applied, here's an example of how to use the `useRawRequests` hook: + +```ts +const raw = useRawRequests(); +const r = await raw("https://myapi.io").get(); +// GET https://myapi.io +``` + ### Headers +There is a way to customize headers for all requests, you can use the `useOptions` hook to set headers, options, and other configurations for all requests. + The `useOptions` function allows you to customize headers following the standard [Headers](https://developer.mozilla.org/en-US/docs/Web/API/Headers) API specification. All headers are shared across all requests, so you only need to set them once. For example, you can set an authorization token for all requests and will be used for all subsequent requests, eg: @@ -141,7 +196,7 @@ const { headers } = useOptions(); const { users } = useRequests(); headers.set("Authorization", "Bearer token"); // ... -const { data: usersRes } = await users.get(); +const t = await users.get(); ``` This sets the `Authorization` header for all requests made using the `useRequests` hook. diff --git a/__tests__/init.spec.ts b/__tests__/init.spec.ts index 9e2808a..e10635c 100644 --- a/__tests__/init.spec.ts +++ b/__tests__/init.spec.ts @@ -1,56 +1,39 @@ import { init } from "@/init"; -import { useRequest } from "@/useRequests"; -import { Service } from "@/service"; import { Options } from "@/options"; +import Service from "@/service"; +import useRequest from "@/useRequests"; -// jest.mock("./service"); -// jest.mock("./options"); +enum Test { + endpoint1 = "/endpoint1", + endpoint2 = "/endpoint2", + endpoint3 = "/endpoint3", +} describe("init", () => { + const baseURL = "http://api.example.io"; beforeEach(() => { jest.clearAllMocks(); - // delete globalThis.services; + init(baseURL, { ...Test }); }); - it("should initialize options correctly", () => { - // const mockInstance = { opts: {} }; - // (Options.instance as jest.Mock).mockReturnValue(mockInstance); - const base = "http://myapi.io/test"; - enum endpoints { - endpoint1 = "/endpoint1", - } - init(base, { ...endpoints }); - // expect(Options.instance).toHaveBeenCalled(); - // expect(mockInstance.opts).toEqual({ base }); + it("should set the baseURL correctly", () => { + expect(Options.instance().baseURL).toBe(baseURL); }); - // it("should create services correctly", () => { - // const base = "http://example.com"; - // const endpoints = { endpoint1: "/api/endpoint1" }; + it("should create services correctly", () => { + const ctx = useRequest(); + expect(ctx.endpoint1).toBeDefined(); + expect(ctx.endpoint1).toBeInstanceOf(Service); - // init(base, endpoints); + expect(ctx.endpoint2).toBeDefined(); + expect(ctx.endpoint2).toBeInstanceOf(Service); - // expect(Service).toHaveBeenCalledWith("/api/endpoint1"); - // }); - - // it("should define globalThis.services correctly", () => { - // const base = "http://example.com"; - // const endpoints = { endpoint1: "/api/endpoint1" }; - - // init(base, endpoints); - - // expect(globalThis.services).toBeDefined(); - // expect(globalThis.services.endpoint1).toBeInstanceOf(Service); - // }); - - // it("should not redefine globalThis.services if already defined", () => { - // globalThis.services = { existingService: "existing" }; - - // const base = "http://example.com"; - // const endpoints = { endpoint1: "/api/endpoint1" }; - - // init(base, endpoints); + expect(ctx.endpoint3).toBeDefined(); + expect(ctx.endpoint3).toBeInstanceOf(Service); + }); - // expect(globalThis.services).toEqual({ existingService: "existing" }); - // }); + it("should define globalThis.useRequests correctly", () => { + // @ts-ignore + expect(globalThis.useRequests).toBeDefined(); + }); }); diff --git a/__tests__/service.spec.ts b/__tests__/service.spec.ts index 61304a0..3805753 100644 --- a/__tests__/service.spec.ts +++ b/__tests__/service.spec.ts @@ -1,92 +1,107 @@ import Service from "@/service"; import Options from "@/options"; -global.fetch = jest.fn(() => ({ - json: () => Promise.resolve({}), -})) as jest.Mock; - describe("service", () => { beforeEach(() => { Options.instance = jest.fn().mockReturnValue({ - baseURL: "http://example.com", + baseURL: "https://api.example.com", }); }); - describe("build", () => { - it("should return empty string if no path is provided", () => { + describe("buildUrl", () => { + it("should return nothing when no path and resource are provided", () => { + const service = new Service(); + const url = service["buildUrl"](); + expect(url).toBe(""); + }); + + it("should return resource if no path is provided", () => { const service = new Service("/resource"); - expect(service["build"]()).toBe("/resource"); + const url = service["buildUrl"](); + expect(url).toBe("/resource"); }); - it("should return the path if a string path is provided", () => { + it("should return the path and resource", () => { const service = new Service("/resource"); - expect(service["build"]("/path")).toBe("/path"); + const url = service["buildUrl"]("/path"); + expect(url).toBe("/resource/path"); }); - it("should include query parameters", () => { + it("should return use the path property", () => { const service = new Service("/resource"); - const path = { path: "/another" }; - expect(service["build"](path)).toBe("/resource/another"); + const url = service["buildUrl"]({ path: "/another" }); + expect(url).toBe("/resource/another"); }); it("should replace parameters in resource", () => { - const service = new Service("/resource/:id"); - const path = { params: { id: "123" } }; - - // @ts-ignore - expect(service["build"](path)).toBe("/resource/123"); + const service = new Service("/resource/:id"); + const url = service["buildUrl"]({ params: { id: "123" } }); + expect(url).toBe("/resource/123"); }); it("should throw an error if path parameters are missing", () => { const service = new Service("/resource/:id"); - expect(() => service["build"]({})).toThrow("Missing path parameters"); + expect(() => service["buildUrl"]({})).toThrow("Missing path parameters"); }); it("should throw an error if multiple path parameters are missing", () => { const service = new Service("/resource/:a/test/:b/test/:c"); - expect(() => service["build"]({})).toThrow("Missing path parameters"); + expect(() => service["buildUrl"]({})).toThrow("Missing path parameters"); }); it("should include query parameters", () => { const service = new Service("/resource"); - const path = { query: { search: "test" } }; - expect(service["build"](path)).toBe("/resource?search=test"); + const url = service["buildUrl"]({ query: { search: "test" } }); + expect(url).toBe("/resource?search=test"); }); it("should build a URL with both path and query parameters", () => { const service = new Service("/resource/:id"); - const path = { params: { id: "123" }, query: { search: "test" } }; - - // @ts-ignore - expect(service["build"](path)).toBe("/resource/123?search=test"); + const url = service["buildUrl"]({ + params: { id: "123" }, + query: { search: "test" }, + }); + expect(url).toBe("/resource/123?search=test"); }); }); - describe("request", () => { - it("should construct a Request object correctly", async () => { + describe("buildRequest", () => { + it("should construct a Request object correctly", () => { const service = new Service("/resource"); - await service["request"]({ method: "get", path: "/hello" }); - expect(global.fetch).toHaveBeenCalled(); + const req = service["buildRequest"]({ method: "GET", path: "/hello" }); + expect(req.url).toBe("https://api.example.com/resource/hello"); + }); + + it("should throw an error if baseURL is not provided", () => { + Options.instance = jest.fn().mockReturnValue({}); + const service = new Service("/resource"); + expect(() => service["buildRequest"]({ method: "GET" })).toThrow( + "Missing baseURL in options" + ); + }); + + it("should use the resource as baseURL if bypass is true", async () => { + const url = "https://api.example.com/resource"; + const service = new Service(url, { bypass: true }); + const req = await service["buildRequest"]({ method: "GET" }); + expect(req.url).toBe(url); }); }); - describe("response", () => { + describe("buildResponse", () => { it("should return data undefined when body is not present", async () => { const service = new Service("/resource"); - const res = await service["response"](new Response()); - const data = await res.data(); - expect(data).toBeNull(); + const res = await service["buildResponse"](new Response()); + expect(await res.data).toBeNull(); }); - it("should return return object with data", async () => { + it("should return an object with data", async () => { const dummy = { data: { key: "value" } }; const service = new Service("/resource"); - const res = await service["response"]( + const res = await service["buildResponse"]( new Response(JSON.stringify(dummy)) ); - - const data = await res.data(); - expect(data).toEqual(dummy.data); + expect(await res.data).toEqual(dummy.data); }); }); }); diff --git a/__tests__/useRawRequests.spec.ts b/__tests__/useRawRequests.spec.ts new file mode 100644 index 0000000..ed67f69 --- /dev/null +++ b/__tests__/useRawRequests.spec.ts @@ -0,0 +1,72 @@ +import { useRawRequest } from "@/index"; +import Service from "@/service"; + +global.fetch = jest.fn(() => ({ + json: () => Promise.resolve({}), +})) as jest.Mock; + +describe("useRawRequest", () => { + it("should return a function", async () => { + const raw = useRawRequest(); + expect(raw).toBeDefined(); + expect(raw).toBeInstanceOf(Function); + }); + + it("should return an instance of service", async () => { + const raw = useRawRequest(); + expect(raw("http://api.example.io/example")).toBeInstanceOf(Service); + }); + + it("should make a GET request", async () => { + const raw = useRawRequest(); + const { status } = await raw("http://api.test.com").get(); + expect(status).toBe(200); + }); + + it("should make a POST request", async () => { + const raw = useRawRequest(); + const { status } = await raw("http://api.test.com").post({}); + expect(status).toBe(200); + }); + + it("should make a PUT request", async () => { + const raw = useRawRequest(); + const { status } = await raw("http://api.test.com").put({}); + expect(status).toBe(200); + }); +}); + +describe("useRawRequest: http methods", () => { + let raw: ReturnType; + + beforeEach(() => { + raw = useRawRequest(); + }); + + it("should make a GET request", async () => { + const { status } = await raw("http://api.test.com").get(); + expect(status).toBe(200); + }); + + it("should make a POST request", async () => { + const { status } = await raw("http://api.test.com").post({}); + expect(status).toBe(200); + }); + + it("should make a PUT request", async () => { + const { status } = await raw("http://api.test.com").put({}); + expect(status).toBe(200); + }); + + it("should make a DELETE request", async () => { + const { status } = await raw("http://api.test.com").delete(); + expect(status).toBe(200); + }); + + it("should make a PATCH request", async () => { + const { status } = await raw("http://api.test.com").patch([ + { op: "add", path: "/test", value: "test" }, + ]); + expect(status).toBe(200); + }); +}); diff --git a/__tests__/useRequests.spec.ts b/__tests__/useRequests.spec.ts index c363557..3e864d3 100644 --- a/__tests__/useRequests.spec.ts +++ b/__tests__/useRequests.spec.ts @@ -1,11 +1,62 @@ -import { useRequest } from "@/useRequests"; +import { useRequests, init } from "@/index"; import context from "@/context"; -enum Test {} +global.fetch = jest.fn(() => ({ + json: () => Promise.resolve({}), +})) as jest.Mock; + +enum Api { + test = "/test", +} + +describe("useRequests: without init", () => { + it("should throw an error if not initialized first", () => { + expect(() => useRequests()).toThrow( + "init must be called before using useRequest" + ); + }); +}); + +describe("useRequests", () => { + beforeEach(() => { + init("http://api.example.io", { ...Api }); + }); -describe("useRequest", () => { it("should return the context.useRequests object", () => { - const result = useRequest(); + const result = useRequests(); expect(result).toBe(context.useRequests); + expect(result).toHaveProperty("test"); + }); + + it("should make a GET request", async () => { + const { test } = useRequests(); + const { status } = await test.get(); + expect(status).toBe(200); + }); + + it("should make a POST request", async () => { + const { test } = useRequests(); + const { status } = await test.post({ data: "test" }); + expect(status).toBe(200); + }); + + it("should make a PUT request", async () => { + const { test } = useRequests(); + const { status } = await test.put({}); + expect(status).toBe(200); + }); + + it("should make a DELETE request", async () => { + const { test } = useRequests(); + const { status } = await test.delete(); + expect(status).toBe(200); + }); + + it("should make a PATCH request", async () => { + const { test } = useRequests(); + const { status } = await test.patch([ + { op: "add", path: "/test", value: "test" }, + ]); + expect(status).toBe(200); }); }); diff --git a/assets/examples/1.gif b/assets/examples/1.gif new file mode 100644 index 0000000..77a1565 Binary files /dev/null and b/assets/examples/1.gif differ diff --git a/assets/examples/2.gif b/assets/examples/2.gif new file mode 100644 index 0000000..26633b8 Binary files /dev/null and b/assets/examples/2.gif differ diff --git a/examples/run.ts b/examples/run.ts new file mode 100644 index 0000000..f72897f --- /dev/null +++ b/examples/run.ts @@ -0,0 +1,65 @@ +import { init, useRequests, useRawRequest, useOptions } from "../src"; + +// Test the library with the JSONPlaceholder API +// https://jsonplaceholder.typicode.com/ + +// can be defined globally +enum Api { + posts = "/posts", + comments = "/comments", +} +init("https://jsonplaceholder.typicode.com", { ...Api }); + +const normal = async () => { + console.info("✨ Normal requests"); + const { posts, comments } = useRequests(); + + let t = await posts.get(); + console.log(t.status, "GET /posts"); + + t = await posts.post({}); + console.log(t.status, "POST /posts"); + + t = await posts.put({}, { path: "/:id", params: { id: 1 } }); + console.log(t.status, "PUT /posts/1"); + + t = await posts.patch([{ op: "add", path: "/tests", value: true }], { + path: "/:id", + params: { id: 1 }, + }); + console.log(t.status, "PATCH /posts/1"); + + t = await posts.delete({ path: "/:id", params: { id: 1 } }); + console.log(t.status, "DELETE /posts/1"); + + t = await posts.get({ path: "/:id/comments", params: { id: 1 } }); + console.log(t.status, "GET /posts/1/comments"); + + t = await comments.get({ query: { postId: 1 } }); + console.log(t.status, "GET /comments?postId=1"); + console.log("\n"); +}; + +// same requests can be done with raw requests +const raw = async () => { + console.info("⚡️ Raw requests"); + const raw = useRawRequest(); + const r = await raw("https://jsonplaceholder.typicode.com"); + + let t = await r.get("/posts"); + console.log(t.status, "GET /posts"); + + // parameters in url are also supported and can be passed as an object too + // but is doesnt throw an error if the parameter is not passed + t = await r.get({ + path: "/posts/:id", + params: { id: 1 }, + }); + console.log(t.status, "GET /posts/1"); +}; + +// run all examples sequentially +(async () => { + await normal(); + await raw(); +})(); diff --git a/package-lock.json b/package-lock.json index 7bd9881..5081a0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@types/jest": "29.5.13", "jest": "29.7.0", "ts-jest": "29.2.5", + "tsconfig-paths": "4.2.0", "tsup": "8.3.0", "typescript": "5.6.2" } @@ -3820,6 +3821,15 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -4762,6 +4772,29 @@ "node": ">=10" } }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/tsup": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.3.0.tgz", diff --git a/package.json b/package.json index 4d174ef..178eb2d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "use-requests", - "description": "Type-Safe HTTP client hook (react) to handle requests based on native fetch api with some magic under the hood ✨", + "description": "Type-Safe HTTP client hook helper to handle requests based on native fetch api with some magic under the hood ✨", "version": "1.0.15", "author": "Frangeris Peguero ", "license": "MIT", @@ -24,6 +24,7 @@ "@types/jest": "29.5.13", "jest": "29.7.0", "ts-jest": "29.2.5", + "tsconfig-paths": "4.2.0", "tsup": "8.3.0", "typescript": "5.6.2" } diff --git a/src/index.ts b/src/index.ts index 862251a..8277db9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,8 @@ import init from "./init"; import useRequests from "./useRequests"; import useOptions from "./useOptions"; +import useRawRequest from "./useRawRequest"; -export { init, useRequests, useOptions }; +export { init, useRequests, useOptions, useRawRequest }; export default useRequests; diff --git a/src/service.ts b/src/service.ts index b21790f..1573528 100644 --- a/src/service.ts +++ b/src/service.ts @@ -1,62 +1,83 @@ +import { RequestPath, ServiceOptions } from "./types"; import Options from "./options"; -import { ServiceResponse, RequestPath, PatchOperation } from "./types"; + +class ServiceResponse extends Response { + private response?: Response; + + constructor(res: Response) { + super(res.body, res); + this.response = res; + } + + public get data() { + return (async () => { + const response = this.response; + if (response) { + const { ok, body } = response; + let data = null; + if (ok && body) { + const reader = body?.getReader(); + const decoder = new TextDecoder(); + let result = ""; + while (true) { + const { value, done } = await reader!.read(); + if (done) break; + result += decoder.decode(value, { stream: true }); + } + reader?.releaseLock(); + data = JSON.parse(result) as { data: any }; + } + + return data?.data ?? null; + } + + return null; + })(); + } +} export class Service

{ private resource?: string; private controller: AbortController; + private options?: ServiceOptions; - constructor(resource?: string) { + constructor(resource?: string, options: ServiceOptions = { bypass: false }) { if (resource) { this.resource = resource; } + this.options = options; this.controller = new AbortController(); } - private request( - req: RequestInit & { path?: RequestPath

} - ): Promise { - const { baseURL, headers } = Options.instance(); - if (!baseURL) { - throw new Error("Missing baseURL in options"); - } - - const { path, ...rest } = req; - const url = baseURL + this.build(path); - const request = new Request(url, { - headers, - signal: this.controller.signal, - ...rest, - }); - - return fetch(request); - } + private buildUrl(path?: RequestPath

): string { + // when no resource, a raw request + let url = this.resource ?? ""; - private build(path?: RequestPath

) { + // no need to build path if (typeof path === "string") { - return path; + return url + path; } - let url = this.resource || ""; + // not path provided, plain url if (!path) { return url; } // build complex path const { params, query, path: customPath } = path; + if (customPath) { + url += customPath; + } + if (params) { for (const k in params) { - // @ts-ignore url = url.replace(`:${k}`, `${params[k]}`); } } else if (url.includes(":")) { throw new Error("Missing path parameters"); } - if (customPath) { - url += customPath; - } - if (query) { const params = Object.entries(query).map(([k, v]) => [k, v.toString()]); const qs = new URLSearchParams(params); @@ -66,76 +87,97 @@ export class Service

{ return url; } - private async response(res: Response): Promise> { - const newRes = res.clone() as Response as ServiceResponse; - newRes.data = async () => { - try { - const { data } = await res.json(); - return data as T; - } catch (error) { - return null; + private buildRequest(req: RequestInit & { path?: RequestPath

}): Request { + let baseURL = ""; + const { headers } = Options.instance(); + + // for normal requests (not raw), base url is required + if (!this.options?.bypass) { + const { baseURL: initialURL } = Options.instance(); + if (!initialURL) { + throw new Error("Missing baseURL in options"); } - }; + baseURL = initialURL; + } - return newRes; + const { path, ...rest } = req; + const url = baseURL + this.buildUrl(path); + const request = new Request(url, { + headers, + signal: this.controller.signal, + ...rest, + }); + + return request; + } + + private buildResponse(res: Response): ServiceResponse { + return new ServiceResponse(res); + } + + private makeRequest(req: Request): Promise { + return fetch(req); } // HTTP methods async get(path?: RequestPath

) { - const response = await this.request({ - path, - method: "GET", - }); + const req = this.buildRequest({ path, method: "GET" }); + const res = await this.makeRequest(req); - return this.response(response); + return this.buildResponse(res); } async post( payload: any, path?: RequestPath

): Promise> { - const request = await this.request({ + const req = await this.buildRequest({ path, method: "POST", body: JSON.stringify(payload), }); + const res = await this.makeRequest(req); - return this.response(request); + return this.buildResponse(res); } async put( payload: any, path?: RequestPath

): Promise> { - const request = await this.request({ + const req = await this.buildRequest({ path, method: "PUT", body: JSON.stringify(payload), }); + const res = await this.makeRequest(req); - return this.response(request); + return this.buildResponse(res); } async delete(path?: RequestPath

): Promise> { - const request = await this.request({ - path, - method: "DELETE", - }); + const req = await this.buildRequest({ path, method: "DELETE" }); + const res = await this.makeRequest(req); - return this.response(request); + return this.buildResponse(res); } async patch( - ops: PatchOperation[], + ops: { + path: string; + op: "add" | "remove" | "replace" | "move" | "copy" | "test"; + value?: any; + }[], path?: RequestPath

): Promise> { - const request = await this.request({ + const req = await this.buildRequest({ path, method: "PATCH", body: JSON.stringify(ops), }); + const res = await this.makeRequest(req); - return this.response(request); + return this.buildResponse(res); } } diff --git a/src/types.ts b/src/types.ts index 7a777d5..cbd964a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,7 +7,7 @@ export type InitOptions = { export type Services = { [K in keyof T]: Service }; -export type ServiceResponse = { data: () => Promise } & Response; +export type ServiceResponse = { data: R | null } & Response; export type RequestPath

= | { @@ -26,8 +26,6 @@ export type ExtractParams = ? ExtractParams : {}; -export type PatchOperation = { - path: string; - op: "add" | "remove" | "replace" | "move" | "copy" | "test"; - value?: any; +export type ServiceOptions = { + bypass?: boolean; }; diff --git a/src/useRawRequest.ts b/src/useRawRequest.ts new file mode 100644 index 0000000..e114152 --- /dev/null +++ b/src/useRawRequest.ts @@ -0,0 +1,9 @@ +import Service from "./service"; + +export function useRawRequest() { + return (url: string) => { + return new Service(url, { bypass: true }); + }; +} + +export default useRawRequest; diff --git a/src/useRequests.ts b/src/useRequests.ts index 2b7629e..bd3f5df 100644 --- a/src/useRequests.ts +++ b/src/useRequests.ts @@ -1,7 +1,12 @@ -import { Services } from "./types"; -import context from "./context"; +import { Services } from "@/types"; +import context from "@/context"; export function useRequest() { + const ctx = context.useRequests; + if (!ctx) { + throw new Error("init must be called before using useRequest"); + } + return context.useRequests as Services; } diff --git a/tsconfig.json b/tsconfig.json index 8b6d950..e09cf3b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,5 +12,8 @@ } }, "exclude": ["node_modules", "jest.config.js"], - "typeRoots": ["./node_modules/@types"] + "typeRoots": ["./node_modules/@types"], + "ts-node": { + "require": ["tsconfig-paths/register"] + } }