Compare commits
5 Commits
graphql-re
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e24db22ac6 | ||
|
|
4d50f22df4 | ||
| c604bd8e4d | |||
| 2e31f6cf49 | |||
| b753481754 |
25
.eslintrc.js
25
.eslintrc.js
@@ -4,7 +4,10 @@ module.exports = {
|
|||||||
es6: true,
|
es6: true,
|
||||||
node: true
|
node: true
|
||||||
},
|
},
|
||||||
ignorePatterns: [ "test/*"],
|
extends: [
|
||||||
|
"eslint:recommended"
|
||||||
|
],
|
||||||
|
ignorePatterns: ["test/*", ".eslintrc.js"],
|
||||||
parser: "@typescript-eslint/parser",
|
parser: "@typescript-eslint/parser",
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: "tsconfig.json",
|
project: "tsconfig.json",
|
||||||
@@ -14,8 +17,6 @@ module.exports = {
|
|||||||
rules: {
|
rules: {
|
||||||
"@typescript-eslint/adjacent-overload-signatures": "error",
|
"@typescript-eslint/adjacent-overload-signatures": "error",
|
||||||
"@typescript-eslint/array-type": "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-assertions": "error",
|
||||||
"@typescript-eslint/consistent-type-definitions": "error",
|
"@typescript-eslint/consistent-type-definitions": "error",
|
||||||
"@typescript-eslint/explicit-member-accessibility": [
|
"@typescript-eslint/explicit-member-accessibility": [
|
||||||
@@ -24,19 +25,6 @@ module.exports = {
|
|||||||
accessibility: "explicit"
|
accessibility: "explicit"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"@typescript-eslint/indent": [
|
|
||||||
"off",
|
|
||||||
4,
|
|
||||||
{
|
|
||||||
FunctionDeclaration: {
|
|
||||||
parameters: "first"
|
|
||||||
},
|
|
||||||
FunctionExpression: {
|
|
||||||
parameters: "first"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"@typescript-eslint/interface-name-prefix": "off",
|
|
||||||
"@typescript-eslint/member-delimiter-style": [
|
"@typescript-eslint/member-delimiter-style": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
@@ -56,9 +44,8 @@ module.exports = {
|
|||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
"@typescript-eslint/no-misused-new": "error",
|
"@typescript-eslint/no-misused-new": "error",
|
||||||
"@typescript-eslint/no-namespace": "error",
|
"@typescript-eslint/no-namespace": "error",
|
||||||
"@typescript-eslint/no-parameter-properties": "off",
|
|
||||||
"@typescript-eslint/no-use-before-define": "off",
|
"@typescript-eslint/no-use-before-define": "off",
|
||||||
"@typescript-eslint/no-var-requires": "error",
|
"@typescript-eslint/no-require-imports": "off",
|
||||||
"@typescript-eslint/prefer-for-of": "error",
|
"@typescript-eslint/prefer-for-of": "error",
|
||||||
"@typescript-eslint/prefer-function-type": "error",
|
"@typescript-eslint/prefer-function-type": "error",
|
||||||
"@typescript-eslint/prefer-namespace-keyword": "error",
|
"@typescript-eslint/prefer-namespace-keyword": "error",
|
||||||
@@ -85,7 +72,7 @@ module.exports = {
|
|||||||
"eol-last": "error",
|
"eol-last": "error",
|
||||||
eqeqeq: ["error", "smart"],
|
eqeqeq: ["error", "smart"],
|
||||||
"guard-for-in": "error",
|
"guard-for-in": "error",
|
||||||
"id-blacklist": ["error", "any", "Number", "number", "String", "string", "Boolean", "boolean", "Undefined", "undefined"],
|
"id-denylist": ["error", "any", "Number", "number", "String", "string", "Boolean", "boolean", "Undefined", "undefined"],
|
||||||
"id-match": "error",
|
"id-match": "error",
|
||||||
"import/order": "error",
|
"import/order": "error",
|
||||||
"max-classes-per-file": ["error", 1],
|
"max-classes-per-file": ["error", 1],
|
||||||
|
|||||||
337
README_SCHEMA_STITCHING.md
Normal file
337
README_SCHEMA_STITCHING.md
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
# GraphQL Schema Stitching - Unified Gateway
|
||||||
|
|
||||||
|
This service now implements **GraphQL Schema Stitching** to combine multiple GraphQL schemas into a single unified endpoint.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Client Application │
|
||||||
|
└────────────────────────┬────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ Single GraphQL Endpoint
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ API Gateway (port 3080) │
|
||||||
|
│ /graphql endpoint │
|
||||||
|
└────────────────────────┬────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ Moleculer RPC
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Gateway Service (Schema Stitching) │
|
||||||
|
│ - Combines local + remote schemas │
|
||||||
|
│ - Routes queries to appropriate service │
|
||||||
|
└────────┬────────────────────────────────────┬───────────────┘
|
||||||
|
│ │
|
||||||
|
│ Local Schema │ Remote Schema
|
||||||
|
▼ ▼
|
||||||
|
┌────────────────────┐ ┌────────────────────────┐
|
||||||
|
│ Local Services │ │ Remote GraphQL Server │
|
||||||
|
│ - ComicVine │ │ (port 3000) │
|
||||||
|
│ - Metron │ │ - Core Service │
|
||||||
|
│ - Metadata │ │ - Other queries │
|
||||||
|
└────────────────────┘ └────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## What is Schema Stitching?
|
||||||
|
|
||||||
|
Schema stitching combines multiple GraphQL schemas into a single unified schema. This allows you to:
|
||||||
|
|
||||||
|
1. **Query multiple services** through a single endpoint
|
||||||
|
2. **Combine data** from different sources in one request
|
||||||
|
3. **Maintain service independence** while providing a unified API
|
||||||
|
4. **Gradually migrate** services without breaking existing clients
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Set the remote GraphQL server URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export REMOTE_GRAPHQL_URL="http://localhost:3000/graphql"
|
||||||
|
```
|
||||||
|
|
||||||
|
If not set, it defaults to `http://localhost:3000/graphql`.
|
||||||
|
|
||||||
|
### Service Files
|
||||||
|
|
||||||
|
- **[`services/gateway.service.ts`](services/gateway.service.ts)** - Gateway service with schema stitching logic
|
||||||
|
- **[`services/api.service.ts`](services/api.service.ts)** - API gateway routing to the gateway service
|
||||||
|
- **[`services/graphql.service.ts`](services/graphql.service.ts)** - Original local GraphQL service (still available)
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### 1. Schema Introspection
|
||||||
|
|
||||||
|
On startup, the gateway service:
|
||||||
|
1. Introspects the remote GraphQL server at port 3000
|
||||||
|
2. Builds a client schema from the introspection result
|
||||||
|
3. Creates a local executable schema from the metadata service
|
||||||
|
4. Stitches both schemas together
|
||||||
|
|
||||||
|
### 2. Query Routing
|
||||||
|
|
||||||
|
When a query is received:
|
||||||
|
1. The gateway analyzes the query
|
||||||
|
2. Routes local queries (ComicVine, Metron) to local resolvers
|
||||||
|
3. Routes remote queries to the remote GraphQL server
|
||||||
|
4. Combines results if the query spans both schemas
|
||||||
|
|
||||||
|
### 3. Fallback Behavior
|
||||||
|
|
||||||
|
If the remote server is unavailable:
|
||||||
|
- The gateway starts with **local schema only**
|
||||||
|
- Logs a warning about remote unavailability
|
||||||
|
- Continues to serve local queries normally
|
||||||
|
- Remote queries will fail gracefully
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Starting the Service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The unified GraphQL endpoint will be available at: `http://localhost:3080/graphql`
|
||||||
|
|
||||||
|
### Example: Local Query (Metadata Service)
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
query SearchComicVine {
|
||||||
|
searchComicVine(input: {
|
||||||
|
query: "Batman",
|
||||||
|
resources: "volume",
|
||||||
|
limit: 5
|
||||||
|
}) {
|
||||||
|
number_of_total_results
|
||||||
|
results {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
start_year
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Remote Query (Core Service on port 3000)
|
||||||
|
|
||||||
|
Assuming your remote server has queries like `getUser`, `getComics`, etc.:
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
query GetUser {
|
||||||
|
getUser(id: "123") {
|
||||||
|
id
|
||||||
|
username
|
||||||
|
email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Combined Query (Both Services)
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
query CombinedQuery {
|
||||||
|
# Local metadata service
|
||||||
|
searchComicVine(input: {
|
||||||
|
query: "Batman",
|
||||||
|
resources: "volume",
|
||||||
|
limit: 3
|
||||||
|
}) {
|
||||||
|
results {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remote core service
|
||||||
|
getUser(id: "123") {
|
||||||
|
id
|
||||||
|
username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
### 1. Single Endpoint
|
||||||
|
- Clients only need to know about one GraphQL endpoint
|
||||||
|
- Simplifies frontend configuration
|
||||||
|
- Easier to manage authentication/authorization
|
||||||
|
|
||||||
|
### 2. Flexible Queries
|
||||||
|
- Query data from multiple services in one request
|
||||||
|
- Reduce network round trips
|
||||||
|
- Better performance for complex data requirements
|
||||||
|
|
||||||
|
### 3. Service Independence
|
||||||
|
- Each service maintains its own schema
|
||||||
|
- Services can be developed and deployed independently
|
||||||
|
- No tight coupling between services
|
||||||
|
|
||||||
|
### 4. Gradual Migration
|
||||||
|
- Add new services without breaking existing clients
|
||||||
|
- Migrate queries between services transparently
|
||||||
|
- Maintain backward compatibility
|
||||||
|
|
||||||
|
## Monitoring & Debugging
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
The gateway service logs important events:
|
||||||
|
|
||||||
|
```
|
||||||
|
[GATEWAY] Initializing Apollo Gateway with Schema Stitching...
|
||||||
|
[GATEWAY] Attempting to introspect remote schema at http://localhost:3000/graphql
|
||||||
|
[GATEWAY] Successfully introspected remote schema
|
||||||
|
[GATEWAY] Stitching local and remote schemas together...
|
||||||
|
[GATEWAY] Schema stitching completed successfully
|
||||||
|
[GATEWAY] Apollo Gateway Server started successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
### Introspection
|
||||||
|
|
||||||
|
Query the stitched schema:
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
query IntrospectionQuery {
|
||||||
|
__schema {
|
||||||
|
queryType {
|
||||||
|
name
|
||||||
|
fields {
|
||||||
|
name
|
||||||
|
description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
Check gateway status:
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
query GetGatewayInfo {
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Remote Server Unavailable
|
||||||
|
|
||||||
|
**Symptom**: Warning logs about remote schema introspection failure
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Ensure the remote server is running on port 3000
|
||||||
|
2. Check the `REMOTE_GRAPHQL_URL` environment variable
|
||||||
|
3. Verify network connectivity
|
||||||
|
4. The gateway will continue with local schema only
|
||||||
|
|
||||||
|
### Query Routing Issues
|
||||||
|
|
||||||
|
**Symptom**: Queries to remote service fail or return null
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Check that the remote server is responding
|
||||||
|
2. Verify the query syntax matches the remote schema
|
||||||
|
3. Use introspection to see available fields
|
||||||
|
4. Check gateway logs for routing errors
|
||||||
|
|
||||||
|
### Type Conflicts
|
||||||
|
|
||||||
|
**Symptom**: Errors about duplicate types or conflicting definitions
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Ensure type names are unique across schemas
|
||||||
|
2. Use schema transformation if needed
|
||||||
|
3. Consider renaming conflicting types in one schema
|
||||||
|
4. Check the `mergeTypes` configuration in [`gateway.service.ts`](services/gateway.service.ts)
|
||||||
|
|
||||||
|
## Advanced Configuration
|
||||||
|
|
||||||
|
### Custom Executors
|
||||||
|
|
||||||
|
Modify the executor in [`gateway.service.ts`](services/gateway.service.ts:95) to add:
|
||||||
|
- Authentication headers
|
||||||
|
- Request logging
|
||||||
|
- Error handling
|
||||||
|
- Caching
|
||||||
|
|
||||||
|
### Schema Transformations
|
||||||
|
|
||||||
|
Use `@graphql-tools/wrap` to transform schemas:
|
||||||
|
- Rename types
|
||||||
|
- Filter fields
|
||||||
|
- Add custom directives
|
||||||
|
- Modify field arguments
|
||||||
|
|
||||||
|
### Performance Optimization
|
||||||
|
|
||||||
|
Consider implementing:
|
||||||
|
- **DataLoader** for batching requests
|
||||||
|
- **Response caching** at the gateway level
|
||||||
|
- **Query complexity analysis** to prevent expensive queries
|
||||||
|
- **Rate limiting** per client or query type
|
||||||
|
|
||||||
|
## Migration from Separate Endpoints
|
||||||
|
|
||||||
|
### Before (Separate Endpoints)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Frontend code
|
||||||
|
const metadataClient = new ApolloClient({
|
||||||
|
uri: 'http://localhost:3080/graphql'
|
||||||
|
});
|
||||||
|
|
||||||
|
const coreClient = new ApolloClient({
|
||||||
|
uri: 'http://localhost:3000/graphql'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (Unified Gateway)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Frontend code
|
||||||
|
const client = new ApolloClient({
|
||||||
|
uri: 'http://localhost:3080/graphql' // Single endpoint!
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comparison with Apollo Federation
|
||||||
|
|
||||||
|
| Feature | Schema Stitching | Apollo Federation |
|
||||||
|
|---------|------------------|-------------------|
|
||||||
|
| Setup Complexity | Moderate | Higher |
|
||||||
|
| Service Independence | Good | Excellent |
|
||||||
|
| Type Merging | Manual | Automatic |
|
||||||
|
| Best For | Existing services | New microservices |
|
||||||
|
| Learning Curve | Lower | Higher |
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [GraphQL API Documentation](README_GRAPHQL.md)
|
||||||
|
- [Architecture Overview](ARCHITECTURE.md)
|
||||||
|
- [Main README](README.md)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions:
|
||||||
|
1. Check the gateway service logs
|
||||||
|
2. Verify both servers are running
|
||||||
|
3. Test each service independently
|
||||||
|
4. Review the schema stitching configuration
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Add authentication/authorization at gateway level
|
||||||
|
- [ ] Implement DataLoader for batching
|
||||||
|
- [ ] Add response caching
|
||||||
|
- [ ] Implement query complexity analysis
|
||||||
|
- [ ] Add rate limiting
|
||||||
|
- [ ] Support for GraphQL subscriptions
|
||||||
|
- [ ] Schema transformation utilities
|
||||||
|
- [ ] Automated schema versioning
|
||||||
191
models/graphql/resolvers.ts
Normal file
191
models/graphql/resolvers.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* GraphQL Resolvers for ThreeTwo Metadata Service
|
||||||
|
* Maps GraphQL queries to Moleculer service actions
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const resolvers = {
|
||||||
|
Query: {
|
||||||
|
/**
|
||||||
|
* Search ComicVine for volumes, issues, characters, etc.
|
||||||
|
*/
|
||||||
|
searchComicVine: async (_: any, { input }: any, context: any) => {
|
||||||
|
const { broker } = context;
|
||||||
|
|
||||||
|
if (!broker) {
|
||||||
|
throw new Error("Broker not available in context");
|
||||||
|
}
|
||||||
|
|
||||||
|
return broker.call("comicvine.search", {
|
||||||
|
query: input.query,
|
||||||
|
resources: input.resources,
|
||||||
|
format: input.format || "json",
|
||||||
|
sort: input.sort,
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
field_list: input.fieldList,
|
||||||
|
limit: input.limit?.toString(),
|
||||||
|
offset: input.offset?.toString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advanced volume-based search with scoring and filtering
|
||||||
|
*/
|
||||||
|
volumeBasedSearch: async (_: any, { input }: any, context: any) => {
|
||||||
|
const { broker } = context;
|
||||||
|
|
||||||
|
if (!broker) {
|
||||||
|
throw new Error("Broker not available in context");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await broker.call("comicvine.volumeBasedSearch", {
|
||||||
|
query: input.query,
|
||||||
|
resources: input.resources,
|
||||||
|
format: input.format || "json",
|
||||||
|
limit: input.limit,
|
||||||
|
offset: input.offset,
|
||||||
|
fieldList: input.fieldList,
|
||||||
|
scorerConfiguration: input.scorerConfiguration,
|
||||||
|
rawFileDetails: input.rawFileDetails,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform the result to match GraphQL schema
|
||||||
|
return {
|
||||||
|
results: result.results || result,
|
||||||
|
totalResults: result.totalResults || result.length || 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get volume details by URI
|
||||||
|
*/
|
||||||
|
getVolume: async (_: any, { input }: any, context: any) => {
|
||||||
|
const { broker } = context;
|
||||||
|
|
||||||
|
if (!broker) {
|
||||||
|
throw new Error("Broker not available in context");
|
||||||
|
}
|
||||||
|
|
||||||
|
return broker.call("comicvine.getVolumes", {
|
||||||
|
volumeURI: input.volumeURI,
|
||||||
|
fieldList: input.fieldList,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all issues for a series by comic object ID
|
||||||
|
*/
|
||||||
|
getIssuesForSeries: async (_: any, { comicObjectId }: any, context: any) => {
|
||||||
|
const { broker } = context;
|
||||||
|
|
||||||
|
if (!broker) {
|
||||||
|
throw new Error("Broker not available in context");
|
||||||
|
}
|
||||||
|
|
||||||
|
return broker.call("comicvine.getIssuesForSeries", {
|
||||||
|
comicObjectId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get generic ComicVine resource (issues, volumes, etc.)
|
||||||
|
*/
|
||||||
|
getComicVineResource: async (_: any, { input }: any, context: any) => {
|
||||||
|
const { broker } = context;
|
||||||
|
|
||||||
|
if (!broker) {
|
||||||
|
throw new Error("Broker not available in context");
|
||||||
|
}
|
||||||
|
|
||||||
|
return broker.call("comicvine.getResource", {
|
||||||
|
resources: input.resources,
|
||||||
|
filter: input.filter,
|
||||||
|
fieldList: input.fieldList,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get story arcs for a volume
|
||||||
|
*/
|
||||||
|
getStoryArcs: async (_: any, { volumeId }: any, context: any) => {
|
||||||
|
const { broker } = context;
|
||||||
|
|
||||||
|
if (!broker) {
|
||||||
|
throw new Error("Broker not available in context");
|
||||||
|
}
|
||||||
|
|
||||||
|
return broker.call("comicvine.getStoryArcs", {
|
||||||
|
volumeId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get weekly pull list from League of Comic Geeks
|
||||||
|
*/
|
||||||
|
getWeeklyPullList: async (_: any, { input }: any, context: any) => {
|
||||||
|
const { broker } = context;
|
||||||
|
|
||||||
|
if (!broker) {
|
||||||
|
throw new Error("Broker not available in context");
|
||||||
|
}
|
||||||
|
|
||||||
|
const locgResponse = await broker.call("comicvine.getWeeklyPullList", {
|
||||||
|
startDate: input.startDate,
|
||||||
|
currentPage: input.currentPage.toString(),
|
||||||
|
pageSize: input.pageSize.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform LOCG response to match GraphQL schema
|
||||||
|
return {
|
||||||
|
result: locgResponse.result.map((item: any) => ({
|
||||||
|
name: item.issueName,
|
||||||
|
publisher: item.publisher,
|
||||||
|
url: item.issueUrl,
|
||||||
|
cover: item.coverImageUrl,
|
||||||
|
description: item.description || null,
|
||||||
|
price: item.price || null,
|
||||||
|
rating: item.rating || null,
|
||||||
|
pulls: item.pulls || null,
|
||||||
|
potw: item.potw || null,
|
||||||
|
publicationDate: item.publicationDate || null,
|
||||||
|
})),
|
||||||
|
meta: locgResponse.meta,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch resource from Metron API
|
||||||
|
*/
|
||||||
|
fetchMetronResource: async (_: any, { input }: any, context: any) => {
|
||||||
|
const { broker } = context;
|
||||||
|
|
||||||
|
if (!broker) {
|
||||||
|
throw new Error("Broker not available in context");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await broker.call("metron.fetchResource", {
|
||||||
|
resource: input.resource,
|
||||||
|
method: input.method,
|
||||||
|
query: input.query,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: result,
|
||||||
|
status: 200,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
Mutation: {
|
||||||
|
/**
|
||||||
|
* Placeholder for future mutations
|
||||||
|
*/
|
||||||
|
_empty: (): null => null,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Custom scalar resolver for JSON
|
||||||
|
JSON: {
|
||||||
|
__parseValue: (value: any): any => value,
|
||||||
|
__serialize: (value: any): any => value,
|
||||||
|
__parseLiteral: (ast: any): any => ast.value,
|
||||||
|
},
|
||||||
|
};
|
||||||
357
models/graphql/typedef.ts
Normal file
357
models/graphql/typedef.ts
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import { gql } from "graphql-tag";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GraphQL Type Definitions for ThreeTwo Metadata Service
|
||||||
|
* Covers ComicVine and Metron API endpoints
|
||||||
|
*/
|
||||||
|
export const typeDefs = gql`
|
||||||
|
# ============================================
|
||||||
|
# ComicVine Types
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Image URLs for various sizes
|
||||||
|
type ImageUrls {
|
||||||
|
icon_url: String
|
||||||
|
medium_url: String
|
||||||
|
screen_url: String
|
||||||
|
screen_large_url: String
|
||||||
|
small_url: String
|
||||||
|
super_url: String
|
||||||
|
thumb_url: String
|
||||||
|
tiny_url: String
|
||||||
|
original_url: String
|
||||||
|
image_tags: String
|
||||||
|
}
|
||||||
|
|
||||||
|
# Publisher information
|
||||||
|
type Publisher {
|
||||||
|
id: Int
|
||||||
|
name: String
|
||||||
|
api_detail_url: String
|
||||||
|
}
|
||||||
|
|
||||||
|
# Volume information
|
||||||
|
type Volume {
|
||||||
|
id: Int!
|
||||||
|
name: String!
|
||||||
|
api_detail_url: String
|
||||||
|
site_detail_url: String
|
||||||
|
start_year: String
|
||||||
|
publisher: Publisher
|
||||||
|
count_of_issues: Int
|
||||||
|
image: ImageUrls
|
||||||
|
description: String
|
||||||
|
deck: String
|
||||||
|
}
|
||||||
|
|
||||||
|
# Issue information
|
||||||
|
type Issue {
|
||||||
|
id: Int!
|
||||||
|
name: String
|
||||||
|
issue_number: String
|
||||||
|
api_detail_url: String
|
||||||
|
site_detail_url: String
|
||||||
|
cover_date: String
|
||||||
|
store_date: String
|
||||||
|
volume: Volume
|
||||||
|
image: ImageUrls
|
||||||
|
description: String
|
||||||
|
person_credits: [PersonCredit!]
|
||||||
|
character_credits: [CharacterCredit!]
|
||||||
|
team_credits: [TeamCredit!]
|
||||||
|
location_credits: [LocationCredit!]
|
||||||
|
story_arc_credits: [StoryArcCredit!]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Person credit (writer, artist, etc.)
|
||||||
|
type PersonCredit {
|
||||||
|
id: Int
|
||||||
|
name: String
|
||||||
|
api_detail_url: String
|
||||||
|
site_detail_url: String
|
||||||
|
role: String
|
||||||
|
}
|
||||||
|
|
||||||
|
# Character credit
|
||||||
|
type CharacterCredit {
|
||||||
|
id: Int
|
||||||
|
name: String
|
||||||
|
api_detail_url: String
|
||||||
|
site_detail_url: String
|
||||||
|
}
|
||||||
|
|
||||||
|
# Team credit
|
||||||
|
type TeamCredit {
|
||||||
|
id: Int
|
||||||
|
name: String
|
||||||
|
api_detail_url: String
|
||||||
|
site_detail_url: String
|
||||||
|
}
|
||||||
|
|
||||||
|
# Location credit
|
||||||
|
type LocationCredit {
|
||||||
|
id: Int
|
||||||
|
name: String
|
||||||
|
api_detail_url: String
|
||||||
|
site_detail_url: String
|
||||||
|
}
|
||||||
|
|
||||||
|
# Story arc credit
|
||||||
|
type StoryArcCredit {
|
||||||
|
id: Int
|
||||||
|
name: String
|
||||||
|
api_detail_url: String
|
||||||
|
site_detail_url: String
|
||||||
|
deck: String
|
||||||
|
description: String
|
||||||
|
image: ImageUrls
|
||||||
|
}
|
||||||
|
|
||||||
|
# ComicVine search result
|
||||||
|
type ComicVineSearchResult {
|
||||||
|
error: String!
|
||||||
|
limit: Int!
|
||||||
|
offset: Int!
|
||||||
|
number_of_page_results: Int!
|
||||||
|
number_of_total_results: Int!
|
||||||
|
status_code: Int!
|
||||||
|
results: [SearchResultItem!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generic search result item (can be volume, issue, etc.)
|
||||||
|
type SearchResultItem {
|
||||||
|
id: Int
|
||||||
|
name: String
|
||||||
|
api_detail_url: String
|
||||||
|
site_detail_url: String
|
||||||
|
image: ImageUrls
|
||||||
|
description: String
|
||||||
|
deck: String
|
||||||
|
# Volume-specific fields
|
||||||
|
start_year: String
|
||||||
|
publisher: Publisher
|
||||||
|
count_of_issues: Int
|
||||||
|
# Issue-specific fields
|
||||||
|
issue_number: String
|
||||||
|
volume: Volume
|
||||||
|
cover_date: String
|
||||||
|
}
|
||||||
|
|
||||||
|
# Volume-based search result with scoring
|
||||||
|
type VolumeSearchResult {
|
||||||
|
volume: Volume!
|
||||||
|
score: Float
|
||||||
|
matchedIssues: [Issue!]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Volume-based search response
|
||||||
|
type VolumeBasedSearchResponse {
|
||||||
|
results: [VolumeSearchResult!]!
|
||||||
|
totalResults: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
# Weekly pull list item (from League of Comic Geeks)
|
||||||
|
type MetadataPullListItem {
|
||||||
|
name: String
|
||||||
|
publisher: String
|
||||||
|
url: String
|
||||||
|
cover: String
|
||||||
|
description: String
|
||||||
|
price: String
|
||||||
|
rating: Float
|
||||||
|
pulls: Int
|
||||||
|
potw: Int
|
||||||
|
publicationDate: String
|
||||||
|
}
|
||||||
|
|
||||||
|
# Paginated pull list response
|
||||||
|
type MetadataPullListResponse {
|
||||||
|
result: [MetadataPullListItem!]!
|
||||||
|
meta: MetadataPaginationMeta!
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pagination metadata
|
||||||
|
type MetadataPaginationMeta {
|
||||||
|
currentPage: Int!
|
||||||
|
totalPages: Int!
|
||||||
|
pageSize: Int!
|
||||||
|
totalCount: Int!
|
||||||
|
hasNextPage: Boolean!
|
||||||
|
hasPreviousPage: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
# Story arc with enriched data
|
||||||
|
type StoryArc {
|
||||||
|
id: Int!
|
||||||
|
name: String!
|
||||||
|
deck: String
|
||||||
|
description: String
|
||||||
|
image: ImageUrls
|
||||||
|
issues: [Issue!]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generic ComicVine resource response
|
||||||
|
type ComicVineResourceResponse {
|
||||||
|
error: String!
|
||||||
|
limit: Int!
|
||||||
|
offset: Int!
|
||||||
|
number_of_page_results: Int!
|
||||||
|
number_of_total_results: Int!
|
||||||
|
status_code: Int!
|
||||||
|
results: [SearchResultItem!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
# Volume detail response
|
||||||
|
type VolumeDetailResponse {
|
||||||
|
error: String!
|
||||||
|
status_code: Int!
|
||||||
|
results: Volume!
|
||||||
|
}
|
||||||
|
|
||||||
|
# Issues for series response
|
||||||
|
type IssuesForSeriesResponse {
|
||||||
|
error: String!
|
||||||
|
limit: Int!
|
||||||
|
offset: Int!
|
||||||
|
number_of_page_results: Int!
|
||||||
|
number_of_total_results: Int!
|
||||||
|
status_code: Int!
|
||||||
|
results: [Issue!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Metron Types
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Generic Metron resource (flexible JSON response)
|
||||||
|
scalar JSON
|
||||||
|
|
||||||
|
type MetronResponse {
|
||||||
|
data: JSON
|
||||||
|
status: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Input Types
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Search parameters
|
||||||
|
input SearchInput {
|
||||||
|
query: String!
|
||||||
|
resources: String!
|
||||||
|
format: String
|
||||||
|
sort: String
|
||||||
|
field_list: String
|
||||||
|
limit: Int
|
||||||
|
offset: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
# Volume-based search configuration
|
||||||
|
input VolumeSearchInput {
|
||||||
|
query: String!
|
||||||
|
resources: String!
|
||||||
|
format: String
|
||||||
|
limit: Int
|
||||||
|
offset: Int
|
||||||
|
fieldList: String
|
||||||
|
scorerConfiguration: ScorerConfigurationInput
|
||||||
|
rawFileDetails: JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
# Scorer configuration for matching
|
||||||
|
input ScorerConfigurationInput {
|
||||||
|
searchParams: SearchParamsInput
|
||||||
|
}
|
||||||
|
|
||||||
|
# Search parameters for scoring
|
||||||
|
input SearchParamsInput {
|
||||||
|
name: String
|
||||||
|
number: String
|
||||||
|
year: String
|
||||||
|
volume: String
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get volumes input
|
||||||
|
input GetVolumesInput {
|
||||||
|
volumeURI: String!
|
||||||
|
fieldList: String
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get resource input
|
||||||
|
input GetResourceInput {
|
||||||
|
resources: String!
|
||||||
|
filter: String
|
||||||
|
fieldList: String
|
||||||
|
}
|
||||||
|
|
||||||
|
# Weekly pull list input
|
||||||
|
input WeeklyPullListInput {
|
||||||
|
startDate: String!
|
||||||
|
currentPage: Int!
|
||||||
|
pageSize: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
# Metron fetch resource input
|
||||||
|
input MetronFetchInput {
|
||||||
|
resource: String!
|
||||||
|
method: String!
|
||||||
|
query: String
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Queries
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
"""
|
||||||
|
Search ComicVine for volumes, issues, characters, etc.
|
||||||
|
"""
|
||||||
|
searchComicVine(input: SearchInput!): ComicVineSearchResult!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Advanced volume-based search with scoring and filtering
|
||||||
|
"""
|
||||||
|
volumeBasedSearch(input: VolumeSearchInput!): VolumeBasedSearchResponse!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Get volume details by URI
|
||||||
|
"""
|
||||||
|
getVolume(input: GetVolumesInput!): VolumeDetailResponse!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Get all issues for a series by comic object ID
|
||||||
|
"""
|
||||||
|
getIssuesForSeries(comicObjectId: ID!): IssuesForSeriesResponse!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Get generic ComicVine resource (issues, volumes, etc.)
|
||||||
|
"""
|
||||||
|
getComicVineResource(input: GetResourceInput!): ComicVineResourceResponse!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Get story arcs for a volume
|
||||||
|
"""
|
||||||
|
getStoryArcs(volumeId: Int!): [StoryArc!]!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Get weekly pull list from League of Comic Geeks
|
||||||
|
"""
|
||||||
|
getWeeklyPullList(input: WeeklyPullListInput!): MetadataPullListResponse!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Fetch resource from Metron API
|
||||||
|
"""
|
||||||
|
fetchMetronResource(input: MetronFetchInput!): MetronResponse!
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Mutations
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
"""
|
||||||
|
Placeholder for future mutations
|
||||||
|
"""
|
||||||
|
_empty: String
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
BrokerOptions,
|
BrokerOptions,
|
||||||
Errors,
|
Errors,
|
||||||
MetricRegistry,
|
MetricRegistry,
|
||||||
ServiceBroker,
|
|
||||||
} from "moleculer";
|
} from "moleculer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,7 +90,7 @@ const brokerConfig: BrokerOptions = {
|
|||||||
// Backoff factor for delay. 2 means exponential backoff.
|
// Backoff factor for delay. 2 means exponential backoff.
|
||||||
factor: 2,
|
factor: 2,
|
||||||
// A function to check failed requests.
|
// A function to check failed requests.
|
||||||
check: (err: Errors.MoleculerError) => err && !!err.retryable,
|
check: (err: Errors.MoleculerError | Error) => err && !!(err as Errors.MoleculerError).retryable,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Limit of calling level. If it reaches the limit, broker will throw an MaxCallLevelError error. (Infinite loop protection)
|
// Limit of calling level. If it reaches the limit, broker will throw an MaxCallLevelError error. (Infinite loop protection)
|
||||||
@@ -138,7 +137,7 @@ const brokerConfig: BrokerOptions = {
|
|||||||
// Number of milliseconds to switch from open to half-open state
|
// Number of milliseconds to switch from open to half-open state
|
||||||
halfOpenTime: 10 * 1000,
|
halfOpenTime: 10 * 1000,
|
||||||
// A function to check failed requests.
|
// A function to check failed requests.
|
||||||
check: (err: Errors.MoleculerError) => err && err.code >= 500,
|
check: (err: Errors.MoleculerError | Error) => err && (err as Errors.MoleculerError).code >= 500,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Settings of bulkhead feature. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Bulkhead
|
// Settings of bulkhead feature. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Bulkhead
|
||||||
@@ -154,7 +153,7 @@ const brokerConfig: BrokerOptions = {
|
|||||||
// Enable action & event parameter validation. More info: https://moleculer.services/docs/0.14/validating.html
|
// Enable action & event parameter validation. More info: https://moleculer.services/docs/0.14/validating.html
|
||||||
validator: true,
|
validator: true,
|
||||||
|
|
||||||
errorHandler: null,
|
errorHandler: undefined,
|
||||||
|
|
||||||
// Enable/disable built-in metrics function. More info: https://moleculer.services/docs/0.14/metrics.html
|
// Enable/disable built-in metrics function. More info: https://moleculer.services/docs/0.14/metrics.html
|
||||||
metrics: {
|
metrics: {
|
||||||
|
|||||||
25829
package-lock.json
generated
25829
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
57
package.json
57
package.json
@@ -21,48 +21,53 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "^9.7.0",
|
"@faker-js/faker": "^9.7.0",
|
||||||
"@types/jsdom": "^16.2.14",
|
"@types/jsdom": "^21.1.6",
|
||||||
"@types/lodash": "^4.14.171",
|
"@types/lodash": "^4.14.171",
|
||||||
"@types/string-similarity": "^4.0.0",
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^2.26.0",
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
"@typescript-eslint/parser": "^2.26.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint": "^6.8.0",
|
"eslint-plugin-import": "^2.29.1",
|
||||||
"eslint-plugin-import": "^2.20.2",
|
"eslint-plugin-prefer-arrow": "^1.2.3",
|
||||||
"eslint-plugin-prefer-arrow": "^1.2.2",
|
"jest": "^29.7.0",
|
||||||
"jest": "^25.1.0",
|
"jest-cli": "^29.7.0",
|
||||||
"jest-cli": "^25.1.0",
|
"moleculer-repl": "^0.7.4",
|
||||||
"moleculer-repl": "^0.6.2",
|
|
||||||
"puppeteer": "^24.7.1",
|
"puppeteer": "^24.7.1",
|
||||||
"puppeteer-extra": "^3.3.6",
|
"puppeteer-extra": "^3.3.6",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"telnet-client": "^2.2.5",
|
"telnet-client": "^2.2.5",
|
||||||
"threetwo-ui-typings": "^1.0.14",
|
"threetwo-ui-typings": "^1.0.14",
|
||||||
"ts-jest": "^25.3.0",
|
"ts-jest": "^29.1.2",
|
||||||
"ts-node": "^8.8.1"
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/axios": "^0.14.0",
|
"@graphql-tools/delegate": "^12.0.8",
|
||||||
"@types/jest": "^25.1.4",
|
"@graphql-tools/schema": "^10.0.31",
|
||||||
|
"@graphql-tools/stitch": "^10.1.12",
|
||||||
|
"@graphql-tools/utils": "^11.0.0",
|
||||||
|
"@graphql-tools/wrap": "^11.1.8",
|
||||||
|
"@types/jest": "^29.5.12",
|
||||||
"@types/mkdirp": "^1.0.0",
|
"@types/mkdirp": "^1.0.0",
|
||||||
"@types/node": "^13.9.8",
|
"@types/node": "^13.9.8",
|
||||||
"axios": "^0.21.1",
|
"axios": "^1.7.7",
|
||||||
"comicgeeks": "^1.1.0",
|
"comicgeeks": "^1.1.0",
|
||||||
"date-fns": "^2.27.0",
|
"date-fns": "^2.27.0",
|
||||||
"delay": "^5.0.0",
|
"delay": "^5.0.0",
|
||||||
"dotenv": "^10.0.0",
|
"dotenv": "^10.0.0",
|
||||||
"got": "^12.0.1",
|
"got": "^12.0.1",
|
||||||
|
"graphql": "^16.13.1",
|
||||||
|
"graphql-tag": "^2.12.6",
|
||||||
"imghash": "^0.0.9",
|
"imghash": "^0.0.9",
|
||||||
"ioredis": "^4.28.1",
|
"ioredis": "^4.28.1",
|
||||||
"jsdom": "^19.0.0",
|
"jsdom": "^24.1.0",
|
||||||
"leven": "^3.1.0",
|
"leven": "^3.1.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moleculer": "^0.14.28",
|
"moleculer": "^0.14.28",
|
||||||
|
"moleculer-apollo-server": "^0.4.0",
|
||||||
"moleculer-web": "^0.10.5",
|
"moleculer-web": "^0.10.5",
|
||||||
"nats": "^1.3.2",
|
"nats": "^1.3.2",
|
||||||
"paginate-info": "^1.0.4",
|
"paginate-info": "^1.0.4",
|
||||||
"query-string": "^7.0.1",
|
"query-string": "^7.0.1"
|
||||||
"string-similarity": "^4.0.4",
|
|
||||||
"typescript": "^3.8.3"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.x.x"
|
"node": ">= 10.x.x"
|
||||||
@@ -76,15 +81,15 @@
|
|||||||
"js"
|
"js"
|
||||||
],
|
],
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(ts|tsx)$": "ts-jest"
|
"^.+\\.(ts|tsx)$": [
|
||||||
|
"ts-jest",
|
||||||
|
{
|
||||||
|
"tsconfig": "tsconfig.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"testMatch": [
|
"testMatch": [
|
||||||
"**/*.spec.(ts|js)"
|
"**/*.spec.(ts|js)"
|
||||||
],
|
]
|
||||||
"globals": {
|
|
||||||
"ts-jest": {
|
|
||||||
"tsConfig": "tsconfig.json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { IncomingMessage } from "http";
|
import { Service, ServiceBroker } from "moleculer";
|
||||||
import { Service, ServiceBroker, Context } from "moleculer";
|
|
||||||
import ApiGateway from "moleculer-web";
|
import ApiGateway from "moleculer-web";
|
||||||
|
|
||||||
export default class ApiService extends Service {
|
export default class ApiService extends Service {
|
||||||
@@ -56,6 +55,163 @@ export default class ApiService extends Service {
|
|||||||
// Enable/disable logging
|
// Enable/disable logging
|
||||||
logging: true,
|
logging: true,
|
||||||
},
|
},
|
||||||
|
// GraphQL Gateway endpoint with schema stitching
|
||||||
|
{
|
||||||
|
path: "/graphql",
|
||||||
|
whitelist: ["gateway.query"],
|
||||||
|
cors: {
|
||||||
|
origin: "*",
|
||||||
|
methods: ["GET", "POST", "OPTIONS"],
|
||||||
|
allowedHeaders: ["*"],
|
||||||
|
exposedHeaders: [],
|
||||||
|
credentials: false,
|
||||||
|
maxAge: 3600,
|
||||||
|
},
|
||||||
|
aliases: {
|
||||||
|
"POST /": async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { query, variables, operationName } = req.body;
|
||||||
|
|
||||||
|
const result = await req.$ctx.broker.call("gateway.query", {
|
||||||
|
query,
|
||||||
|
variables,
|
||||||
|
operationName,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify(result));
|
||||||
|
} catch (error: any) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
errors: [{
|
||||||
|
message: error.message,
|
||||||
|
extensions: {
|
||||||
|
code: error.code || "INTERNAL_SERVER_ERROR",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GET /": async (req: any, res: any) => {
|
||||||
|
// Support GraphQL Playground/introspection via GET
|
||||||
|
const query = req.$params.query;
|
||||||
|
const variables = req.$params.variables
|
||||||
|
? JSON.parse(req.$params.variables)
|
||||||
|
: undefined;
|
||||||
|
const operationName = req.$params.operationName;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await req.$ctx.broker.call("gateway.query", {
|
||||||
|
query,
|
||||||
|
variables,
|
||||||
|
operationName,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify(result));
|
||||||
|
} catch (error: any) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
errors: [{
|
||||||
|
message: error.message,
|
||||||
|
extensions: {
|
||||||
|
code: error.code || "INTERNAL_SERVER_ERROR",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bodyParsers: {
|
||||||
|
json: {
|
||||||
|
strict: false,
|
||||||
|
limit: "1MB",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mappingPolicy: "restrict",
|
||||||
|
logging: true,
|
||||||
|
},
|
||||||
|
// Standalone metadata GraphQL endpoint (no stitching)
|
||||||
|
// This endpoint exposes only the local metadata schema for external services to stitch
|
||||||
|
{
|
||||||
|
path: "/metadata-graphql",
|
||||||
|
whitelist: ["gateway.queryLocal"],
|
||||||
|
cors: {
|
||||||
|
origin: "*",
|
||||||
|
methods: ["GET", "POST", "OPTIONS"],
|
||||||
|
allowedHeaders: ["*"],
|
||||||
|
exposedHeaders: [],
|
||||||
|
credentials: false,
|
||||||
|
maxAge: 3600,
|
||||||
|
},
|
||||||
|
aliases: {
|
||||||
|
"POST /": async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { query, variables, operationName } = req.body;
|
||||||
|
|
||||||
|
const result = await req.$ctx.broker.call("gateway.queryLocal", {
|
||||||
|
query,
|
||||||
|
variables,
|
||||||
|
operationName,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify(result));
|
||||||
|
} catch (error: any) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
errors: [{
|
||||||
|
message: error.message,
|
||||||
|
extensions: {
|
||||||
|
code: error.code || "INTERNAL_SERVER_ERROR",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GET /": async (req: any, res: any) => {
|
||||||
|
// Support GraphQL Playground/introspection via GET
|
||||||
|
const query = req.$params.query;
|
||||||
|
const variables = req.$params.variables
|
||||||
|
? JSON.parse(req.$params.variables)
|
||||||
|
: undefined;
|
||||||
|
const operationName = req.$params.operationName;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await req.$ctx.broker.call("gateway.queryLocal", {
|
||||||
|
query,
|
||||||
|
variables,
|
||||||
|
operationName,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify(result));
|
||||||
|
} catch (error: any) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
errors: [{
|
||||||
|
message: error.message,
|
||||||
|
extensions: {
|
||||||
|
code: error.code || "INTERNAL_SERVER_ERROR",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bodyParsers: {
|
||||||
|
json: {
|
||||||
|
strict: false,
|
||||||
|
limit: "1MB",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mappingPolicy: "restrict",
|
||||||
|
logging: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Do not log client side errors (does not log an error response when the error.code is 400<=X<500)
|
// Do not log client side errors (does not log an error response when the error.code is 400<=X<500)
|
||||||
log4XXResponses: false,
|
log4XXResponses: false,
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
import { Service, ServiceBroker, Context } from "moleculer";
|
import { Service, ServiceBroker, Context } from "moleculer";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { isNil, isUndefined } from "lodash";
|
import { isNil } from "lodash";
|
||||||
import { fetchReleases, FilterTypes, SortTypes } from "comicgeeks";
|
|
||||||
import { matchScorer, rankVolumes } from "../utils/searchmatchscorer.utils";
|
import { matchScorer, rankVolumes } from "../utils/searchmatchscorer.utils";
|
||||||
import { scrapeIssuePage, getWeeklyPullList } from "../utils/scraping.utils";
|
import { getWeeklyPullList } from "../utils/scraping.utils";
|
||||||
const { calculateLimitAndOffset, paginate } = require("paginate-info");
|
const { calculateLimitAndOffset, paginate } = require("paginate-info");
|
||||||
const { MoleculerError } = require("moleculer").Errors;
|
const { MoleculerError } = require("moleculer").Errors;
|
||||||
|
|
||||||
@@ -25,7 +24,7 @@ export default class ComicVineService extends Service {
|
|||||||
format: string;
|
format: string;
|
||||||
sort: string;
|
sort: string;
|
||||||
query: string;
|
query: string;
|
||||||
field_list: string;
|
fieldList: string;
|
||||||
limit: string;
|
limit: string;
|
||||||
offset: string;
|
offset: string;
|
||||||
resources: string;
|
resources: string;
|
||||||
@@ -61,10 +60,11 @@ export default class ComicVineService extends Service {
|
|||||||
process.env.COMICVINE_API_KEY,
|
process.env.COMICVINE_API_KEY,
|
||||||
params: {
|
params: {
|
||||||
format: "json",
|
format: "json",
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
field_list: fieldList,
|
field_list: fieldList,
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
"Accept": "application/json",
|
||||||
"User-Agent": "ThreeTwo",
|
"User-Agent": "ThreeTwo",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -97,7 +97,7 @@ export default class ComicVineService extends Service {
|
|||||||
filter: `volume:${comicBookDetails.sourcedMetadata.comicvine.volumeInformation.id}`,
|
filter: `volume:${comicBookDetails.sourcedMetadata.comicvine.volumeInformation.id}`,
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
"Accept": "application/json",
|
||||||
"User-Agent": "ThreeTwo",
|
"User-Agent": "ThreeTwo",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -173,6 +173,7 @@ export default class ComicVineService extends Service {
|
|||||||
limit: "100",
|
limit: "100",
|
||||||
format: "json",
|
format: "json",
|
||||||
filter: `${filter}`,
|
filter: `${filter}`,
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
field_list: `${fieldList}`,
|
field_list: `${fieldList}`,
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
@@ -201,7 +202,7 @@ export default class ComicVineService extends Service {
|
|||||||
searchParams: {
|
searchParams: {
|
||||||
name: string;
|
name: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
number: string;
|
issueNumber?: string;
|
||||||
year: string;
|
year: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -209,40 +210,84 @@ export default class ComicVineService extends Service {
|
|||||||
}>
|
}>
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
console.log(
|
|
||||||
"Searching against: ",
|
|
||||||
ctx.params.scorerConfiguration.searchParams
|
|
||||||
);
|
|
||||||
const { rawFileDetails, scorerConfiguration } =
|
const { rawFileDetails, scorerConfiguration } =
|
||||||
ctx.params;
|
ctx.params;
|
||||||
|
if (!scorerConfiguration) {
|
||||||
|
throw new Error("scorerConfiguration is required");
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
"Searching against: ",
|
||||||
|
scorerConfiguration.searchParams
|
||||||
|
);
|
||||||
const results: any = [];
|
const results: any = [];
|
||||||
console.log(
|
console.log(
|
||||||
"passed to fetchVolumesFromCV",
|
"passed to fetchVolumesFromCV",
|
||||||
ctx.params
|
ctx.params
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Send initial status to client
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "CV_SCRAPING_STATUS",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
message: `Starting volume search for: ${scorerConfiguration.searchParams.name}`,
|
||||||
|
stage: "fetching_volumes",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
const volumes = await this.fetchVolumesFromCV(
|
const volumes = await this.fetchVolumesFromCV(
|
||||||
ctx.params,
|
ctx.params,
|
||||||
results
|
results
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Notify client that volume fetching is complete
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "CV_SCRAPING_STATUS",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
message: `Fetched ${volumes.length} volumes, now ranking matches...`,
|
||||||
|
stage: "ranking_volumes",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
// 1. Run the current batch of volumes through the matcher
|
// 1. Run the current batch of volumes through the matcher
|
||||||
const potentialVolumeMatches = rankVolumes(
|
const potentialVolumeMatches = rankVolumes(
|
||||||
volumes,
|
volumes,
|
||||||
ctx.params.scorerConfiguration
|
ctx.params.scorerConfiguration
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Sort by totalScore in descending order to prioritize best matches
|
||||||
|
potentialVolumeMatches.sort((a: any, b: any) => b.totalScore - a.totalScore);
|
||||||
|
|
||||||
|
// Notify client about ranked matches
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "CV_SCRAPING_STATUS",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
message: `Found ${potentialVolumeMatches.length} potential volume matches, searching for issues...`,
|
||||||
|
stage: "searching_issues",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
// 2. Construct the filter string
|
// 2. Construct the filter string
|
||||||
// 2a. volume: 1111|2222|3333
|
// 2a. volume: 1111|2222|3333
|
||||||
let volumeIdString = "volume:";
|
let volumeIdString = "volume:";
|
||||||
potentialVolumeMatches.map(
|
potentialVolumeMatches.map(
|
||||||
(volumeId: string, idx: number) => {
|
(volumeMatch: any, idx: number) => {
|
||||||
if (
|
if (
|
||||||
idx >=
|
idx >=
|
||||||
potentialVolumeMatches.length - 1
|
potentialVolumeMatches.length - 1
|
||||||
) {
|
) {
|
||||||
volumeIdString += `${volumeId}`;
|
volumeIdString += `${volumeMatch.id}`;
|
||||||
return volumeIdString;
|
return volumeIdString;
|
||||||
}
|
}
|
||||||
volumeIdString += `${volumeId}|`;
|
volumeIdString += `${volumeMatch.id}|`;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -250,20 +295,20 @@ export default class ComicVineService extends Service {
|
|||||||
let coverDateFilter = "";
|
let coverDateFilter = "";
|
||||||
if (
|
if (
|
||||||
!isNil(
|
!isNil(
|
||||||
ctx.params.scorerConfiguration.searchParams
|
scorerConfiguration.searchParams.year
|
||||||
.year
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
const issueYear = parseInt(
|
const issueYear = parseInt(
|
||||||
ctx.params.scorerConfiguration.searchParams
|
scorerConfiguration.searchParams.year,
|
||||||
.year,
|
|
||||||
10
|
10
|
||||||
);
|
);
|
||||||
coverDateFilter = `cover_date:${
|
coverDateFilter = `cover_date:${
|
||||||
issueYear - 1
|
issueYear - 1
|
||||||
}-01-01|${issueYear + 1}-12-31`;
|
}-01-01|${issueYear + 1}-12-31`;
|
||||||
}
|
}
|
||||||
const filterString = `issue_number:${ctx.params.scorerConfiguration.searchParams.number},${volumeIdString},${coverDateFilter}`;
|
// Access 'number' property from searchParams (may be passed as 'number' or 'issueNumber')
|
||||||
|
const issueNumber = (scorerConfiguration.searchParams as any).number || scorerConfiguration.searchParams.issueNumber;
|
||||||
|
const filterString = `issue_number:${issueNumber},${volumeIdString},${coverDateFilter}`;
|
||||||
console.log(filterString);
|
console.log(filterString);
|
||||||
|
|
||||||
const issueMatches = await axios({
|
const issueMatches = await axios({
|
||||||
@@ -286,6 +331,39 @@ export default class ComicVineService extends Service {
|
|||||||
console.log(
|
console.log(
|
||||||
`Total issues matching the criteria: ${issueMatches.data.results.length}`
|
`Total issues matching the criteria: ${issueMatches.data.results.length}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle case when no issues are found
|
||||||
|
if (issueMatches.data.results.length === 0) {
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "CV_SCRAPING_STATUS",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
message: "No matching issues found. Try adjusting your search criteria.",
|
||||||
|
stage: "complete",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
finalMatches: [],
|
||||||
|
rawFileDetails,
|
||||||
|
scorerConfiguration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify client about issue matches found
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "CV_SCRAPING_STATUS",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
message: `Found ${issueMatches.data.results.length} issue matches, fetching volume details...`,
|
||||||
|
stage: "fetching_volume_details",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
// 3. get volume information for the issue matches
|
// 3. get volume information for the issue matches
|
||||||
if (issueMatches.data.results.length === 1) {
|
if (issueMatches.data.results.length === 1) {
|
||||||
const volumeInformation =
|
const volumeInformation =
|
||||||
@@ -299,9 +377,44 @@ export default class ComicVineService extends Service {
|
|||||||
);
|
);
|
||||||
issueMatches.data.results[0].volumeInformation =
|
issueMatches.data.results[0].volumeInformation =
|
||||||
volumeInformation;
|
volumeInformation;
|
||||||
return issueMatches.data;
|
|
||||||
|
// Notify scoring for single match
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "CV_SCRAPING_STATUS",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
message: "Scoring 1 match...",
|
||||||
|
stage: "scoring_matches",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Score the single match
|
||||||
|
const scoredMatch = await this.broker.call(
|
||||||
|
"comicvine.getComicVineMatchScores",
|
||||||
|
{
|
||||||
|
finalMatches: issueMatches.data.results,
|
||||||
|
rawFileDetails,
|
||||||
|
scorerConfiguration,
|
||||||
}
|
}
|
||||||
const finalMatches = issueMatches.data.results.map(
|
);
|
||||||
|
|
||||||
|
// Notify completion
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "CV_SCRAPING_STATUS",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
message: "Search complete! Found 1 match.",
|
||||||
|
stage: "complete",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return scoredMatch;
|
||||||
|
}
|
||||||
|
const finalMatchesPromises = issueMatches.data.results.map(
|
||||||
async (issue: any) => {
|
async (issue: any) => {
|
||||||
const volumeDetails =
|
const volumeDetails =
|
||||||
await this.broker.call(
|
await this.broker.call(
|
||||||
@@ -316,8 +429,23 @@ export default class ComicVineService extends Service {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Wait for all volume details to be fetched
|
||||||
|
const finalMatches = await Promise.all(finalMatchesPromises);
|
||||||
|
|
||||||
|
// Notify client about scoring
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "CV_SCRAPING_STATUS",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
message: `Scoring ${finalMatches.length} matches...`,
|
||||||
|
stage: "scoring_matches",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
// Score the final matches
|
// Score the final matches
|
||||||
const foo = await this.broker.call(
|
const scoredMatches = await this.broker.call(
|
||||||
"comicvine.getComicVineMatchScores",
|
"comicvine.getComicVineMatchScores",
|
||||||
{
|
{
|
||||||
finalMatches,
|
finalMatches,
|
||||||
@@ -325,14 +453,50 @@ export default class ComicVineService extends Service {
|
|||||||
scorerConfiguration,
|
scorerConfiguration,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return Promise.all(finalMatches);
|
|
||||||
} catch (error) {
|
// Notify completion
|
||||||
console.log(error);
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "CV_SCRAPING_STATUS",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
message: "Search complete! Returning scored matches.",
|
||||||
|
stage: "complete",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return scoredMatches;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as any;
|
||||||
|
console.error("Error in volumeBasedSearch:", error);
|
||||||
|
|
||||||
|
// Surface error to UI
|
||||||
|
await this.broker.call("socket.broadcast", {
|
||||||
|
namespace: "/",
|
||||||
|
event: "CV_SCRAPING_STATUS",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
message: `Error during search: ${error.message || "Unknown error"}`,
|
||||||
|
stage: "error",
|
||||||
|
error: {
|
||||||
|
message: error.message,
|
||||||
|
code: error.code,
|
||||||
|
type: error.type,
|
||||||
|
retryable: error.retryable,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-throw or return error response
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
getComicVineMatchScores: {
|
getComicVineMatchScores: {
|
||||||
rest: "POST /getComicVineMatchScores",
|
rest: "POST /getComicVineMatchScores",
|
||||||
|
timeout: 120000, // 2 minutes - allows time for image downloads and hash calculations
|
||||||
handler: async (
|
handler: async (
|
||||||
ctx: Context<{
|
ctx: Context<{
|
||||||
finalMatches: any[];
|
finalMatches: any[];
|
||||||
@@ -382,7 +546,8 @@ export default class ComicVineService extends Service {
|
|||||||
const issuePromises =
|
const issuePromises =
|
||||||
volumeData.results.issues.map(
|
volumeData.results.issues.map(
|
||||||
async (issue: any) => {
|
async (issue: any) => {
|
||||||
const issueUrl = `${CV_BASE_URL}issue/4000-${issue.id}/?api_key=${process.env.COMICVINE_API_KEY}&format=json&field_list=story_arc_credits,description,image`;
|
const issueUrl = `${CV_BASE_URL}issue/4000-${issue.id}/?api_key=${process.env.COMICVINE_API_KEY}` +
|
||||||
|
"&format=json&field_list=story_arc_credits,description,image";
|
||||||
try {
|
try {
|
||||||
const issueResponse =
|
const issueResponse =
|
||||||
await axios.get(issueUrl, {
|
await axios.get(issueUrl, {
|
||||||
@@ -411,7 +576,8 @@ export default class ComicVineService extends Service {
|
|||||||
})
|
})
|
||||||
) || []
|
) || []
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (err: unknown) {
|
||||||
|
const error = err as any;
|
||||||
console.error(
|
console.error(
|
||||||
"An error occurred while fetching issue data:",
|
"An error occurred while fetching issue data:",
|
||||||
error.message
|
error.message
|
||||||
@@ -475,11 +641,12 @@ export default class ComicVineService extends Service {
|
|||||||
try {
|
try {
|
||||||
const response = await axios.get(issuesUrl, {
|
const response = await axios.get(issuesUrl, {
|
||||||
params: {
|
params: {
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
api_key: process.env.COMICVINE_API_KEY,
|
api_key: process.env.COMICVINE_API_KEY,
|
||||||
filter: `volume:${volumeId}`,
|
filter: `volume:${volumeId}`,
|
||||||
format: "json",
|
format: "json",
|
||||||
field_list:
|
// eslint-disable-next-line camelcase
|
||||||
"id,name,image,issue_number,cover_date,description",
|
field_list: "id,name,image,issue_number,cover_date,description",
|
||||||
limit: 100,
|
limit: 100,
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
@@ -492,10 +659,8 @@ export default class ComicVineService extends Service {
|
|||||||
const issuesWithDescriptionImageAndYear =
|
const issuesWithDescriptionImageAndYear =
|
||||||
response.data.results.map((issue: any) => {
|
response.data.results.map((issue: any) => {
|
||||||
const year = issue.cover_date
|
const year = issue.cover_date
|
||||||
? new Date(
|
? new Date(issue.cover_date).getFullYear()
|
||||||
issue.cover_date
|
: null;
|
||||||
).getFullYear()
|
|
||||||
: null; // Extract the year from cover_date
|
|
||||||
return {
|
return {
|
||||||
...issue,
|
...issue,
|
||||||
year,
|
year,
|
||||||
@@ -505,7 +670,8 @@ export default class ComicVineService extends Service {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return issuesWithDescriptionImageAndYear;
|
return issuesWithDescriptionImageAndYear;
|
||||||
} catch (error) {
|
} catch (err: unknown) {
|
||||||
|
const error = err as any;
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
"Error fetching issues from ComicVine:",
|
"Error fetching issues from ComicVine:",
|
||||||
error.message
|
error.message
|
||||||
|
|||||||
191
services/gateway.service.ts
Normal file
191
services/gateway.service.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { Service, ServiceBroker } from "moleculer";
|
||||||
|
import { ApolloServer } from "@apollo/server";
|
||||||
|
import { stitchSchemas } from "@graphql-tools/stitch";
|
||||||
|
import { print, getIntrospectionQuery, buildClientSchema } from "graphql";
|
||||||
|
import { AsyncExecutor } from "@graphql-tools/utils";
|
||||||
|
import axios from "axios";
|
||||||
|
import { typeDefs } from "../models/graphql/typedef";
|
||||||
|
import { resolvers } from "../models/graphql/resolvers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GraphQL Gateway Service with Schema Stitching
|
||||||
|
* Combines the local metadata schema with the remote GraphQL server
|
||||||
|
*/
|
||||||
|
export default class GatewayService extends Service {
|
||||||
|
private apolloServer?: ApolloServer;
|
||||||
|
private localApolloServer?: ApolloServer;
|
||||||
|
|
||||||
|
public constructor(broker: ServiceBroker) {
|
||||||
|
super(broker);
|
||||||
|
|
||||||
|
this.parseServiceSchema({
|
||||||
|
name: "gateway",
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
remoteGraphQLUrl: process.env.REMOTE_GRAPHQL_URL || "http://localhost:3000/graphql",
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
/**
|
||||||
|
* Execute a GraphQL query through the stitched schema
|
||||||
|
*/
|
||||||
|
query: {
|
||||||
|
params: {
|
||||||
|
query: "string",
|
||||||
|
variables: { type: "object", optional: true },
|
||||||
|
operationName: { type: "string", optional: true },
|
||||||
|
},
|
||||||
|
async handler(ctx: any) {
|
||||||
|
if (!this.apolloServer) {
|
||||||
|
throw new Error("Apollo Gateway Server not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { query, variables, operationName } = ctx.params;
|
||||||
|
|
||||||
|
const response = await this.apolloServer.executeOperation(
|
||||||
|
{ query, variables, operationName },
|
||||||
|
{ contextValue: { broker: this.broker, ctx } }
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.body.kind === "single" ? response.body.singleResult : response;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a GraphQL query against local metadata schema only
|
||||||
|
*/
|
||||||
|
queryLocal: {
|
||||||
|
params: {
|
||||||
|
query: "string",
|
||||||
|
variables: { type: "object", optional: true },
|
||||||
|
operationName: { type: "string", optional: true },
|
||||||
|
},
|
||||||
|
async handler(ctx: any) {
|
||||||
|
if (!this.localApolloServer) {
|
||||||
|
throw new Error("Local Apollo Server not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { query, variables, operationName } = ctx.params;
|
||||||
|
|
||||||
|
const response = await this.localApolloServer.executeOperation(
|
||||||
|
{ query, variables, operationName },
|
||||||
|
{ contextValue: { broker: this.broker, ctx } }
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.body.kind === "single" ? response.body.singleResult : response;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* Create an executor for the remote GraphQL server
|
||||||
|
*/
|
||||||
|
createRemoteExecutor(): AsyncExecutor {
|
||||||
|
const remoteUrl = this.settings.remoteGraphQLUrl;
|
||||||
|
|
||||||
|
return async ({ document, variables }) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
remoteUrl,
|
||||||
|
{ query: print(document), variables },
|
||||||
|
{ headers: { "Content-Type": "application/json" }, timeout: 30000 }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
errors: [{
|
||||||
|
message: `Remote server error: ${error.message}`,
|
||||||
|
extensions: { code: "REMOTE_GRAPHQL_ERROR" },
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Apollo Server with stitched schema
|
||||||
|
*/
|
||||||
|
async initApolloGateway() {
|
||||||
|
this.logger.info("Initializing Apollo Gateway...");
|
||||||
|
|
||||||
|
const { makeExecutableSchema } = await import("@graphql-tools/schema");
|
||||||
|
const { execute } = await import("graphql");
|
||||||
|
|
||||||
|
// Create local schema
|
||||||
|
const localSchema = makeExecutableSchema({ typeDefs, resolvers });
|
||||||
|
|
||||||
|
// Create standalone local Apollo Server for /metadata-graphql endpoint
|
||||||
|
this.localApolloServer = new ApolloServer({ schema: localSchema, introspection: true });
|
||||||
|
await this.localApolloServer.start();
|
||||||
|
this.logger.info("Local metadata Apollo Server started");
|
||||||
|
|
||||||
|
// Create local executor
|
||||||
|
const localExecutor: AsyncExecutor = async ({ document, variables, context }) => execute({
|
||||||
|
schema: localSchema,
|
||||||
|
document,
|
||||||
|
variableValues: variables,
|
||||||
|
contextValue: { broker: context?.broker || this.broker, ctx: context?.ctx },
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
// Try to introspect remote schema
|
||||||
|
let remoteSchema = null;
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
this.settings.remoteGraphQLUrl,
|
||||||
|
{ query: getIntrospectionQuery() },
|
||||||
|
{ headers: { "Content-Type": "application/json" }, timeout: 30000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.data.errors) {
|
||||||
|
remoteSchema = buildClientSchema(response.data.data);
|
||||||
|
this.logger.info("Remote schema introspected successfully");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.warn(`Remote schema unavailable: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stitch schemas or use local only
|
||||||
|
const schema = remoteSchema
|
||||||
|
? stitchSchemas({
|
||||||
|
subschemas: [
|
||||||
|
{ schema: localSchema, executor: localExecutor },
|
||||||
|
{ schema: remoteSchema, executor: this.createRemoteExecutor() },
|
||||||
|
],
|
||||||
|
mergeTypes: false,
|
||||||
|
})
|
||||||
|
: localSchema;
|
||||||
|
|
||||||
|
this.apolloServer = new ApolloServer({ schema, introspection: true });
|
||||||
|
await this.apolloServer.start();
|
||||||
|
this.logger.info("Apollo Gateway started");
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop Apollo Gateway Server
|
||||||
|
*/
|
||||||
|
async stopApolloGateway() {
|
||||||
|
if (this.localApolloServer) {
|
||||||
|
await this.localApolloServer.stop();
|
||||||
|
this.localApolloServer = undefined;
|
||||||
|
}
|
||||||
|
if (this.apolloServer) {
|
||||||
|
await this.apolloServer.stop();
|
||||||
|
this.apolloServer = undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service lifecycle hooks
|
||||||
|
*/
|
||||||
|
async started() {
|
||||||
|
await this.initApolloGateway();
|
||||||
|
},
|
||||||
|
|
||||||
|
async stopped() {
|
||||||
|
await this.stopApolloGateway();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,6 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Context, Service, ServiceBroker } from "moleculer";
|
import { Context, Service, ServiceBroker } from "moleculer";
|
||||||
|
|
||||||
const METRON_BASE_URL = "https://metron.cloud/api/";
|
|
||||||
|
|
||||||
export default class MetronService extends Service {
|
export default class MetronService extends Service {
|
||||||
public constructor(public broker: ServiceBroker) {
|
public constructor(public broker: ServiceBroker) {
|
||||||
super(broker);
|
super(broker);
|
||||||
@@ -27,19 +25,15 @@ export default class MetronService extends Service {
|
|||||||
console.log(ctx.params);
|
console.log(ctx.params);
|
||||||
const results = await axios({
|
const results = await axios({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `https://metron.cloud/api/${ctx.params.resource}`,
|
url: `https://metron.cloud/api/${ctx.params.resource}/`,
|
||||||
params: {
|
params: {
|
||||||
name: ctx.params.query.name,
|
name: ctx.params.query.name,
|
||||||
page: ctx.params.query.page,
|
page: ctx.params.query.page,
|
||||||
},
|
},
|
||||||
headers: {
|
|
||||||
"Authorization": "Basic ZnJpc2hpOlRpdHVAMTU4OA=="
|
|
||||||
},
|
|
||||||
auth: {
|
auth: {
|
||||||
"username": "frishi",
|
username: "frishi",
|
||||||
"password": "Titu@1588"
|
password: "Titu@1588",
|
||||||
}
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
return results.data;
|
return results.data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,29 +34,42 @@ SOFTWARE.
|
|||||||
import { createWriteStream, existsSync, mkdirSync } from "fs";
|
import { createWriteStream, existsSync, mkdirSync } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import https from "https";
|
import https from "https";
|
||||||
import stringSimilarity from "string-similarity";
|
import { isNil, isUndefined } from "lodash";
|
||||||
import { isNil, map, isUndefined } from "lodash";
|
|
||||||
import leven from "leven";
|
import leven from "leven";
|
||||||
import { isAfter, isSameYear, parseISO } from "date-fns";
|
import { isAfter, isSameYear, parseISO } from "date-fns";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute string similarity score (0-1) using Levenshtein distance.
|
||||||
|
* Replaces deprecated string-similarity package.
|
||||||
|
*/
|
||||||
|
const compareTwoStrings = (str1: string, str2: string): number => {
|
||||||
|
if (str1 === str2) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const maxLen = Math.max(str1.length, str2.length);
|
||||||
|
if (maxLen === 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 1 - leven(str1, str2) / maxLen;
|
||||||
|
};
|
||||||
|
|
||||||
const imghash = require("imghash");
|
const imghash = require("imghash");
|
||||||
|
|
||||||
export const matchScorer = async (
|
export const matchScorer = async (
|
||||||
searchMatches: Promise<any>[],
|
searchMatches: any[],
|
||||||
searchQuery: any,
|
searchQuery: any,
|
||||||
rawFileDetails: any
|
rawFileDetails: any
|
||||||
): Promise<any> => {
|
): Promise<any> => {
|
||||||
const scoredMatches: any = [];
|
const scoredMatches: any = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const matches = await Promise.all(searchMatches);
|
// SearchMatches is already an array of match objects, not promises
|
||||||
|
for (const match of searchMatches) {
|
||||||
for (const match of matches) {
|
|
||||||
match.score = 0;
|
match.score = 0;
|
||||||
|
|
||||||
// Check for the issue name match
|
// Check for the issue name match
|
||||||
if (!isNil(searchQuery.name) && !isNil(match.name)) {
|
if (!isNil(searchQuery.name) && !isNil(match.name)) {
|
||||||
const issueNameScore = stringSimilarity.compareTwoStrings(
|
const issueNameScore = compareTwoStrings(
|
||||||
searchQuery.name,
|
searchQuery.name,
|
||||||
match.name
|
match.name
|
||||||
);
|
);
|
||||||
@@ -93,7 +106,7 @@ export const rankVolumes = (volumes: any, scorerConfiguration: any) => {
|
|||||||
// 2. If there is a strong string comparison between the volume name and the issue name ??
|
// 2. If there is a strong string comparison between the volume name and the issue name ??
|
||||||
const issueNumber = parseInt(scorerConfiguration.searchParams.number, 10);
|
const issueNumber = parseInt(scorerConfiguration.searchParams.number, 10);
|
||||||
const issueYear = parseISO(scorerConfiguration.searchParams.year);
|
const issueYear = parseISO(scorerConfiguration.searchParams.year);
|
||||||
const foo = volumes.map((volume: any, idx: number) => {
|
const rankedVolumes = volumes.map((volume: any) => {
|
||||||
let volumeMatchScore = 0;
|
let volumeMatchScore = 0;
|
||||||
const volumeStartYear = !isNil(volume.start_year)
|
const volumeStartYear = !isNil(volume.start_year)
|
||||||
? parseISO(volume.start_year)
|
? parseISO(volume.start_year)
|
||||||
@@ -104,7 +117,7 @@ export const rankVolumes = (volumes: any, scorerConfiguration: any) => {
|
|||||||
const lastIssueNumber = !isNil(volume.last_issue)
|
const lastIssueNumber = !isNil(volume.last_issue)
|
||||||
? parseInt(volume.last_issue.issue_number, 10)
|
? parseInt(volume.last_issue.issue_number, 10)
|
||||||
: null;
|
: null;
|
||||||
let issueNameMatchScore = stringSimilarity.compareTwoStrings(
|
let issueNameMatchScore = compareTwoStrings(
|
||||||
scorerConfiguration.searchParams.name,
|
scorerConfiguration.searchParams.name,
|
||||||
volume.name
|
volume.name
|
||||||
);
|
);
|
||||||
@@ -112,7 +125,7 @@ export const rankVolumes = (volumes: any, scorerConfiguration: any) => {
|
|||||||
// If not, move on.
|
// If not, move on.
|
||||||
let subtitleMatchScore = 0;
|
let subtitleMatchScore = 0;
|
||||||
if (!isNil(scorerConfiguration.searchParams.subtitle)) {
|
if (!isNil(scorerConfiguration.searchParams.subtitle)) {
|
||||||
subtitleMatchScore = stringSimilarity.compareTwoStrings(
|
subtitleMatchScore = compareTwoStrings(
|
||||||
scorerConfiguration.searchParams.subtitle,
|
scorerConfiguration.searchParams.subtitle,
|
||||||
volume.name
|
volume.name
|
||||||
);
|
);
|
||||||
@@ -132,22 +145,28 @@ export const rankVolumes = (volumes: any, scorerConfiguration: any) => {
|
|||||||
// 3. If issue number falls in the range of candidate volume's first issue # and last issue #, +3 to volumeMatchScore
|
// 3. If issue number falls in the range of candidate volume's first issue # and last issue #, +3 to volumeMatchScore
|
||||||
if (!isNil(firstIssueNumber) && !isNil(lastIssueNumber)) {
|
if (!isNil(firstIssueNumber) && !isNil(lastIssueNumber)) {
|
||||||
if (
|
if (
|
||||||
firstIssueNumber <= issueNumber ||
|
firstIssueNumber <= issueNumber &&
|
||||||
issueNumber <= lastIssueNumber
|
issueNumber <= lastIssueNumber
|
||||||
) {
|
) {
|
||||||
volumeMatchScore += 3;
|
volumeMatchScore += 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (issueNameMatchScore > 0.5 && volumeMatchScore > 2) {
|
if (issueNameMatchScore > 0.5 && volumeMatchScore > 2) {
|
||||||
console.log(`Found a match for criteria, volume ID: ${volume.id}`);
|
console.log(`Found a match for criteria, volume ID: ${volume.id}, score: ${volumeMatchScore}, name match: ${issueNameMatchScore.toFixed(2)}`);
|
||||||
return volume.id;
|
return {
|
||||||
|
id: volume.id,
|
||||||
|
volumeMatchScore,
|
||||||
|
issueNameMatchScore,
|
||||||
|
totalScore: volumeMatchScore + issueNameMatchScore,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
});
|
});
|
||||||
return foo.filter((item: any) => !isNil(item));
|
return rankedVolumes.filter((item: any) => !isNil(item));
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateLevenshteinDistance = async (match: any, rawFileDetails: any) =>
|
const calculateLevenshteinDistance = async (match: any, rawFileDetails: any) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise(resolve => {
|
||||||
https.get(match.image.small_url, (response: any) => {
|
https.get(match.image.small_url, (response: any) => {
|
||||||
console.log(rawFileDetails.cover.filePath);
|
console.log(rawFileDetails.cover.filePath);
|
||||||
const fileName = match.id + "_" + rawFileDetails.name + ".jpg";
|
const fileName = match.id + "_" + rawFileDetails.name + ".jpg";
|
||||||
@@ -161,6 +180,7 @@ const calculateLevenshteinDistance = async (match: any, rawFileDetails: any) =>
|
|||||||
);
|
);
|
||||||
const fileStream = response.pipe(file);
|
const fileStream = response.pipe(file);
|
||||||
fileStream.on("finish", async () => {
|
fileStream.on("finish", async () => {
|
||||||
|
try {
|
||||||
// 1. hash of the cover image we have on hand
|
// 1. hash of the cover image we have on hand
|
||||||
const coverFileName = rawFileDetails.cover.filePath
|
const coverFileName = rawFileDetails.cover.filePath
|
||||||
.split("/")
|
.split("/")
|
||||||
@@ -191,9 +211,12 @@ const calculateLevenshteinDistance = async (match: any, rawFileDetails: any) =>
|
|||||||
} else {
|
} else {
|
||||||
match.score -= 2;
|
match.score -= 2;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
resolve(match);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(`Image hashing failed for ${fileName}, skipping score adjustment:`, errorMessage);
|
||||||
resolve(match);
|
resolve(match);
|
||||||
} else {
|
|
||||||
reject({ error: "Couldn't calculate hashes." });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user