@mapomodule/core 1.0.0-alpha.9 → 2.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.mjs +21 -0
- package/dist/module.d.cts +2 -0
- package/dist/module.d.mts +2 -0
- package/dist/module.d.ts +2 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +20 -0
- package/dist/types.d.mts +13 -0
- package/package.json +58 -15
- package/src/index.ts +4 -0
- package/src/module.ts +102 -0
- package/src/runtime/__tests__/plugins/00.fetch.spec.ts +144 -0
- package/src/runtime/api/crud.ts +130 -0
- package/src/runtime/api/multipart.ts +78 -0
- package/src/runtime/auth/useCanAccessRoute.ts +48 -0
- package/src/runtime/auth/useMapoAuth.ts +91 -0
- package/src/runtime/composables/useMapo.ts +61 -0
- package/src/runtime/middleware/auth.ts +24 -0
- package/src/runtime/middleware/permissions.ts +52 -0
- package/src/runtime/middleware/roles.ts +23 -0
- package/src/runtime/page-meta.nuxt.d.ts +23 -0
- package/src/runtime/plugins/00.fetch.ts +85 -0
- package/src/runtime/plugins/01.init.server.ts +62 -0
- package/src/runtime/types.nuxt.d.ts +18 -0
- package/src/runtime/types.ts +88 -0
- package/src/runtime/utils/useMapoFetch.ts +41 -0
- package/src/types.ts +13 -0
- package/CHANGELOG.md +0 -32
- package/LICENSE.md +0 -20
- package/api/crud.js +0 -107
- package/api/multipart.js +0 -45
- package/index.js +0 -124
- package/interceptor/auth.js +0 -26
- package/interceptor/index.js +0 -5
- package/middleware/auth.js +0 -9
- package/middleware/permissions.js +0 -19
- package/middleware/roles.js +0 -10
- package/module.js +0 -40
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# @mapomodule/core
|
|
2
|
+
|
|
3
|
+
The service layer of Mapo. Provides the typed CRUD factory, auth composable, route middleware, the `$mapoFetch` interceptor, and the boot plugins that wire everything together.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
In a Nuxt 4 app — typically you don't install this directly; install the meta-package [`mapomodule`](../../mapomodule/) which registers `core` together with `@mapomodule/store`. If you really want it standalone:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @mapomodule/core
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// nuxt.config.ts
|
|
15
|
+
export default defineNuxtConfig({
|
|
16
|
+
modules: ["@mapomodule/core"],
|
|
17
|
+
mapoCore: {
|
|
18
|
+
authLoginUrl: "/api/auth/login",
|
|
19
|
+
userInfoApi: "/api/profiles/me/",
|
|
20
|
+
logoutUrl: "/api/auth/logout",
|
|
21
|
+
loginUrl: "/login",
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
When installed via `mapomodule`, configure under the `mapo` key instead.
|
|
27
|
+
|
|
28
|
+
## What you get (auto-imported)
|
|
29
|
+
|
|
30
|
+
| API | Purpose |
|
|
31
|
+
| ----------------- | --------------------------------------------------------------------------- |
|
|
32
|
+
| `useCrud<T>(url)` | Typed CRUD repository: `list`, `get`, `create`, `update`, `patch`, `delete` |
|
|
33
|
+
| `useMapoAuth()` | `login`, `logout`, `fetchUser` — drives the auth store |
|
|
34
|
+
| `useMapoFetch()` | Pre-configured `$fetch` with 401/403 interceptors |
|
|
35
|
+
| `useMapo()` | Facade exposing the above as a single object |
|
|
36
|
+
| `auth` middleware | Route middleware: redirects unauthenticated users to `loginUrl` |
|
|
37
|
+
|
|
38
|
+
## Dev workflow
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pnpm dev:core # from monorepo root — watch mode
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
All endpoints fall back to the `MAPO_DEFAULTS` constant exported from this package. See [docs/modules/core](../../../docs/modules/core.md) for the full reference.
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "/home/runner/work/mapo-new/mapo-new/packages/@mapomodule/core/src/index.js";
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "/home/runner/work/mapo-new/mapo-new/packages/@mapomodule/core/src/index.js";
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "/home/runner/work/mapo-new/mapo-new/packages/@mapomodule/core/src/index.js";
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createJiti } from "file:///home/runner/work/mapo-new/mapo-new/node_modules/.pnpm/jiti@2.6.1/node_modules/jiti/lib/jiti.mjs";
|
|
2
|
+
|
|
3
|
+
const jiti = createJiti(import.meta.url, {
|
|
4
|
+
"interopDefault": true,
|
|
5
|
+
"alias": {
|
|
6
|
+
"@mapomodule/core": "/home/runner/work/mapo-new/mapo-new/packages/@mapomodule/core"
|
|
7
|
+
},
|
|
8
|
+
"transformOptions": {
|
|
9
|
+
"babel": {
|
|
10
|
+
"plugins": []
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
/** @type {import("/home/runner/work/mapo-new/mapo-new/packages/@mapomodule/core/src/index.js")} */
|
|
16
|
+
const _module = await jiti.import("/home/runner/work/mapo-new/mapo-new/packages/@mapomodule/core/src/index.ts");
|
|
17
|
+
|
|
18
|
+
export const MultipartPolicyEnum = _module.MultipartPolicyEnum;
|
|
19
|
+
export const CoreCookieEnum = _module.CoreCookieEnum;
|
|
20
|
+
export const useCanAccessRoute = _module.useCanAccessRoute;
|
|
21
|
+
export const useMapoFetch = _module.useMapoFetch;
|
package/dist/module.d.ts
ADDED
package/dist/module.json
ADDED
package/dist/module.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createJiti } from "file:///home/runner/work/mapo-new/mapo-new/node_modules/.pnpm/jiti@2.6.1/node_modules/jiti/lib/jiti.mjs";
|
|
2
|
+
|
|
3
|
+
const jiti = createJiti(import.meta.url, {
|
|
4
|
+
"interopDefault": true,
|
|
5
|
+
"alias": {
|
|
6
|
+
"@mapomodule/core": "/home/runner/work/mapo-new/mapo-new/packages/@mapomodule/core"
|
|
7
|
+
},
|
|
8
|
+
"transformOptions": {
|
|
9
|
+
"babel": {
|
|
10
|
+
"plugins": []
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
/** @type {import("/home/runner/work/mapo-new/mapo-new/packages/@mapomodule/core/src/module.js")} */
|
|
16
|
+
const _module = await jiti.import("/home/runner/work/mapo-new/mapo-new/packages/@mapomodule/core/src/module.ts");
|
|
17
|
+
|
|
18
|
+
export default _module?.default ?? _module;
|
|
19
|
+
export const MultipartPolicyEnum = _module.MultipartPolicyEnum;
|
|
20
|
+
export const CoreCookieEnum = _module.CoreCookieEnum;
|
package/dist/types.d.mts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ModuleHooks, ModuleRuntimeHooks, ModuleRuntimeConfig, ModulePublicRuntimeConfig } from './module.mjs'
|
|
2
|
+
|
|
3
|
+
declare module '#app' {
|
|
4
|
+
interface RuntimeNuxtHooks extends ModuleRuntimeHooks {}
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
declare module '@nuxt/schema' {
|
|
8
|
+
interface NuxtHooks extends ModuleHooks {}
|
|
9
|
+
interface RuntimeConfig extends ModuleRuntimeConfig {}
|
|
10
|
+
interface PublicRuntimeConfig extends ModulePublicRuntimeConfig {}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export * from "./module.mjs"
|
package/package.json
CHANGED
|
@@ -1,22 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mapomodule/core",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
6
|
-
"keywords": [
|
|
7
|
-
"mapo",
|
|
8
|
-
"nuxt",
|
|
9
|
-
"Admin",
|
|
10
|
-
"Panel"
|
|
11
|
-
],
|
|
3
|
+
"version": "2.0.0-beta.1",
|
|
4
|
+
"description": "Mapo core: API layer, auth, composables, middleware",
|
|
5
|
+
"type": "module",
|
|
12
6
|
"license": "MIT",
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
7
|
+
"author": "Lotrek Agency (https://lotrek.it)",
|
|
8
|
+
"homepage": "https://lotrekagency.github.io/mapo-new/",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/lotrekagency/mapo-new.git",
|
|
12
|
+
"directory": "packages/@mapomodule/core"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/lotrekagency/mapo-new/issues"
|
|
17
16
|
},
|
|
18
17
|
"publishConfig": {
|
|
19
|
-
"access": "public"
|
|
18
|
+
"access": "public",
|
|
19
|
+
"tag": "next"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/module.d.mts",
|
|
24
|
+
"import": "./dist/module.mjs"
|
|
25
|
+
},
|
|
26
|
+
"./types": {
|
|
27
|
+
"types": "./dist/index.d.mts",
|
|
28
|
+
"import": "./dist/index.mjs"
|
|
29
|
+
},
|
|
30
|
+
"./runtime/*": {
|
|
31
|
+
"types": "./src/runtime/*.ts",
|
|
32
|
+
"import": "./dist/runtime/*.js",
|
|
33
|
+
"default": "./src/runtime/*"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"src"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"dev": "nuxt-module-build build --stub --watch",
|
|
42
|
+
"build": "nuxt-module-build build && tsc -p tsconfig.build-types.json",
|
|
43
|
+
"prepare": "nuxt-module-build build --stub",
|
|
44
|
+
"dev:prepare": "nuxt-module-build build --stub",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"lint": "eslint src"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@mapomodule/store": "2.0.0-beta.1",
|
|
50
|
+
"@mapomodule/utils": "2.0.0-beta.1"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"nuxt": ">=4.0.0"
|
|
20
54
|
},
|
|
21
|
-
"
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@nuxt/kit": "4.4.2",
|
|
57
|
+
"@nuxt/module-builder": "^1.0.2",
|
|
58
|
+
"@nuxt/schema": "4.4.2",
|
|
59
|
+
"nuxt": "^4.4.2",
|
|
60
|
+
"ofetch": "^1.5.1",
|
|
61
|
+
"pinia": "^3.0.4",
|
|
62
|
+
"vue": "^3.5.32",
|
|
63
|
+
"vue-router": "^5.0.4"
|
|
64
|
+
}
|
|
22
65
|
}
|
package/src/index.ts
ADDED
package/src/module.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
defineNuxtModule,
|
|
4
|
+
addImports,
|
|
5
|
+
addPlugin,
|
|
6
|
+
addRouteMiddleware,
|
|
7
|
+
addTypeTemplate,
|
|
8
|
+
hasNuxtModule,
|
|
9
|
+
installModule,
|
|
10
|
+
createResolver,
|
|
11
|
+
} from "@nuxt/kit";
|
|
12
|
+
import type { NuxtModule } from "@nuxt/schema";
|
|
13
|
+
import { MAPO_DEFAULTS } from "./types";
|
|
14
|
+
import type { MapoOptions } from "./types";
|
|
15
|
+
|
|
16
|
+
export type { MapoOptions } from "./types";
|
|
17
|
+
export type {
|
|
18
|
+
ListParams,
|
|
19
|
+
PaginatedResponse,
|
|
20
|
+
ApiResponse,
|
|
21
|
+
CrudConfig,
|
|
22
|
+
CrudOptions,
|
|
23
|
+
CrudRepository,
|
|
24
|
+
MultipartPolicy,
|
|
25
|
+
Credentials,
|
|
26
|
+
AuthToken,
|
|
27
|
+
} from "./runtime/types";
|
|
28
|
+
export { MultipartPolicyEnum, CoreCookieEnum } from "./runtime/types";
|
|
29
|
+
export type { MapoFacade, MapoApiLayer } from "./runtime/composables/useMapo";
|
|
30
|
+
export type { RoutePermissions } from "./runtime/auth/useCanAccessRoute";
|
|
31
|
+
|
|
32
|
+
export default defineNuxtModule<MapoOptions>({
|
|
33
|
+
meta: {
|
|
34
|
+
name: "@mapomodule/core",
|
|
35
|
+
configKey: "mapoCore",
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
defaults: MAPO_DEFAULTS,
|
|
39
|
+
|
|
40
|
+
async setup(options, nuxt) {
|
|
41
|
+
const resolver = createResolver(import.meta.url);
|
|
42
|
+
|
|
43
|
+
addTypeTemplate({
|
|
44
|
+
filename: "types/mapo-core-page-meta.d.ts",
|
|
45
|
+
getContents: () =>
|
|
46
|
+
readFileSync(
|
|
47
|
+
resolver.resolve("./runtime/page-meta.nuxt.d.ts"),
|
|
48
|
+
"utf-8",
|
|
49
|
+
),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (!hasNuxtModule("@mapomodule/store")) {
|
|
53
|
+
await installModule("@mapomodule/store");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// `defaults` guarantees these are set at runtime, but MapoOptions keeps
|
|
57
|
+
// them optional — spread the defaults so the assignment is fully defined.
|
|
58
|
+
nuxt.options.runtimeConfig.public.mapoCore = {
|
|
59
|
+
...MAPO_DEFAULTS,
|
|
60
|
+
...options,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
addPlugin({ src: resolver.resolve("./runtime/plugins/00.fetch") });
|
|
64
|
+
addPlugin({
|
|
65
|
+
src: resolver.resolve("./runtime/plugins/01.init.server"),
|
|
66
|
+
mode: "server",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
addImports([
|
|
70
|
+
{ name: "useCrud", from: resolver.resolve("./runtime/api/crud") },
|
|
71
|
+
{
|
|
72
|
+
name: "useMapoAuth",
|
|
73
|
+
from: resolver.resolve("./runtime/auth/useMapoAuth"),
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "useMapo",
|
|
77
|
+
from: resolver.resolve("./runtime/composables/useMapo"),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "useCanAccessRoute",
|
|
81
|
+
from: resolver.resolve("./runtime/auth/useCanAccessRoute"),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "useMapoFetch",
|
|
85
|
+
from: resolver.resolve("./runtime/utils/useMapoFetch"),
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
addRouteMiddleware({
|
|
90
|
+
name: "auth",
|
|
91
|
+
path: resolver.resolve("./runtime/middleware/auth"),
|
|
92
|
+
});
|
|
93
|
+
addRouteMiddleware({
|
|
94
|
+
name: "permissions",
|
|
95
|
+
path: resolver.resolve("./runtime/middleware/permissions"),
|
|
96
|
+
});
|
|
97
|
+
addRouteMiddleware({
|
|
98
|
+
name: "roles",
|
|
99
|
+
path: resolver.resolve("./runtime/middleware/roles"),
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
}) satisfies NuxtModule<MapoOptions>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { ref } from "vue";
|
|
3
|
+
|
|
4
|
+
// Mock setupPlugin function behavior for testing
|
|
5
|
+
function setupFetchPlugin() {
|
|
6
|
+
let redirectInFlight = false;
|
|
7
|
+
const pending = ref(0);
|
|
8
|
+
|
|
9
|
+
const handle = (status: number, requestUrl: string, logoutUrl: string) => {
|
|
10
|
+
if (
|
|
11
|
+
status === 401 &&
|
|
12
|
+
!requestUrl.includes(logoutUrl) &&
|
|
13
|
+
!redirectInFlight
|
|
14
|
+
) {
|
|
15
|
+
redirectInFlight = true;
|
|
16
|
+
// In real plugin, would redirect; here we just track the intent
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const mapoFetch = {
|
|
21
|
+
onRequest: () => {
|
|
22
|
+
pending.value++;
|
|
23
|
+
},
|
|
24
|
+
onResponse: (ctx: { response: { status: number }; request: string }) => {
|
|
25
|
+
pending.value--;
|
|
26
|
+
handle(ctx.response.status, ctx.request, "/logout");
|
|
27
|
+
},
|
|
28
|
+
onResponseError: (ctx: {
|
|
29
|
+
response?: { status: number };
|
|
30
|
+
request: string;
|
|
31
|
+
}) => {
|
|
32
|
+
pending.value--;
|
|
33
|
+
handle(ctx.response?.status ?? 0, ctx.request, "/logout");
|
|
34
|
+
},
|
|
35
|
+
onRequestError: () => {
|
|
36
|
+
pending.value--;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return { pending, mapoFetch };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("Mapo Fetch Plugin — Pending Counter", () => {
|
|
44
|
+
let fixture: ReturnType<typeof setupFetchPlugin>;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
fixture = setupFetchPlugin();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should increment pending on request and decrement on successful response", () => {
|
|
51
|
+
const { pending, mapoFetch } = fixture;
|
|
52
|
+
|
|
53
|
+
expect(pending.value).toBe(0);
|
|
54
|
+
|
|
55
|
+
// Simulate request
|
|
56
|
+
mapoFetch.onRequest();
|
|
57
|
+
expect(pending.value).toBe(1);
|
|
58
|
+
|
|
59
|
+
// Simulate successful response (200)
|
|
60
|
+
mapoFetch.onResponse({
|
|
61
|
+
response: { status: 200 },
|
|
62
|
+
request: "/api/users",
|
|
63
|
+
});
|
|
64
|
+
expect(pending.value).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should decrement pending exactly once on HTTP error (not double-decrement)", () => {
|
|
68
|
+
const { pending, mapoFetch } = fixture;
|
|
69
|
+
|
|
70
|
+
expect(pending.value).toBe(0);
|
|
71
|
+
|
|
72
|
+
// Simulate request
|
|
73
|
+
mapoFetch.onRequest();
|
|
74
|
+
expect(pending.value).toBe(1);
|
|
75
|
+
|
|
76
|
+
// Simulate HTTP error (500): onResponseError is called instead of onResponse
|
|
77
|
+
mapoFetch.onResponseError({
|
|
78
|
+
response: { status: 500 },
|
|
79
|
+
request: "/api/users",
|
|
80
|
+
});
|
|
81
|
+
expect(pending.value).toBe(0);
|
|
82
|
+
// Should NOT be -1 (which would indicate double-decrement)
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should decrement pending on network error (onRequestError)", () => {
|
|
86
|
+
const { pending, mapoFetch } = fixture;
|
|
87
|
+
|
|
88
|
+
expect(pending.value).toBe(0);
|
|
89
|
+
|
|
90
|
+
// Simulate request
|
|
91
|
+
mapoFetch.onRequest();
|
|
92
|
+
expect(pending.value).toBe(1);
|
|
93
|
+
|
|
94
|
+
// Simulate network error: onRequestError is called
|
|
95
|
+
mapoFetch.onRequestError();
|
|
96
|
+
expect(pending.value).toBe(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should handle multiple concurrent requests correctly", () => {
|
|
100
|
+
const { pending, mapoFetch } = fixture;
|
|
101
|
+
|
|
102
|
+
// Three concurrent requests
|
|
103
|
+
mapoFetch.onRequest(); // Request 1
|
|
104
|
+
mapoFetch.onRequest(); // Request 2
|
|
105
|
+
mapoFetch.onRequest(); // Request 3
|
|
106
|
+
expect(pending.value).toBe(3);
|
|
107
|
+
|
|
108
|
+
// First completes successfully
|
|
109
|
+
mapoFetch.onResponse({
|
|
110
|
+
response: { status: 200 },
|
|
111
|
+
request: "/api/users",
|
|
112
|
+
});
|
|
113
|
+
expect(pending.value).toBe(2);
|
|
114
|
+
|
|
115
|
+
// Second fails with HTTP error
|
|
116
|
+
mapoFetch.onResponseError({
|
|
117
|
+
response: { status: 500 },
|
|
118
|
+
request: "/api/posts",
|
|
119
|
+
});
|
|
120
|
+
expect(pending.value).toBe(1);
|
|
121
|
+
|
|
122
|
+
// Third fails with network error
|
|
123
|
+
mapoFetch.onRequestError();
|
|
124
|
+
expect(pending.value).toBe(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should never go negative", () => {
|
|
128
|
+
const { pending, mapoFetch } = fixture;
|
|
129
|
+
|
|
130
|
+
// Ensure pending never goes negative even with edge cases
|
|
131
|
+
mapoFetch.onRequest();
|
|
132
|
+
expect(pending.value).toBe(1);
|
|
133
|
+
|
|
134
|
+
// Simulate response
|
|
135
|
+
mapoFetch.onResponse({
|
|
136
|
+
response: { status: 200 },
|
|
137
|
+
request: "/api/users",
|
|
138
|
+
});
|
|
139
|
+
expect(pending.value).toBe(0);
|
|
140
|
+
|
|
141
|
+
// Should not go negative
|
|
142
|
+
expect(pending.value).toBeGreaterThanOrEqual(0);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { normalizeEndpoint } from "@mapomodule/utils";
|
|
2
|
+
import { applyMultipartPolicy } from "./multipart";
|
|
3
|
+
import { useMapoFetch } from "../utils/useMapoFetch";
|
|
4
|
+
import { MultipartPolicyEnum } from "../types";
|
|
5
|
+
import type {
|
|
6
|
+
PaginatedResponse,
|
|
7
|
+
CrudConfig,
|
|
8
|
+
CrudOptions,
|
|
9
|
+
CrudRepository,
|
|
10
|
+
} from "../types";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates a typed CRUD repository bound to a resource endpoint.
|
|
14
|
+
*
|
|
15
|
+
* The returned repository exposes common operations (`list`, `detail`, `create`,
|
|
16
|
+
* `update`, `partialUpdate`, `delete`) plus utility actions (`updateOrCreate`,
|
|
17
|
+
* `updateOrder`). Requests are executed through the shared Mapo fetch client.
|
|
18
|
+
*
|
|
19
|
+
* Configuration precedence:
|
|
20
|
+
* - `higherConf` defines repository-level default request options.
|
|
21
|
+
* - Per-call config arguments override repository-level defaults.
|
|
22
|
+
*
|
|
23
|
+
* Multipart handling:
|
|
24
|
+
* - Write methods pass payloads through `applyMultipartPolicy`, using
|
|
25
|
+
* `MultipartPolicyEnum.Auto` unless overridden in `CrudOptions`.
|
|
26
|
+
*
|
|
27
|
+
* @typeParam T Entity type handled by the repository.
|
|
28
|
+
* @param endpoint Resource endpoint (normalized to a trailing-slash base path).
|
|
29
|
+
* @param higherConf Optional repository-level fetch configuration defaults.
|
|
30
|
+
* @returns A typed `CrudRepository<T>` for the specified endpoint.
|
|
31
|
+
*/
|
|
32
|
+
export function useCrud<T>(
|
|
33
|
+
endpoint: string,
|
|
34
|
+
higherConf?: CrudConfig,
|
|
35
|
+
): CrudRepository<T> {
|
|
36
|
+
const base = normalizeEndpoint(endpoint);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Merges higher-level CRUD config with per-call config.
|
|
40
|
+
*
|
|
41
|
+
* Per-call options take precedence over defaults provided at repository
|
|
42
|
+
* creation time.
|
|
43
|
+
*/
|
|
44
|
+
function merged(local?: CrudConfig): Record<string, unknown> {
|
|
45
|
+
return { ...higherConf, ...local };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Executes a typed request through the shared Mapo fetch client.
|
|
50
|
+
*
|
|
51
|
+
* @param path Target URL path.
|
|
52
|
+
* @param options Request options passed to `$mapoFetch`.
|
|
53
|
+
* @returns A promise resolving to the typed response payload.
|
|
54
|
+
*/
|
|
55
|
+
function fetch<R>(
|
|
56
|
+
path: string,
|
|
57
|
+
options: Record<string, unknown>,
|
|
58
|
+
): Promise<R> {
|
|
59
|
+
return useMapoFetch().fetch(path, options as never) as Promise<R>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
list(params, config) {
|
|
64
|
+
return fetch<PaginatedResponse<T>>(base, {
|
|
65
|
+
method: "GET",
|
|
66
|
+
params,
|
|
67
|
+
...merged(config),
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
detail(id, config) {
|
|
72
|
+
return fetch<T>(`${base}${id}/`, { method: "GET", ...merged(config) });
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
create(data, config, opts: CrudOptions = {}) {
|
|
76
|
+
const body = applyMultipartPolicy(
|
|
77
|
+
data as Record<string, unknown>,
|
|
78
|
+
opts.multipart ?? MultipartPolicyEnum.Auto,
|
|
79
|
+
);
|
|
80
|
+
return fetch<T>(base, { method: "POST", body, ...merged(config) });
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
update(id, data, config, opts: CrudOptions = {}) {
|
|
84
|
+
const body = applyMultipartPolicy(
|
|
85
|
+
data as Record<string, unknown>,
|
|
86
|
+
opts.multipart ?? MultipartPolicyEnum.Auto,
|
|
87
|
+
);
|
|
88
|
+
return fetch<T>(`${base}${id}/`, {
|
|
89
|
+
method: "PUT",
|
|
90
|
+
body,
|
|
91
|
+
...merged(config),
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
partialUpdate(id, diff, config, opts: CrudOptions = {}) {
|
|
96
|
+
const body = applyMultipartPolicy(
|
|
97
|
+
diff as Record<string, unknown>,
|
|
98
|
+
opts.multipart ?? MultipartPolicyEnum.Auto,
|
|
99
|
+
);
|
|
100
|
+
return fetch<T>(`${base}${id}/`, {
|
|
101
|
+
method: "PATCH",
|
|
102
|
+
body,
|
|
103
|
+
...merged(config),
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
delete(id, config) {
|
|
108
|
+
return fetch<void>(`${base}${id}/`, {
|
|
109
|
+
method: "DELETE",
|
|
110
|
+
...merged(config),
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
updateOrCreate(data, config, opts) {
|
|
115
|
+
if (data.id) {
|
|
116
|
+
const { id, ...rest } = data;
|
|
117
|
+
return this.update(id, rest as Partial<T>, config, opts);
|
|
118
|
+
}
|
|
119
|
+
return this.create(data as Partial<T>, config, opts);
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
updateOrder(startId, endId, config) {
|
|
123
|
+
return fetch<void>(`${base}update_order/`, {
|
|
124
|
+
method: "POST",
|
|
125
|
+
body: { startId, endId },
|
|
126
|
+
...merged(config),
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { MultipartPolicy } from "../types";
|
|
2
|
+
import { MultipartPolicyEnum } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Flattened file entry extracted from a nested payload.
|
|
6
|
+
*/
|
|
7
|
+
interface FileEntry {
|
|
8
|
+
/** Dot-notation path where the file/blob was found in the original payload. */
|
|
9
|
+
path: string;
|
|
10
|
+
/** File-like value to append to FormData. */
|
|
11
|
+
file: File | Blob;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Recursively collects all `File`/`Blob` values from a nested payload.
|
|
16
|
+
*
|
|
17
|
+
* @param obj Value to inspect.
|
|
18
|
+
* @param path Current dot-notation traversal path.
|
|
19
|
+
* @returns A flat list of file entries with their original paths.
|
|
20
|
+
*/
|
|
21
|
+
function collectFiles(obj: unknown, path = ""): FileEntry[] {
|
|
22
|
+
if (obj instanceof File || obj instanceof Blob) {
|
|
23
|
+
return [{ path, file: obj }];
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(obj)) {
|
|
26
|
+
return obj.flatMap((item, i) =>
|
|
27
|
+
collectFiles(item, path ? `${path}.${i}` : String(i)),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (obj !== null && typeof obj === "object") {
|
|
31
|
+
return Object.entries(obj as Record<string, unknown>).flatMap(
|
|
32
|
+
([key, val]) => collectFiles(val, path ? `${path}.${key}` : key),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Converts a payload into multipart `FormData`.
|
|
40
|
+
*
|
|
41
|
+
* The full payload is serialized under the `data` field, while every extracted
|
|
42
|
+
* `File`/`Blob` is appended as a dedicated FormData field at its dot-path key.
|
|
43
|
+
*
|
|
44
|
+
* @param obj Source payload object.
|
|
45
|
+
* @returns Multipart form data representation of `obj`.
|
|
46
|
+
*/
|
|
47
|
+
function toFormData(obj: Record<string, unknown>): FormData {
|
|
48
|
+
const files = collectFiles(obj);
|
|
49
|
+
const fd = new FormData();
|
|
50
|
+
// JSON.stringify naturally drops File/Blob — used as the plain-data field
|
|
51
|
+
fd.append("data", JSON.stringify(obj));
|
|
52
|
+
for (const { path, file } of files) {
|
|
53
|
+
fd.append(path, file);
|
|
54
|
+
}
|
|
55
|
+
return fd;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Applies the configured multipart policy to a request payload.
|
|
60
|
+
*
|
|
61
|
+
* Policy behavior:
|
|
62
|
+
* - `Disable`: always return the original object payload.
|
|
63
|
+
* - `Force`: always return a `FormData` payload.
|
|
64
|
+
* - `Auto`: return `FormData` only when at least one `File`/`Blob` is present.
|
|
65
|
+
*
|
|
66
|
+
* @param payload Request payload object.
|
|
67
|
+
* @param policy Multipart transformation policy.
|
|
68
|
+
* @returns Either the original payload or a `FormData` instance.
|
|
69
|
+
*/
|
|
70
|
+
export function applyMultipartPolicy(
|
|
71
|
+
payload: Record<string, unknown>,
|
|
72
|
+
policy: MultipartPolicy,
|
|
73
|
+
): FormData | Record<string, unknown> {
|
|
74
|
+
if (policy === MultipartPolicyEnum.Disable) return payload;
|
|
75
|
+
if (policy === MultipartPolicyEnum.Force) return toFormData(payload);
|
|
76
|
+
// auto: transform only if File/Blob are present
|
|
77
|
+
return collectFiles(payload).length > 0 ? toFormData(payload) : payload;
|
|
78
|
+
}
|