first commit
This commit is contained in:
20
libs/web/shared/infrastructure/README.md
Normal file
20
libs/web/shared/infrastructure/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# web-shared-infrastructure
|
||||
|
||||
Cliente GraphQL de la web.
|
||||
|
||||
- **Import**: `@task-stream/web/shared/infrastructure`
|
||||
- **Capa**: `infrastructure` · **Tags**: `scope:web-shared`, `type:infrastructure`
|
||||
|
||||
## Contenido
|
||||
|
||||
- `provideGraphql()`: Apollo con HTTP para queries/mutations y `graphql-ws` para subscriptions; caché desactivada.
|
||||
- `AccessTokenSource` (puerto): de dónde sale el token (lo implementa identity).
|
||||
- `API_CONFIG` / `apiConfigFromLocation()`: la API se sirve en el mismo origen (`/api`, `/graphql`).
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint web-shared-infrastructure
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
34
libs/web/shared/infrastructure/eslint.config.mjs
Normal file
34
libs/web/shared/infrastructure/eslint.config.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import nx from '@nx/eslint-plugin';
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [
|
||||
...nx.configs['flat/angular'],
|
||||
...nx.configs['flat/angular-template'],
|
||||
...baseConfig,
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
rules: {
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'attribute',
|
||||
prefix: 'ts',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'ts',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
13
libs/web/shared/infrastructure/project.json
Normal file
13
libs/web/shared/infrastructure/project.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "web-shared-infrastructure",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/web/shared/infrastructure/src",
|
||||
"prefix": "ts",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:web-shared", "type:infrastructure"],
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
4
libs/web/shared/infrastructure/src/index.ts
Normal file
4
libs/web/shared/infrastructure/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './lib/api-config';
|
||||
export * from './lib/access-token-source';
|
||||
export * from './lib/graphql.provider';
|
||||
export * from './lib/graphql-errors';
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Port used by the GraphQL client to authenticate requests. Implemented by
|
||||
* the identity context (which knows how to refresh and sign out).
|
||||
*/
|
||||
export abstract class AccessTokenSource {
|
||||
/** A valid access token (refreshed if needed), or null when signed out. */
|
||||
abstract accessToken(): Promise<string | null>;
|
||||
/** The API rejected the token: the session is over. */
|
||||
abstract unauthenticated(): void;
|
||||
}
|
||||
32
libs/web/shared/infrastructure/src/lib/api-config.ts
Normal file
32
libs/web/shared/infrastructure/src/lib/api-config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
InjectionToken,
|
||||
makeEnvironmentProviders,
|
||||
type EnvironmentProviders,
|
||||
} from '@angular/core';
|
||||
|
||||
export interface ApiConfig {
|
||||
/** Base of the REST endpoints, e.g. `/api`. */
|
||||
restUrl: string;
|
||||
/** GraphQL over HTTP, e.g. `/graphql`. */
|
||||
graphqlUrl: string;
|
||||
/** GraphQL subscriptions (graphql-ws), e.g. `ws://localhost:4200/graphql`. */
|
||||
graphqlWsUrl: string;
|
||||
}
|
||||
|
||||
export const API_CONFIG = new InjectionToken<ApiConfig>('API_CONFIG');
|
||||
|
||||
export function provideApiConfig(config: ApiConfig): EnvironmentProviders {
|
||||
return makeEnvironmentProviders([{ provide: API_CONFIG, useValue: config }]);
|
||||
}
|
||||
|
||||
/** Relative URLs resolved against the page, so the dev proxy / reverse proxy can serve the API. */
|
||||
export function apiConfigFromLocation(
|
||||
location: Location = window.location,
|
||||
): ApiConfig {
|
||||
const ws = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return {
|
||||
restUrl: '/api',
|
||||
graphqlUrl: '/graphql',
|
||||
graphqlWsUrl: `${ws}//${location.host}/graphql`,
|
||||
};
|
||||
}
|
||||
10
libs/web/shared/infrastructure/src/lib/graphql-errors.ts
Normal file
10
libs/web/shared/infrastructure/src/lib/graphql-errors.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { CombinedGraphQLErrors } from '@apollo/client/errors';
|
||||
|
||||
/** A message fit for a toast from whatever the GraphQL client threw. */
|
||||
export function errorMessage(error: unknown): string {
|
||||
if (CombinedGraphQLErrors.is(error)) {
|
||||
return error.errors.map((e) => e.message).join('\n');
|
||||
}
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
95
libs/web/shared/infrastructure/src/lib/graphql.provider.ts
Normal file
95
libs/web/shared/infrastructure/src/lib/graphql.provider.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
inject,
|
||||
makeEnvironmentProviders,
|
||||
type EnvironmentProviders,
|
||||
} from '@angular/core';
|
||||
import { ApolloLink, InMemoryCache } from '@apollo/client';
|
||||
import { SetContextLink } from '@apollo/client/link/context';
|
||||
import { ErrorLink } from '@apollo/client/link/error';
|
||||
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
|
||||
import { getMainDefinition } from '@apollo/client/utilities';
|
||||
import { CombinedGraphQLErrors } from '@apollo/client/errors';
|
||||
import { provideApollo } from 'apollo-angular';
|
||||
import { HttpLink } from 'apollo-angular/http';
|
||||
import { createClient } from 'graphql-ws';
|
||||
import { AccessTokenSource } from './access-token-source';
|
||||
import { API_CONFIG } from './api-config';
|
||||
|
||||
/**
|
||||
* Apollo client for the task-stream API: HTTP for queries/mutations,
|
||||
* graphql-ws for subscriptions (token in `connection_init`).
|
||||
*
|
||||
* The cache is disabled on purpose: state lives in the NgRx signal stores,
|
||||
* which are fed by queries and kept fresh by subscriptions.
|
||||
*/
|
||||
export function provideGraphql(): EnvironmentProviders {
|
||||
return makeEnvironmentProviders([
|
||||
provideApollo(() => {
|
||||
const config = inject(API_CONFIG);
|
||||
const tokens = inject(AccessTokenSource);
|
||||
const httpLink = inject(HttpLink);
|
||||
|
||||
const auth = new SetContextLink(async (prevContext) => {
|
||||
const token = await tokens.accessToken();
|
||||
return token
|
||||
? {
|
||||
headers: {
|
||||
...prevContext['headers'],
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
});
|
||||
|
||||
const errors = new ErrorLink(({ error }) => {
|
||||
if (
|
||||
CombinedGraphQLErrors.is(error) &&
|
||||
error.errors.some((e) => e.extensions?.['code'] === 'UNAUTHENTICATED')
|
||||
) {
|
||||
tokens.unauthenticated();
|
||||
}
|
||||
});
|
||||
|
||||
const ws = new GraphQLWsLink(
|
||||
createClient({
|
||||
url: config.graphqlWsUrl,
|
||||
lazy: true,
|
||||
retryAttempts: Infinity,
|
||||
shouldRetry: () => true,
|
||||
connectionParams: async () => {
|
||||
const token = await tokens.accessToken();
|
||||
return token ? { authorization: `Bearer ${token}` } : {};
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const http = ApolloLink.from([
|
||||
auth,
|
||||
errors,
|
||||
httpLink.create({ uri: config.graphqlUrl }),
|
||||
]);
|
||||
|
||||
const link = ApolloLink.split(
|
||||
({ query }) => {
|
||||
const definition = getMainDefinition(query);
|
||||
return (
|
||||
definition.kind === 'OperationDefinition' &&
|
||||
definition.operation === 'subscription'
|
||||
);
|
||||
},
|
||||
ApolloLink.from([errors, ws]),
|
||||
http,
|
||||
);
|
||||
|
||||
return {
|
||||
link,
|
||||
cache: new InMemoryCache(),
|
||||
defaultOptions: {
|
||||
query: { fetchPolicy: 'no-cache' },
|
||||
watchQuery: { fetchPolicy: 'no-cache' },
|
||||
mutate: { fetchPolicy: 'no-cache' },
|
||||
},
|
||||
};
|
||||
}),
|
||||
]);
|
||||
}
|
||||
27
libs/web/shared/infrastructure/tsconfig.json
Normal file
27
libs/web/shared/infrastructure/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
12
libs/web/shared/infrastructure/tsconfig.lib.json
Normal file
12
libs/web/shared/infrastructure/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user