From cd68f5538cbffb4fa1d25296077b610310f8b81b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Belin?= Date: Thu, 7 Nov 2024 20:13:13 +0100 Subject: [PATCH] Port the `Client` class --- lib/client.d.ts | 90 +++++++++++++++++++++++ src/author.coffee | 12 +-- src/blog.coffee | 6 +- src/client.coffee | 76 +++++++++++++++++++ src/client.ts | 157 ---------------------------------------- src/comment.coffee | 16 ++-- src/usage.coffee | 4 +- test/blog_test.coffee | 4 +- test/client_test.coffee | 52 +++++++++++++ test/client_test.js | 67 ----------------- 10 files changed, 240 insertions(+), 244 deletions(-) create mode 100644 lib/client.d.ts create mode 100644 src/client.coffee delete mode 100644 src/client.ts create mode 100644 test/client_test.coffee delete mode 100644 test/client_test.js diff --git a/lib/client.d.ts b/lib/client.d.ts new file mode 100644 index 0000000..7d4ff60 --- /dev/null +++ b/lib/client.d.ts @@ -0,0 +1,90 @@ +import {Blog} from "./blog.js"; +import {CheckResult} from "./check_result.js"; +import {Comment} from "./comment.js"; + +/** + * Submits comments to the [Akismet](https://akismet.com) service. + */ +export class Client { + + /** + * The Akismet API key. + */ + apiKey: string; + + /** + * The base URL of the remote API endpoint. + */ + baseUrl: URL; + + /** + * The front page or home URL of the instance making requests. + */ + blog: Blog; + + /** + * Value indicating whether the client operates in test mode. + */ + isTest: boolean; + + /** + * The user agent string to use when making requests. + */ + userAgent: string; + + /** + * Creates a new client. + * @param apiKey The Akismet API key. + * @param blog The front page or home URL of the instance making requests. + * @param options An object providing values to initialize this instance. + */ + constructor(apiKey: string, blog: Blog, options?: ClientOptions); + + /** + * Checks the specified comment against the service database, and returns a value indicating whether it is spam. + * @param comment The comment to be checked. + * @returns A value indicating whether the specified comment is spam. + */ + checkComment(comment: Comment): Promise; + + /** + * Submits the specified comment that was incorrectly marked as spam but should not have been. + * @param comment The comment to be submitted. + * @returns Resolves once the comment has been submitted. + */ + submitHam(comment: Comment): Promise; + + /** + * Submits the specified comment that was not marked as spam but should have been. + * @param comment The comment to be submitted. + * @returns Resolves once the comment has been submitted. + */ + submitSpam(comment: Comment): Promise; + + /** + * Checks the API key against the service database, and returns a value indicating whether it is valid. + * @returns `true` if the specified API key is valid, otherwise `false`. + */ + verifyKey(): Promise; +} + +/** + * Defines the options of a {@link Client} instance. + */ +export type ClientOptions = Partial<{ + + /** + * The base URL of the remote API endpoint. + */ + baseUrl: URL|string; + + /** + * Value indicating whether the client operates in test mode. + */ + isTest: boolean; + + /** + * The user agent string to use when making requests. + */ + userAgent: string; +}>; diff --git a/src/author.coffee b/src/author.coffee index 9fa80b6..f9de9f6 100644 --- a/src/author.coffee +++ b/src/author.coffee @@ -24,12 +24,12 @@ export class Author # Creates a new author from the specified JSON object. @fromJson: (json) -> new @ - email: if typeof json.comment_author_email == "string" then json.comment_author_email else "" - ipAddress: if typeof json.user_ip == "string" then json.user_ip else "" - name: if typeof json.comment_author == "string" then json.comment_author else "" - role: if typeof json.user_role == "string" then json.user_role else "" - url: if typeof json.comment_author_url == "string" then json.comment_author_url else "" - userAgent: if typeof json.user_agent == "string" then json.user_agent else "" + email: if typeof json.comment_author_email is "string" then json.comment_author_email else "" + ipAddress: if typeof json.user_ip is "string" then json.user_ip else "" + name: if typeof json.comment_author is "string" then json.comment_author else "" + role: if typeof json.user_role is "string" then json.user_role else "" + url: if typeof json.comment_author_url is "string" then json.comment_author_url else "" + userAgent: if typeof json.user_agent is "string" then json.user_agent else "" # Returns a JSON representation of this object. toJSON: -> diff --git a/src/blog.coffee b/src/blog.coffee index 6a6364e..19af54e 100644 --- a/src/blog.coffee +++ b/src/blog.coffee @@ -15,9 +15,9 @@ export class Blog # Creates a new blog from the specified JSON object. @fromJson: (json) -> new @ - charset: if typeof json.blog_charset == "string" then json.blog_charset else "" - languages: if typeof json.blog_lang == "string" then json.blog_lang.split(",").map (language) -> language.trim() else [] - url: if typeof json.blog == "string" then json.blog else "" + charset: if typeof json.blog_charset is "string" then json.blog_charset else "" + languages: if typeof json.blog_lang is "string" then json.blog_lang.split(",").map (language) -> language.trim() else [] + url: if typeof json.blog is "string" then json.blog else "" # Returns a JSON representation of this object. toJSON: -> diff --git a/src/client.coffee b/src/client.coffee new file mode 100644 index 0000000..9cfc1a7 --- /dev/null +++ b/src/client.coffee @@ -0,0 +1,76 @@ +import {CheckResult} from "./check_result.js" + +# Submits comments to the [Akismet](https://akismet.com) service. +export class Client + + # The response returned by the "submit-ham" and "submit-spam" endpoints when the outcome is a success. + @_success = "Thanks for making the web a better place." + + # The package version. + @_version = "16.2.1" + + # Creates a new client. + constructor: (apiKey, blog, options = {}) -> + {baseUrl = "https://rest.akismet.com"} = options + [nodeVersion] = process.version.slice(1).split "." + url = if baseUrl instanceof URL then baseUrl.href else baseUrl + + # The Akismet API key. + @apiKey = apiKey + + # The base URL of the remote API endpoint. + @baseUrl = new URL(if url.endsWith("/") then url else "#{url}/") + + # The front page or home URL of the instance making requests. + @blog = blog + + # Value indicating whether the client operates in test mode. + @isTest = options.isTest ? no + + # The user agent string to use when making requests. + @userAgent = options.userAgent ? "Node.js/#{nodeVersion} | Akismet/#{Client._version}" + + # Checks the specified comment against the service database, and returns a value indicating whether it is spam. + checkComment: (comment) -> + response = await @_fetch "1.1/comment-check", comment.toJSON() + switch + when await response.text() is "false" then CheckResult.ham + when response.headers.get("x-akismet-pro-tip") is "discard" then CheckResult.pervasiveSpam + else CheckResult.spam + + # Submits the specified comment that was incorrectly marked as spam but should not have been. + submitHam: (comment) -> + response = await @_fetch "1.1/submit-ham", comment.toJSON() + throw Error "Invalid server response." unless await response.text() is Client._success + + # Submits the specified comment that was not marked as spam but should have been. + submitSpam: (comment) -> + response = await @_fetch "1.1/submit-spam", comment.toJSON() + throw Error "Invalid server response." unless await response.text() is Client._success + + # Checks the API key against the service database, and returns a value indicating whether it is valid. + verifyKey: -> + try + response = await @_fetch "1.1/verify-key" + await response.text() is "valid" + catch + no + + # Queries the service by posting the specified fields to a given end point, and returns the response. + _fetch: (endpoint, fields = {}) -> + body = new URLSearchParams {@blog.toJSON()..., api_key: @apiKey} + body.set "is_test", "1" if @isTest + + for [key, value] from Object.entries fields + unless Array.isArray value then body.set key, String(value) + else + index = 0 + body.set "#{key}[#{index++}]", String(item) for item from value + + response = await fetch new URL(endpoint, @baseUrl), {method: "POST", headers: {"user-agent": @userAgent}, body} + throw Error "#{response.status} #{response.statusText}" unless response.ok + + {headers} = response + throw Error "#{headers.get "x-akismet-alert-code"} #{headers.get "x-akismet-alert-msg"}" if headers.has "x-akismet-alert-code" + throw Error headers.get "x-akismet-debug-help" if headers.has "x-akismet-debug-help" + response diff --git a/src/client.ts b/src/client.ts deleted file mode 100644 index 6d992e1..0000000 --- a/src/client.ts +++ /dev/null @@ -1,157 +0,0 @@ -import process from "node:process"; -import type {Blog} from "./blog.js"; -import {CheckResult} from "./check_result.js"; -import type {Comment} from "./comment.js"; - -/** - * Submits comments to the [Akismet](https://akismet.com) service. - */ -export class Client { - - /** - * The response returned by the `submit-ham` and `submit-spam` endpoints when the outcome is a success. - */ - static readonly #success = "Thanks for making the web a better place."; - - /** - * The package version. - */ - static readonly #version = "16.2.1"; - - /** - * The Akismet API key. - */ - readonly apiKey: string; - - /** - * The base URL of the remote API endpoint. - */ - readonly baseUrl: URL; - - /** - * The front page or home URL of the instance making requests. - */ - readonly blog: Blog; - - /** - * Value indicating whether the client operates in test mode. - */ - readonly isTest: boolean; - - /** - * The user agent string to use when making requests. - */ - readonly userAgent: string; - - /** - * Creates a new client. - * @param apiKey The Akismet API key. - * @param blog The front page or home URL of the instance making requests. - * @param options An object providing values to initialize this instance. - */ - constructor(apiKey: string, blog: Blog, options: ClientOptions = {}) { - const {baseUrl = "https://rest.akismet.com"} = options; - const url = baseUrl instanceof URL ? baseUrl.href : baseUrl; - - this.apiKey = apiKey; - this.baseUrl = new URL(url.endsWith("/") ? url : `${url}/`); - this.blog = blog; - this.isTest = options.isTest ?? false; - - const [nodeVersion] = process.version.slice(1).split("."); - this.userAgent = options.userAgent ?? `Node.js/${nodeVersion} | Akismet/${Client.#version}`; - } - - /** - * Checks the specified comment against the service database, and returns a value indicating whether it is spam. - * @param comment The comment to be checked. - * @returns A value indicating whether the specified comment is spam. - */ - async checkComment(comment: Comment): Promise { - const response = await this.#fetch("1.1/comment-check", comment.toJSON()); - return await response.text() == "false" - ? CheckResult.ham - : response.headers.get("x-akismet-pro-tip") == "discard" ? CheckResult.pervasiveSpam : CheckResult.spam; - } - - /** - * Submits the specified comment that was incorrectly marked as spam but should not have been. - * @param comment The comment to be submitted. - * @returns Resolves once the comment has been submitted. - */ - async submitHam(comment: Comment): Promise { - const response = await this.#fetch("1.1/submit-ham", comment.toJSON()); - if (await response.text() != Client.#success) throw Error("Invalid server response."); - } - - /** - * Submits the specified comment that was not marked as spam but should have been. - * @param comment The comment to be submitted. - * @returns Resolves once the comment has been submitted. - */ - async submitSpam(comment: Comment): Promise { - const response = await this.#fetch("1.1/submit-spam", comment.toJSON()); - if (await response.text() != Client.#success) throw Error("Invalid server response."); - } - - /** - * Checks the API key against the service database, and returns a value indicating whether it is valid. - * @returns `true` if the specified API key is valid, otherwise `false`. - */ - async verifyKey(): Promise { - try { - const response = await this.#fetch("1.1/verify-key"); - return await response.text() == "valid"; - } - catch { - return false; - } - } - - /** - * Queries the service by posting the specified fields to a given end point, and returns the response. - * @param endpoint The URL of the end point to query. - * @param fields The fields describing the query body. - * @returns The server response. - */ - async #fetch(endpoint: string, fields: Record = {}): Promise { - const body = new URLSearchParams({...this.blog.toJSON(), api_key: this.apiKey}); - if (this.isTest) body.set("is_test", "1"); - - for (const [key, value] of Object.entries(fields)) - if (!Array.isArray(value)) body.set(key, String(value)); - else { - let index = 0; - for (const item of value) body.set(`${key}[${index++}]`, String(item)); - } - - const response = await fetch(new URL(endpoint, this.baseUrl), {method: "POST", headers: {"user-agent": this.userAgent}, body}); - if (!response.ok) throw Error(`${response.status} ${response.statusText}`); - - const {headers} = response; - if (headers.has("x-akismet-alert-code")) throw Error(`${headers.get("x-akismet-alert-code")} ${headers.get("x-akismet-alert-msg")}`); - if (headers.has("x-akismet-debug-help")) throw Error(headers.get("x-akismet-debug-help")!); - return response; - } -} - -/** - * Defines the options of a {@link Client} instance. - */ -export type ClientOptions = Partial<{ - - /** - * The base URL of the remote API endpoint. - */ - baseUrl: URL|string; - - /** - * Value indicating whether the client operates in test mode. - */ - isTest: boolean; - - /** - * The user agent string to use when making requests. - */ - userAgent: string; -}>; diff --git a/src/comment.coffee b/src/comment.coffee index e564489..5131cde 100644 --- a/src/comment.coffee +++ b/src/comment.coffee @@ -1,3 +1,5 @@ +import {Author} from "./author.js" + # Represents a comment submitted by an author. export class Comment @@ -36,14 +38,14 @@ export class Comment hasAuthor = Object.keys(json).filter((key) -> key.startsWith "comment_author" or key.startsWith "user").length > 0 new @ author: if hasAuthor then Author.fromJson json else null - content: if typeof json.comment_content == "string" then json.comment_content else "" + content: if typeof json.comment_content is "string" then json.comment_content else "" context: if Array.isArray json.comment_context then json.comment_context else [] - date: if typeof json.comment_date_gmt == "string" then new Date json.comment_date_gmt else null - permalink: if typeof json.permalink == "string" then json.permalink else "" - postModified: if typeof json.comment_post_modified_gmt == "string" then new Date json.comment_post_modified_gmt else null - recheckReason: if typeof json.recheck_reason == "string" then json.recheck_reason else "" - referrer: if typeof json.referrer == "string" then json.referrer else "" - type: if typeof json.comment_type == "string" then json.comment_type else "" + date: if typeof json.comment_date_gmt is "string" then new Date json.comment_date_gmt else null + permalink: if typeof json.permalink is "string" then json.permalink else "" + postModified: if typeof json.comment_post_modified_gmt is "string" then new Date json.comment_post_modified_gmt else null + recheckReason: if typeof json.recheck_reason is "string" then json.recheck_reason else "" + referrer: if typeof json.referrer is "string" then json.referrer else "" + type: if typeof json.comment_type is "string" then json.comment_type else "" # Returns a JSON representation of this object. toJSON: -> diff --git a/src/usage.coffee b/src/usage.coffee index 7ecbcc7..f624676 100644 --- a/src/usage.coffee +++ b/src/usage.coffee @@ -19,6 +19,6 @@ export class Usage # Creates a new usage from the specified JSON object. @fromJson: (json) -> new @ limit: if Number.isInteger(json.limit) then json.limit else -1 - percentage: if typeof json.percentage == "number" then json.percentage else 0 - throttled: if typeof json.throttled == "boolean" then json.throttled else no + percentage: if typeof json.percentage is "number" then json.percentage else 0 + throttled: if typeof json.throttled is "boolean" then json.throttled else no usage: if Number.isInteger(json.usage) then json.usage else 0 diff --git a/test/blog_test.coffee b/test/blog_test.coffee index d8f3f2a..57af1a8 100644 --- a/test/blog_test.coffee +++ b/test/blog_test.coffee @@ -8,7 +8,7 @@ describe "Blog", -> it "should return an empty instance with an empty map", -> blog = Blog.fromJson {} equal blog.charset.length, 0 - equal blog.languages.length, 0 + equal blog.languages.size, 0 equal blog.url, null it "should return an initialized instance with a non-empty map", -> @@ -18,7 +18,7 @@ describe "Blog", -> blog_lang: "en, fr" equal blog.charset, "UTF-8" - deepEqual blog.languages, ["en", "fr"] + deepEqual Array.from(blog.languages), ["en", "fr"] ok blog.url instanceof URL equal blog.url.href, "https://github.com/cedx/akismet.js" diff --git a/test/client_test.coffee b/test/client_test.coffee new file mode 100644 index 0000000..d30547d --- /dev/null +++ b/test/client_test.coffee @@ -0,0 +1,52 @@ +import {Author, AuthorRole, Blog, CheckResult, Client, Comment, CommentType} from "@cedx/akismet" +import {doesNotReject, equal, ok} from "node:assert/strict" +import {env} from "node:process" +import {describe, it} from "node:test" + +# Tests the features of the `Client` class. +describe "Client", -> + # The client used to query the remote API. + client = new Client env.AKISMET_API_KEY ? "", new Blog(url: "https://github.com/cedx/akismet.js"), isTest: yes + + # A comment with content marked as ham. + ham = new Comment + author: new Author + ipAddress: "192.168.0.1" + name: "Akismet" + role: AuthorRole.administrator + url: "https://belin.io" + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36" + content: "I'm testing out the Service API." + referrer: "https://www.npmjs.com/package/@cedx/akismet" + type: CommentType.comment + + # A comment with content marked as spam. + spam = new Comment + author: new Author + email: "akismet-guaranteed-spam@example.com" + ipAddress: "127.0.0.1" + name: "viagra-test-123" + userAgent: "Spam Bot/6.6.6" + content: "Spam!" + type: CommentType.blogPost + + describe "checkComment()", -> + it "should return `CheckResult.ham` for valid comment (e.g. ham)", -> + equal await client.checkComment(ham), CheckResult.ham + + it "should return `CheckResult.spam` for invalid comment (e.g. spam)", -> + isSpam = new Set [CheckResult.spam, CheckResult.pervasiveSpam] + ok isSpam.has await client.checkComment spam + + describe "submitHam()", -> + it "should complete without any error", -> await doesNotReject client.submitHam ham + + describe "submitSpam()", -> + it "should complete without any error", -> await doesNotReject client.submitSpam spam + + describe "verifyKey()", -> + it "should return `true` for a valid API key", -> + ok await client.verifyKey() + + it "should return `false` for an invalid API key", -> + equal await new Client("0123456789-ABCDEF", client.blog, isTest: yes).verifyKey(), no diff --git a/test/client_test.js b/test/client_test.js deleted file mode 100644 index e2e7645..0000000 --- a/test/client_test.js +++ /dev/null @@ -1,67 +0,0 @@ -import {Author, AuthorRole, Blog, CheckResult, Client, Comment, CommentType} from "@cedx/akismet"; -import {doesNotReject, equal, ok} from "node:assert/strict"; -import {env} from "node:process"; -import {describe, it} from "node:test"; - -/** - * Tests the features of the {@link Client} class. - */ -describe("Client", () => { - // The client used to query the remote API. - const client = new Client( - env.AKISMET_API_KEY ?? "", - new Blog({url: "https://github.com/cedx/akismet.js"}), - {isTest: true} - ); - - // A comment with content marked as ham. - const ham = new Comment({ - author: new Author({ - ipAddress: "192.168.0.1", - name: "Akismet", - role: AuthorRole.administrator, - url: "https://belin.io", - userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36" - }), - content: "I'm testing out the Service API.", - referrer: "https://www.npmjs.com/package/@cedx/akismet", - type: CommentType.comment - }); - - // A comment with content marked as spam. - const spam = new Comment({ - author: new Author({ - email: "akismet-guaranteed-spam@example.com", - ipAddress: "127.0.0.1", - name: "viagra-test-123", - userAgent: "Spam Bot/6.6.6" - }), - content: "Spam!", - type: CommentType.blogPost - }); - - describe("checkComment()", () => { - it("should return `CheckResult.ham` for valid comment (e.g. ham)", async () => - equal(await client.checkComment(ham), CheckResult.ham)); - - it("should return `CheckResult.spam` for invalid comment (e.g. spam)", async () => { - /** @type {Set} */ - const isSpam = new Set([CheckResult.spam, CheckResult.pervasiveSpam]); - ok(isSpam.has(await client.checkComment(spam))); - }); - }); - - describe("submitHam()", () => - it("should complete without any error", () => doesNotReject(client.submitHam(ham)))); - - describe("submitSpam()", () => - it("should complete without any error", () => doesNotReject(client.submitSpam(spam)))); - - describe("verifyKey()", () => { - it("should return `true` for a valid API key", async () => - ok(await client.verifyKey())); - - it("should return `false` for an invalid API key", async () => - equal(await new Client("0123456789-ABCDEF", client.blog, {isTest: true}).verifyKey(), false)); - }); -});