@jgardner04/ghost-mcp-server 1.13.4 → 1.14.0

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.
Files changed (40) hide show
  1. package/README.md +68 -0
  2. package/package.json +7 -3
  3. package/src/__tests__/helpers/testUtils.js +15 -1
  4. package/src/__tests__/mcp_server.test.js +152 -1
  5. package/src/__tests__/mcp_server_pages.test.js +23 -6
  6. package/src/controllers/__tests__/imageController.test.js +2 -2
  7. package/src/controllers/imageController.js +11 -10
  8. package/src/mcp_server.js +647 -1203
  9. package/src/routes/__tests__/imageRoutes.test.js +2 -2
  10. package/src/schemas/__tests__/common.test.js +3 -3
  11. package/src/schemas/__tests__/pageSchemas.test.js +11 -2
  12. package/src/schemas/common.js +3 -2
  13. package/src/schemas/pageSchemas.js +1 -1
  14. package/src/schemas/postSchemas.js +1 -1
  15. package/src/services/__tests__/createResourceService.test.js +468 -0
  16. package/src/services/__tests__/ghostService.test.js +0 -19
  17. package/src/services/__tests__/ghostServiceImproved.members.test.js +0 -3
  18. package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +2 -2
  19. package/src/services/__tests__/ghostServiceImproved.pages.test.js +0 -3
  20. package/src/services/__tests__/ghostServiceImproved.posts.test.js +0 -1
  21. package/src/services/__tests__/ghostServiceImproved.tags.test.js +0 -3
  22. package/src/services/__tests__/ghostServiceImproved.tiers.test.js +3 -5
  23. package/src/services/__tests__/imageProcessingService.test.js +148 -177
  24. package/src/services/__tests__/images.test.js +78 -0
  25. package/src/services/createResourceService.js +138 -0
  26. package/src/services/ghostApiClient.js +240 -0
  27. package/src/services/ghostService.js +1 -19
  28. package/src/services/ghostServiceImproved.js +76 -915
  29. package/src/services/imageProcessingService.js +100 -56
  30. package/src/services/images.js +54 -0
  31. package/src/services/members.js +127 -0
  32. package/src/services/newsletters.js +63 -0
  33. package/src/services/pageService.js +2 -2
  34. package/src/services/pages.js +116 -0
  35. package/src/services/posts.js +116 -0
  36. package/src/services/tags.js +118 -0
  37. package/src/services/tiers.js +72 -0
  38. package/src/services/validators.js +218 -0
  39. package/src/utils/__tests__/imageInputResolver.test.js +134 -0
  40. package/src/utils/imageInputResolver.js +127 -0
@@ -252,7 +252,6 @@ describe('ghostServiceImproved - Tags', () => {
252
252
 
253
253
  it('should throw not found error when tag does not exist', async () => {
254
254
  const error404 = new GhostAPIError('tags.read', 'Tag not found', 404);
255
- error404.response = { status: 404 };
256
255
  api.tags.read.mockRejectedValue(error404);
257
256
 
258
257
  const rejection = getTag('non-existent');
@@ -411,7 +410,6 @@ describe('ghostServiceImproved - Tags', () => {
411
410
 
412
411
  it('should throw not found error if tag does not exist', async () => {
413
412
  const error404 = new GhostAPIError('tags.read', 'Tag not found', 404);
414
- error404.response = { status: 404 };
415
413
  api.tags.read.mockRejectedValue(error404);
416
414
 
417
415
  const rejection = updateTag('non-existent', { name: 'Test' });
@@ -438,7 +436,6 @@ describe('ghostServiceImproved - Tags', () => {
438
436
 
439
437
  it('should throw not found error if tag does not exist', async () => {
440
438
  const error404 = new GhostAPIError('tags.delete', 'Tag not found', 404);
441
- error404.response = { status: 404 };
442
439
  api.tags.delete.mockRejectedValue(error404);
443
440
 
444
441
  const rejection = deleteTag('non-existent');
@@ -295,9 +295,7 @@ describe('ghostServiceImproved - Tiers', () => {
295
295
  });
296
296
 
297
297
  it('should throw ValidationError when ID is missing', async () => {
298
- await expect(updateTier('', { name: 'Updated' })).rejects.toThrow(
299
- 'Tier ID is required for update'
300
- );
298
+ await expect(updateTier('', { name: 'Updated' })).rejects.toThrow('Tier ID is required');
301
299
  });
302
300
 
303
301
  it('should throw ValidationError for invalid update data', async () => {
@@ -327,11 +325,11 @@ describe('ghostServiceImproved - Tiers', () => {
327
325
  });
328
326
 
329
327
  it('should throw ValidationError when ID is missing', async () => {
330
- await expect(deleteTier()).rejects.toThrow('Tier ID is required for deletion');
328
+ await expect(deleteTier()).rejects.toThrow('Tier ID is required');
331
329
  });
332
330
 
333
331
  it('should throw ValidationError when ID is empty string', async () => {
334
- await expect(deleteTier('')).rejects.toThrow('Tier ID is required for deletion');
332
+ await expect(deleteTier('')).rejects.toThrow('Tier ID is required');
335
333
  });
336
334
 
337
335
  it('should throw NotFoundError when tier does not exist', async () => {
@@ -1,204 +1,175 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
3
- import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
4
-
5
- // Mock dotenv
6
- vi.mock('dotenv', () => mockDotenv());
1
+ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
2
+ import sharp from 'sharp';
3
+ import fs from 'fs/promises';
4
+ import path from 'path';
5
+ import os from 'os';
7
6
 
8
- // Mock logger
7
+ import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
9
8
  vi.mock('../../utils/logger.js', () => ({
10
9
  createContextLogger: createMockContextLogger(),
11
10
  }));
12
11
 
13
- // Mock sharp with chainable API
14
- const mockMetadata = vi.fn();
15
- const mockResize = vi.fn();
16
- const mockJpeg = vi.fn();
17
- const mockToFile = vi.fn();
18
-
19
- const createMockSharp = () => {
20
- const instance = {
21
- metadata: mockMetadata,
22
- resize: mockResize,
23
- jpeg: mockJpeg,
24
- };
25
-
26
- // Make methods chainable
27
- mockResize.mockReturnValue(instance);
28
- mockJpeg.mockReturnValue(instance);
29
- instance.toFile = mockToFile;
30
-
31
- return instance;
32
- };
33
-
34
- vi.mock('sharp', () => ({
35
- default: vi.fn(() => createMockSharp()),
36
- }));
37
-
38
- // Mock fs
39
- vi.mock('fs', () => ({
40
- default: {
41
- existsSync: vi.fn(),
42
- },
43
- }));
12
+ // Import under test (real sharp, real filesystem)
13
+ const { processImage } = await import('../imageProcessingService.js');
14
+
15
+ const tmpRoot = path.join(os.tmpdir(), `ghost-mcp-img-test-${Date.now()}`);
16
+ const fixtures = {};
17
+
18
+ async function makeRasterFixture(format, width, height = width) {
19
+ const file = path.join(tmpRoot, `in-${format}-${width}.${format === 'jpeg' ? 'jpg' : format}`);
20
+ await sharp({
21
+ create: {
22
+ width,
23
+ height,
24
+ channels: format === 'png' ? 4 : 3,
25
+ background: { r: 10, g: 50, b: 150, alpha: 1 },
26
+ },
27
+ })
28
+ [format]()
29
+ .toFile(file);
30
+ return file;
31
+ }
32
+
33
+ beforeAll(async () => {
34
+ await fs.mkdir(tmpRoot, { recursive: true });
35
+
36
+ fixtures.pngSmall = await makeRasterFixture('png', 800);
37
+ fixtures.pngLarge = await makeRasterFixture('png', 2000);
38
+ fixtures.jpegSmall = await makeRasterFixture('jpeg', 800);
39
+ fixtures.jpegLarge = await makeRasterFixture('jpeg', 2000);
40
+ fixtures.webpSmall = await makeRasterFixture('webp', 800);
41
+ fixtures.webpLarge = await makeRasterFixture('webp', 2000);
42
+
43
+ // SVG: write minimal vector source
44
+ fixtures.svg = path.join(tmpRoot, 'in.svg');
45
+ await fs.writeFile(
46
+ fixtures.svg,
47
+ '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#39f"/></svg>'
48
+ );
49
+
50
+ // GIF: sharp cannot reliably create animated GIFs across versions; use a
51
+ // known-valid 1x1 transparent GIF89a byte sequence for a deterministic fixture.
52
+ fixtures.gif = path.join(tmpRoot, 'in.gif');
53
+ await fs.writeFile(
54
+ fixtures.gif,
55
+ Buffer.from(
56
+ '47494638396101000100800000ffffff00000021f90401000001002c00000000010001000002024401003b',
57
+ 'hex'
58
+ )
59
+ );
60
+
61
+ // ICO: minimal 1x1 ICO header (used only for passthrough byte comparison).
62
+ fixtures.ico = path.join(tmpRoot, 'in.ico');
63
+ await fs.writeFile(
64
+ fixtures.ico,
65
+ Buffer.from(
66
+ '00000100010001010000010020003000000016000000280000000100000002000000010020000000000004000000000000000000000000000000000000000000000000000000000000000000000000',
67
+ 'hex'
68
+ )
69
+ );
70
+ });
44
71
 
45
- // Mock path - use actual implementation but allow spying
46
- vi.mock('path', async () => {
47
- const actual = await vi.importActual('path');
48
- return {
49
- default: actual.default,
50
- ...actual,
51
- };
72
+ afterAll(async () => {
73
+ await fs.rm(tmpRoot, { recursive: true, force: true });
52
74
  });
53
75
 
54
- // Import after mocks are set up
55
- import { processImage } from '../imageProcessingService.js';
56
- import sharp from 'sharp';
57
- import fs from 'fs';
58
-
59
- describe('imageProcessingService', () => {
60
- beforeEach(() => {
61
- vi.clearAllMocks();
62
- // Reset mock implementations
63
- mockMetadata.mockResolvedValue({ width: 800, size: 100000 });
64
- mockToFile.mockResolvedValue();
65
- fs.existsSync.mockReturnValue(true);
76
+ async function metaOf(file) {
77
+ return sharp(file).metadata();
78
+ }
79
+
80
+ describe('processImage — format passthrough', () => {
81
+ it('PNG <= 1200: preserves format and passes through unmodified', async () => {
82
+ const out = await processImage(fixtures.pngSmall, tmpRoot);
83
+ expect(path.extname(out).toLowerCase()).toBe('.png');
84
+ const m = await metaOf(out);
85
+ expect(m.format).toBe('png');
86
+ expect(m.width).toBe(800);
66
87
  });
67
88
 
68
- describe('Input Validation', () => {
69
- it('should accept valid inputPath and outputDir', async () => {
70
- const inputPath = '/tmp/test-image.jpg';
71
- const outputDir = '/tmp/output';
72
-
73
- await processImage(inputPath, outputDir);
74
-
75
- expect(sharp).toHaveBeenCalledWith(inputPath);
76
- expect(mockToFile).toHaveBeenCalled();
77
- });
78
-
79
- it('should reject missing inputPath', async () => {
80
- await expect(processImage(undefined, '/tmp/output')).rejects.toThrow(
81
- 'Invalid processing parameters'
82
- );
83
- expect(sharp).not.toHaveBeenCalled();
84
- });
85
-
86
- it('should reject missing outputDir', async () => {
87
- await expect(processImage('/tmp/test.jpg', undefined)).rejects.toThrow(
88
- 'Invalid processing parameters'
89
- );
90
- expect(sharp).not.toHaveBeenCalled();
91
- });
89
+ it('PNG > 1200: resized but still PNG (no JPEG conversion)', async () => {
90
+ const out = await processImage(fixtures.pngLarge, tmpRoot);
91
+ expect(path.extname(out).toLowerCase()).toBe('.png');
92
+ const m = await metaOf(out);
93
+ expect(m.format).toBe('png');
94
+ expect(m.width).toBe(1200);
92
95
  });
93
96
 
94
- describe('Path Security', () => {
95
- it('should resolve paths correctly', async () => {
96
- const inputPath = './relative/path/image.jpg';
97
- const outputDir = './output';
98
-
99
- await processImage(inputPath, outputDir);
100
-
101
- expect(fs.existsSync).toHaveBeenCalledWith(expect.stringContaining('image.jpg'));
102
- expect(mockToFile).toHaveBeenCalledWith(expect.stringContaining('output'));
103
- });
104
-
105
- it('should throw error when input file does not exist', async () => {
106
- fs.existsSync.mockReturnValue(false);
107
-
108
- await expect(processImage('/tmp/nonexistent.jpg', '/tmp/output')).rejects.toThrow(
109
- 'Input file does not exist'
110
- );
111
- expect(sharp).not.toHaveBeenCalled();
112
- });
97
+ it('JPEG <= 1200: passes through as JPEG', async () => {
98
+ const out = await processImage(fixtures.jpegSmall, tmpRoot);
99
+ expect(path.extname(out).toLowerCase()).toMatch(/\.jpe?g$/);
100
+ const m = await metaOf(out);
101
+ expect(m.format).toBe('jpeg');
102
+ expect(m.width).toBe(800);
113
103
  });
114
104
 
115
- describe('Image Processing', () => {
116
- it('should not resize image when width <= MAX_WIDTH (1200)', async () => {
117
- mockMetadata.mockResolvedValue({ width: 1000, size: 100000 });
118
-
119
- await processImage('/tmp/small-image.jpg', '/tmp/output');
120
-
121
- expect(mockResize).not.toHaveBeenCalled();
122
- expect(mockJpeg).toHaveBeenCalledWith({ quality: 80 });
123
- expect(mockToFile).toHaveBeenCalled();
124
- });
125
-
126
- it('should resize image when width > MAX_WIDTH (1200)', async () => {
127
- mockMetadata.mockResolvedValue({ width: 2000, size: 200000 });
128
-
129
- await processImage('/tmp/large-image.jpg', '/tmp/output');
130
-
131
- expect(mockResize).toHaveBeenCalledWith({ width: 1200 });
132
- expect(mockJpeg).toHaveBeenCalledWith({ quality: 80 });
133
- expect(mockToFile).toHaveBeenCalled();
134
- });
135
-
136
- it('should generate output filename with timestamp and processed prefix', async () => {
137
- const inputPath = '/tmp/test-photo.jpg';
138
- const outputDir = '/tmp/output';
139
-
140
- // Mock Date.now to get predictable filename
141
- const mockTimestamp = 1234567890;
142
- vi.spyOn(Date, 'now').mockReturnValue(mockTimestamp);
105
+ it('JPEG > 1200: resized as JPEG', async () => {
106
+ const out = await processImage(fixtures.jpegLarge, tmpRoot);
107
+ const m = await metaOf(out);
108
+ expect(m.format).toBe('jpeg');
109
+ expect(m.width).toBe(1200);
110
+ });
143
111
 
144
- await processImage(inputPath, outputDir);
112
+ it('WEBP <= 1200: passes through as WEBP', async () => {
113
+ const out = await processImage(fixtures.webpSmall, tmpRoot);
114
+ const m = await metaOf(out);
115
+ expect(m.format).toBe('webp');
116
+ expect(m.width).toBe(800);
117
+ });
145
118
 
146
- expect(mockToFile).toHaveBeenCalledWith(
147
- expect.stringMatching(/processed-1234567890-test-photo\.jpg$/)
148
- );
119
+ it('WEBP > 1200: resized as WEBP', async () => {
120
+ const out = await processImage(fixtures.webpLarge, tmpRoot);
121
+ const m = await metaOf(out);
122
+ expect(m.format).toBe('webp');
123
+ expect(m.width).toBe(1200);
124
+ });
149
125
 
150
- vi.restoreAllMocks();
151
- });
126
+ it('SVG: passthrough — output bytes identical to input (never touched by sharp)', async () => {
127
+ const out = await processImage(fixtures.svg, tmpRoot);
128
+ expect(path.extname(out).toLowerCase()).toBe('.svg');
129
+ const [a, b] = await Promise.all([fs.readFile(fixtures.svg), fs.readFile(out)]);
130
+ expect(a.equals(b)).toBe(true);
131
+ });
152
132
 
153
- it('should convert image to JPEG with quality setting of 80', async () => {
154
- await processImage('/tmp/image.png', '/tmp/output');
133
+ it('GIF: passthrough output bytes identical to input (animation-safe)', async () => {
134
+ const out = await processImage(fixtures.gif, tmpRoot);
135
+ expect(path.extname(out).toLowerCase()).toBe('.gif');
136
+ const [a, b] = await Promise.all([fs.readFile(fixtures.gif), fs.readFile(out)]);
137
+ expect(a.equals(b)).toBe(true);
138
+ });
155
139
 
156
- expect(mockJpeg).toHaveBeenCalledWith({ quality: 80 });
157
- expect(mockToFile).toHaveBeenCalled();
158
- });
140
+ it('ICO: passthrough — output bytes identical to input', async () => {
141
+ const out = await processImage(fixtures.ico, tmpRoot, { purpose: 'icon' });
142
+ expect(path.extname(out).toLowerCase()).toBe('.ico');
143
+ const [a, b] = await Promise.all([fs.readFile(fixtures.ico), fs.readFile(out)]);
144
+ expect(a.equals(b)).toBe(true);
145
+ });
159
146
 
160
- it('should handle images with multiple dots in filename', async () => {
161
- const inputPath = '/tmp/my.test.image.png';
162
- const outputDir = '/tmp/output';
163
- const mockTimestamp = 9999999999;
164
- vi.spyOn(Date, 'now').mockReturnValue(mockTimestamp);
147
+ it('output path is distinct from input path', async () => {
148
+ const out = await processImage(fixtures.pngSmall, tmpRoot);
149
+ expect(out).not.toBe(fixtures.pngSmall);
150
+ });
151
+ });
165
152
 
166
- await processImage(inputPath, outputDir);
153
+ describe('processImage — validation & errors', () => {
154
+ it('rejects missing inputPath', async () => {
155
+ await expect(processImage(undefined, tmpRoot)).rejects.toThrow(/Invalid processing parameters/);
156
+ });
167
157
 
168
- expect(mockToFile).toHaveBeenCalledWith(
169
- expect.stringMatching(/processed-9999999999-my\.test\.image\.jpg$/)
170
- );
158
+ it('rejects missing outputDir', async () => {
159
+ await expect(processImage(fixtures.pngSmall, undefined)).rejects.toThrow(
160
+ /Invalid processing parameters/
161
+ );
162
+ });
171
163
 
172
- vi.restoreAllMocks();
173
- });
164
+ it('throws when input file does not exist', async () => {
165
+ await expect(processImage('/nonexistent/xyz.png', tmpRoot)).rejects.toThrow(
166
+ /Input file does not exist/
167
+ );
174
168
  });
175
169
 
176
- describe('Error Handling', () => {
177
- it('should catch and re-throw sharp processing failures', async () => {
178
- const processingError = new Error('Sharp processing failed');
179
- mockMetadata.mockRejectedValue(processingError);
180
-
181
- await expect(processImage('/tmp/corrupt.jpg', '/tmp/output')).rejects.toThrow(
182
- 'Image processing failed: Sharp processing failed'
183
- );
184
- });
185
-
186
- it('should include original error message in re-thrown error', async () => {
187
- const originalMessage = 'Input buffer contains unsupported image format';
188
- mockToFile.mockRejectedValue(new Error(originalMessage));
189
-
190
- await expect(processImage('/tmp/bad-format.dat', '/tmp/output')).rejects.toThrow(
191
- `Image processing failed: ${originalMessage}`
192
- );
193
- });
194
-
195
- it('should handle errors during JPEG conversion', async () => {
196
- const conversionError = new Error('JPEG conversion failed');
197
- mockToFile.mockRejectedValue(conversionError);
198
-
199
- await expect(processImage('/tmp/image.jpg', '/tmp/output')).rejects.toThrow(
200
- 'Image processing failed: JPEG conversion failed'
201
- );
202
- });
170
+ it('wraps sharp failures with context', async () => {
171
+ const bogus = path.join(tmpRoot, 'bogus.png');
172
+ await fs.writeFile(bogus, 'not an image');
173
+ await expect(processImage(bogus, tmpRoot)).rejects.toThrow(/Image processing failed/);
203
174
  });
204
175
  });
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
3
+ import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
4
+ import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
5
+
6
+ vi.mock('@tryghost/admin-api', () => mockGhostApiModule());
7
+ vi.mock('dotenv', () => mockDotenv());
8
+ vi.mock('../../utils/logger.js', () => ({
9
+ createContextLogger: createMockContextLogger(),
10
+ }));
11
+
12
+ // Stub path validation so these tests focus on argument shaping, not FS.
13
+ vi.mock('../validators.js', () => ({
14
+ validators: { validateImagePath: vi.fn().mockResolvedValue(undefined) },
15
+ }));
16
+
17
+ import { uploadImage } from '../images.js';
18
+ import { api } from '../ghostApiClient.js';
19
+ import { ValidationError } from '../../errors/index.js';
20
+
21
+ describe('images.uploadImage (domain module used by MCP server)', () => {
22
+ beforeEach(() => {
23
+ vi.clearAllMocks();
24
+ });
25
+
26
+ it('forwards purpose and ref to the SDK', async () => {
27
+ const expected = { url: 'https://cdn/x.png', ref: 'original.png' };
28
+ api.images.upload.mockResolvedValue(expected);
29
+
30
+ const result = await uploadImage('/tmp/x.png', {
31
+ purpose: 'icon',
32
+ ref: 'original.png',
33
+ });
34
+
35
+ expect(result).toEqual(expected);
36
+ expect(api.images.upload).toHaveBeenCalledWith({
37
+ file: '/tmp/x.png',
38
+ purpose: 'icon',
39
+ ref: 'original.png',
40
+ });
41
+ });
42
+
43
+ it('omits purpose/ref when not provided', async () => {
44
+ api.images.upload.mockResolvedValue({ url: 'https://cdn/x.png' });
45
+ await uploadImage('/tmp/x.png');
46
+ expect(api.images.upload).toHaveBeenCalledWith({ file: '/tmp/x.png' });
47
+ });
48
+
49
+ it('accepts each allowed purpose value', async () => {
50
+ api.images.upload.mockResolvedValue({ url: 'https://cdn/x' });
51
+ for (const purpose of ['image', 'profile_image', 'icon']) {
52
+ await uploadImage('/tmp/x.png', { purpose });
53
+ }
54
+ expect(api.images.upload).toHaveBeenCalledTimes(3);
55
+ });
56
+
57
+ it('rejects unknown purpose', async () => {
58
+ await expect(uploadImage('/tmp/x.png', { purpose: 'banner' })).rejects.toBeInstanceOf(
59
+ ValidationError
60
+ );
61
+ expect(api.images.upload).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it('rejects non-string ref', async () => {
65
+ await expect(uploadImage('/tmp/x.png', { ref: 42 })).rejects.toThrow(/must be a string/);
66
+ });
67
+
68
+ it('rejects ref longer than 200 chars', async () => {
69
+ await expect(uploadImage('/tmp/x.png', { ref: 'x'.repeat(201) })).rejects.toThrow(
70
+ /cannot exceed 200/
71
+ );
72
+ });
73
+
74
+ it('accepts ref of exactly 200 chars', async () => {
75
+ api.images.upload.mockResolvedValue({ url: 'https://cdn/x' });
76
+ await expect(uploadImage('/tmp/x.png', { ref: 'x'.repeat(200) })).resolves.toBeDefined();
77
+ });
78
+ });
@@ -0,0 +1,138 @@
1
+ import { GhostAPIError, ValidationError } from '../errors/index.js';
2
+ import { handleApiRequest, readResource, updateWithOCC, deleteResource } from './ghostApiClient.js';
3
+ import { validators } from './validators.js';
4
+
5
+ /**
6
+ * Factory that generates standard CRUD service methods for a Ghost CMS resource.
7
+ *
8
+ * Each domain (posts, pages, tags, etc.) shares the same structural patterns:
9
+ * create → validate → API add → 422 mapping
10
+ * update → requireId → optional validation → OCC edit → optional 422 mapping
11
+ * remove → deleteResource
12
+ * getOne → readResource
13
+ * getList → browse with defaults → empty-array fallback
14
+ *
15
+ * Domain-specific behavior is injected via config hooks.
16
+ *
17
+ * @param {Object} config - Resource configuration
18
+ * @param {string} config.resource - Ghost API resource name (e.g., 'posts')
19
+ * @param {string} config.label - Human-readable label (e.g., 'Post')
20
+ * @param {Object} [config.listDefaults] - Default options for getList (e.g., { limit: 15, include: 'tags,authors' })
21
+ * @param {Object} [config.createDefaults] - Default data merged into create payload (e.g., { status: 'draft' })
22
+ * @param {Object} [config.createOptions] - Default options for create API call (e.g., { source: 'html' })
23
+ * @param {Function} [config.validateCreate] - Validation function called before create: (data) => void | Promise<void>
24
+ * @param {Function} [config.validateUpdate] - Validation function called before update: (id, data) => void | Promise<void>
25
+ * @param {boolean} [config.catch422OnUpdate=false] - Whether to catch 422 errors on update and wrap as ValidationError
26
+ * @returns {Object} Object with { create, update, remove, getOne, getList } methods
27
+ *
28
+ * SECURITY: HTML content must be sanitized before reaching this function.
29
+ * See htmlContentSchema in schemas/common.js for the validation gate.
30
+ */
31
+ export function createResourceService(config) {
32
+ const {
33
+ resource,
34
+ label,
35
+ listDefaults = { limit: 15 },
36
+ createDefaults = {},
37
+ createOptions = {},
38
+ validateCreate,
39
+ validateUpdate,
40
+ catch422OnUpdate = false,
41
+ } = config;
42
+
43
+ /**
44
+ * Creates a new resource.
45
+ * @param {Object} data - Resource data
46
+ * @param {Object} [options] - API request options (merged with createOptions)
47
+ * @returns {Promise<Object>} Created resource
48
+ */
49
+ async function create(data, options = {}) {
50
+ if (validateCreate) {
51
+ await validateCreate(data);
52
+ }
53
+
54
+ const dataWithDefaults = {
55
+ ...createDefaults,
56
+ ...data,
57
+ };
58
+
59
+ const mergedOptions = { ...createOptions, ...options };
60
+
61
+ try {
62
+ return await handleApiRequest(resource, 'add', dataWithDefaults, mergedOptions);
63
+ } catch (error) {
64
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
65
+ throw new ValidationError(`${label} creation failed due to validation errors`, [
66
+ { field: label.toLowerCase(), message: error.originalError },
67
+ ]);
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Updates an existing resource with optimistic concurrency control.
75
+ * @param {string} id - Resource ID
76
+ * @param {Object} updateData - Fields to update
77
+ * @param {Object} [options={}] - API request options
78
+ * @returns {Promise<Object>} Updated resource
79
+ */
80
+ async function update(id, updateData, options = {}) {
81
+ validators.requireId(id, label);
82
+
83
+ if (validateUpdate) {
84
+ await validateUpdate(id, updateData);
85
+ }
86
+
87
+ if (catch422OnUpdate) {
88
+ try {
89
+ return await updateWithOCC(resource, id, updateData, options, label);
90
+ } catch (error) {
91
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
92
+ throw new ValidationError(`${label} update failed`, [
93
+ { field: label.toLowerCase(), message: error.originalError },
94
+ ]);
95
+ }
96
+ throw error;
97
+ }
98
+ }
99
+
100
+ return updateWithOCC(resource, id, updateData, options, label);
101
+ }
102
+
103
+ /**
104
+ * Deletes a resource by ID.
105
+ * @param {string} id - Resource ID
106
+ * @returns {Promise<Object>} Deletion confirmation
107
+ */
108
+ async function remove(id) {
109
+ return deleteResource(resource, id, label);
110
+ }
111
+
112
+ /**
113
+ * Retrieves a single resource by ID.
114
+ * @param {string} id - Resource ID
115
+ * @param {Object} [options={}] - API request options
116
+ * @returns {Promise<Object>} Resource object
117
+ */
118
+ async function getOne(id, options = {}) {
119
+ return readResource(resource, id, label, options);
120
+ }
121
+
122
+ /**
123
+ * Lists resources with optional filtering and pagination.
124
+ * @param {Object} [options={}] - Query options
125
+ * @returns {Promise<Array>} Array of resources (empty array if none found)
126
+ */
127
+ async function getList(options = {}) {
128
+ const mergedOptions = {
129
+ ...listDefaults,
130
+ ...options,
131
+ };
132
+
133
+ const result = await handleApiRequest(resource, 'browse', {}, mergedOptions);
134
+ return result || [];
135
+ }
136
+
137
+ return { create, update, remove, getOne, getList };
138
+ }