🍔 Added files

This commit is contained in:
2021-05-08 23:20:56 -07:00
commit 94bd9bd1a6
24 changed files with 23736 additions and 0 deletions

35
.editorconfig Normal file
View File

@@ -0,0 +1,35 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# Change these settings to your own preference
indent_style = tab
indent_size = 4
space_after_anon_function = true
# We recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
indent_style = space
indent_size = 4
[{package,bower}.json]
indent_style = space
indent_size = 2
[*.{yml,yaml}]
trim_trailing_whitespace = false
indent_style = space
indent_size = 2
[*.js]
quote_type = "double"

143
.eslintrc.js Normal file
View File

@@ -0,0 +1,143 @@
module.exports = {
env: {
browser: true,
es6: true,
node: true
},
ignorePatterns: [ "test/*"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: "tsconfig.json",
sourceType: "module"
},
plugins: ["prefer-arrow", "import", "@typescript-eslint"],
rules: {
"@typescript-eslint/adjacent-overload-signatures": "error",
"@typescript-eslint/array-type": "error",
"@typescript-eslint/ban-types": "error",
"@typescript-eslint/class-name-casing": "off",
"@typescript-eslint/consistent-type-assertions": "error",
"@typescript-eslint/consistent-type-definitions": "error",
"@typescript-eslint/explicit-member-accessibility": [
"error",
{
accessibility: "explicit"
}
],
"@typescript-eslint/indent": [
"off",
4,
{
FunctionDeclaration: {
parameters: "first"
},
FunctionExpression: {
parameters: "first"
}
}
],
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/member-delimiter-style": [
"error",
{
multiline: {
delimiter: "semi",
requireLast: true
},
singleline: {
delimiter: "semi",
requireLast: false
}
}
],
"@typescript-eslint/member-ordering": "error",
"@typescript-eslint/no-empty-function": "error",
"@typescript-eslint/no-empty-interface": "error",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-misused-new": "error",
"@typescript-eslint/no-namespace": "error",
"@typescript-eslint/no-parameter-properties": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-var-requires": "true",
"@typescript-eslint/prefer-for-of": "error",
"@typescript-eslint/prefer-function-type": "error",
"@typescript-eslint/prefer-namespace-keyword": "error",
"@typescript-eslint/quotes": [
"error",
"double",
{
avoidEscape: true
}
],
"@typescript-eslint/semi": ["error", "always"],
"@typescript-eslint/triple-slash-reference": "error",
"@typescript-eslint/type-annotation-spacing": "error",
"@typescript-eslint/unified-signatures": "error",
"arrow-body-style": "error",
"arrow-parens": ["error", "as-needed"],
camelcase: "error",
"capitalized-comments": "error",
"comma-dangle": ["error", "always-multiline"],
complexity: "off",
"constructor-super": "error",
curly: "error",
"dot-notation": "error",
"eol-last": "error",
eqeqeq: ["error", "smart"],
"guard-for-in": "error",
"id-blacklist": ["error", "any", "Number", "number", "String", "string", "Boolean", "boolean", "Undefined", "undefined"],
"id-match": "error",
"import/order": "error",
"max-classes-per-file": ["error", 1],
"max-len": [
"error",
{
"ignoreUrls": true ,
code: 160
}
],
"new-parens": "error",
"no-bitwise": "error",
"no-caller": "error",
"no-cond-assign": "error",
"no-console": "off",
"no-debugger": "error",
"no-empty": "error",
"no-eval": "error",
"no-fallthrough": "off",
"no-invalid-this": "off",
"no-multiple-empty-lines": "error",
"no-new-wrappers": "error",
"no-shadow": [
"error",
{
hoist: "all"
}
],
"no-throw-literal": "error",
"no-trailing-spaces": "error",
"no-undef-init": "error",
"no-underscore-dangle": "error",
"no-unsafe-finally": "error",
"no-unused-expressions": "error",
"no-unused-labels": "error",
"no-var": "error",
"object-shorthand": "error",
"one-var": ["error", "never"],
"prefer-arrow/prefer-arrow-functions": "error",
"prefer-const": "error",
"quote-props": ["error", "consistent-as-needed"],
radix: "error",
"space-before-function-paren": [
"error",
{
anonymous: "never",
asyncArrow: "always",
named: "never"
}
],
"spaced-comment": "error",
"use-isnan": "error",
"valid-typeof": "off",
}
};

70
.gitignore vendored Normal file
View File

@@ -0,0 +1,70 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# next.js build output
.next
# JetBrains IDE
.idea
# Don't track transpiled files
dist/
comics/
userdata/
.DS_Store

38
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,38 @@
{
// Use IntelliSense to learn about possible Node.js debug attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug",
"program": "${workspaceRoot}/node_modules/moleculer/bin/moleculer-runner.js",
"sourceMaps": true,
"runtimeArgs": [
"--nolazy",
"-r",
"ts-node/register"
],
"cwd": "${workspaceRoot}",
"args": [
"services/**/*.service.ts"
]
},
{
"type": "node",
"request": "launch",
"name": "Jest",
"program": "${workspaceRoot}/node_modules/jest-cli/bin/jest.js",
"args": [
"--runInBand"
],
"cwd": "${workspaceRoot}",
"runtimeArgs": [
"--inspect-brk",
"--nolazy"
]
}
]
}

40
README.md Normal file
View File

@@ -0,0 +1,40 @@
[![Moleculer](https://badgen.net/badge/Powered%20by/Moleculer/0e83cd)](https://moleculer.services)
# threetwo-import-service
This is a [Moleculer](https://moleculer.services/)-based microservices project. Generated with the [Moleculer CLI](https://moleculer.services/docs/0.14/moleculer-cli.html).
## Usage
Start the project with `npm run dev` command.
After starting, open the http://localhost:3000/ URL in your browser.
On the welcome page you can test the generated services via API Gateway and check the nodes & services.
In the terminal, try the following commands:
- `nodes` - List all connected nodes.
- `actions` - List all registered service actions.
- `call greeter.hello` - Call the `greeter.hello` action.
- `call greeter.welcome --name John` - Call the `greeter.welcome` action with the `name` parameter.
- `call products.list` - List the products (call the `products.list` action).
## Services
- **api**: API Gateway services
- **greeter**: Sample service with `hello` and `welcome` actions.
- **products**: Sample DB service. To use with MongoDB, set `MONGO_URI` environment variables and install MongoDB adapter with `npm i moleculer-db-adapter-mongo`.
## Mixins
- **db.mixin**: Database access mixin for services. Based on [moleculer-db](https://github.com/moleculerjs/moleculer-db#readme)
## Useful links
* Moleculer website: https://moleculer.services/
* Moleculer Documentation: https://moleculer.services/docs/0.14/
## NPM scripts
- `npm run dev`: Start development mode (load all services locally with hot-reload & REPL)
- `npm run start`: Start production mode (set `SERVICES` env variable to load certain services)
- `npm run cli`: Start a CLI and connect to production. Don't forget to set production namespace with `--ns` argument in script
- `npm run lint`: Run ESLint
- `npm run ci`: Run continuous test mode with watching
- `npm test`: Run tests & generate coverage report

3
data/products.db Normal file
View File

@@ -0,0 +1,3 @@
{"name":"Samsung Galaxy S10 Plus","quantity":10,"price":704,"_id":"MXoybiJcoo4K1Rmm"}
{"name":"iPhone 11 Pro","quantity":25,"price":999,"_id":"2icANVEMoCj6nGjG"}
{"name":"Huawei P30 Pro","quantity":15,"price":679,"_id":"6KE9pJvNQ83ez7qi"}

View File

@@ -0,0 +1,58 @@
export interface IFolderResponse {
data: Array<IFolderData>;
}
export interface IExtractionOptions {
extractTarget: string;
sourceFolder: string;
targetExtractionFolder: string;
paginationOptions: IPaginationOptions;
extractionMode: string;
}
export interface IPaginationOptions {
pageLimit: number;
page: number;
}
export interface IComicVineSearchMatch {
description: string;
id: number;
volumes: string;
}
export interface IFolderData {
name: string;
extension: string;
containedIn: string;
isFile: boolean;
isLink: boolean;
}
export interface IExplodedPathResponse {
exploded: Array<string>;
fileName: string;
}
export interface IComicBookCoverMetadata {
name: string;
path: string;
containedIn: string;
fileSize: string;
imageHash: string;
dimensions: {
width: string;
height: string;
};
}
export interface IExtractedComicBookCoverFile {
name: string;
path: string;
fileSize: number;
}
export interface IExtractComicBookCoverErrorResponse {
message: string;
errorCode: string;
data: string;
}

20
mixins/db.mixin.ts Normal file
View File

@@ -0,0 +1,20 @@
const path = require("path");
const mkdir = require("mkdirp").sync;
const DbService = require("moleculer-db");
const MongoAdapter = require("moleculer-db-adapter-mongoose");
export const DbMixin = (collection, model) => {
if(process.env.MONGO_URI) {
return {
mixins: [DbService],
adapter: new MongoAdapter(process.env.MONGO_URI, {
user: process.env.MONGO_INITDB_ROOT_USERNAME,
pass: process.env.MONGO_INITDB_ROOT_PASSWORD,
keepAlive: true,
}),
model: model,
collection
};
}
mkdir(path.resolve("./data"));
};

67
models/comic.model.ts Normal file
View File

@@ -0,0 +1,67 @@
const mongoose = require("mongoose");
const paginate = require("mongoose-paginate");
const ComicSchema = mongoose.Schema({
name: String,
type: String,
import: {
isImported: Boolean,
matchedResult: {
score: String,
},
},
userAddedMetadata: {
tags: [],
},
comicStructure: {
cover: {
thumb: String,
medium: String,
large: String,
},
collection: {
publishDate: String,
type: String, // issue, series, trade paperback
metadata: {
publisher: String,
issueNumber: String,
description: String,
synopsis: String,
team: {
writer: String,
inker: String,
penciler: String,
colorist: String,
},
},
},
},
sourcedMetadata: {
comicvine: {},
shortboxed: {},
gcd: {},
},
rawFileDetails: {
fileName: String,
path: String,
extension: String,
},
acquisition: {
wanted: Boolean,
release: {},
torrent: {
sourceApplication: String,
magnet: String,
tracker: String,
status: String,
},
usenet: {
sourceApplication: String,
},
},
});
ComicSchema.plugin(paginate);
const Comic = mongoose.model("Comic", ComicSchema);
export default Comic;

187
moleculer.config.ts Normal file
View File

@@ -0,0 +1,187 @@
"use strict";
import {BrokerOptions, Errors, MetricRegistry, ServiceBroker} from "moleculer";
/**
* Moleculer ServiceBroker configuration file
*
* More info about options:
* https://moleculer.services/docs/0.14/configuration.html
*
*
* Overwriting options in production:
* ================================
* You can overwrite any option with environment variables.
* For example to overwrite the "logLevel" value, use `LOGLEVEL=warn` env var.
* To overwrite a nested parameter, e.g. retryPolicy.retries, use `RETRYPOLICY_RETRIES=10` env var.
*
* To overwrite brokers deeply nested default options, which are not presented in "moleculer.config.js",
* use the `MOL_` prefix and double underscore `__` for nested properties in .env file.
* For example, to set the cacher prefix to `MYCACHE`, you should declare an env var as `MOL_CACHER__OPTIONS__PREFIX=mycache`.
* It will set this:
* {
* cacher: {
* options: {
* prefix: "mycache"
* }
* }
* }
*/
const brokerConfig: BrokerOptions = {
// Namespace of nodes to segment your nodes on the same network.
namespace: "",
// Unique node identifier. Must be unique in a namespace.
nodeID: null,
// Custom metadata store. Store here what you want. Accessing: `this.broker.metadata`
metadata: {},
// Enable/disable logging or use custom logger. More info: https://moleculer.services/docs/0.14/logging.html
// Available logger types: "Console", "File", "Pino", "Winston", "Bunyan", "debug", "Log4js", "Datadog"
logger: {
type: "Console",
options: {
// Using colors on the output
colors: true,
// Print module names with different colors (like docker-compose for containers)
moduleColors: false,
// Line formatter. It can be "json", "short", "simple", "full", a `Function` or a template string like "{timestamp} {level} {nodeID}/{mod}: {msg}"
formatter: "full",
// Custom object printer. If not defined, it uses the `util.inspect` method.
objectPrinter: null,
// Auto-padding the module name in order to messages begin at the same column.
autoPadding: false,
},
},
// Default log level for built-in console logger. It can be overwritten in logger options above.
// Available values: trace, debug, info, warn, error, fatal
logLevel: "info",
// Define transporter.
// More info: https://moleculer.services/docs/0.14/networking.html
// Note: During the development, you don't need to define it because all services will be loaded locally.
// In production you can set it via `TRANSPORTER=nats://localhost:4222` environment variable.
transporter: null, // "NATS"
// Define a cacher.
// More info: https://moleculer.services/docs/0.14/caching.html
cacher: "Memory",
// Define a serializer.
// Available values: "JSON", "Avro", "ProtoBuf", "MsgPack", "Notepack", "Thrift".
// More info: https://moleculer.services/docs/0.14/networking.html#Serialization
serializer: "JSON",
// Number of milliseconds to wait before reject a request with a RequestTimeout error. Disabled: 0
requestTimeout: 10 * 1000,
// Retry policy settings. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Retry
retryPolicy: {
// Enable feature
enabled: false,
// Count of retries
retries: 5,
// First delay in milliseconds.
delay: 100,
// Maximum delay in milliseconds.
maxDelay: 1000,
// Backoff factor for delay. 2 means exponential backoff.
factor: 2,
// A function to check failed requests.
check: (err: Errors.MoleculerError) => err && !!err.retryable,
},
// Limit of calling level. If it reaches the limit, broker will throw an MaxCallLevelError error. (Infinite loop protection)
maxCallLevel: 100,
// Number of seconds to send heartbeat packet to other nodes.
heartbeatInterval: 10,
// Number of seconds to wait before setting node to unavailable status.
heartbeatTimeout: 30,
// Cloning the params of context if enabled. High performance impact, use it with caution!
contextParamsCloning: false,
// Tracking requests and waiting for running requests before shuting down. More info: https://moleculer.services/docs/0.14/context.html#Context-tracking
tracking: {
// Enable feature
enabled: false,
// Number of milliseconds to wait before shuting down the process.
shutdownTimeout: 5000,
},
// Disable built-in request & emit balancer. (Transporter must support it, as well.). More info: https://moleculer.services/docs/0.14/networking.html#Disabled-balancer
disableBalancer: false,
// Settings of Service Registry. More info: https://moleculer.services/docs/0.14/registry.html
registry: {
// Define balancing strategy. More info: https://moleculer.services/docs/0.14/balancing.html
// Available values: "RoundRobin", "Random", "CpuUsage", "Latency", "Shard"
strategy: "RoundRobin",
// Enable local action call preferring. Always call the local action instance if available.
preferLocal: true,
},
// Settings of Circuit Breaker. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Circuit-Breaker
circuitBreaker: {
// Enable feature
enabled: false,
// Threshold value. 0.5 means that 50% should be failed for tripping.
threshold: 0.5,
// Minimum request count. Below it, CB does not trip.
minRequestCount: 20,
// Number of seconds for time window.
windowTime: 60,
// Number of milliseconds to switch from open to half-open state
halfOpenTime: 10 * 1000,
// A function to check failed requests.
check: (err: Errors.MoleculerError) => err && err.code >= 500,
},
// Settings of bulkhead feature. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Bulkhead
bulkhead: {
// Enable feature.
enabled: false,
// Maximum concurrent executions.
concurrency: 10,
// Maximum size of queue
maxQueueSize: 100,
},
// Enable action & event parameter validation. More info: https://moleculer.services/docs/0.14/validating.html
validator: true,
errorHandler: null,
// Enable/disable built-in metrics function. More info: https://moleculer.services/docs/0.14/metrics.html
metrics: {
enabled: false,
// Available built-in reporters: "Console", "CSV", "Event", "Prometheus", "Datadog", "StatsD"
reporter: {
type: "Console",
},
},
// Enable built-in tracing function. More info: https://moleculer.services/docs/0.14/tracing.html
tracing: {
enabled: true,
// Available built-in exporters: "Console", "Datadog", "Event", "EventLegacy", "Jaeger", "Zipkin"
exporter: {
type: "Console", // Console exporter is only for development!
},
},
// Register custom middlewares
middlewares: [],
// Register custom REPL commands.
replCommands: null,
/*
// Called after broker created.
created : (broker: ServiceBroker): void => {},
// Called after broker started.
started: async (broker: ServiceBroker): Promise<void> => {},
stopped: async (broker: ServiceBroker): Promise<void> => {},
*/
};
export = brokerConfig;

21209
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

79
package.json Normal file
View File

@@ -0,0 +1,79 @@
{
"name": "threetwo-import-service",
"version": "1.0.0",
"description": "My Moleculer-based microservices project",
"scripts": {
"build": "tsc --build tsconfig.json",
"dev": "ts-node ./node_modules/moleculer/bin/moleculer-runner.js --hot --repl --config moleculer.config.ts services/**/*.service.ts",
"start": "moleculer-runner --config dist/moleculer.config.js",
"cli": "moleculer connect NATS",
"ci": "jest --watch",
"test": "jest --coverage",
"lint": "eslint --ext .js,.ts ."
},
"keywords": [
"microservices",
"moleculer"
],
"author": "",
"devDependencies": {
"@types/lodash": "^4.14.168",
"@types/unzipper": "^0.10.3",
"@typescript-eslint/eslint-plugin": "^2.26.0",
"@typescript-eslint/parser": "^2.26.0",
"eslint": "^6.8.0",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-prefer-arrow": "^1.2.2",
"jest": "^25.1.0",
"jest-cli": "^25.1.0",
"moleculer-repl": "^0.6.2",
"ts-jest": "^25.3.0",
"ts-node": "^8.8.1"
},
"dependencies": {
"@root/walk": "^1.1.0",
"@types/jest": "^25.1.4",
"@types/mkdirp": "^1.0.0",
"@types/node": "^13.9.8",
"@types/pino": "^6.3.8",
"fs-extra": "^10.0.0",
"lodash": "^4.17.21",
"moleculer": "^0.14.0",
"moleculer-db": "^0.8.4",
"moleculer-db-adapter-mongo": "^0.4.7",
"moleculer-db-adapter-mongoose": "^0.8.9",
"moleculer-web": "^0.9.0",
"mongoose": "^5.12.7",
"mongoose-paginate": "^5.0.3",
"nats": "^1.3.2",
"node-unrar-js": "^1.0.2",
"pino": "^6.11.3",
"pino-pretty": "^4.7.1",
"sharp": "^0.28.1",
"typescript": "^3.8.3",
"unzipper": "^0.10.11"
},
"engines": {
"node": ">= 10.x.x"
},
"jest": {
"coverageDirectory": "<rootDir>/coverage",
"testEnvironment": "node",
"moduleFileExtensions": [
"ts",
"tsx",
"js"
],
"transform": {
"^.+\\.(ts|tsx)$": "ts-jest"
},
"testMatch": [
"**/*.spec.(ts|js)"
],
"globals": {
"ts-jest": {
"tsConfig": "tsconfig.json"
}
}
}
}

BIN
public/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

754
public/index.html Normal file
View File

@@ -0,0 +1,754 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name=viewport content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,minimal-ui">
<title>threetwo-import-service - Moleculer Microservices Project</title>
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,600,700" rel="stylesheet">
<link href="https://unpkg.com/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
<link rel="shortcut icon" type="image/png" href="https://moleculer.services/icon/favicon-16x16.png"/>
<script src="https://unpkg.com/vue@2.6.11/dist/vue.js"></script>
<style type="text/css">
html {
font-family: "Source Sans Pro", Helvetica, Arial, sans-serif;
font-size: 18px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
padding: 0;
margin: 0;
color: black;
text-shadow: 1px 1px 3px rgba(0,0,0,0.2);
padding-bottom: 60px; /* footer */
}
.cursor-pointer {
cursor: pointer;
user-select: none;
}
header, footer {
text-align: center;
color: white;
text-shadow: 1px 1px 3px rgba(0,0,0,0.6);
}
header a, header a.router-link-exact-active,
footer a, footer a.router-link-exact-active
{
color: #63dcfd;
}
header {
background-image: linear-gradient(45deg, #e37682 0%, #5f4d93 100%);
padding: 1em;
box-shadow: 0 3px 10px rgba(0,0,0,0.6);
margin-bottom: 1em;
}
footer {
background-image: linear-gradient(135deg, #e37682 0%, #5f4d93 100%);
padding: 0.75em;
font-size: 0.8em;
box-shadow: 0 -3px 10px rgba(0,0,0,0.6);
position: fixed;
left: 0; right: 0; bottom: 0;
margin-top: 1em;
}
footer .footer-links {
margin-top: 0.5em;
}
footer .footer-links a {
margin: 0 0.5em;
}
a, a.router-link-exact-active {
color: #3CAFCE;
text-decoration: none;
}
nav ul {
list-style: none;
padding: 0;
margin: 0;
}
nav ul li {
display: inline-block;
padding: 0.25em 0.75em;
cursor: pointer;
font-weight: 300;
font-size: 1.25em;
border-bottom: 2px solid transparent;
transition: color .1s linear, border-bottom .1s linear;
}
nav ul li.active {
border-bottom: 2px solid #63dcfd;
}
nav ul li:hover {
color: #63dcfd;
}
button, .button {
background-color: #3CAFCE;
border: 0;
border-radius: 8px;
color: white;
font-family: "Source Sans Pro", Helvetica, Arial, sans-serif;
font-size: 16px;
font-weight: 400;
padding: 0.5em 1em;
box-shadow: 0 4px 6px -1px rgba(0,0,0,.2);
cursor: pointer;
user-select: none;
}
button i, .button i {
margin-right: 0.5em;
}
button:hover, .button:hover {
background-color: #4ba3bb;
}
code {
font-family: "Consolas", 'Courier New', Courier, monospace;
color: #555;
}
main {
max-width: 1260px;
margin: 0 auto;
padding: 1em 1em;
}
main section#home > .content {
text-align: center;
}
main section#home h1 {
font-size: 2em;
font-weight: 400;
margin-top: 0;
}
main section#home h3 {
font-size: 1.25em;
font-weight: 600;
}
pre.broker-options {
display: inline-block;
text-align: left;
font-size: 0.9em;
}
.boxes {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.boxes .box {
width: 200px;
padding: 0.25em 1em;
margin: 0.5em;
background: rgba(60, 175, 206, 0.1);
border: 1px solid grey;
border-radius: 0.25em;
}
.boxes .box .caption {
font-weight: 300;
font-size: 0.9em;
margin-bottom: 0.5em;
}
.boxes .box .value {
font-weight: 600;
font-size: 1.1em;
}
main input {
border: 1px solid #3CAFCE;
border-radius: 4px;
padding: 2px 6px;
font-family: "Source Sans Pro";
}
main fieldset {
border: 1px solid lightgrey;
border-radius: 8px;
box-shadow: 2px 2px 10px rgba(0,0,0,0.4);
background-color: aliceblue;
margin-bottom: 2em;
}
main fieldset legend {
background-color: #cce7ff;
border: 1px solid lightgrey;
padding: 4px 10px;
border-radius: 8px;
}
main fieldset .content {
padding-left: 2em;
}
main fieldset .request {
margin-bottom: 0.5em;
}
main fieldset .parameters .field {
margin-bottom: 0.25em;
}
main fieldset .parameters .field label {
min-width: 80px;
display: inline-block;
text-align: right;
margin-right: 0.5em;
}
main fieldset .response {
margin-top: 1em;
}
main fieldset .response pre {
margin: 0.5em 0;
font-size: 0.9em;
}
pre.json .string { color: #885800; }
pre.json .number { color: blue; }
pre.json .boolean { color: magenta; }
pre.json .null { color: red; }
pre.json .key { color: green; }
main h4 {
font-weight: 600;
margin: 0.25em -1.0em;
}
.badge {
display: inline-block;
background-color: dimgray;
color: white;
padding: 2px 6px;
border-radius: 7px;
font-size: 0.7em;
font-weight: 600;
}
.badge.green {
background-color: limegreen;
}
.badge.red {
background-color: firebrick;
}
.badge.orange {
background-color: #fab000;
color: black;
}
table {
width: 100%;
/*max-width: 1000px;*/
border: 1px solid lightgrey;
border-radius: 8px;
background-color: aliceblue;
}
table th {
padding: 2px 4px;
background-color: #cce7ff;
border-radius: 4px;
}
table tr.offline td {
font-style: italic;
color: #777;
}
table tr.local td {
/*color: blue;*/
}
table tr:not(:last-child) td {
border-bottom: 1px solid #ddd;
}
table td {
text-align: center;
position: relative;
padding: 2px 4px;
}
table th:nth-child(1), table td:nth-child(1) {
text-align: left
}
table tr.service td:nth-child(1) {
font-weight: bold;
}
table tr.action td:nth-child(1) {
padding-left: 2em;
}
table tr td:nth-child(2) {
font-family: monospace;
font-size: 0.8em;
}
.bar {
position: absolute;
left: 0; right: 0; top: 0; bottom: 0;
width: 0;
height: 100%;
background-color: rgba(0,0,0,0.3);
}
</style>
</head>
<body>
<div id="app">
<header>
<a href="https://moleculer.services/docs/0.14/" target="_blank"><img class="logo" src="https://moleculer.services/images/logo/logo_with_text_horizontal_100h_shadow.png" /></a>
<nav>
<ul>
<li v-for="item in menu" :class="{ active: page == item.id}" @click="changePage(item.id)">{{ item.caption }}</li>
</ul>
</nav>
</header>
<main>
<section id="home" v-if="page == 'home'">
<div class="content">
<h1>Welcome to your Moleculer microservices project!</h1>
<p>Check out the <a href="https://moleculer.services/docs/0.14/" target="_blank">Moleculer documentation</a> to learn how to customize this project.</p>
<template v-if="broker">
<h3>Configuration</h3>
<div class="boxes">
<div class="box">
<div class="caption">Namespace</div>
<div class="value">{{ broker.namespace || "&lt;not set&gt;" }}</div>
</div>
<div class="box">
<div class="caption">Transporter</div>
<div class="value">{{ broker.transporter || "&lt;no transporter&gt;" }}</div>
</div>
<div class="box">
<div class="caption">Serializer</div>
<div class="value">{{ broker.serializer || "JSON" }}</div>
</div>
<div class="box">
<div class="caption">Strategy</div>
<div class="value">{{ broker.registry.strategy || "Round Robin" }}</div>
</div>
<div class="box">
<div class="caption">Cacher</div>
<div class="value">{{ broker.cacher ? "Enabled" : "Disabled" }}</div>
</div>
<div class="box">
<div class="caption">Logger</div>
<div class="value">{{ broker.logger ? "Enabled" : "Disabled" }}</div>
</div>
<div class="box">
<div class="caption">Metrics</div>
<div class="value">{{ broker.metrics.enabled ? "Enabled" : "Disabled" }}</div>
</div>
<div class="box">
<div class="caption">Tracing</div>
<div class="value">{{ broker.tracing.enabled ? "Enabled" : "Disabled" }}</div>
</div>
</div>
<h3 class="cursor-pointer" @click="showBrokerOptions = !showBrokerOptions">Broker options <i :class="'fa fa-angle-' + (showBrokerOptions ? 'up' : 'down')"></i></h3>
<pre v-if="showBrokerOptions" class="broker-options"><code>{{ broker }}</code></pre>
</template>
</div>
</section>
<template v-for="(section, name) in requests">
<section :id="name" v-if="page == name">
<fieldset v-for="item in section">
<legend>
Action '<code>{{ item.action }}</code>'
</legend>
<div class="content">
<div class="request">
<h4>Request:</h4>
<code>{{ item.method || 'GET' }} <a target="_blank" :href="item.rest">{{ item.rest }} </a></code>
<a class="button" @click="callAction(item)">
<i class="fa fa-rocket"></i>
Execute
</a>
</div>
<div v-if="item.fields" class="parameters">
<h4>Parameters:</h4>
<div class="field" v-for="field in item.fields">
<label for="">{{ field.label }}: </label>
<input :type="field.type" :value="getFieldValue(field)" @input="setFieldValue(field, $event.target.value)"></input>
</div>
</div>
<div class="response" v-if="item.status">
<h4>Response:
<div class="badge" :class="{ green: item.status < 400, red: item.status >= 400 || item.status == 'ERR' }">{{ item.status }}</div>
<div class="badge time">{{ humanize(item.duration) }}</div>
</h4>
<pre><code>{{ item.response }}</code></pre>
</div>
</div>
</fieldset>
</section>
</template>
<section id="nodes" v-if="page == 'nodes'">
<table>
<thead>
<th>Node ID</th>
<th>Type</th>
<th>Version</th>
<th>IP</th>
<th>Hostname</th>
<th>Status</th>
<th>CPU</th>
</thead>
<tbody>
<tr v-for="node in nodes" :class="{ offline: !node.available, local: node.local }" :key="node.id">
<td>{{ node.id }}</td>
<td>{{ node.client.type }}</td>
<td>{{ node.client.version }}</td>
<td>{{ node.ipList[0] }}</td>
<td>{{ node.hostname }}</td>
<td><div class="badge" :class="{ green: node.available, red: !node.available }">{{ node.available ? "Online": "Offline" }}</div></td>
<td>
<div class="bar" :style="{ width: node.cpu != null ? node.cpu + '%' : '0' }"></div>
{{ node.cpu != null ? Number(node.cpu).toFixed(0) + '%' : '-' }}
</td>
</tr>
</tbody>
</table>
</section>
<section id="services" v-if="page == 'services'">
<table>
<thead>
<th>Service/Action name</th>
<th>REST</th>
<th>Parameters</th>
<th>Instances</th>
<th>Status</th>
</thead>
<tbody>
<template v-for="svc in filteredServices">
<tr class="service">
<td>
{{ svc.name }}
<div v-if="svc.version" class="badge">{{ svc.version }}</div>
</td>
<td>{{ svc.settings.rest ? svc.settings.rest : svc.fullName }}</td>
<td></td>
<td class="badges">
<div class="badge" v-for="nodeID in svc.nodes">
{{ nodeID }}
</div>
</td>
<td>
<div v-if="svc.nodes.length > 0" class="badge green">Online</div>
<div v-else class="badge red">Offline</div>
</td>
</tr>
<tr v-for="action in getServiceActions(svc)" :class="{ action: true, offline: !action.available, local: action.hasLocal }">
<td>
{{ action.name }}
<div v-if="action.action.cache" class="badge orange">cached</div>
</td>
<td v-html="getActionREST(svc, action)"></td>
<td :title="getActionParams(action)">
{{ getActionParams(action, 40) }}
</td>
<td></td>
<td>
<div v-if="action.available" class="badge green">Online</div>
<div v-else class="badge red">Offline</div>
</td>
</tr>
</template>
</tbody>
</table>
</section>
</main>
<footer>
<div class="footer-copyright">
Copyright &copy; 2016-2020 - Moleculer
</div>
<div class="footer-links">
<a href="https://github.com/moleculerjs/moleculer" class="footer-link" target="_blank">Github</a>
<a href="https://twitter.com/MoleculerJS" class="footer-link" target="_blank">Twitter</a>
<a href="https://discord.gg/TSEcDRP" class="footer-link" target="_blank">Discord</a>
<a href="https://stackoverflow.com/questions/tagged/moleculer" class="footer-link" target="_blank">Stack Overflow</a>
</div>
</footer>
</div>
<script type="text/javascript">
var app = new Vue({
el: "#app",
data() {
return {
menu: [
{ id: "home", caption: "Home" },
{ id: "greeter", caption: "Greeter service" },
{ id: "products", caption: "Products service" },
{ id: "nodes", caption: "Nodes" },
{ id: "services", caption: "Services" }
],
page: "home",
requests: {
greeter: [
{ id: "hello", action: "greeter.hello", rest: "/api/greeter/hello", response: null, status: null, duration: null },
{ id: "welcome", action: "greeter.welcome", rest: "/api/greeter/welcome", fields: [
{ field: "name", label: "Name", type: "text", paramType: "param", model: "welcomeName" }
], response: null, status: null, duration: null }
],
products: [
{ id: "list", action: "products.list", rest: "/api/products/", response: null, status: null, duration: null, afterResponse: response => !this.fields.productID && (this.fields.productID = response.rows[0]._id) },
{ id: "create", action: "products.create", rest: "/api/products/", method: "POST", fields: [
{ field: "name", label: "Name", type: "text", paramType: "body", model: "productName" },
{ field: "price", label: "Price", type: "number", paramType: "body", model: "productPrice" },
], response: null, status: null, duration: null, afterResponse: response => this.fields.productID = response._id },
{ id: "get", action: "products.get", rest: "/api/products/:id", method: "GET", fields: [
{ field: "id", label: "ID", type: "text", paramType: "url", model: "productID" }
], response: null, status: null, duration: null },
{ id: "update", action: "products.update", rest: "/api/products/:id", method: "PUT", fields: [
{ field: "id", label: "ID", type: "text", paramType: "url", model: "productID" },
{ field: "name", label: "Name", type: "text", paramType: "body", model: "productName" },
{ field: "price", label: "Price", type: "number", paramType: "body", model: "productPrice" },
], response: null, status: null, duration: null },
{ id: "increase", action: "products.increaseQuantity", rest: "/api/products/:id/quantity/increase", method: "PUT", fields: [
{ field: "id", label: "ID", type: "text", paramType: "url", model: "productID" },
{ field: "value", label: "Value", type: "number", paramType: "body", model: "productValue" },
], response: null, status: null, duration: null },
{ id: "decrease", action: "products.decreaseQuantity", rest: "/api/products/:id/quantity/decrease", method: "PUT", fields: [
{ field: "id", label: "ID", type: "text", paramType: "url", model: "productID" },
{ field: "value", label: "Value", type: "number", paramType: "body", model: "productValue" },
], response: null, status: null, duration: null },
{ id: "delete", action: "products.delete", rest: "/api/products/:id", method: "DELETE", fields: [
{ field: "id", label: "ID", type: "text", paramType: "url", model: "productID" }
], response: null, status: null, duration: null },
]
},
fields: {
welcomeName: "John",
productID: null,
productName: "Xiamoi Mi 9T",
productPrice: 299,
productValue: 1
},
broker: null,
nodes: [],
services: [],
actions: {},
showBrokerOptions: false
};
},
computed: {
filteredServices() {
return this.services.filter(svc => !svc.name.startsWith("$"));
}
},
methods: {
changePage(page) {
this.page = page;
this.updatePageResources();
},
humanize(ms) {
return ms > 1500 ? (ms / 1500).toFixed(2) + " s" : ms + " ms";
},
getFieldValue(field) {
return this.fields[field.model];
},
setFieldValue(field, newValue) {
if (field.type == "number")
this.fields[field.model] = Number(newValue);
else
this.fields[field.model] = newValue;
},
getServiceActions(svc) {
return Object.keys(svc.actions)
.map(name => this.actions[name])
.filter(action => !!action);
},
getActionParams(action, maxLen) {
if (action.action && action.action.params) {
const s = Object.keys(action.action.params).join(", ");
return s.length > maxLen ? s.substr(0, maxLen) + "…" : s;
}
return "-";
},
getActionREST(svc, action) {
if (action.action.rest) {
let prefix = svc.fullName || svc.name;
if (typeof(svc.settings.rest) == "string")
prefix = svc.settings.rest;
if (typeof action.action.rest == "string") {
if (action.action.rest.indexOf(" ") !== -1) {
const p = action.action.rest.split(" ");
return "<span class='badge'>" + p[0] + "</span> " + prefix + p[1];
} else {
return "<span class='badge'>*</span> " + prefix + action.action.rest;
}
} else {
return "<span class='badge'>" + (action.action.rest.method || "*") + "</span> " + prefix + action.action.rest.path;
}
}
return "";
},
callAction: function (item) {
var startTime = Date.now();
let url = item.rest;
const method = item.method || "GET";
let body = null;
let params = null;
if (item.fields) {
body = {};
params = {};
item.fields.forEach(field => {
const value = this.getFieldValue(field);
if (field.paramType == "body")
body[field.field] = value;
else if (field.paramType == "param")
params[field.field] = value;
else if (field.paramType == "url")
url = url.replace(":" + field.field, value);
});
if (body && method == "GET") {
body = null;
}
if (params) {
url += "?" + new URLSearchParams(params).toString();
}
}
return fetch(url, {
method,
body: body ? JSON.stringify(body) : null,
headers: {
'Content-Type': 'application/json'
}
}).then(function(res) {
item.status = res.status;
item.duration = Date.now() - startTime;
return res.json().then(json => {
item.response = json;
if (item.afterResponse)
return item.afterResponse(json);
});
}).catch(function (err) {
item.status = "ERR";
item.duration = Date.now() - startTime;
item.response = err.message;
console.log(err);
});
},
updateBrokerOptions: function (name) {
this.req("/api/~node/options", null).then(res => this.broker = res);
},
updateNodeList: function (name) {
this.req("/api/~node/list", null).then(res => {
res.sort((a,b) => a.id.localeCompare(b.id));
this.nodes = res;
});
},
updateServiceList: function (name) {
this.req("/api/~node/services?withActions=true", null)
.then(res => {
this.services = res;
res.sort((a,b) => a.name.localeCompare(b.name));
res.forEach(svc => svc.nodes.sort());
})
.then(() => this.req("/api/~node/actions", null))
.then(res => {
res.sort((a,b) => a.name.localeCompare(b.name));
const actions = res.reduce((a,b) => {
a[b.name] = b;
return a;
}, {});
Vue.set(this, "actions", actions);
});
},
req: function (url, params) {
return fetch(url, { method: "GET", body: params ? JSON.stringify(params) : null })
.then(function(res) {
return res.json();
});
},
updatePageResources() {
if (this.page == 'nodes') return this.updateNodeList();
if (this.page == 'services') return this.updateServiceList();
}
},
mounted() {
var self = this;
setInterval(function () {
self.updatePageResources();
}, 2000);
this.updateBrokerOptions();
}
});
</script>
</body>
</html>

174
services/api.service.ts Normal file
View File

@@ -0,0 +1,174 @@
import {IncomingMessage} from "http";
import {Service, ServiceBroker, Context} from "moleculer";
import ApiGateway from "moleculer-web";
export default class ApiService extends Service {
public constructor(broker: ServiceBroker) {
super(broker);
// @ts-ignore
this.parseServiceSchema({
name: "api",
mixins: [ApiGateway],
// More info about settings: https://moleculer.services/docs/0.14/moleculer-web.html
settings: {
port: process.env.PORT || 3000,
routes: [{
path: "/api",
whitelist: [
// Access to any actions in all services under "/api" URL
"**",
],
// Route-level Express middlewares. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Middlewares
use: [],
// Enable/disable parameter merging method. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Disable-merging
mergeParams: true,
// Enable authentication. Implement the logic into `authenticate` method. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Authentication
authentication: false,
// Enable authorization. Implement the logic into `authorize` method. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Authorization
authorization: false,
// The auto-alias feature allows you to declare your route alias directly in your services.
// The gateway will dynamically build the full routes from service schema.
autoAliases: true,
aliases:{},
/**
* Before call hook. You can check the request.
* @param {Context} ctx
* @param {Object} route
* @param {IncomingMessage} req
* @param {ServerResponse} res
* @param {Object} data
onBeforeCall(ctx: Context<any,{userAgent: string}>,
route: object, req: IncomingMessage, res: ServerResponse) {
Set request headers to context meta
ctx.meta.userAgent = req.headers["user-agent"];
},
*/
/**
* After call hook. You can modify the data.
* @param {Context} ctx
* @param {Object} route
* @param {IncomingMessage} req
* @param {ServerResponse} res
* @param {Object} data
*
onAfterCall(ctx: Context, route: object, req: IncomingMessage, res: ServerResponse, data: object) {
// Async function which return with Promise
return doSomething(ctx, res, data);
},
*/
// Calling options. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Calling-options
callingOptions: {},
bodyParsers: {
json: {
strict: false,
limit: "1MB",
},
urlencoded: {
extended: true,
limit: "1MB",
},
},
// Mapping policy setting. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Mapping-policy
mappingPolicy: "all", // Available values: "all", "restrict"
// Enable/disable logging
logging: true,
}],
// Do not log client side errors (does not log an error response when the error.code is 400<=X<500)
log4XXResponses: false,
// Logging the request parameters. Set to any log level to enable it. E.g. "info"
logRequestParams: null,
// Logging the response data. Set to any log level to enable it. E.g. "info"
logResponseData: null,
// Serve assets from "public" folder
assets: {
folder: "public",
// Options to `server-static` module
options: {},
},
},
methods: {
/**
* Authenticate the request. It checks the `Authorization` token value in the request header.
* Check the token value & resolve the user by the token.
* The resolved user will be available in `ctx.meta.user`
*
* PLEASE NOTE, IT'S JUST AN EXAMPLE IMPLEMENTATION. DO NOT USE IN PRODUCTION!
*
* @param {Context} ctx
* @param {any} route
* @param {IncomingMessage} req
* @returns {Promise}
async authenticate (ctx: Context, route: any, req: IncomingMessage): Promise < any > => {
// Read the token from header
const auth = req.headers.authorization;
if (auth && auth.startsWith("Bearer")) {
const token = auth.slice(7);
// Check the token. Tip: call a service which verify the token. E.g. `accounts.resolveToken`
if (token === "123456") {
// Returns the resolved user. It will be set to the `ctx.meta.user`
return {
id: 1,
name: "John Doe",
};
} else {
// Invalid token
throw new ApiGateway.Errors.UnAuthorizedError(ApiGateway.Errors.ERR_INVALID_TOKEN, {
error: "Invalid Token",
});
}
} else {
// No token. Throw an error or do nothing if anonymous access is allowed.
// Throw new E.UnAuthorizedError(E.ERR_NO_TOKEN);
return null;
}
},
*/
/**
* Authorize the request. Check that the authenticated user has right to access the resource.
*
* PLEASE NOTE, IT'S JUST AN EXAMPLE IMPLEMENTATION. DO NOT USE IN PRODUCTION!
*
* @param {Context} ctx
* @param {Object} route
* @param {IncomingMessage} req
* @returns {Promise}
async authorize (ctx: Context < any, {
user: string;
} > , route: Record<string, undefined>, req: IncomingMessage): Promise < any > => {
// Get the authenticated user.
const user = ctx.meta.user;
// It check the `auth` property in action schema.
// @ts-ignore
if (req.$action.auth === "required" && !user) {
throw new ApiGateway.Errors.UnAuthorizedError("NO_RIGHTS", {
error: "Unauthorized",
});
}
},
*/
},
});
}
}

View File

@@ -0,0 +1,80 @@
"use strict";
import { Context, Service, ServiceBroker, ServiceSchema } from "moleculer";
import { DbMixin } from "../mixins/db.mixin";
import Comic from "../models/comic.model";
import {
walkFolder,
extractArchive,
getCovers,
} from "../utils/uncompression.utils";
import {
IExtractionOptions,
IFolderData,
IFolderResponse,
} from "../interfaces/folder.interface";
export default class ProductsService extends Service {
// @ts-ignore
public constructor(public broker: ServiceBroker, schema: ServiceSchema<{}> = {}) {
super(broker);
console.log(DbMixin);
this.parseServiceSchema(
Service.mergeSchemas(
{
name: "import",
mixins: [DbMixin("comics", Comic)],
settings: {
// Available fields in the responses
fields: ["_id", "name", "quantity", "price"],
// Validator for the `create` & `insert` actions.
entityValidator: {
name: "string|min:3",
price: "number|positive",
},
},
hooks: {},
actions: {
hello: {
rest: "POST /hello",
params: {
id: "string",
},
/** @param {Context} ctx */
async handler(
ctx: Context<{ id: string; value: number }>
) {
return { koo: "loo" };
},
},
getComicCovers: {
rest: "POST /getComicCovers",
params: {
extractionOptions: "object",
walkedFolders: "array",
},
async handler(
ctx: Context<{
extractionOptions: IExtractionOptions;
walkedFolders: IFolderData[];
}>
) {
console.log(ctx.params);
const foo = await getCovers(
ctx.params.extractionOptions,
ctx.params.walkedFolders
);
return foo;
},
},
},
methods: {},
},
schema
)
);
}
}

View File

@@ -0,0 +1,113 @@
"use strict";
import { ServiceBroker } from "moleculer";
import TestService from "../../services/products.service";
describe("Test 'products' service", () => {
describe("Test actions", () => {
const broker = new ServiceBroker({ logger: false });
const service = broker.createService(TestService);
service.seedDB = null; // Disable seeding
beforeAll(() => broker.start());
afterAll(() => broker.stop());
const record = {
name: "Awesome item",
price: 999,
};
let newID: string;
it("should contains the seeded items", async () => {
const res = await broker.call("products.list");
expect(res).toEqual({ page: 1, pageSize: 10, rows: [], total: 0, totalPages: 0 });
});
it("should add the new item", async () => {
const res: any = await broker.call("products.create", record);
expect(res).toEqual({
_id: expect.any(String),
name: "Awesome item",
price: 999,
quantity: 0,
});
newID = res._id;
const res2 = await broker.call("products.count");
expect(res2).toBe(1);
});
it("should get the saved item", async () => {
const res = await broker.call("products.get", { id: newID });
expect(res).toEqual({
_id: expect.any(String),
name: "Awesome item",
price: 999,
quantity: 0,
});
const res2 = await broker.call("products.list");
expect(res2).toEqual({
page: 1,
pageSize: 10,
rows: [{ _id: newID, name: "Awesome item", price: 999, quantity: 0 }],
total: 1,
totalPages: 1,
});
});
it("should update an item", async () => {
const res = await broker.call("products.update", { id: newID, price: 499 });
expect(res).toEqual({
_id: expect.any(String),
name: "Awesome item",
price: 499,
quantity: 0,
});
});
it("should get the updated item", async () => {
const res = await broker.call("products.get", { id: newID });
expect(res).toEqual({
_id: expect.any(String),
name: "Awesome item",
price: 499,
quantity: 0,
});
});
it("should increase the quantity", async () => {
const res = await broker.call("products.increaseQuantity", { id: newID, value: 5 });
expect(res).toEqual({
_id: expect.any(String),
name: "Awesome item",
price: 499,
quantity: 5,
});
});
it("should decrease the quantity", async () => {
const res = await broker.call("products.decreaseQuantity", { id: newID, value: 2 });
expect(res).toEqual({
_id: expect.any(String),
name: "Awesome item",
price: 499,
quantity: 3,
});
});
it("should remove the updated item", async () => {
const res = await broker.call("products.remove", { id: newID });
expect(res).toBe(1);
const res2 = await broker.call("products.count");
expect(res2).toBe(0);
const res3 = await broker.call("products.list");
expect(res3).toEqual({ page: 1, pageSize: 10, rows: [], total: 0, totalPages: 0 });
});
});
});

View File

@@ -0,0 +1,85 @@
"use strict";
import { ServiceBroker } from "moleculer";
import DbService from "moleculer-db";
import DbMixin from "../../../mixins/db.mixin";
describe("Test DB mixin", () => {
describe("Test schema generator", () => {
const broker = new ServiceBroker({ logger: false, cacher: "Memory" });
beforeAll(() => broker.start());
afterAll(() => broker.stop());
it("check schema properties", async () => {
const schema = new DbMixin("my-collection").start();
expect(schema.mixins).toEqual([DbService]);
// @ts-ignore
expect(schema.adapter).toBeInstanceOf(DbService.MemoryAdapter);
expect(schema.started).toBeDefined();
expect(schema.events["cache.clean.my-collection"]).toBeInstanceOf(Function);
});
it("check cache event handler", async () => {
jest.spyOn(broker.cacher, "clean");
const schema = new DbMixin("my-collection").start();
// @ts-ignore
await schema.events["cache.clean.my-collection"].call({ broker, fullName: "my-service" });
expect(broker.cacher.clean).toBeCalledTimes(1);
expect(broker.cacher.clean).toBeCalledWith("my-service.*");
});
describe("Check service started handler", () => {
it("should not call seedDB method", async () => {
const schema = new DbMixin("my-collection").start();
schema.adapter.count = jest.fn(async () => 10);
const seedDBFn = jest.fn();
// @ts-ignore
await schema.started.call({ broker, logger: broker.logger, adapter: schema.adapter, seedDB: seedDBFn });
expect(schema.adapter.count).toBeCalledTimes(1);
expect(schema.adapter.count).toBeCalledWith();
expect(seedDBFn).toBeCalledTimes(0);
});
it("should call seedDB method", async () => {
const schema = new DbMixin("my-collection").start();
schema.adapter.count = jest.fn(async () => 0);
const seedDBFn = jest.fn();
// @ts-ignore
await schema.started.call({ broker, logger: broker.logger, adapter: schema.adapter, seedDB: seedDBFn });
expect(schema.adapter.count).toBeCalledTimes(2);
expect(schema.adapter.count).toBeCalledWith();
expect(seedDBFn).toBeCalledTimes(1);
expect(seedDBFn).toBeCalledWith();
});
});
it("should broadcast a cache clear event", async () => {
const schema = new DbMixin("my-collection").start();
const ctx = {
broadcast: jest.fn(),
};
await schema.methods.entityChanged(null, null, ctx);
expect(ctx.broadcast).toBeCalledTimes(1);
expect(ctx.broadcast).toBeCalledWith("cache.clean.my-collection");
});
});
});

View File

@@ -0,0 +1,40 @@
"use strict";
import { Errors, ServiceBroker} from "moleculer";
import TestService from "../../../services/greeter.service";
describe("Test 'greeter' service", () => {
const broker = new ServiceBroker({ logger: false });
broker.createService(TestService);
beforeAll(() => broker.start());
afterAll(() => broker.stop());
describe("Test 'greeter.hello' action", () => {
it("should return with 'Hello Moleculer'", async () => {
const res = await broker.call("greeter.hello");
expect(res).toBe("Hello Moleculer");
});
});
describe("Test 'greeter.welcome' action", () => {
it("should return with 'Welcome'", async () => {
const res = await broker.call("greeter.welcome", { name: "Adam" });
expect(res).toBe("Welcome, Adam");
});
it("should reject an ValidationError", async () => {
expect.assertions(1);
try {
await broker.call("greeter.welcome");
} catch (err) {
expect(err).toBeInstanceOf(Errors.ValidationError);
}
});
});
});

View File

@@ -0,0 +1,180 @@
"use strict";
import { Context, Errors, ServiceBroker } from "moleculer";
import TestService from "../../../services/products.service";
describe("Test 'products' service", () => {
describe("Test actions", () => {
const broker = new ServiceBroker({ logger: false });
const service = broker.createService(TestService);
jest.spyOn(service.adapter, "updateById");
jest.spyOn(service, "transformDocuments");
jest.spyOn(service, "entityChanged");
beforeAll(() => broker.start());
afterAll(() => broker.stop());
const record = {
_id: "123",
name: "Awesome thing",
price: 999,
quantity: 25,
createdAt: Date.now(),
};
describe("Test 'products.increaseQuantity'", () => {
it("should call the adapter updateById method & transform result", async () => {
service.adapter.updateById.mockImplementation(async () => record);
service.transformDocuments.mockClear();
service.entityChanged.mockClear();
const res = await broker.call("products.increaseQuantity", {
id: "123",
value: 10,
});
expect(res).toEqual({
_id: "123",
name: "Awesome thing",
price: 999,
quantity: 25,
});
expect(service.adapter.updateById).toBeCalledTimes(1);
expect(service.adapter.updateById).toBeCalledWith("123", { $inc: { quantity: 10 } } );
expect(service.transformDocuments).toBeCalledTimes(1);
expect(service.transformDocuments).toBeCalledWith(expect.any(Context), { id: "123", value: 10 }, record);
expect(service.entityChanged).toBeCalledTimes(1);
expect(service.entityChanged).toBeCalledWith("updated", { _id: "123", name: "Awesome thing", price: 999, quantity: 25 }, expect.any(Context));
});
});
describe("Test 'products.decreaseQuantity'", () => {
it("should call the adapter updateById method & transform result", async () => {
service.adapter.updateById.mockClear();
service.transformDocuments.mockClear();
service.entityChanged.mockClear();
const res = await broker.call("products.decreaseQuantity", {
id: "123",
value: 10,
});
expect(res).toEqual({
_id: "123",
name: "Awesome thing",
price: 999,
quantity: 25,
});
expect(service.adapter.updateById).toBeCalledTimes(1);
expect(service.adapter.updateById).toBeCalledWith("123", { $inc: { quantity: -10 } } );
expect(service.transformDocuments).toBeCalledTimes(1);
expect(service.transformDocuments).toBeCalledWith(expect.any(Context), { id: "123", value: 10 }, record);
expect(service.entityChanged).toBeCalledTimes(1);
expect(service.entityChanged).toBeCalledWith("updated", { _id: "123", name: "Awesome thing", price: 999, quantity: 25 }, expect.any(Context));
});
it("should throw error if params is not valid", async () => {
service.adapter.updateById.mockClear();
service.transformDocuments.mockClear();
service.entityChanged.mockClear();
expect.assertions(2);
try {
await broker.call("products.decreaseQuantity", {
id: "123",
value: -5,
});
} catch (err) {
expect(err).toBeInstanceOf(Errors.ValidationError);
expect(err.data).toEqual([{
action: "products.decreaseQuantity",
actual: -5,
field: "value",
message: "The 'value' field must be a positive number.",
nodeID: broker.nodeID,
type: "numberPositive",
}]);
}
});
});
});
describe("Test methods", () => {
const broker = new ServiceBroker({ logger: false });
const service = broker.createService(TestService);
jest.spyOn(service.adapter, "insertMany");
jest.spyOn(service, "seedDB");
beforeAll(() => broker.start());
afterAll(() => broker.stop());
describe("Test 'seedDB'", () => {
it("should be called after service started & DB connected", async () => {
expect(service.seedDB).toBeCalledTimes(1);
expect(service.seedDB).toBeCalledWith();
});
it("should insert 3 documents", async () => {
expect(service.adapter.insertMany).toBeCalledTimes(1);
expect(service.adapter.insertMany).toBeCalledWith([
{ name: "Samsung Galaxy S10 Plus", quantity: 10, price: 704 },
{ name: "iPhone 11 Pro", quantity: 25, price: 999 },
{ name: "Huawei P30 Pro", quantity: 15, price: 679 },
]);
});
});
});
describe("Test hooks", () => {
const broker = new ServiceBroker({ logger: false });
const createActionFn = jest.fn();
// @ts-ignore
broker.createService(TestService, {
actions: {
create: {
handler: createActionFn,
},
},
});
beforeAll(() => broker.start());
afterAll(() => broker.stop());
describe("Test before 'create' hook", () => {
it("should add quantity with zero", async () => {
await broker.call("products.create", {
id: "111",
name: "Test product",
price: 100,
});
expect(createActionFn).toBeCalledTimes(1);
expect(createActionFn.mock.calls[0][0].params).toEqual({
id: "111",
name: "Test product",
price: 100,
quantity: 0,
});
});
});
});
});

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"noImplicitAny": false,
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true,
"pretty": true,
"target": "es6",
"outDir": "dist",
},
"include": ["./**/*"],
"exclude": [
"node_modules/**/*",
"test"
]
}

20
utils/logger.utils.ts Normal file
View File

@@ -0,0 +1,20 @@
import { default as Pino } from "pino";
import { default as pinopretty } from "pino-pretty";
export const logger = Pino({
name: "threetwo!",
prettyPrint: { colorize: true },
// crlf: false,
// errorLikeObjectKeys: ["err", "error"],
// errorProps: "",
// levelFirst: false, // --levelFirst
messageKey: "msg", // --messageKey
levelKey: "level", // --levelKey
// messageFormat: false, // --messageFormat
// timestampKey: "time", // --timestampKey
// translateTime: false, // --translateTime
// search: "foo == `bar`", // --search
// ignore: "pid,hostname", // --ignore
// hideObject: false, // --hideObject
// singleLine: false,
});

View File

@@ -0,0 +1,323 @@
/*
* MIT License
*
* Copyright (c) 2021 Rishi Ghan
*
The MIT License (MIT)
Copyright (c) 2021 Rishi Ghan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
/*
* Revision History:
* Initial: 2021/05/04 Rishi Ghan
*/
import { createReadStream, createWriteStream } from "fs";
import path from "path";
import { default as unzipper } from "unzipper";
import _ from "lodash";
import { each, isEmpty, map, remove, indexOf } from "lodash";
import {
IExplodedPathResponse,
IExtractComicBookCoverErrorResponse,
IExtractedComicBookCoverFile,
IExtractionOptions,
IFolderData,
} from "../interfaces/folder.interface";
import { logger } from "./logger.utils";
const { writeFile, readFile } = require("fs").promises;
const sharp = require("sharp");
const unrarer = require("node-unrar-js");
const Walk = require("@root/walk");
const fse = require("fs-extra");
export const unrar = async (
extractionOptions: IExtractionOptions,
walkedFolder: IFolderData,
): Promise<
| IExtractedComicBookCoverFile
| IExtractedComicBookCoverFile[]
| IExtractComicBookCoverErrorResponse
> => {
const paths = constructPaths(extractionOptions, walkedFolder);
const directoryOptions = {
mode: 0o2775,
};
const fileBuffer = await readFile(paths.inputFilePath).catch(err =>
console.error("Failed to read file", err),
);
try {
await fse.ensureDir(paths.targetPath, directoryOptions);
logger.info(`${paths.targetPath} was created.`);
} catch (error) {
logger.error(`${error}: Couldn't create directory.`);
}
const extractor = await unrarer.createExtractorFromData({ data: fileBuffer });
switch (extractionOptions.extractTarget) {
case "cover":
return new Promise(async (resolve, reject) => {
try {
let fileNameToExtract = "";
const list = extractor.getFileList();
const fileHeaders = [...list.fileHeaders];
each(fileHeaders, async fileHeader => {
const fileName = explodePath(fileHeader.name).fileName;
if (
fileName !== "" &&
fileHeader.flags.directory === false &&
isEmpty(fileNameToExtract)
) {
logger.info(`Attempting to write ${fileHeader.name}`);
fileNameToExtract = fileHeader.name;
const file = extractor.extract({ files: [fileHeader.name] });
const extractedFile = [...file.files][0];
const fileArrayBuffer = extractedFile.extraction;
await writeFile(
paths.targetPath + "/" + fileName,
fileArrayBuffer,
);
resolve({
name: `${fileName}`,
path: paths.targetPath,
fileSize: fileHeader.packSize,
});
}
});
} catch (error) {
logger.error(`${error}: Couldn't write file.`);
reject(error);
}
});
case "all":
return new Promise(async (resolve, reject) => {
try {
const files = extractor.extract({});
const extractedFiles = [...files.files];
const comicBookCoverFiles: IExtractedComicBookCoverFile[] = [];
for (const file of extractedFiles) {
logger.info(`Attempting to write ${file.fileHeader.name}`);
const fileBuffer = file.extraction;
const fileName = explodePath(file.fileHeader.name).fileName;
if (fileName !== "" && file.fileHeader.flags.directory === false) {
await writeFile(paths.targetPath + "/" + fileName, fileBuffer);
}
comicBookCoverFiles.push({
name: `${file.fileHeader.name}`,
path: paths.targetPath,
fileSize: file.fileHeader.packSize,
});
}
resolve(_.flatten(comicBookCoverFiles));
} catch (error) {
resolve({
message: `${error}`,
errorCode: "500",
data: walkedFolder.name,
});
}
});
default:
return {
message: "File format not supported, yet.",
errorCode: "90",
data: "asda",
};
}
};
export const unzip = async (
extractionOptions: IExtractionOptions,
walkedFolder: IFolderData,
): Promise<
| IExtractedComicBookCoverFile[]
| IExtractedComicBookCoverFile
| IExtractComicBookCoverErrorResponse
> => {
const directoryOptions = {
mode: 0o2775,
};
const paths = constructPaths(extractionOptions, walkedFolder);
try {
await fse.ensureDir(paths.targetPath, directoryOptions);
logger.info(`${paths.targetPath} was created or already exists.`);
} catch (error) {
logger.error(`${error} Couldn't create directory.`);
}
const extractedFiles: IExtractedComicBookCoverFile[] = [];
const zip = createReadStream(paths.inputFilePath).pipe(
unzipper.Parse({ forceStream: true }),
);
for await (const entry of zip) {
const fileName = explodePath(entry.path).fileName;
const size = entry.vars.uncompressedSize;
if (
extractedFiles.length === 1 &&
extractionOptions.extractTarget === "cover"
) {
break;
}
if (fileName !== "" && entry.type !== "Directory") {
logger.info(`Attempting to write ${fileName}`);
entry.pipe(createWriteStream(paths.targetPath + "/" + fileName));
extractedFiles.push({
name: fileName,
fileSize: size,
path: paths.targetPath,
});
}
entry.autodrain();
}
return new Promise(async (resolve, reject) => {
logger.info("");
resolve(_.flatten(extractedFiles));
});
};
export const extractArchive = async (
extractionOptions: IExtractionOptions,
walkedFolder: IFolderData,
): Promise<
| IExtractedComicBookCoverFile
| IExtractedComicBookCoverFile[]
| IExtractComicBookCoverErrorResponse
> => {
switch (walkedFolder.extension) {
case ".cbz":
return await unzip(extractionOptions, walkedFolder);
case ".cbr":
return await unrar(extractionOptions, walkedFolder);
default:
return {
message: "File format not supported, yet.",
errorCode: "90",
data: `${extractionOptions}`,
};
}
};
export const getCovers = async (
options: IExtractionOptions,
walkedFolders: IFolderData[],
): Promise<
| IExtractedComicBookCoverFile
| IExtractComicBookCoverErrorResponse
| IExtractedComicBookCoverFile[]
| (
| IExtractedComicBookCoverFile
| IExtractComicBookCoverErrorResponse
| IExtractedComicBookCoverFile[]
)[]
| IExtractComicBookCoverErrorResponse
> => {
switch (options.extractionMode) {
case "bulk":
const extractedDataPromises = map(walkedFolders, async folder => await extractArchive(options, folder));
return Promise.all(extractedDataPromises).then(data => _.flatten(data));
case "single":
return await extractArchive(options, walkedFolders[0]);
default:
logger.error("Unknown extraction mode selected.");
return {
message: "Unknown extraction mode selected.",
errorCode: "90",
data: `${options}`,
};
}
};
export const walkFolder = async (folder: string): Promise<IFolderData[]> => {
const result: IFolderData[] = [];
let walkResult: IFolderData = {
name: "",
extension: "",
containedIn: "",
isFile: false,
isLink: true,
};
const walk = Walk.create({ sort: filterOutDotFiles });
await walk(folder, async (err, pathname, dirent) => {
if (err) {
logger.error("Failed to lstat directory", { error: err });
return false;
}
if ([".cbz", ".cbr"].includes(path.extname(dirent.name))) {
walkResult = {
name: path.basename(dirent.name, path.extname(dirent.name)),
extension: path.extname(dirent.name),
containedIn: path.dirname(pathname),
isFile: dirent.isFile(),
isLink: dirent.isSymbolicLink(),
};
logger.info(
`Scanned ${dirent.name} contained in ${path.dirname(pathname)}`,
);
result.push(walkResult);
}
});
return result;
};
export const explodePath = (filePath: string): IExplodedPathResponse => {
const exploded = filePath.split("/");
const fileName = remove(exploded, item => indexOf(exploded, item) === exploded.length - 1).join("");
return {
exploded,
fileName,
};
};
const constructPaths = (
extractionOptions: IExtractionOptions,
walkedFolder: IFolderData,
) => ({
targetPath:
extractionOptions.targetExtractionFolder + "/" + walkedFolder.name,
inputFilePath:
walkedFolder.containedIn +
"/" +
walkedFolder.name +
walkedFolder.extension,
});
export const extractMetadataFromImage = async (
imageFilePath: string,
): Promise<unknown> => {
const image = await sharp(imageFilePath)
.metadata()
.then(function(metadata) {
return metadata;
});
return image;
};
const filterOutDotFiles = entities => entities.filter(ent => !ent.name.startsWith("."));