@lexho111/plainblog 0.0.9 → 0.0.11
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/Blog.js +136 -187
- package/Formatter.js +80 -0
- package/README.md +8 -1
- package/blog.db +0 -0
- package/blog.json +9 -0
- package/model/APIModel.js +41 -0
- package/{model.js → model/DatabaseModel.js} +5 -4
- package/model/fileModel.js +25 -0
- package/package.json +2 -1
- package/test/blog.test.js +7 -5
- package/test/model.test.js +151 -0
- package/test/server.test.js +28 -1
- package/test/simpleServer.js +43 -0
package/Blog.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import http from "http";
|
|
2
2
|
import fetch from "node-fetch";
|
|
3
|
-
import { promises as fs } from "fs";
|
|
4
3
|
import { URLSearchParams } from "url";
|
|
4
|
+
import { fromEvent, firstValueFrom } from "rxjs";
|
|
5
|
+
import { map, scan, takeUntil, last, defaultIfEmpty } from "rxjs/operators";
|
|
5
6
|
import Article from "./Article.js";
|
|
6
|
-
import
|
|
7
|
+
import DatabaseModel from "./model/DatabaseModel.js";
|
|
8
|
+
import { fetchData, postData } from "./model/APIModel.js";
|
|
9
|
+
import { save as saveToFile, load as loadFromFile } from "./model/fileModel.js";
|
|
10
|
+
import { formatHTML, formatMarkdown, validate } from "./Formatter.js";
|
|
7
11
|
|
|
8
12
|
export default class Blog {
|
|
9
13
|
constructor() {
|
|
@@ -15,8 +19,8 @@ export default class Blog {
|
|
|
15
19
|
|
|
16
20
|
this.database = {
|
|
17
21
|
type: "sqlite",
|
|
18
|
-
username: "
|
|
19
|
-
password: "
|
|
22
|
+
username: "user1",
|
|
23
|
+
password: "password1",
|
|
20
24
|
host: "localhost",
|
|
21
25
|
dbname: "blog",
|
|
22
26
|
};
|
|
@@ -24,9 +28,9 @@ export default class Blog {
|
|
|
24
28
|
|
|
25
29
|
// Private fields
|
|
26
30
|
#server = null;
|
|
27
|
-
#
|
|
31
|
+
#databaseModel;
|
|
28
32
|
#isExternalAPI = false;
|
|
29
|
-
#
|
|
33
|
+
#apiUrl = "";
|
|
30
34
|
|
|
31
35
|
setTitle(title) {
|
|
32
36
|
this.title = title;
|
|
@@ -43,119 +47,68 @@ export default class Blog {
|
|
|
43
47
|
/** initializes database */
|
|
44
48
|
async init() {
|
|
45
49
|
if (this.#isExternalAPI) {
|
|
46
|
-
|
|
50
|
+
console.log("external API");
|
|
51
|
+
await this.loadFromAPI();
|
|
47
52
|
} else {
|
|
48
|
-
console.log(`
|
|
49
|
-
this.#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
this.#
|
|
53
|
+
console.log(`database: ${this.database.type}`);
|
|
54
|
+
this.#databaseModel = new DatabaseModel(this.database);
|
|
55
|
+
console.log(`connected to database`);
|
|
56
|
+
await this.#databaseModel.initialize();
|
|
57
|
+
const dbTitle = await this.#databaseModel.getBlogTitle();
|
|
58
|
+
const dbArticles = await this.#databaseModel.findAll();
|
|
59
|
+
let title = "";
|
|
60
|
+
if (this.title.length > 0) title = this.title; // use blog title if set
|
|
61
|
+
else title = dbTitle;
|
|
62
|
+
const responseData = { title: title, articles: dbArticles };
|
|
63
|
+
this.#applyBlogData(responseData);
|
|
54
64
|
}
|
|
55
65
|
}
|
|
56
66
|
|
|
57
|
-
// EXTERNAL DATA API
|
|
58
|
-
#APIUrl = "";
|
|
59
|
-
|
|
60
|
-
setAPI(APIUrl) {
|
|
61
|
-
this.#APIUrl = APIUrl;
|
|
62
|
-
this.#isExternalAPI = true;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** fetch data from an URL */
|
|
66
|
-
async #fetchDataFromApi(apiUrl) {
|
|
67
|
-
try {
|
|
68
|
-
const response = await fetch(apiUrl);
|
|
69
|
-
|
|
70
|
-
if (!response.ok) {
|
|
71
|
-
throw new Error(`HTTP error! status: ${response.status}`);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const data = await response.json();
|
|
75
|
-
return data;
|
|
76
|
-
} catch (error) {
|
|
77
|
-
console.error("Failed to fetch data:", error);
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** post data to an URL */
|
|
83
|
-
async #postDataToApi(apiUrl, postData) {
|
|
84
|
-
const response = await fetch(apiUrl, {
|
|
85
|
-
method: "POST",
|
|
86
|
-
body: JSON.stringify(postData),
|
|
87
|
-
headers: { "Content-Type": "application/json" },
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
const data = await response.json();
|
|
91
|
-
return data;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
67
|
/** post a blog article */
|
|
95
|
-
async
|
|
96
|
-
|
|
68
|
+
async postArticle(req, res) {
|
|
69
|
+
// OLD CODE
|
|
70
|
+
/*let body = "";
|
|
97
71
|
req.on("data", (chunk) => {
|
|
98
72
|
body += chunk.toString();
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
73
|
+
});*/
|
|
74
|
+
// NEW CODE
|
|
75
|
+
// Create a stream of the body string
|
|
76
|
+
const body$ = fromEvent(req, "data").pipe(
|
|
77
|
+
map((chunk) => chunk.toString()),
|
|
78
|
+
scan((acc, chunk) => acc + chunk, ""), // Accumulate chunks
|
|
79
|
+
takeUntil(fromEvent(req, "end")), // Stop when 'end' emits
|
|
80
|
+
last(), // Emit only the final full string
|
|
81
|
+
defaultIfEmpty("") // Handle empty bodies
|
|
82
|
+
);
|
|
105
83
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
84
|
+
const body = await firstValueFrom(body$);
|
|
85
|
+
|
|
86
|
+
//req.on("end", async () => {
|
|
87
|
+
const params = new URLSearchParams(body);
|
|
88
|
+
const title = params.get("title");
|
|
89
|
+
const content = params.get("content");
|
|
90
|
+
|
|
91
|
+
if (title && content) {
|
|
92
|
+
const newArticleData = { title, content };
|
|
93
|
+
try {
|
|
94
|
+
// Save the new article to the database via the ApiServer
|
|
95
|
+
if (this.#databaseModel) await this.#databaseModel.save(newArticleData);
|
|
96
|
+
if (this.#isExternalAPI) await postData(this.#apiUrl, newArticleData);
|
|
97
|
+
// Add the article to the local list for immediate display
|
|
98
|
+
this.addArticle(new Article(title, content));
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error("Failed to post new article to API:", error);
|
|
116
101
|
}
|
|
117
|
-
|
|
118
|
-
res.writeHead(303, { Location: "/" });
|
|
119
|
-
res.end();
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** everything that happens in /api */
|
|
124
|
-
async #jsonAPI(req, res) {
|
|
125
|
-
// GET all blog data
|
|
126
|
-
if (req.method === "GET" && req.url === "/api") {
|
|
127
|
-
// controller
|
|
128
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
129
|
-
const dbArticles = await this.#apiServer.findAll();
|
|
130
|
-
const responseData = {
|
|
131
|
-
title: this.title, // Keep the title from the original constant
|
|
132
|
-
articles: dbArticles,
|
|
133
|
-
};
|
|
134
|
-
console.log(`responseData: ${responseData}`);
|
|
135
|
-
res.end(JSON.stringify(responseData));
|
|
136
102
|
}
|
|
137
103
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
req.on("data", (chunk) => (body += chunk.toString()));
|
|
142
|
-
req.on("end", async () => {
|
|
143
|
-
const newArticle = JSON.parse(body);
|
|
144
|
-
|
|
145
|
-
// local
|
|
146
|
-
await this.#apiServer.save(newArticle); // --> to api server
|
|
147
|
-
// extern
|
|
148
|
-
|
|
149
|
-
res.writeHead(201, { "Content-Type": "application/json" });
|
|
150
|
-
res.end(JSON.stringify(newArticle));
|
|
151
|
-
});
|
|
152
|
-
}
|
|
104
|
+
res.writeHead(303, { Location: "/" });
|
|
105
|
+
res.end();
|
|
106
|
+
//});
|
|
153
107
|
}
|
|
154
108
|
|
|
155
109
|
/** start a http server with default port 8080 */
|
|
156
110
|
async startServer(port = 8080) {
|
|
157
|
-
if (this.#
|
|
158
|
-
await this.#apiServer.initialize();
|
|
111
|
+
if (this.#databaseModel === undefined) await this.init(); // init blog if it didn't already happen
|
|
159
112
|
|
|
160
113
|
const server = http.createServer(async (req, res) => {
|
|
161
114
|
// API routes
|
|
@@ -167,8 +120,8 @@ export default class Blog {
|
|
|
167
120
|
// Web Page Routes
|
|
168
121
|
// POST new article
|
|
169
122
|
if (req.method === "POST" && req.url === "/") {
|
|
170
|
-
await this.#
|
|
171
|
-
await this
|
|
123
|
+
await this.#databaseModel.updateBlogTitle(this.title);
|
|
124
|
+
await this.postArticle(req, res);
|
|
172
125
|
// GET artciles
|
|
173
126
|
} else if (req.method === "GET" && req.url === "/") {
|
|
174
127
|
// load articles
|
|
@@ -187,7 +140,7 @@ export default class Blog {
|
|
|
187
140
|
|
|
188
141
|
return new Promise((resolve) => {
|
|
189
142
|
this.#server.listen(port, () => {
|
|
190
|
-
console.log(`
|
|
143
|
+
console.log(`server running at http://localhost:${port}/`);
|
|
191
144
|
resolve(); // Resolve the promise when the server is listening
|
|
192
145
|
});
|
|
193
146
|
});
|
|
@@ -207,42 +160,8 @@ export default class Blog {
|
|
|
207
160
|
});
|
|
208
161
|
}
|
|
209
162
|
|
|
210
|
-
/** save blog content to file */
|
|
211
|
-
async save(filename = this.filename) {
|
|
212
|
-
if (this.#apiServer === undefined) this.init(); // init blog if it didn't already happen
|
|
213
|
-
//await this.#apiServer.initialize();
|
|
214
|
-
if (this.#isExternalAPI) await this.loadFromAPI(this.#APIUrl);
|
|
215
|
-
if (!filename) {
|
|
216
|
-
console.error("Error: Filename not provided and not set previously.");
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
this.filename = filename;
|
|
220
|
-
const data = {
|
|
221
|
-
title: this.title,
|
|
222
|
-
articles: this.articles,
|
|
223
|
-
};
|
|
224
|
-
|
|
225
|
-
try {
|
|
226
|
-
await fs.writeFile(filename, JSON.stringify(data, null, 2));
|
|
227
|
-
console.log(`Blog data saved to ${filename}`);
|
|
228
|
-
} catch (err) {
|
|
229
|
-
console.error("Error saving blog data:", err);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/** load blog content from file */
|
|
234
|
-
async load(filename) {
|
|
235
|
-
this.filename = filename;
|
|
236
|
-
const data = await fs.readFile(filename, "utf8");
|
|
237
|
-
const jsonData = JSON.parse(data);
|
|
238
|
-
this.title = jsonData.title;
|
|
239
|
-
this.articles = jsonData.articles.map(
|
|
240
|
-
(article) => new Article(article.title, article.content)
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
163
|
/** Populates the blog's title and articles from a data object. */
|
|
245
|
-
#
|
|
164
|
+
#applyBlogData(data) {
|
|
246
165
|
this.articles = []; // Clear existing articles before loading new ones
|
|
247
166
|
this.setTitle(data.title);
|
|
248
167
|
// Assuming the API returns an array of objects with title and content
|
|
@@ -250,61 +169,91 @@ export default class Blog {
|
|
|
250
169
|
for (const articleData of data.articles) {
|
|
251
170
|
this.addArticle(new Article(articleData.title, articleData.content));
|
|
252
171
|
}
|
|
253
|
-
this.#apiDataLoaded = true; // Mark that we have successfully loaded data
|
|
254
172
|
}
|
|
255
173
|
}
|
|
256
174
|
|
|
257
|
-
async
|
|
258
|
-
|
|
175
|
+
async save(filename = this.filename) {
|
|
176
|
+
if (this.#databaseModel === undefined) this.init(); // init blog if it didn't already happen
|
|
177
|
+
//await this.#apiServer.initialize();
|
|
178
|
+
if (this.#isExternalAPI) await this.loadFromAPI();
|
|
179
|
+
const blogData = { title: this.title, articles: this.articles };
|
|
180
|
+
saveToFile(filename, blogData);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async load(filename) {
|
|
184
|
+
loadFromFile(filename, (title, articles) => {
|
|
185
|
+
this.title = title;
|
|
186
|
+
this.articles = articles.map(
|
|
187
|
+
(article) => new Article(article.title, article.content)
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async loadFromAPI() {
|
|
193
|
+
const data = await fetchData(this.#apiUrl);
|
|
259
194
|
if (data) {
|
|
260
|
-
this.#
|
|
195
|
+
this.#applyBlogData(data);
|
|
261
196
|
}
|
|
262
197
|
}
|
|
263
198
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
199
|
+
// controller
|
|
200
|
+
/** everything that happens in /api */
|
|
201
|
+
async #jsonAPI(req, res) {
|
|
202
|
+
// GET all blog data
|
|
203
|
+
if (req.method === "GET" && req.url === "/api") {
|
|
204
|
+
// controller
|
|
205
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
206
|
+
const dbArticles = await this.#databaseModel.findAll();
|
|
207
|
+
const responseData = {
|
|
208
|
+
title: this.title, // Keep the title from the original constant
|
|
209
|
+
articles: dbArticles,
|
|
210
|
+
};
|
|
211
|
+
console.log(`responseData: ${responseData}`);
|
|
212
|
+
res.end(JSON.stringify(responseData));
|
|
270
213
|
}
|
|
271
|
-
}
|
|
272
214
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
215
|
+
// POST a new article
|
|
216
|
+
else if (req.method === "POST" && req.url === "/api/articles") {
|
|
217
|
+
let body = "";
|
|
218
|
+
req.on("data", (chunk) => (body += chunk.toString()));
|
|
219
|
+
req.on("end", async () => {
|
|
220
|
+
const newArticle = JSON.parse(body);
|
|
221
|
+
|
|
222
|
+
// local
|
|
223
|
+
await this.#databaseModel.save(newArticle); // --> to api server
|
|
224
|
+
// extern
|
|
225
|
+
|
|
226
|
+
res.writeHead(201, { "Content-Type": "application/json" });
|
|
227
|
+
res.end(JSON.stringify(newArticle));
|
|
228
|
+
});
|
|
278
229
|
}
|
|
230
|
+
}
|
|
279
231
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
</div>
|
|
307
|
-
</body>
|
|
308
|
-
</html>`;
|
|
232
|
+
/** set external API */
|
|
233
|
+
setAPI(APIUrl) {
|
|
234
|
+
this.#apiUrl = APIUrl;
|
|
235
|
+
this.#isExternalAPI = true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** print markdown to the console */
|
|
239
|
+
async print() {
|
|
240
|
+
const data = {
|
|
241
|
+
title: this.title,
|
|
242
|
+
articles: this.articles,
|
|
243
|
+
};
|
|
244
|
+
const markdown = formatMarkdown(data);
|
|
245
|
+
console.log(markdown);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** render this blog content to valid html */
|
|
249
|
+
toHTML() {
|
|
250
|
+
const data = {
|
|
251
|
+
title: this.title,
|
|
252
|
+
style: this.style,
|
|
253
|
+
articles: this.articles,
|
|
254
|
+
};
|
|
255
|
+
const html = formatHTML(data);
|
|
256
|
+
if (validate(html)) return html;
|
|
257
|
+
else throw new Error("Error. Invalid HTML!");
|
|
309
258
|
}
|
|
310
259
|
}
|
package/Formatter.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** format content to html */
|
|
2
|
+
export function formatHTML(data) {
|
|
3
|
+
const script = `function generateRandomContent(length) {
|
|
4
|
+
let str = "";
|
|
5
|
+
const char = "A";
|
|
6
|
+
for (let i = 0; i < length; i++) {
|
|
7
|
+
const rnd = Math.random() * ("z".charCodeAt(0) - "A".charCodeAt(0)); // A z.charCodeAt(0)"
|
|
8
|
+
const char1 = String.fromCharCode(char.charCodeAt(0) + rnd);
|
|
9
|
+
str += char1;
|
|
10
|
+
}
|
|
11
|
+
return str;
|
|
12
|
+
}
|
|
13
|
+
function fillWithContent() {
|
|
14
|
+
let title = document.getElementById("title");
|
|
15
|
+
let cont = document.getElementById("content");
|
|
16
|
+
cont.value = generateRandomContent(200);
|
|
17
|
+
title.value = generateRandomContent(50);
|
|
18
|
+
}`;
|
|
19
|
+
const button = `<button type="button" onClick="fillWithContent();" style="margin: 4px;">generate random text</button>`;
|
|
20
|
+
return `<!DOCTYPE html>
|
|
21
|
+
<html lang="de">
|
|
22
|
+
<head>
|
|
23
|
+
<meta charset="UTF-8">
|
|
24
|
+
<title>${data.title}</title>
|
|
25
|
+
<style>${data.style}</style>
|
|
26
|
+
</head>
|
|
27
|
+
<body>
|
|
28
|
+
<h1>${data.title}</h1>
|
|
29
|
+
<div style="width: 500px;">
|
|
30
|
+
<form action="/" method="POST">
|
|
31
|
+
<h3>Add a New Article</h3>
|
|
32
|
+
<input type="text" id="title" name="title" placeholder="Article Title" required style="display: block; width: 300px; margin-bottom: 10px;">
|
|
33
|
+
<textarea id="content" name="content" placeholder="Article Content" required style="display: block; width: 300px; height: 100px; margin-bottom: 10px;"></textarea>
|
|
34
|
+
<button type="submit">Add Article</button>
|
|
35
|
+
<script>
|
|
36
|
+
${script}
|
|
37
|
+
</script>
|
|
38
|
+
</form>
|
|
39
|
+
<hr>
|
|
40
|
+
${data.articles
|
|
41
|
+
.map(
|
|
42
|
+
(article) => `
|
|
43
|
+
<article style="overflow-wrap: break-word;">
|
|
44
|
+
<h2>${article.title}</h2>
|
|
45
|
+
<p>${article.getContentShort()}</p>
|
|
46
|
+
</article>`
|
|
47
|
+
)
|
|
48
|
+
.join("")}
|
|
49
|
+
</div>
|
|
50
|
+
</body>
|
|
51
|
+
</html>`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function formatMarkdown(data) {
|
|
55
|
+
let markdown = "";
|
|
56
|
+
markdown += `# ${data.title}\n`;
|
|
57
|
+
for (const article of data.articles) {
|
|
58
|
+
markdown += `## ${article.title}\n`;
|
|
59
|
+
markdown += article.content;
|
|
60
|
+
markdown += "\n";
|
|
61
|
+
}
|
|
62
|
+
return markdown;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function validate(html) {
|
|
66
|
+
let test = true; // all tests passed
|
|
67
|
+
if (!(html.includes("<html") && html.includes("</html"))) {
|
|
68
|
+
console.error("html not ok");
|
|
69
|
+
test = false;
|
|
70
|
+
}
|
|
71
|
+
if (!(html.includes("<head") && html.includes("</head"))) {
|
|
72
|
+
console.error("head not ok");
|
|
73
|
+
test = false;
|
|
74
|
+
}
|
|
75
|
+
if (!(html.includes("<body") && html.includes("</body"))) {
|
|
76
|
+
console.error("body not ok");
|
|
77
|
+
test = false;
|
|
78
|
+
}
|
|
79
|
+
return test;
|
|
80
|
+
}
|
package/README.md
CHANGED
|
@@ -42,10 +42,17 @@ await blog.init(); // load data from database
|
|
|
42
42
|
blog.startServer(8080);
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
### set an
|
|
45
|
+
### set an API to fetch data from an external database
|
|
46
46
|
|
|
47
47
|
```
|
|
48
|
+
import Blog from "@lexho111/plainblog";
|
|
49
|
+
|
|
50
|
+
const blog = new Blog();
|
|
48
51
|
blog.setAPI("http://example.com:5432/blog")
|
|
52
|
+
blog.setStyle("body { font-family: Arial, sans-serif; } h1 { color: #333; }");
|
|
53
|
+
await blog.init(); // load data from database
|
|
54
|
+
|
|
55
|
+
blog.startServer(8080);
|
|
49
56
|
```
|
|
50
57
|
|
|
51
58
|
save data to file
|
package/blog.db
CHANGED
|
Binary file
|
package/blog.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fetch from "node-fetch";
|
|
2
|
+
import { defer, firstValueFrom, timer, of } from "rxjs";
|
|
3
|
+
import { retry, mergeMap, catchError } from "rxjs/operators";
|
|
4
|
+
|
|
5
|
+
// EXTERNAL DATA API
|
|
6
|
+
|
|
7
|
+
/** fetch data from an URL */
|
|
8
|
+
export async function fetchData(apiUrl) {
|
|
9
|
+
const request$ = defer(() => fetch(apiUrl)).pipe(
|
|
10
|
+
// 1. Check response status inside the stream
|
|
11
|
+
mergeMap(async (response) => {
|
|
12
|
+
if (!response.ok)
|
|
13
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
14
|
+
return response.json();
|
|
15
|
+
}),
|
|
16
|
+
// 2. Retry 3 times, waiting 1s, 2s, 3s respectively
|
|
17
|
+
retry({
|
|
18
|
+
count: 3,
|
|
19
|
+
delay: (error, retryCount) => timer(retryCount * 1000),
|
|
20
|
+
}),
|
|
21
|
+
// 3. Handle final failure
|
|
22
|
+
catchError((error) => {
|
|
23
|
+
console.error("Failed to fetch data after retries:", error);
|
|
24
|
+
return of(null);
|
|
25
|
+
})
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return firstValueFrom(request$);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** post data to an URL */
|
|
32
|
+
export async function postData(apiUrl, postData) {
|
|
33
|
+
const response = await fetch(apiUrl, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
body: JSON.stringify(postData),
|
|
36
|
+
headers: { "Content-Type": "application/json" },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const data = await response.json();
|
|
40
|
+
return data;
|
|
41
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Sequelize, DataTypes } from "sequelize";
|
|
2
2
|
|
|
3
|
-
export default class
|
|
3
|
+
export default class DatabaseModel {
|
|
4
4
|
#username;
|
|
5
5
|
#password;
|
|
6
6
|
#host;
|
|
@@ -16,7 +16,7 @@ export default class ApiServer {
|
|
|
16
16
|
if (databasetype === "sqlite") {
|
|
17
17
|
this.#sequelize = new Sequelize({
|
|
18
18
|
dialect: "sqlite",
|
|
19
|
-
storage:
|
|
19
|
+
storage: `./${this.#dbname}.db`,
|
|
20
20
|
logging: false,
|
|
21
21
|
});
|
|
22
22
|
} else if (databasetype === "postgres") {
|
|
@@ -49,7 +49,7 @@ export default class ApiServer {
|
|
|
49
49
|
"Article",
|
|
50
50
|
{
|
|
51
51
|
title: DataTypes.STRING,
|
|
52
|
-
content: DataTypes.
|
|
52
|
+
content: DataTypes.TEXT,
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
timestamps: false, // Assuming you don't need createdAt/updatedAt for this simple model
|
|
@@ -70,12 +70,13 @@ export default class ApiServer {
|
|
|
70
70
|
async initialize() {
|
|
71
71
|
// This creates the tables if they don't exist.
|
|
72
72
|
await this.#sequelize.sync({ alter: true });
|
|
73
|
+
console.log("database tables synced and ready.");
|
|
73
74
|
|
|
74
75
|
// Check for and create the initial blog title right after syncing.
|
|
75
76
|
const blogInfoCount = await this.#BlogInfo.count();
|
|
76
77
|
if (blogInfoCount === 0) {
|
|
77
78
|
await this.#BlogInfo.create({ title: "My Default Blog Title" });
|
|
78
|
-
console.log("
|
|
79
|
+
console.log("initialized blog title in database.");
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { promises as fs } from "fs";
|
|
2
|
+
|
|
3
|
+
/** save blog content to file */
|
|
4
|
+
export async function save(filename, data) {
|
|
5
|
+
if (!filename) {
|
|
6
|
+
console.error("Error: Filename not provided and not set previously.");
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
await fs.writeFile(filename, JSON.stringify(data, null, 2));
|
|
12
|
+
console.log(`Blog data saved to ${filename}`);
|
|
13
|
+
} catch (err) {
|
|
14
|
+
console.error("Error saving blog data:", err);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** load blog content from file */
|
|
19
|
+
export async function load(filename, f) {
|
|
20
|
+
const data = await fs.readFile(filename, "utf8");
|
|
21
|
+
const jsonData = JSON.parse(data);
|
|
22
|
+
const title = jsonData.title;
|
|
23
|
+
const articles = jsonData.articles;
|
|
24
|
+
f(title, articles);
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lexho111/plainblog",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
4
4
|
"description": "A tool for creating and serving a minimalist, single-page blog.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"node-fetch": "^3.3.2",
|
|
20
20
|
"pg": "^8.16.3",
|
|
21
21
|
"pg-hstore": "^2.3.4",
|
|
22
|
+
"rxjs": "^7.8.2",
|
|
22
23
|
"sequelize": "^6.37.7",
|
|
23
24
|
"sqlite3": "^5.1.7"
|
|
24
25
|
},
|
package/test/blog.test.js
CHANGED
|
@@ -61,7 +61,7 @@ describe("test blog", () => {
|
|
|
61
61
|
expect(html).toContain(style);
|
|
62
62
|
}
|
|
63
63
|
});
|
|
64
|
-
test("print method logs title and articles to console", () => {
|
|
64
|
+
test("print method logs title and articles to console", async () => {
|
|
65
65
|
// Arrange: Set up the blog with a title and articles
|
|
66
66
|
const myblog = new Blog();
|
|
67
67
|
myblog.setTitle("My Test Blog");
|
|
@@ -71,12 +71,14 @@ describe("test blog", () => {
|
|
|
71
71
|
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {});
|
|
72
72
|
|
|
73
73
|
// Act: Call the method we want to test
|
|
74
|
-
myblog.print();
|
|
74
|
+
await myblog.print();
|
|
75
75
|
|
|
76
76
|
// Assert: Check if console.log was called with the correct strings
|
|
77
|
-
expect(consoleSpy).
|
|
78
|
-
|
|
79
|
-
expect(
|
|
77
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
78
|
+
const output = consoleSpy.mock.calls[0][0];
|
|
79
|
+
expect(output).toContain("# My Test Blog");
|
|
80
|
+
expect(output).toContain("## Article 1");
|
|
81
|
+
expect(output).toContain("Content 1");
|
|
80
82
|
|
|
81
83
|
// Clean up the spy to restore the original console.log
|
|
82
84
|
consoleSpy.mockRestore();
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import Blog from "../Blog.js";
|
|
2
|
+
import Article from "../Article.js";
|
|
3
|
+
import { fetchData, postData } from "../model/APIModel.js";
|
|
4
|
+
import { server } from "./simpleServer.js";
|
|
5
|
+
|
|
6
|
+
function generateRandomContent(length) {
|
|
7
|
+
let str = "";
|
|
8
|
+
const char = "A";
|
|
9
|
+
for (let i = 0; i < length; i++) {
|
|
10
|
+
const rnd = Math.random() * ("z".charCodeAt(0) - "A".charCodeAt(0)); // A z.charCodeAt(0)"
|
|
11
|
+
const char1 = String.fromCharCode(char.charCodeAt(0) + rnd);
|
|
12
|
+
str += char1;
|
|
13
|
+
}
|
|
14
|
+
return str;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("File Model test", () => {
|
|
18
|
+
it("should load blog data from blog.json", async () => {
|
|
19
|
+
const blog = new Blog();
|
|
20
|
+
blog.setStyle(
|
|
21
|
+
"body { font-family: Arial, sans-serif; } h1 { color: #333; }"
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const content = generateRandomContent(200);
|
|
25
|
+
|
|
26
|
+
const article = new Article("hello", content);
|
|
27
|
+
blog.addArticle(article);
|
|
28
|
+
|
|
29
|
+
await blog.load("blog.json");
|
|
30
|
+
|
|
31
|
+
expect(await blog.toHTML()).toContain(content);
|
|
32
|
+
expect(blog).toBeDefined();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("API Model test", () => {
|
|
37
|
+
beforeAll(() => {
|
|
38
|
+
const port = 8081;
|
|
39
|
+
server.listen(port, () => {
|
|
40
|
+
console.log(`Simple server running at http://localhost:${port}/`);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
return new Promise((done) => {
|
|
45
|
+
server.close(done);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should load blog data from API", async () => {
|
|
50
|
+
const blog = new Blog();
|
|
51
|
+
blog.setAPI("http://localhost:8081/blog");
|
|
52
|
+
blog.setStyle(
|
|
53
|
+
"body { font-family: Arial, sans-serif; } h1 { color: #333; }"
|
|
54
|
+
);
|
|
55
|
+
await blog.init();
|
|
56
|
+
|
|
57
|
+
const content = generateRandomContent(200);
|
|
58
|
+
|
|
59
|
+
await new Promise((resolve) => {
|
|
60
|
+
const req = {
|
|
61
|
+
on: (event, cb) => {
|
|
62
|
+
if (event === "data") {
|
|
63
|
+
setTimeout(() => cb(`title=hello&content=${content}`), 0);
|
|
64
|
+
}
|
|
65
|
+
if (event === "end") {
|
|
66
|
+
setTimeout(() => cb(), 10);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
off: () => {},
|
|
70
|
+
};
|
|
71
|
+
const res = {
|
|
72
|
+
writeHead: () => {},
|
|
73
|
+
end: resolve,
|
|
74
|
+
};
|
|
75
|
+
blog.postArticle(req, res);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(await blog.toHTML()).toContain(content);
|
|
79
|
+
expect(blog).toBeDefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should fetch data from API", async () => {
|
|
83
|
+
const API = "http://localhost:8081/blog";
|
|
84
|
+
const data = await fetchData(API);
|
|
85
|
+
expect(data).toBeDefined();
|
|
86
|
+
expect(data.title).toBe("Mock Blog Title");
|
|
87
|
+
expect(data.articles).toHaveLength(2);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("should post blog data to API", async () => {
|
|
91
|
+
const API = "http://localhost:8081/blog";
|
|
92
|
+
const newArticle = { title: "New Post", content: "New Content" };
|
|
93
|
+
const data = await postData(API, newArticle);
|
|
94
|
+
expect(data).toEqual(newArticle);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("Database Model test", () => {
|
|
99
|
+
it("should be empty", async () => {
|
|
100
|
+
const blog = new Blog();
|
|
101
|
+
blog.database.type = "sqlite";
|
|
102
|
+
blog.database.dbname = "test";
|
|
103
|
+
blog.setStyle(
|
|
104
|
+
"body { font-family: Arial, sans-serif; } h1 { color: #333; }"
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
//const article = new Article("hello", "hello world1!");
|
|
108
|
+
//blog.postArticle(article);
|
|
109
|
+
|
|
110
|
+
//await blog.load("blog.json");
|
|
111
|
+
|
|
112
|
+
expect(await blog.toHTML()).not.toContain("<article>");
|
|
113
|
+
expect(blog).toBeDefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("should load blog data from sqlite database", async () => {
|
|
117
|
+
const blog = new Blog();
|
|
118
|
+
blog.database.type = "sqlite";
|
|
119
|
+
blog.database.dbname = "test_" + Date.now();
|
|
120
|
+
blog.setStyle(
|
|
121
|
+
"body { font-family: Arial, sans-serif; } h1 { color: #333; }"
|
|
122
|
+
);
|
|
123
|
+
await blog.init();
|
|
124
|
+
|
|
125
|
+
const content = generateRandomContent(200);
|
|
126
|
+
|
|
127
|
+
await new Promise((resolve) => {
|
|
128
|
+
const req = {
|
|
129
|
+
on: (event, cb) => {
|
|
130
|
+
if (event === "data") {
|
|
131
|
+
setTimeout(() => cb(`title=hello&content=${content}`), 0);
|
|
132
|
+
}
|
|
133
|
+
if (event === "end") {
|
|
134
|
+
setTimeout(() => cb(), 20);
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
off: () => {},
|
|
138
|
+
};
|
|
139
|
+
const res = {
|
|
140
|
+
writeHead: () => {},
|
|
141
|
+
end: resolve,
|
|
142
|
+
};
|
|
143
|
+
blog.postArticle(req, res);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
//await blog.load("blog.json");
|
|
147
|
+
|
|
148
|
+
expect(await blog.toHTML()).toContain(content);
|
|
149
|
+
expect(blog).toBeDefined();
|
|
150
|
+
});
|
|
151
|
+
});
|
package/test/server.test.js
CHANGED
|
@@ -13,6 +13,7 @@ import Blog from "../Blog.js";
|
|
|
13
13
|
|
|
14
14
|
const PORT = 8080;
|
|
15
15
|
const apiBaseUrl = `http://localhost:${PORT}`;
|
|
16
|
+
|
|
16
17
|
describe("server test", () => {
|
|
17
18
|
const blog = new Blog(); // Create one blog instance for all tests
|
|
18
19
|
blog.setTitle("My Blog");
|
|
@@ -23,6 +24,7 @@ describe("server test", () => {
|
|
|
23
24
|
blog.setStyle(
|
|
24
25
|
"body { font-family: Arial, sans-serif; } h1 { color: #333; }"
|
|
25
26
|
);
|
|
27
|
+
await blog.init();
|
|
26
28
|
// Await it to ensure the server is running before tests start.
|
|
27
29
|
await blog.startServer(PORT);
|
|
28
30
|
});
|
|
@@ -33,12 +35,35 @@ describe("server test", () => {
|
|
|
33
35
|
await blog.closeServer();
|
|
34
36
|
});
|
|
35
37
|
|
|
36
|
-
test("
|
|
38
|
+
test("check if server responds to /", async () => {
|
|
39
|
+
// get articles from blog api
|
|
40
|
+
const response = await fetch(`${apiBaseUrl}/`, {
|
|
41
|
+
method: "GET",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(response.status).toBe(200);
|
|
45
|
+
const html = await response.text();
|
|
46
|
+
expect(html).toContain("My Blog");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("check if server responds to /api", async () => {
|
|
50
|
+
// get articles from blog api
|
|
51
|
+
const response = await fetch(`${apiBaseUrl}/api`, {
|
|
52
|
+
method: "GET",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(response.status).toBe(200);
|
|
56
|
+
const responseData = await response.json();
|
|
57
|
+
expect(responseData.title).toBe("My Blog");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("should post a new article via /api/articles", async () => {
|
|
37
61
|
const newArticle = {
|
|
38
62
|
title: "Test Title from Jest",
|
|
39
63
|
content: "This is the content of the test article.",
|
|
40
64
|
};
|
|
41
65
|
|
|
66
|
+
// post new article to the blog
|
|
42
67
|
const response = await fetch(`${apiBaseUrl}/api/articles`, {
|
|
43
68
|
method: "POST",
|
|
44
69
|
headers: { "Content-Type": "application/json" },
|
|
@@ -48,5 +73,7 @@ describe("server test", () => {
|
|
|
48
73
|
expect(response.status).toBe(201);
|
|
49
74
|
const responseData = await response.json();
|
|
50
75
|
expect(responseData).toEqual(newArticle);
|
|
76
|
+
|
|
77
|
+
expect(blog.toHTML()).toContain(newArticle.content); // does blog contain my new article?
|
|
51
78
|
});
|
|
52
79
|
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
|
|
3
|
+
const port = 8081;
|
|
4
|
+
|
|
5
|
+
export const server = http.createServer((req, res) => {
|
|
6
|
+
// Set CORS headers to allow requests from different origins if needed
|
|
7
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
8
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
9
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
10
|
+
|
|
11
|
+
if (req.method === "OPTIONS") {
|
|
12
|
+
res.writeHead(204);
|
|
13
|
+
res.end();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (req.url === "/blog") {
|
|
18
|
+
if (req.method === "GET") {
|
|
19
|
+
const data = {
|
|
20
|
+
title: "Mock Blog Title",
|
|
21
|
+
articles: [
|
|
22
|
+
{ title: "Mock Article 1", content: "Content of mock article 1" },
|
|
23
|
+
{ title: "Mock Article 2", content: "Content of mock article 2" },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
27
|
+
res.end(JSON.stringify(data));
|
|
28
|
+
} else if (req.method === "POST") {
|
|
29
|
+
let body = "";
|
|
30
|
+
req.on("data", (chunk) => (body += chunk.toString()));
|
|
31
|
+
req.on("end", () => {
|
|
32
|
+
res.writeHead(201, { "Content-Type": "application/json" });
|
|
33
|
+
res.end(body); // Echo the received data back
|
|
34
|
+
});
|
|
35
|
+
} else {
|
|
36
|
+
res.writeHead(405); // Method Not Allowed
|
|
37
|
+
res.end();
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
res.writeHead(404); // Not Found
|
|
41
|
+
res.end();
|
|
42
|
+
}
|
|
43
|
+
});
|