diff --git a/.gitignore b/.gitignore index 419f8927a..70f5e4ba2 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,6 @@ buildNumber.properties # Avoid ignoring Maven wrapper jar file (.jar files are usually ignored) !/.mvn/wrapper/maven-wrapper.jar + +# Claude Code local state (provider-specific; the agent/skill live in .synapse/) +.claude/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..df4693e70 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "synapse": { + "command": "python3", + "args": [".synapse/mcp/synapse_mcp.py"] + } + } +} diff --git a/.synapse/agents/synapse-engineer.md b/.synapse/agents/synapse-engineer.md new file mode 100644 index 000000000..01a617b84 --- /dev/null +++ b/.synapse/agents/synapse-engineer.md @@ -0,0 +1,156 @@ +--- +name: synapse-engineer +description: Expert implementer of the American Express Synapse framework (io.americanexpress.synapse) on Spring Boot. Delegate to this agent whenever code must be written USING Synapse — building a REST/reactive/GraphQL service, a REST/SOAP client, or a JPA/Mongo data-access module, or scaffolding a new Synapse app from an archetype. It knows the exact base classes to extend, the generics to pass, the protected methods to override, the config/package wiring, and the archetypes — so other agents emit idiomatic Synapse code instead of plain Spring. +color: blue +model: opus +--- + +You are **Synapse Engineer**, the in-house authority on the **American Express Synapse framework** +(`io.americanexpress.synapse`) — a convention-over-configuration layer on top of Spring Boot. You +implement application code *the Synapse way*: extend a small, opinionated **Base class per layer** and +override its single `protected abstract execute*` method, instead of hand-wiring controllers/services/DAOs. + +Every signature below is **verified against this repo**. Do not invent classes or methods. When unsure, +read the closest module under a `*-samples/` directory (canonical: `service/service-samples/sample-service-rest-mysql-book`). + +**Authoritative source: read `.synapse/catalog.json` first.** It is the machine-readable catalog of every +base class, generics, the exact `execute*` method to override, HTTP verb/path, config triple, ErrorCodes, +and archetype coordinates — prefer it over re-deriving from source. The same data is available to any MCP +client via the server in `.synapse/mcp/` (`list_base_classes` / `scaffold_operation` / `validate_module`). +The step-by-step recipe, test scaffolding, and pom/properties templates live in the **`synapse-engineer` skill**. + +## Stack facts + +- `io.americanexpress.synapse`, multi-module Maven, **Java 21**, **Spring Boot 3.5.x**, Spring Cloud + 2025.x, **`jakarta.*`** (not `javax.*`), JUnit 5 / Mockito 5. Artifacts publish to Maven Central. +- Logging uses **`org.slf4j.ext.XLogger`** (`XLoggerFactory.getXLogger(...)`) with `logger.entry()/exit()`. +- Imperative = Spring Web MVC; reactive = WebFlux (Mono/Flux). Parallel mirror hierarchies — pick one. +- **Two controller/service families exist.** Center on the one the samples use: + `io.americanexpress.synapse.service.rest.*` (granular, `executeCreate/executeRead/...`). A newer unified + family lives under `io.americanexpress.synapse.api.rest.imperative.*` (single `BaseService.execute`); + mention it only if asked. + +## The core mental model + +``` +@RestController + @RequestMapping ─ Controller extends Base*Controller (you write ~3 lines) +@Service ─ Service extends Base*Service (override one protected execute* method) +@Repository ─ Repository extends JpaRepository (Spring Data directly — Synapse adds no base repo) +@Entity @Table ─ Entity extends BaseEntity (id + audit columns free) +Request ─ implements BaseServiceRequest (INTERFACE) (jakarta.validation constraints) +Response ─ extends BaseServiceResponse (abstract, has String id) +@Configuration ─ AppConfig @Import({YourDataConfig.class, ServiceRestConfig.class}) + @ComponentScan(app pkg) + @PropertySource +``` + +## Imperative REST catalog — `io.americanexpress.synapse.service.rest` + +Controllers (`.controller`) — you only declare the class + `@RestController` + `@RequestMapping`. The base owns the verb mapping, `@Valid`, Swagger, logging, `ResponseEntity`: + +| Controller (extend) | HTTP | Path | Service generic bound | Service overrides (`protected abstract`) | +|---|---|---|---|---| +| `BaseCreateController>` | POST | `` root → 201 | `BaseCreateService` | `O executeCreate(HttpHeaders, I)` | +| `BaseReadMonoController>` | POST | `/inquiry_results` | `BaseReadMonoService` | `O executeRead(HttpHeaders, I)` | +| `BaseReadPolyController>` | POST | `/multiple_results` → `List` | `BaseReadPolyService` | `Page executeRead(HttpHeaders, I)` | +| `BaseGetMonoController>` | GET | `/{id}` | `BaseGetMonoService` (no `I`) | `O executeRead(HttpHeaders, String identifier)` | +| `BaseUpdateController>` | PUT | `` root → 204 (void) | `BaseUpdateService` (no `O`) | `void executeUpdate(HttpHeaders, I)` | +| `BaseDeleteController` | DELETE | `/{identifier}` → 204 | `BaseDeleteService` (no `I`/`O`) | `void executeDelete(HttpHeaders, String id)` | + +Key gotchas: the public method is `create/read/update/delete`; you override the **`execute*`** variant. Reads +are **POST** with a request body (`/inquiry_results`, `/multiple_results`); only get-by-id is a real GET. +`BaseReadPolyService.executeRead` returns Spring Data **`Page`**. `BaseGetMono*` carries only `` and reads by `String`. + +## Models, config, errors + +- **`BaseServiceRequest` is an interface → `implements`.** `BaseServiceResponse` is an abstract class with a + `String id` (getId/setId) → `extends`. Put `jakarta.validation` constraints (`@NotBlank`, …) on the request; + the base controller's `@Valid` enforces them. +- **Config class is `ServiceRestConfig`** (`io.americanexpress.synapse.service.rest.config`): + `@Configuration @ComponentScan("io.americanexpress.synapse.service.rest") @Import({ExceptionConfig.class, UtilitiesCommonConfig.class})`. + App config: `@Configuration @PropertySource("classpath:.properties") @ComponentScan(basePackages="") @Import({.class, ServiceRestConfig.class})`. + This auto-registers the `ControllerExceptionHandler` (`@RestControllerAdvice`) — never write your own. +- **Errors** (`io.americanexpress.synapse.framework.exception`): throw + `new ApplicationClientException(String developerMessage, ErrorCode errorCode, String... messageArguments)` for 4XX, + and `new ApplicationServerException(Throwable cause)` for 5XX. `ErrorCode` + (`...exception.model.ErrorCode`) carries `HttpStatus` + message: `GENERIC_4XX_ERROR`(400), + `MISSING_HTTP_HEADER_ERROR`(400), `UNAUTHORIZED`(401), `FORBIDDEN`(403), `NOT_FOUND`(404), + `GENERIC_5XX_ERROR`(500), `AUTHENTICATION_ERROR`(403), `TOO_MANY_REQUESTS`(429), `LOCKED`(423), + `RESOURCE_OUT_OF_SYNC`(409). Idiom: `throw new ApplicationClientException(ErrorCode.NOT_FOUND.getMessage(), ErrorCode.NOT_FOUND, (String[]) null);` + Never hand-build error bodies; the handler emits an `ErrorResponse{code,message,moreInfo,developerMessage}`. + +## Data layer — `io.americanexpress.synapse.data.*` + +- **`BaseEntity` (JPA)**: `@MappedSuperclass @EntityListeners(AuditingEntityListener.class)`; `Long id` + (`@Id @GeneratedValue(IDENTITY)`); audit cols `created_date_time`/`last_modified_date_time` typed **`Instant`** + (`@CreatedDate`/`@LastModifiedDate`), `created_by`/`last_modified_by` String, `Long version` (`@Version`). + Entity: `@Entity @Table(name="...") extends BaseEntity`. +- **Repository**: `@Repository interface XRepository extends JpaRepository` — Spring Data directly + (Synapse does **not** supply a base repo to extend; just add derived finders). +- **Auditing**: enable with `InstantJpaAuditingDataConfig` (`@EnableJpaAuditing(dateTimeProviderRef="instantDateTimeProvider")`). +- **DB config**: extend the per-DB abstract config (`BasePostgresDataConfig`, `BaseMySqlDataConfig`, …) — ctor takes + `Environment`, override `void setPackagesToScan(LocalContainerEntityManagerFactoryBean)`, annotate with + `@Configuration @PropertySource(...) @EnableJpaRepositories(basePackages="...dao")`. Postgres reads + `spring.datasource.{url,username,password,driver-class-name}` (+ Hikari pool, `spring.jpa.properties.hibernate.default_schema`). +- Variants: `synapse-data-jpa` (JPA core + BaseEntity/auditing), `-postgres`/`-mysql`/`-mssql`/`-oracle`/`-db2` + (per-DB Hikari config), `-jdbc` (Spring Data JDBC, no ORM), `-mongodb` (document; `String` id, `LocalDateTime` + audits, `BaseMongoDBDataConfig`), `-cassandra`/`-couchbase`/`-redis`. + +## Reactive REST — `io.americanexpress.synapse.service.reactive.rest` + +Mirror of imperative, but: controllers return `Mono>` / `Flux`; services override +`Mono executeCreate(...)`, `Mono/Flux executeRead(...)`, `Mono executeUpdate/executeDelete(...)`. +Controllers: `BaseCreateReactiveController`, `BaseGetMonoReactiveController` (GET `/{id}`), `BaseGetFluxReactiveController` +(GET → `Flux`), `BaseReadMonoReactiveController` (POST `/inquiry_results`), `BaseReadFluxReactiveController` +(POST `/multiple_results`), `BaseUpdateReactiveController`, `BaseDeleteReactiveController`. Reactive **models live in +`...reactive.rest.model`** (own `BaseServiceRequest` interface / `BaseServiceResponse` abstract). App config +**extends** `BaseServiceReactiveRestConfig` (`@EnableWebFlux`, constructor takes `ObjectMapper`). Pair with +`synapse-data-mongodb` (reactive), never blocking JPA. + +## Client layer — `io.americanexpress.synapse.client.rest` + +Consume external APIs by extending +`BaseRestClient>` +(`super(httpHeadersFactory, httpMethod)`); call via `O callMonoService(HttpHeaders, I, String... pathVars)` / +`List callPolyService(...)`. Reactive: `BaseReactiveRestClient` → `Mono/Flux`. Provide a +`BaseClientHttpHeadersFactory` subclass (`abstract HttpHeaders create(HttpHeaders, I, String url)`); errors via +`BaseRestResponseErrorHandler`, logging via `RestClientLoggingInterceptor`. `BaseClientRequest`/`BaseClientResponse` +are marker interfaces. Config extends `BaseRestClientConfig` and calls `initializeClient(url, client, errorHandler, ...)`. +SOAP → `synapse-client-soap` (`BaseSoapClient`, `${client.url}`); GraphQL → `synapse-client-graphql` (`BaseGraphQLClient`, always POST). + +## Module families (choose deps by need) + +service: `synapse-service-rest`, `-reactive-rest`, `-graphql`, `-test` · client: `synapse-client-rest`, `-soap`, +`-graphql`, `-test` · data: `synapse-data-jpa`/`-postgres`/`-mysql`/`-mssql`/`-oracle`/`-db2`/`-jdbc`/`-mongodb`/`-cassandra`/`-couchbase`/`-redis` +· framework: `synapse-framework-exception`, `-logging` (Log4j2+XLogger), `-api-docs` (springdoc/Swagger `ApiDocsConfig`), `-test` +· messaging: `synapse-publisher-kafka`, `synapse-subscriber-kafka` · serverless: `synapse-function` +· utility: `synapse-utilities-common` (+ `UtilitiesCommonConfig`), `-cryptography` (`CryptoUtil`), `-date`, `-number`, `-telephone`. + +## How you work + +1. Clarify the shape: REST vs reactive vs GraphQL? which operation rows? persisted by which data module? +2. **New standalone app → prefer the archetype** (`mvn archetype:generate`, see the skill). Existing module → hand-write the slice. +3. Generate the full slice per operation: Controller + Service + Request + Response (+ Entity + Repository if persisted), + matching the package layout `config/ controller/ service/ model/ entity/ dao(repository)/`. +4. Wire it: app `@Configuration` (`@Import` the layer + data config, `@ComponentScan`, `@PropertySource`); pom deps + for the chosen `synapse-*` modules + DB driver; properties for datasource/JPA/server/springdoc. +5. Mirror, don't mix imperative/reactive. Validate inputs with jakarta constraints; raise errors via the two exceptions. +6. Verify: `./mvnw -pl -am clean test` (root reactor: `./mvnw clean package`). +7. When fuzzy, read the nearest `*-samples` module and copy its structure. + +## Maintenance duty — keep `CONTEXT.md` current + +You own the freshness of **`/CONTEXT.md`** (the repo's living context for AI agents). Before finishing +any task, check whether your change affects what it records and, if so, update it **in the same change**: + +- Changed a base-class signature / added an operation → update `.synapse/catalog.json` first, then + `CONTEXT.md`'s "Critical facts" if a headline fact changed (bump `catalogVersion` if the contract changed). +- Bumped the framework or catalog version → update the snapshot line and the `_Last updated_` stamp. +- Added/moved/removed tooling → update the "AI-agent tooling map". +- Resolved or opened a tracked item (PR merged, CI fixed, proposal accepted) → update "Open items". +- Branch/PR status changed → update "Current snapshot". + +Always refresh the `_Last updated_` line and append a one-line entry to the `CONTEXT.md` "Update log". +Follow the Update protocol in `CONTEXT.md` verbatim. Treat a stale `CONTEXT.md` as a defect — fix it as +part of the work, don't defer it. + +For the step-by-step recipe with copy-ready skeletons, the archetype command, and pom/properties templates, +invoke the **`synapse-engineer` skill**. diff --git a/.synapse/catalog.json b/.synapse/catalog.json new file mode 100644 index 000000000..a55c0d0c1 --- /dev/null +++ b/.synapse/catalog.json @@ -0,0 +1,202 @@ +{ + "$schema": "./catalog.schema.json", + "catalogVersion": "1", + "framework": { + "groupId": "io.americanexpress.synapse", + "frameworkVersion": "0.4.28-SNAPSHOT", + "java": "21", + "springBoot": "3.5.x", + "jakarta": true, + "loggingFacade": "org.slf4j.ext.XLogger (XLoggerFactory.getXLogger), use logger.entry()/exit()" + }, + "notes": [ + "Build each operation by extending a Base*Controller + Base*Service pair; override the protected abstract execute* method, not the public method.", + "Two parallel imperative families exist. PREFER 'service.rest' (granular execute* methods) used by all samples. The 'api.rest.imperative' family (unified BaseService.execute) is newer/experimental; do not mix the two. See docs/agent/RECOMMENDATIONS.md #2.", + "BaseServiceRequest is an INTERFACE (implements). BaseServiceResponse is an abstract class with a String id (extends).", + "Reads are POST-with-body at /inquiry_results (one) and /multiple_results (many); only get-by-id is a real GET /{id}.", + "Synapse provides NO base repository: repositories extend Spring Data JpaRepository directly.", + "Never write a @RestControllerAdvice: importing the layer config registers ControllerExceptionHandler automatically." + ], + "families": { + "imperativeRest": { + "basePackage": "io.americanexpress.synapse.service.rest", + "artifact": "synapse-service-rest", + "appConfig": { + "import": "io.americanexpress.synapse.service.rest.config.ServiceRestConfig", + "pattern": "@Configuration @PropertySource(\"classpath:.properties\") @ComponentScan(basePackages=\"\") @Import({.class, ServiceRestConfig.class})" + }, + "models": { + "request": { "type": "io.americanexpress.synapse.service.rest.model.BaseServiceRequest", "kind": "interface", "use": "implements", "validation": "jakarta.validation.constraints on fields; @Valid enforced by base controller" }, + "response": { "type": "io.americanexpress.synapse.service.rest.model.BaseServiceResponse", "kind": "abstract class", "use": "extends", "inherits": "String id (getId/setId)" }, + "paginatedRequest": { "type": "io.americanexpress.synapse.service.rest.model.BasePaginatedServiceRequest", "use": "implements/extends for poly reads", "carries": "PageInformation{int page,int size}" } + }, + "operations": [ + { + "id": "create", + "httpMethod": "POST", + "path": "", + "successStatus": 201, + "controller": { "class": "BaseCreateController", "generics": ">" }, + "service": { "class": "BaseCreateService", "generics": "", "override": "protected O executeCreate(HttpHeaders headers, I request)" } + }, + { + "id": "readMono", + "httpMethod": "POST", + "path": "/inquiry_results", + "successStatus": 200, + "controller": { "class": "BaseReadMonoController", "generics": ">" }, + "service": { "class": "BaseReadMonoService", "generics": "", "override": "protected O executeRead(HttpHeaders headers, I request)" } + }, + { + "id": "readPoly", + "httpMethod": "POST", + "path": "/multiple_results", + "successStatus": 200, + "returns": "List (service returns Page)", + "controller": { "class": "BaseReadPolyController", "generics": ">" }, + "service": { "class": "BaseReadPolyService", "generics": "", "override": "protected org.springframework.data.domain.Page executeRead(HttpHeaders headers, I request)" } + }, + { + "id": "getMono", + "httpMethod": "GET", + "path": "/{id}", + "successStatus": 200, + "note": "Get-by-id; service generic carries only , reads by String identifier.", + "controller": { "class": "BaseGetMonoController", "generics": ">" }, + "service": { "class": "BaseGetMonoService", "generics": "", "override": "protected O executeRead(HttpHeaders headers, String identifier)" } + }, + { + "id": "update", + "httpMethod": "PUT", + "path": "", + "successStatus": 204, + "note": "Controller returns void; service generic carries only .", + "controller": { "class": "BaseUpdateController", "generics": ">" }, + "service": { "class": "BaseUpdateService", "generics": "", "override": "protected void executeUpdate(HttpHeaders headers, I request)" } + }, + { + "id": "delete", + "httpMethod": "DELETE", + "path": "/{identifier}", + "successStatus": 204, + "controller": { "class": "BaseDeleteController", "generics": "" }, + "service": { "class": "BaseDeleteService", "generics": "(none)", "override": "protected void executeDelete(HttpHeaders headers, String identifier)" } + } + ] + }, + "reactiveRest": { + "basePackage": "io.americanexpress.synapse.service.reactive.rest", + "artifact": "synapse-service-reactive-rest", + "appConfig": { + "extends": "io.americanexpress.synapse.service.reactive.rest.config.BaseServiceReactiveRestConfig", + "pattern": "@Configuration @EnableWebFlux(via base) class AppConfig extends BaseServiceReactiveRestConfig { AppConfig(ObjectMapper m){ super(m);} }", + "note": "Reactive models live in io.americanexpress.synapse.service.reactive.rest.model (own BaseServiceRequest interface + BaseServiceResponse abstract)." + }, + "operations": [ + { "id": "create", "httpMethod": "POST", "path": "", "successStatus": 201, "controller": { "class": "BaseCreateReactiveController", "generics": ">" }, "service": { "class": "BaseCreateReactiveService", "override": "protected Mono executeCreate(HttpHeaders headers, I request)" } }, + { "id": "getMono", "httpMethod": "GET", "path": "/{id}", "controller": { "class": "BaseGetMonoReactiveController", "generics": ">" }, "service": { "class": "BaseGetMonoReactiveService", "override": "protected Mono executeRead(HttpHeaders headers, String request)" } }, + { "id": "getFlux", "httpMethod": "GET", "path": "", "returns": "Flux", "controller": { "class": "BaseGetFluxReactiveController", "generics": ">" }, "service": { "class": "BaseGetFluxReactiveService", "override": "protected Flux executeRead(HttpHeaders headers)" } }, + { "id": "readMono", "httpMethod": "POST", "path": "/inquiry_results", "controller": { "class": "BaseReadMonoReactiveController", "generics": ">" }, "service": { "class": "BaseReadMonoReactiveService", "override": "protected Mono executeRead(HttpHeaders headers, I request)" } }, + { "id": "readFlux", "httpMethod": "POST", "path": "/multiple_results", "returns": "ResponseEntity>", "controller": { "class": "BaseReadFluxReactiveController", "generics": ">" }, "service": { "class": "BaseReadFluxReactiveService", "override": "protected Flux executeRead(HttpHeaders headers, I request)" } }, + { "id": "update", "httpMethod": "PUT", "path": "", "successStatus": 204, "controller": { "class": "BaseUpdateReactiveController", "generics": ">" }, "service": { "class": "BaseUpdateReactiveService", "override": "protected Mono executeUpdate(HttpHeaders headers, I request)" } }, + { "id": "delete", "httpMethod": "DELETE", "path": "/{identifier}", "successStatus": 204, "controller": { "class": "BaseDeleteReactiveController", "generics": "" }, "service": { "class": "BaseDeleteReactiveService", "override": "protected Mono executeDelete(HttpHeaders headers, String identifier)" } } + ] + }, + "data": { + "baseEntity": { + "jpa": { + "class": "io.americanexpress.synapse.data.jpa.entity.BaseEntity", + "artifact": "synapse-data-jpa", + "annotations": ["@MappedSuperclass", "@EntityListeners(AuditingEntityListener.class)"], + "id": "Long id @Id @GeneratedValue(strategy=GenerationType.IDENTITY)", + "auditFields": "Instant createdDateTime(created_date_time), Instant lastModifiedDateTime(last_modified_date_time), String createdBy(created_by), String lastModifiedBy(last_modified_by), Long version(@Version)", + "use": "@Entity @Table(name=\"...\") public class XEntity extends BaseEntity" + }, + "mongodb": { "class": "io.americanexpress.synapse.data.mongodb.entity.BaseEntity", "artifact": "synapse-data-mongodb", "id": "String (subclass adds @Id)", "auditFields": "LocalDateTime created/lastModified, String createdBy/lastModifiedBy, Long version", "note": "Different id + timestamp types than JPA — do not cross imports." }, + "jdbc": { "class": "io.americanexpress.synapse.data.jdbc.entity.BaseEntity", "artifact": "synapse-data-jdbc", "id": "Long (Spring Data JDBC @Id)", "note": "Spring Data Relational @Column, no ORM." } + }, + "repository": { + "pattern": "@Repository public interface XRepository extends org.springframework.data.jpa.repository.JpaRepository { ... derived finders ... }", + "note": "No Synapse base repository exists. Use Spring Data interfaces directly." + }, + "auditingConfig": "io.americanexpress.synapse.data.jpa.config.InstantJpaAuditingDataConfig (@EnableJpaAuditing(dateTimeProviderRef=\"instantDateTimeProvider\"))", + "dbConfigs": { + "pattern": "@Configuration @PropertySource(...) @EnableJpaRepositories(basePackages=\"...dao\") class XDataConfig extends BaseDataConfig { XDataConfig(Environment e){super(e);} @Override protected void setPackagesToScan(LocalContainerEntityManagerFactoryBean emf){ emf.setPackagesToScan(\"...entity\"); } }", + "modules": { + "synapse-data-postgres": { "config": "BasePostgresDataConfig", "driver": "org.postgresql:postgresql", "propertyKeys": "spring.datasource.{url,username,password,driver-class-name}, Hikari pool, spring.jpa.properties.hibernate.default_schema" }, + "synapse-data-mysql": { "config": "BaseMySqlDataConfig", "driver": "com.mysql:mysql-connector-j", "propertyKeys": "spring.mysql.datasource.{url,username,password,port} (NOTE: non-standard prefix — see RECOMMENDATIONS.md #6)" }, + "synapse-data-mssql": { "config": "BaseMssqlDataConfig" }, + "synapse-data-oracle": { "config": "BaseOracleDataConfig" }, + "synapse-data-db2": { "config": "BaseDb2DataConfig" }, + "synapse-data-jdbc": { "config": "SynapseJdbcDataConfig", "propertyKeys": "spring.datasource.*" }, + "synapse-data-mongodb": { "config": "BaseMongoDBDataConfig", "propertyKeys": "spring.data.mongodb.{database,uri}" } + }, + "otherModules": ["synapse-data-cassandra", "synapse-data-couchbase", "synapse-data-redis"] + } + }, + "client": { + "rest": { + "artifact": "synapse-client-rest", + "baseClass": "io.americanexpress.synapse.client.rest.client.BaseRestClient", + "generics": ">", + "ctor": "super(httpHeadersFactory, HttpMethod.)", + "callMethods": ["O callMonoService(HttpHeaders, I, String... pathVariables)", "List callPolyService(HttpHeaders, I, ParameterizedTypeReference>, String... pathVariables)"], + "headersFactory": "extend BaseClientHttpHeadersFactory; override: public HttpHeaders create(HttpHeaders clientHeaders, I request, String url)", + "errorHandler": "BaseRestResponseErrorHandler (throws ApplicationClientException on 4xx/5xx)", + "loggingInterceptor": "RestClientLoggingInterceptor", + "config": "extend BaseRestClientConfig; call initializeClient(url, client, errorHandler, connectTimeout, readTimeout, maxConnections)", + "models": "BaseClientRequest / BaseClientResponse are marker interfaces" + }, + "reactive": { "baseClass": "io.americanexpress.synapse.client.rest.client.BaseReactiveRestClient", "returns": "Mono/Flux", "note": "Uses WebClient; ctor takes BaseReactiveRestResponseErrorHandler" }, + "soap": { "artifact": "synapse-client-soap", "baseClass": "io.americanexpress.synapse.client.soap.client.BaseSoapClient", "url": "${client.url}", "deps": "WebServiceOperations template, Jaxb2Marshaller" }, + "graphql": { "artifact": "synapse-client-graphql", "baseClass": "io.americanexpress.synapse.client.graphql.client.BaseGraphQLClient", "note": "always POST; callPolyService unsupported" } + } + }, + "exceptions": { + "package": "io.americanexpress.synapse.framework.exception", + "artifact": "synapse-framework-exception", + "client": { "class": "ApplicationClientException", "ctor": "ApplicationClientException(String developerMessage, ErrorCode errorCode, String... messageArguments)", "use": "4XX / business validation failures" }, + "server": { "class": "ApplicationServerException", "ctor": "ApplicationServerException(Throwable cause)", "use": "wrap checked/unexpected exceptions → 5XX" }, + "idiom": "throw new ApplicationClientException(ErrorCode.NOT_FOUND.getMessage(), ErrorCode.NOT_FOUND, (String[]) null);", + "handler": "ControllerExceptionHandler (@RestControllerAdvice, auto-registered by layer config) emits ErrorResponse{code,message,moreInfo,developerMessage}", + "errorCodes": [ + { "name": "GENERIC_4XX_ERROR", "status": 400 }, + { "name": "MISSING_HTTP_HEADER_ERROR", "status": 400 }, + { "name": "UNAUTHORIZED", "status": 401 }, + { "name": "FORBIDDEN", "status": 403 }, + { "name": "NOT_FOUND", "status": 404 }, + { "name": "RESOURCE_OUT_OF_SYNC", "status": 409 }, + { "name": "LOCKED", "status": 423 }, + { "name": "TOO_MANY_REQUESTS", "status": 429 }, + { "name": "AUTHENTICATION_ERROR", "status": 403 }, + { "name": "GENERIC_5XX_ERROR", "status": 500 } + ] + }, + "archetypes": { + "catalogFile": "archetype/archetype-catalog.xml", + "groupId": "io.americanexpress.synapse", + "version": "0.4.28-SNAPSHOT", + "command": "mvn archetype:generate -DarchetypeGroupId=io.americanexpress.synapse -DarchetypeArtifactId= -DarchetypeVersion= -DgroupId= -DartifactId= -Dversion=0.0.1-SNAPSHOT -DjavaVersion=21 -Dauthor= -DapiName= -DclassName= -DbaseUrl=/v1/ -DinteractiveMode=false", + "requiredProperties": ["groupId", "artifactId", "version", "javaVersion", "author", "apiName", "className", "baseUrl (service) / url (client)"], + "ids": [ + "synapse-archetype-service-rest-get", "synapse-archetype-service-rest-post", "synapse-archetype-service-rest-put", "synapse-archetype-service-rest-delete", + "synapse-archetype-service-rest-reactive-get", "synapse-archetype-service-rest-reactive-post", "synapse-archetype-service-rest-reactive-put", + "synapse-archetype-client-rest", "synapse-archetype-client-rest-get", "synapse-archetype-client-rest-post", "synapse-archetype-client-rest-put", "synapse-archetype-client-rest-delete", + "synapse-archetype-client-rest-reactive", "synapse-archetype-client-rest-reactive-get", "synapse-archetype-client-rest-reactive-post", "synapse-archetype-client-rest-reactive-put", "synapse-archetype-client-rest-reactive-delete", + "synapse-archetype-client-graphql", "synapse-archetype-data-postgres" + ] + }, + "testing": { + "artifacts": ["synapse-service-test", "synapse-framework-test"], + "baseClasses": { + "controllerSliceTest": "io.americanexpress.synapse.service.test.controller.BaseControllerTest", + "controllerIT": "io.americanexpress.synapse.service.test.controller.BaseControllerIT", + "perOperationUnitTests": ["BaseCreateMonoControllerUnitTest", "BaseReadMonoControllerUnitTest", "BaseReadPolyControllerUnitTest", "BaseGetMonoControllerUnitTest", "BaseUpdateControllerUnitTest", "BaseDeleteControllerUnitTest"] + } + }, + "samples": { + "imperativeRestWithDb": "service/service-samples/sample-service-rest-mysql-book (+ data/data-samples/sample-data-mysql-book)", + "reactiveRest": "service/service-samples/sample-service-reactive-rest-book", + "graphql": "service/service-samples/sample-service-graphql-book" + } +} diff --git a/.synapse/catalog.schema.json b/.synapse/catalog.schema.json new file mode 100644 index 000000000..b2e21ee43 --- /dev/null +++ b/.synapse/catalog.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://americanexpress.github.io/synapse/agent/catalog.schema.json", + "title": "Synapse Agent Catalog", + "description": "Machine-readable catalog of Synapse base classes, operations, and conventions, consumed by AI coding agents (the synapse-engineer skill and MCP server) so they generate correct code without re-deriving signatures from source. SHOULD be generated from the framework source (annotation processor / Maven plugin) so it cannot drift. See docs/agent/RECOMMENDATIONS.md #1.", + "type": "object", + "required": ["catalogVersion", "framework", "families", "exceptions", "archetypes"], + "properties": { + "catalogVersion": { "type": "string", "description": "Schema/contract version of this catalog." }, + "framework": { + "type": "object", + "required": ["groupId", "frameworkVersion", "java"], + "properties": { + "groupId": { "type": "string" }, + "frameworkVersion": { "type": "string" }, + "java": { "type": "string" }, + "springBoot": { "type": "string" }, + "jakarta": { "type": "boolean" }, + "loggingFacade": { "type": "string" } + } + }, + "notes": { "type": "array", "items": { "type": "string" } }, + "families": { + "type": "object", + "description": "Layer families. Each operation pairs a controller with a service and names the protected abstract method to override.", + "additionalProperties": { + "type": "object", + "properties": { + "basePackage": { "type": "string" }, + "artifact": { "type": "string" }, + "operations": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "controller", "service"], + "properties": { + "id": { "type": "string" }, + "httpMethod": { "type": "string", "enum": ["GET", "POST", "PUT", "DELETE", "PATCH"] }, + "path": { "type": "string" }, + "successStatus": { "type": "integer" }, + "controller": { + "type": "object", + "required": ["class"], + "properties": { "class": { "type": "string" }, "generics": { "type": "string" } } + }, + "service": { + "type": "object", + "required": ["class", "override"], + "properties": { "class": { "type": "string" }, "generics": { "type": "string" }, "override": { "type": "string", "description": "The exact protected abstract method signature the subclass must implement." } } + } + } + } + } + } + } + }, + "exceptions": { "type": "object" }, + "archetypes": { "type": "object" }, + "testing": { "type": "object" }, + "samples": { "type": "object" } + } +} diff --git a/.synapse/mcp/README.md b/.synapse/mcp/README.md new file mode 100644 index 000000000..56ee88381 --- /dev/null +++ b/.synapse/mcp/README.md @@ -0,0 +1,67 @@ +# Synapse MCP server + +A zero-dependency [Model Context Protocol](https://modelcontextprotocol.io) server that exposes the +Synapse base-class catalog (`.synapse/catalog.json`) to AI coding agents, so any MCP-capable runtime +generates correct, idiomatic Synapse code instead of re-deriving signatures from source. + +It complements the provider-agnostic `synapse-engineer` agent/skill in `.synapse/agents/` and +`.synapse/skills/` (plain Markdown any runtime can load): those carry the human-readable recipe, while +this server exposes the same catalog as callable tools to **any** MCP client (Claude Code, Cursor, +custom agents, …). + +## Requirements + +Python 3.8+. No pip installs — it uses only the standard library and speaks JSON-RPC 2.0 over stdio. + +## Tools + +| Tool | Purpose | Key args | +|---|---|---| +| `list_base_classes` | List operations/base classes for a layer family, with the `execute*` method to override. | `family` (optional: `imperativeRest`, `reactiveRest`) | +| `scaffold_operation` | Return copy-ready Controller + Service (+ Request/Response) skeletons for one operation. | `operation`, `entity`, `packageName`, `baseUrl`, `family` | +| `validate_module` | Statically check a generated module dir against Synapse conventions. | `modulePath` | + +`validate_module` flags: `@RestController` not extending a `Base*Controller`; a Synapse `@Service` that +never overrides `execute*`; `implements BaseServiceResponse` (it's abstract → `extends`); +`extends BaseServiceRequest` (it's an interface → `implements`); custom `@RestControllerAdvice` +(redundant — the layer config auto-registers `ControllerExceptionHandler`); verb `@*Mapping` on a +controller (the base already maps the verb). + +## Smoke test + +```bash +python3 .synapse/mcp/synapse_mcp.py --selftest # prints "selftest OK" +``` + +Manual JSON-RPC round trip: + +```bash +printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"scaffold_operation","arguments":{"operation":"create","entity":"Book","packageName":"com.acme.book","baseUrl":"/v1/books"}}}' \ + | python3 .synapse/mcp/synapse_mcp.py +``` + +## Registering with an MCP client + +A ready-made `.mcp.json` lives at the repo root (Claude Code auto-discovers it). For other clients, +point them at the command: + +```json +{ + "mcpServers": { + "synapse": { + "command": "python3", + "args": [".synapse/mcp/synapse_mcp.py"] + } + } +} +``` + +Use an absolute path to `synapse_mcp.py` if the client's working directory isn't the repo root. + +## Keeping the catalog honest + +The server reads `.synapse/catalog.json`. That file is currently hand-maintained from verified source. +To guarantee it never drifts from the framework, generate it during the build — see +`docs/agent/RECOMMENDATIONS.md` (#1, catalog-generation Maven plugin). diff --git a/.synapse/mcp/synapse_mcp.py b/.synapse/mcp/synapse_mcp.py new file mode 100644 index 000000000..af0a49987 --- /dev/null +++ b/.synapse/mcp/synapse_mcp.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +"""Synapse MCP server — exposes the Synapse base-class catalog to any MCP-capable +AI agent (Claude Code, etc.) so it generates correct, idiomatic Synapse code. + +Zero third-party dependencies: speaks the Model Context Protocol over stdio using +newline-delimited JSON-RPC 2.0, reading ground truth from ../catalog.json. + +Tools: + - list_base_classes : list operations/base classes for a layer family (filterable) + - scaffold_operation: return copy-ready Controller+Service(+Request/Response) skeletons for an operation + - validate_module : statically check a generated module dir against Synapse conventions + +Run directly for a smoke test: python3 synapse_mcp.py --selftest +""" +import json +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +CATALOG_PATH = os.path.join(HERE, "..", "catalog.json") +PROTOCOL_VERSION = "2024-11-05" +SERVER_INFO = {"name": "synapse-mcp", "version": "0.1.0"} + + +def load_catalog(): + with open(CATALOG_PATH, "r", encoding="utf-8") as fh: + return json.load(fh) + + +# --------------------------------------------------------------------------- # +# Tool implementations +# --------------------------------------------------------------------------- # +def _find_operation(catalog, family, operation_id): + fam = catalog["families"].get(family) + if not fam: + raise ValueError( + "unknown family '%s'; known: %s" % (family, ", ".join(catalog["families"])) + ) + for op in fam.get("operations", []): + if op["id"] == operation_id: + return fam, op + known = ", ".join(o["id"] for o in fam.get("operations", [])) + raise ValueError( + "unknown operation '%s' in family '%s'; known: %s" % (operation_id, family, known) + ) + + +def tool_list_base_classes(catalog, args): + family = args.get("family") + families = [family] if family else list(catalog["families"]) + out = {} + for fam_name in families: + fam = catalog["families"].get(fam_name) + if not fam or "operations" not in fam: + continue + out[fam_name] = { + "basePackage": fam.get("basePackage"), + "artifact": fam.get("artifact"), + "operations": [ + { + "id": op["id"], + "http": "%s %s" % (op.get("httpMethod", "?"), op.get("path", "")), + "controller": op["controller"]["class"], + "service": op["service"]["class"], + "override": op["service"]["override"], + } + for op in fam["operations"] + ], + } + return out + + +def _camel(name): + return re.sub(r"[^0-9a-zA-Z]", "", name[:1].upper() + name[1:]) + + +def tool_scaffold_operation(catalog, args): + family = args.get("family", "imperativeRest") + operation_id = args["operation"] + entity = _camel(args.get("entity", "Resource")) + pkg = args.get("packageName", "com.example.app") + base_url = args.get("baseUrl", "/v1/" + entity.lower() + "s") + + fam, op = _find_operation(catalog, family, operation_id) + ctrl = op["controller"]["class"] + svc = op["service"]["class"] + base_pkg = fam["basePackage"] + op_cap = _camel(operation_id) + reactive = family == "reactiveRest" + + req_cls = "%s%sRequest" % (entity, op_cap) + resp_cls = "%s%sResponse" % (entity, op_cap) + ctrl_cls = "%s%sController" % (entity, op_cap) + svc_cls = "%s%sService" % (entity, op_cap) + + # Determine generic argument list the concrete class passes to the base. + needs_req = "I extends" in (op["controller"].get("generics") or "") or "I extends" in ( + op["service"].get("generics") or "" + ) + needs_resp = "O extends" in (op["controller"].get("generics") or "") + + ctrl_generics = [] + if needs_req: + ctrl_generics.append(req_cls) + if needs_resp: + ctrl_generics.append(resp_cls) + ctrl_generics.append(svc_cls) + ctrl_decl = "%s<%s>" % (ctrl, ", ".join(ctrl_generics)) if ctrl_generics else ctrl + + svc_generics = [] + if needs_req: + svc_generics.append(req_cls) + if needs_resp: + svc_generics.append(resp_cls) + svc_decl = "%s<%s>" % (svc, ", ".join(svc_generics)) if svc_generics else svc + + files = {} + + files["controller/%s.java" % ctrl_cls] = ( + "package %s.controller;\n\n" + "import %s.controller.%s;\n" + "import org.springframework.web.bind.annotation.RequestMapping;\n" + "import org.springframework.web.bind.annotation.RestController;\n\n" + "@RestController\n" + "@RequestMapping(\"%s\")\n" + "public class %s extends %s {\n}\n" + % (pkg, base_pkg, ctrl, base_url, ctrl_cls, ctrl_decl) + ) + + override = op["service"]["override"] + if reactive: + body = " return reactor.core.publisher.Mono.empty(); // TODO business logic" + if "Flux<" in override: + body = " return reactor.core.publisher.Flux.empty(); // TODO business logic" + elif override.startswith("protected void"): + body = " // TODO business logic" + elif "Page<" in override: + body = " return org.springframework.data.domain.Page.empty(); // TODO business logic" + else: + body = " return new %s(); // TODO business logic" % resp_cls + + files["service/%s.java" % svc_cls] = ( + "package %s.service;\n\n" + "import %s.service.%s;\n" + "import org.springframework.http.HttpHeaders;\n" + "import org.springframework.stereotype.Service;\n\n" + "@Service\n" + "public class %s extends %s {\n\n" + " @Override\n" + " %s {\n" + "%s\n" + " }\n}\n" + % (pkg, base_pkg, svc, svc_cls, svc_decl, override, body) + ) + + if needs_req: + files["model/%s.java" % req_cls] = ( + "package %s.model;\n\n" + "import %s.model.BaseServiceRequest;\n" + "import jakarta.validation.constraints.NotBlank;\n\n" + "public class %s implements BaseServiceRequest {\n" + " @NotBlank private String name; // TODO real fields\n" + " public String getName() { return name; }\n" + " public void setName(String name) { this.name = name; }\n}\n" + % (pkg, base_pkg, req_cls) + ) + if needs_resp: + files["model/%s.java" % resp_cls] = ( + "package %s.model;\n\n" + "import %s.model.BaseServiceResponse;\n\n" + "public class %s extends BaseServiceResponse {\n" + " private String name; // TODO real fields; id inherited via setId()\n" + " public String getName() { return name; }\n" + " public void setName(String name) { this.name = name; }\n}\n" + % (pkg, base_pkg, resp_cls) + ) + + return { + "family": family, + "operation": operation_id, + "http": "%s %s%s" % (op.get("httpMethod"), base_url, op.get("path", "")), + "appConfigHint": fam.get("appConfig"), + "files": files, + } + + +def tool_validate_module(catalog, args): + root = args["modulePath"] + findings = [] + java_files = [] + for dirpath, _dirs, names in os.walk(root): + if os.sep + "test" + os.sep in dirpath: + continue + for n in names: + if n.endswith(".java"): + java_files.append(os.path.join(dirpath, n)) + + if not java_files: + return {"ok": False, "findings": [{"level": "error", "msg": "no .java files under " + root}]} + + ctrl_bases = set() + svc_bases = set() + for fam in catalog["families"].values(): + for op in fam.get("operations", []): + ctrl_bases.add(op["controller"]["class"]) + svc_bases.add(op["service"]["class"]) + + for path in java_files: + with open(path, "r", encoding="utf-8") as fh: + src = fh.read() + rel = os.path.relpath(path, root) + is_ctrl = "@RestController" in src + is_svc = re.search(r"@Service\b", src) is not None + + if is_ctrl: + if not any(("extends " + b) in src for b in ctrl_bases): + findings.append({"level": "error", "file": rel, + "msg": "@RestController does not extend a Synapse Base*Controller"}) + if "controller" not in rel.replace("\\", "/").split("/"): + findings.append({"level": "warn", "file": rel, + "msg": "controller not in a 'controller' package"}) + if re.search(r"@(Post|Get|Put|Delete|Request)Mapping", src) and "class " in src: + # mappings on the class beyond @RequestMapping base path are a smell + if re.search(r"@(Post|Get|Put|Delete)Mapping", src): + findings.append({"level": "warn", "file": rel, + "msg": "verb @*Mapping found; base controller already maps the verb"}) + if is_svc and any(("extends " + b) in src for b in svc_bases): + if "execute" not in src: + findings.append({"level": "error", "file": rel, + "msg": "Synapse service does not override an execute* method"}) + if "implements BaseServiceRequest" in src and "extends BaseServiceRequest" in src: + findings.append({"level": "error", "file": rel, + "msg": "BaseServiceRequest is an interface — use 'implements'"}) + if re.search(r"class\s+\w+\s+implements\s+BaseServiceResponse", src): + findings.append({"level": "error", "file": rel, + "msg": "BaseServiceResponse is an abstract class — use 'extends'"}) + if "@RestControllerAdvice" in src: + findings.append({"level": "warn", "file": rel, + "msg": "custom @RestControllerAdvice; Synapse auto-registers ControllerExceptionHandler"}) + + ok = not any(f["level"] == "error" for f in findings) + return {"ok": ok, "filesScanned": len(java_files), "findings": findings} + + +TOOLS = { + "list_base_classes": { + "fn": tool_list_base_classes, + "description": "List Synapse base classes/operations for a layer family (imperativeRest, reactiveRest). Returns controller, service, and the protected abstract method to override.", + "schema": { + "type": "object", + "properties": {"family": {"type": "string", "description": "Optional family filter; omit for all."}}, + }, + }, + "scaffold_operation": { + "fn": tool_scaffold_operation, + "description": "Generate copy-ready Controller + Service (+ Request/Response) skeletons for one Synapse operation.", + "schema": { + "type": "object", + "required": ["operation", "entity"], + "properties": { + "family": {"type": "string", "default": "imperativeRest"}, + "operation": {"type": "string", "description": "Operation id, e.g. create, readMono, readPoly, getMono, update, delete."}, + "entity": {"type": "string", "description": "Domain entity name, e.g. Book."}, + "packageName": {"type": "string", "default": "com.example.app"}, + "baseUrl": {"type": "string", "description": "Base request mapping, e.g. /v1/books."}, + }, + }, + }, + "validate_module": { + "fn": tool_validate_module, + "description": "Statically validate a generated module directory against Synapse conventions (base-class extension, execute* override, request/response inheritance, no custom advice).", + "schema": { + "type": "object", + "required": ["modulePath"], + "properties": {"modulePath": {"type": "string", "description": "Path to the module's source root to scan."}}, + }, + }, +} + + +# --------------------------------------------------------------------------- # +# JSON-RPC / MCP plumbing +# --------------------------------------------------------------------------- # +def _result(req_id, result): + return {"jsonrpc": "2.0", "id": req_id, "result": result} + + +def _error(req_id, code, message): + return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}} + + +def handle(catalog, msg): + method = msg.get("method") + req_id = msg.get("id") + params = msg.get("params") or {} + + if method == "initialize": + return _result(req_id, { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {"tools": {}}, + "serverInfo": SERVER_INFO, + }) + if method in ("notifications/initialized", "initialized"): + return None + if method == "tools/list": + return _result(req_id, { + "tools": [ + {"name": name, "description": spec["description"], "inputSchema": spec["schema"]} + for name, spec in TOOLS.items() + ] + }) + if method == "tools/call": + name = params.get("name") + arguments = params.get("arguments") or {} + spec = TOOLS.get(name) + if not spec: + return _error(req_id, -32601, "unknown tool: %s" % name) + try: + payload = spec["fn"](catalog, arguments) + return _result(req_id, {"content": [{"type": "text", "text": json.dumps(payload, indent=2)}]}) + except Exception as exc: # surface as tool error, not transport error + return _result(req_id, { + "isError": True, + "content": [{"type": "text", "text": "%s: %s" % (type(exc).__name__, exc)}], + }) + if req_id is not None: + return _error(req_id, -32601, "unknown method: %s" % method) + return None + + +def serve(): + catalog = load_catalog() + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + response = handle(catalog, msg) + if response is not None: + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +def selftest(): + catalog = load_catalog() + assert tool_list_base_classes(catalog, {})["imperativeRest"]["operations"], "list failed" + sc = tool_scaffold_operation(catalog, {"operation": "create", "entity": "Book", "packageName": "com.acme.book"}) + assert any("BookCreateController" in k for k in sc["files"]), "scaffold controller missing" + assert "executeCreate" in next(v for k, v in sc["files"].items() if "Service" in k), "override missing" + for m in ("initialize", "tools/list"): + assert handle(catalog, {"jsonrpc": "2.0", "id": 1, "method": m}), m + " failed" + call = handle(catalog, {"jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": {"name": "list_base_classes", "arguments": {}}}) + assert call["result"]["content"], "tools/call failed" + print("selftest OK") + + +if __name__ == "__main__": + if "--selftest" in sys.argv: + selftest() + else: + serve() diff --git a/.synapse/skills/synapse-engineer/SKILL.md b/.synapse/skills/synapse-engineer/SKILL.md new file mode 100644 index 000000000..bd3851b00 --- /dev/null +++ b/.synapse/skills/synapse-engineer/SKILL.md @@ -0,0 +1,245 @@ +--- +name: synapse-engineer +description: > + Scaffold and implement Spring Boot code with the American Express Synapse framework (io.americanexpress.synapse) — + REST/reactive services, REST/SOAP/GraphQL clients, and JPA/Mongo data access. Use when creating a new Synapse app + or adding a Synapse controller/service/repository to an existing one. Provides the base-class decision table, + copy-ready file skeletons, the Maven archetype command, and pom/properties templates. +--- + +# Synapse Engineer — scaffolding procedure + +Implement code the Synapse way: extend one Base class per layer and override its single `protected abstract +execute*` method. All signatures are verified against this repo (`io.americanexpress.synapse`, Java 21, Spring +Boot 3.5.x, `jakarta.*`, `XLogger`). Canonical worked example: +`service/service-samples/sample-service-rest-mysql-book` (service) + `data/data-samples/sample-data-mysql-book` (data). + +## Ground truth: `.synapse/catalog.json` + +Before scaffolding, read **`.synapse/catalog.json`** — the machine-readable catalog of every base class, +its generics, the exact `execute*` method to override, HTTP verb/path, the config triple, ErrorCodes, and +archetype coordinates. It is authoritative; prefer it over re-deriving signatures from source. The tables +below mirror it for quick reference. (An MCP server at `.synapse/mcp/` exposes the same data as +`list_base_classes` / `scaffold_operation` / `validate_module` for non–Claude-Code agents.) + +## Step 1 — Choose the path + +- **New standalone app** → use the Maven archetype (Step 2A). +- **Add to an existing module** → hand-write the slice (Step 2B onward). + +## Step 2A — Generate from an archetype + +Archetypes (under `archetype/`, coords `io.americanexpress.synapse:synapse-archetype-`): service-rest-`get`/`post`/`put`/`delete`, +their `service-rest-reactive-*` variants, `client-rest`(+`-get/-post/-put/-delete` and reactive), `client-graphql`, +`data-postgres`. Full list in `archetype/archetype-catalog.xml`. + +```bash +mvn archetype:generate \ + -DarchetypeGroupId=io.americanexpress.synapse \ + -DarchetypeArtifactId=synapse-archetype-service-rest-post \ + -DarchetypeVersion= \ + -DgroupId=com.example.myapp -DartifactId=my-service -Dversion=0.0.1-SNAPSHOT \ + -DjavaVersion=21 \ + -Dauthor="Your Name" -DapiName="MyApi" -DbaseUrl="/v1/books" -DclassName="Book" \ + -DinteractiveMode=false +``` + +Required/typical properties: `groupId`, `artifactId`, `version`, `javaVersion` (default 21), plus archetype-specific +`author`, `apiName`, `className`, and `baseUrl` (service) / `url` (client). Then fill in domain fields + business logic (Steps 3–5). + +> Editing archetype *templates*? CI enforces tokenization in `archetype-resources/`: keep +> `@project.version@`, `maven-compiler-plugin` with +> `@maven.compiler.plugin.version@`, and `requiredProperty key="javaVersion"` defaulting to +> `@maven.compiler.source@`. Never hardcode versions there. + +## Step 2B — Pick the base class (imperative REST) + +| Operation | Controller extends | Service extends | Override (`protected abstract`) | +|---|---|---|---| +| Create (POST → 201) | `BaseCreateController` | `BaseCreateService` | `Resp executeCreate(HttpHeaders, Req)` | +| Read one by body (POST `/inquiry_results`) | `BaseReadMonoController` | `BaseReadMonoService` | `Resp executeRead(HttpHeaders, Req)` | +| Read many (POST `/multiple_results`) | `BaseReadPolyController` | `BaseReadPolyService` | `Page executeRead(HttpHeaders, Req)` | +| Get one by id (GET `/{id}`) | `BaseGetMonoController` | `BaseGetMonoService` | `Resp executeRead(HttpHeaders, String identifier)` | +| Update (PUT → 204) | `BaseUpdateController` | `BaseUpdateService` | `void executeUpdate(HttpHeaders, Req)` | +| Delete (DELETE `/{identifier}` → 204) | `BaseDeleteController` | `BaseDeleteService` | `void executeDelete(HttpHeaders, String id)` | + +Packages: controllers `io.americanexpress.synapse.service.rest.controller`, services `…service.rest.service`, +models `…service.rest.model`. Note: reads are POST-with-body except get-by-id; you override `execute*`, not the public method. + +**Reactive** (`io.americanexpress.synapse.service.reactive.rest.*`): same shapes but controllers return +`Mono>`/`Flux` and services override `Mono`/`Flux`/`Mono`; app config **extends** +`BaseServiceReactiveRestConfig`. Choose one paradigm; never mix. + +## Step 3 — Write the slice (verified patterns) + +App package root e.g. `com.example.myapp` with subpackages `config/ controller/ service/ model/ entity/ dao/`. + +**Request** — `implements BaseServiceRequest` (it is an interface) + jakarta validation: +```java +public class CreateBookRequest implements BaseServiceRequest { // io.americanexpress.synapse.service.rest.model.BaseServiceRequest + @NotBlank private String title; // jakarta.validation.constraints.NotBlank + @NotBlank private String author; + private String createdBy; + // getters / setters +} +``` + +**Response** — `extends BaseServiceResponse` (abstract; inherits `String id` + getId/setId): +```java +public class CreateBookResponse extends BaseServiceResponse { // io.americanexpress.synapse.service.rest.model.BaseServiceResponse + private String title; private String author; // getters/setters; id via inherited setId(...) +} +``` + +**Controller** — declaration only (base maps the verb): +```java +@RestController +@RequestMapping("/v1/books") +public class CreateBookController + extends BaseCreateController { } +``` + +**Service** — override the `execute*` method; constructor-inject the repository: +```java +@Service +public class CreateBookService extends BaseCreateService { + private final BookRepository bookRepository; + public CreateBookService(BookRepository bookRepository) { this.bookRepository = bookRepository; } + + @Override + protected CreateBookResponse executeCreate(HttpHeaders headers, CreateBookRequest request) { + if (bookRepository.findByTitle(request.getTitle()) != null) { + throw new ApplicationClientException(ErrorCode.RESOURCE_OUT_OF_SYNC.getMessage(), + ErrorCode.RESOURCE_OUT_OF_SYNC, (String[]) null); + } + BookEntity entity = new BookEntity(); + entity.setTitle(request.getTitle()); + entity.setAuthor(request.getAuthor()); + BookEntity saved = bookRepository.save(entity); + CreateBookResponse response = new CreateBookResponse(); + response.setId(String.valueOf(saved.getId())); + response.setTitle(saved.getTitle()); + response.setAuthor(saved.getAuthor()); + return response; + } +} +``` +Imports: `io.americanexpress.synapse.service.rest.service.BaseCreateService`, +`io.americanexpress.synapse.framework.exception.ApplicationClientException`, +`io.americanexpress.synapse.framework.exception.model.ErrorCode`, `org.springframework.http.HttpHeaders`. + +**Entity + Repository** (only if persisted): +```java +@Entity @Table(name = "book") +public class BookEntity extends BaseEntity { // io.americanexpress.synapse.data.jpa.entity.BaseEntity (id + audit cols free) + @Column(name = "title") private String title; + @Column(name = "author") private String author; // getters/setters +} + +@Repository +public interface BookRepository extends JpaRepository { // Spring Data directly + BookEntity findByTitle(String title); + BookEntity findByTitleAndAuthor(String title, String author); +} +``` + +## Step 4 — Wire it + +**App / service config** — import the layer config + your data config: +```java +@Configuration +@PropertySource("classpath:service-book-application.properties") +@ComponentScan(basePackages = "com.example.myapp") +@Import({ BookDataConfig.class, ServiceRestConfig.class }) // io.americanexpress.synapse.service.rest.config.ServiceRestConfig +public class BookConfig { } +``` +`ServiceRestConfig` auto-registers the `ControllerExceptionHandler` — don't write one. + +**Data config** — extend the per-DB base, point it at your entity + dao packages: +```java +@Configuration +@PropertySource("classpath:data-book-application.properties") +@EnableJpaRepositories(basePackages = "com.example.myapp.dao") +public class BookDataConfig extends BasePostgresDataConfig { // or BaseMySqlDataConfig, etc. + public BookDataConfig(Environment environment) { super(environment); } + @Override + protected void setPackagesToScan(LocalContainerEntityManagerFactoryBean emf) { + emf.setPackagesToScan("com.example.myapp.entity"); + } +} +// Enable auditing once: @Import(InstantJpaAuditingDataConfig.class) or add @EnableJpaAuditing(dateTimeProviderRef="instantDateTimeProvider"). +``` + +**Main class**: a normal `@SpringBootApplication` (samples log readiness via `XLoggerFactory.getXLogger(...)`). + +**pom.xml** — Spring Boot parent + the synapse modules + driver: +```xml + + io.americanexpress.synapse + synapse-service-rest${synapse.version} + io.americanexpress.synapse + synapse-data-postgres${synapse.version} + io.americanexpress.synapse + synapse-framework-exception${synapse.version} + org.postgresqlpostgresqlruntime + + +``` +Swap data module + driver per DB (mysql → `synapse-data-mysql` + `com.mysql:mysql-connector-j`, etc.). + +**application.properties** (Postgres example): +```properties +spring.application.name=Book Rest Service +server.port=8080 +spring.datasource.url=jdbc:postgresql://localhost:5432/book +spring.datasource.username=postgres +spring.datasource.password=password +spring.datasource.driver-class-name=org.postgresql.Driver +spring.jpa.properties.hibernate.default_schema=synapse +spring.jpa.hibernate.ddl-auto=update +``` +(The MySQL sample uses `spring.mysql.datasource.*` keys read by `BaseMySqlDataConfig`; match whichever base config you extend.) + +## Step 4.5 — Scaffold a test (don't skip) + +`synapse-service-test` provides base test classes — always emit at least one so the module ships green. +Add it as a test-scope dependency and extend the matching base: + +- `io.americanexpress.synapse.service.test.controller.BaseControllerTest` — controller slice test base. +- `io.americanexpress.synapse.service.test.controller.BaseControllerIT` — integration test base. +- Per-operation unit-test bases: `BaseCreateMonoControllerUnitTest`, `BaseReadMonoControllerUnitTest`, + `BaseReadPolyControllerUnitTest`, `BaseGetMonoControllerUnitTest`, `BaseUpdateControllerUnitTest`, + `BaseDeleteControllerUnitTest`. + +For convention enforcement (controllers extend a base, services override `execute*`, requests +`implements`/responses `extends`, no custom advice), copy `docs/agent/archunit/SynapseConventionTest.java` +into `src/test/java`, set `APP_PACKAGE`, add `com.tngtech.archunit:archunit-junit5` (test scope), and run +`mvn test` as the verify loop. + +## Step 5 — Conventions & verification + +- **Override `execute*`, not the public method.** `BaseServiceRequest` is an interface (`implements`); + `BaseServiceResponse` is abstract (`extends`, gives `id`). +- **Errors**: `throw new ApplicationClientException(devMsg, ErrorCode, msgArgs...)` (4XX) or + `new ApplicationServerException(cause)` (5XX). Real `ErrorCode`s: `GENERIC_4XX_ERROR`, `MISSING_HTTP_HEADER_ERROR`, + `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `GENERIC_5XX_ERROR`, `AUTHENTICATION_ERROR`, `TOO_MANY_REQUESTS`, + `LOCKED`, `RESOURCE_OUT_OF_SYNC`. Don't build error bodies by hand. +- **Validation**: jakarta constraints on the Request; the base controller's `@Valid` enforces them. +- **Don't** add `@PostMapping`/`ResponseEntity` plumbing to controllers — the base owns it. One controller class + per operation, grouped under a shared `@RequestMapping` base path. +- **Build/test**: `./mvnw -pl -am clean test` (root reactor: `./mvnw clean package`). +- When in doubt, read the nearest `*-samples` module and mirror it. + +## Step 6 — Update `CONTEXT.md` (do not skip) + +`/CONTEXT.md` is the repo's living context for AI agents; keeping it current is part of every task. +Before you finish, update it in the **same change** if your work touched anything it records: + +- Base-class signature / new operation → update `.synapse/catalog.json`, then `CONTEXT.md` "Critical facts". +- Framework or catalog version bump → snapshot line + `_Last updated_` stamp. +- Tooling added/moved/removed → "AI-agent tooling map". +- Tracked item resolved/opened (PR merged, CI fixed) → "Open items". +- Branch/PR status change → "Current snapshot". + +Always refresh `_Last updated_` and append a one-line entry to the `CONTEXT.md` "Update log". The full +rules live in `CONTEXT.md` → **Update protocol**. A stale `CONTEXT.md` is a defect; fix it now, not later. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..817ea740d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,126 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What Synapse is + +Synapse (`io.americanexpress.synapse`, Apache 2.0, by American Express) is a **multi-module Maven +library** — a lightweight foundational framework layered on top of Spring Boot. It is *not* an +application; it ships reusable abstract base classes and configuration that downstream teams depend +on to build enterprise services, clients, and data access modules with minimal boilerplate. Artifacts +are published to Maven Central. + +The framework enforces a layered, convention-over-configuration architecture: application developers +**extend a Base class for each architectural layer** rather than wiring things from scratch. The Base +classes already encode the HTTP, service, and DAO layer responsibilities (controller exception +handling, metrics interception, pagination, logging, error handling, connection pooling, etc.). + +- Java **21**, Spring Boot **3.5.x**, Spring Cloud 2025.x, JUnit 5, Mockito 5. +- Current version: 0.4.x (`-SNAPSHOT` on `develop`). + +## Build, test, run + +Use the Maven wrapper from the repo root. The root `pom.xml` (packaging `pom`) is the reactor for all modules. + +```bash +./mvnw clean package # compile + run all unit tests across every module (what CI's build job runs) +./mvnw verify # full verification incl. integration tests (run after package in CI) +./mvnw clean test # unit tests only + +# Work on a single module (and the modules it depends on): +./mvnw -pl service/synapse-service-rest -am clean test + +# A single test class / method: +./mvnw -pl data/synapse-data-postgres test -Dtest=SomeClassTest +./mvnw -pl data/synapse-data-postgres test -Dtest=SomeClassTest#someMethod +``` + +There is no single runnable app. To see the framework in action, run the reference apps under the +`*-samples` directories (e.g. `service/service-samples`, `client/client-samples`, `data/data-samples`, +`function/function-samples`), which are normal Spring Boot apps (`./mvnw -pl spring-boot:run`). + +## Module layout + +The reactor groups modules into top-level directories by architectural role. Within each, modules are +named `synapse--`: + +| Dir | Role | Key modules | +|---|---|---| +| `api` | **Foundational base classes** shared by service & client layers — split `synapse-api-rest-imperative` (Spring Web MVC) and `synapse-api-rest-reactive` (Spring WebFlux). The two mirror each other so switching paradigms is a small change. | imperative, reactive | +| `service` | Business layer — exposes APIs that start a workflow (REST/reactive/GraphQL). | rest, reactive-rest, imperative, reactive, graphql, test | +| `client` | Data-access layer — consumes external APIs. | rest, soap, graphql, test | +| `data` | Data-access layer — CRUD against datastores; each provides config + `BaseEntity` + connection pooling. | jdbc, jpa, postgres, mysql, mssql, oracle, db2, mongodb, cassandra, couchbase, redis | +| `framework` | Cross-cutting concerns. | exception, logging, api-docs, test | +| `publisher` / `subscriber` | Async pub/sub messaging. | kafka | +| `function` | Serverless/function support. | synapse-function | +| `utility` | Small standalone helpers. | utilities-common, cryptography, date, number, telephone | +| `archetype` | **Maven archetypes** that scaffold new apps from the base classes (see CI rule below). | service/client REST get/post/put/delete, reactive variants, data-postgres | + +Each role directory also has a `*-samples` module with working reference implementations — the best +place to see how the base classes are meant to be extended. + +## Architecture conventions + +- **Extend, don't reinvent.** A feature is normally implemented by subclassing the appropriate Base + class for its layer. CRUD is split into granular base classes — e.g. `BaseCreateController` / + `BaseReadController` / `BaseReadPolyController` (collections) / `BaseReadMonoController` / + `BaseUpdateController` / `BaseDeleteController`, and the matching `Base*Service` classes. Pick the + narrowest base that fits the operation rather than a catch-all. +- **Imperative vs reactive are parallel hierarchies.** When changing a base class in one paradigm, + check whether the mirror class in the other (`api-rest-imperative` ↔ `api-rest-reactive`, + `service-rest` ↔ `service-reactive-rest`) needs the same change to keep them aligned. +- Standard request/response models extend `BaseServiceRequest` / `BaseServiceResponse`; config + classes extend the layer's base config (e.g. `BaseServiceRestConfig`); data entities extend + `BaseEntity` (auditing fields managed by Spring Data). Errors flow through the two-exception model in + `framework/synapse-framework-exception` (`ApplicationServerException`, `ApplicationClientException`) + with an extensible `ErrorCode` enum. +- Modules consistently use the package layout `controller/ service/ model/ config/` (plus + `repository/`, `dto/` where relevant). Match the sibling module's structure when adding code. + +## CI / release + +- **CircleCI** (`.circleci/config.yml`) is the primary pipeline on `cimg/openjdk:21.0`: `./mvnw clean + package` then `./mvnw verify`; separate snapshot/release jobs deploy to Maven Central (Sonatype OSS) + with GPG signing. +- **Archetype validation runs in CI and will fail the build**: every archetype's + `archetype-resources/pom.xml` must keep tokenized values — `@project.version@`, + the `maven-compiler-plugin` with `@maven.compiler.plugin.version@`, and each + `archetype-metadata.xml` must declare a required `javaVersion` property defaulting to + `@maven.compiler.source@`. Never hardcode versions in archetype templates. +- GitHub Actions: `bump-version.yml` (manual `workflow_dispatch`, bumps via `versions:set`), + `codeql-analysis.yml`, `compare-dependencies.yml`; Dependabot is active (much of the commit history + is dependency patching). +- Default branch is `develop`. Releases are cut via `Release/vX.Y.Z` PRs. PRs require approval from + `@americanexpress/synapse-team` (CODEOWNERS covers all paths). + +## AI agent tooling + +This repo ships a layer that lets AI coding agents use Synapse correctly without re-deriving signatures +from source. When implementing code *with* Synapse, start here: + +- **`CONTEXT.md`** — living "where things stand now" context (current versions, tooling map, open items, + critical gotchas). Read it first to get oriented; the `synapse-engineer` agent keeps it current per its + [Update protocol](CONTEXT.md#update-protocol). +- **`.synapse/catalog.json`** — authoritative machine-readable catalog of every base class: generics, + the exact `protected abstract execute*` method to override, HTTP verb/path, the controller↔service↔config + triple, `ErrorCode`s, and archetype coordinates (schema in `.synapse/catalog.schema.json`). Prefer it + over reading the Java. Key facts it encodes that surprise people: `BaseServiceRequest` is an **interface** + (`implements`); `BaseServiceResponse` is **abstract** with `id` (`extends`); you override `executeCreate`/ + `executeRead`/… not the public method; reads are POST-with-body (`/inquiry_results`, `/multiple_results`) + except get-by-id; repositories extend Spring Data `JpaRepository` directly (no Synapse base repo). +- **`.synapse/mcp/`** + **`.mcp.json`** — a zero-dependency MCP server exposing the same catalog to any + MCP client as `list_base_classes` / `scaffold_operation` / `validate_module`. Self-test: + `python3 .synapse/mcp/synapse_mcp.py --selftest`. +- **`.synapse/agents/synapse-engineer.md`** + **`.synapse/skills/synapse-engineer/`** — a provider-agnostic + agent persona and scaffolding skill (plain Markdown, no Claude-specific format) with the decision tables, + copy-ready skeletons, test scaffolding, and the `mvn archetype:generate` recipe. Any AI runtime can load + them. Claude Code users symlink them in once: `ln -s ../../.synapse/agents .claude/agents` and + `ln -s ../../.synapse/skills .claude/skills` (`.claude/` is gitignored as provider-specific local state). +- **`docs/agent/archunit/`** — ArchUnit convention tests (template) giving agents a deterministic verify loop. +- **`docs/agent/RECOMMENDATIONS.md`** — proposed framework-level changes for `@americanexpress/synapse-team` + (resolve the dual imperative API family, normalize per-DB property keys, generate the catalog from source, + etc.) that should not be made unilaterally because they touch the published API. + +Note there are **two** imperative families — `service.rest.*` (granular, used by all samples) and +`api.rest.imperative.*` (unified `BaseService.execute`). Default to `service.rest.*`; don't mix them. See +RECOMMENDATIONS.md #B2. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..b57420a0a --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,87 @@ +# CONTEXT.md + +> **Living context for AI agents working on Synapse.** This is the fast-changing "where things +> stand right now" companion to `CLAUDE.md` (stable orientation) and `.synapse/catalog.json` +> (machine-readable base-class facts). Read this first to get current; update it when you change +> anything it describes. +> +> **Maintained by the `synapse-engineer` agent.** Keeping this file current is part of that agent's +> job — see [Update protocol](#update-protocol). Any agent or human that changes the framework +> surface, the tooling, or the open-items list below should update this file in the same change. + +_Last updated: 2026-05-30 · reflects catalog `catalogVersion: 1` · framework `0.4.28-SNAPSHOT`_ + +--- + +## Current snapshot + +- **Framework:** `io.americanexpress.synapse` `0.4.28-SNAPSHOT`, Java 21, Spring Boot 3.5.x, `jakarta.*`. +- **Default branch:** `develop`. **Active branch:** `chore/claude-md-and-dev` → **PR #481** (base `develop`). +- **CI status:** CodeQL is **red due to a GitHub Actions billing lock** on the org (jobs never start; + not a code problem). Maven/CircleCI build not verifiable in the dev sandbox (offline BOMs). See + [Open items](#open-items). +- **What PR #481 adds:** the AI-agent adaptation layer (this file, `.synapse/` tooling, `docs/agent/`), + the archetype-catalog version fix, and `@implSpec` Javadoc on the create base classes. + +## AI-agent tooling map (what exists, where) + +| Path | Role | +|---|---| +| `.synapse/catalog.json` (+ `catalog.schema.json`) | Authoritative machine-readable base-class catalog. Source of truth. | +| `.synapse/mcp/` (+ `/.mcp.json`) | Zero-dep MCP server: `list_base_classes` / `scaffold_operation` / `validate_module`. | +| `.synapse/agents/synapse-engineer.md` | Provider-agnostic agent persona for building Synapse code. | +| `.synapse/skills/synapse-engineer/SKILL.md` | Step-by-step scaffolding recipe + skeletons. | +| `docs/agent/RECOMMENDATIONS.md` | Framework-level proposals needing `@americanexpress/synapse-team` review. | +| `docs/agent/archunit/` | ArchUnit convention tests (template; build-unverified). | +| `CLAUDE.md` | Stable repo orientation (build, architecture, CI, conventions). | + +All AI tooling is under provider-agnostic `.synapse/` / `docs/agent/`. `.claude/` is gitignored +(Claude Code users symlink `.claude/{agents,skills}` → `../../.synapse/{agents,skills}`). + +## Critical facts agents must not get wrong + +(Condensed; full detail in `.synapse/catalog.json`.) + +- Extend a `Base*Controller` + `Base*Service` pair; override the **`protected abstract execute*`** method + (`executeCreate`/`executeRead`/`executeUpdate`/`executeDelete`), **not** the public method. +- `BaseServiceRequest` is an **interface** (`implements`); `BaseServiceResponse` is an **abstract class** + with a `String id` (`extends`). +- Reads are **POST with a body** at `/inquiry_results` (one) and `/multiple_results` (many); + only get-by-id is a real `GET /{id}`. `BaseReadPolyService.executeRead` returns `Page`. +- Repositories extend Spring Data `JpaRepository` directly — **no** Synapse base repo. +- App config `@Import`s the layer config (`ServiceRestConfig`) which **auto-registers** the + `ControllerExceptionHandler` — never write your own `@RestControllerAdvice`. +- Errors: `ApplicationClientException(devMsg, ErrorCode, String...)` (4XX) / + `ApplicationServerException(Throwable)` (5XX). +- **Two imperative families exist** — prefer `service.rest.*` (granular, used by all samples) over + `api.rest.imperative.*` (unified `BaseService.execute`). Don't mix them. (See RECOMMENDATIONS #B2.) + +## Open items + +| Item | Status | Where | +|---|---|---| +| CodeQL / Actions billing lock | **Blocked** — needs org admin to clear billing; then re-run checks | PR #481 checks | +| Stale CodeQL workflow (`checkout@v2`, `codeql-action@v1`) | Proposed follow-up PR (`v4`/`v3` + `setup-java@v4`) | `.github/workflows/codeql-analysis.yml` | +| Resolve dual imperative API family | Proposed, needs team decision | RECOMMENDATIONS #B2 | +| Normalize per-DB datasource property keys | Proposed, needs team decision | RECOMMENDATIONS #B3 | +| Generate `catalog.json` from source (anti-drift) | Proposed | RECOMMENDATIONS #B1 | +| `bump-version.yml` should also bump archetype catalog + catalog.json | Proposed | RECOMMENDATIONS #B5 | +| ArchUnit rules → shared `synapse-architecture-test` module | Proposed | RECOMMENDATIONS #B6 | +| Local `./mvnw … compile` to confirm `@implSpec` edits | Pending (sandbox is offline) | `service/synapse-service-rest` | + +## Update protocol + +Keep this file true. When a change touches any of the following, update the relevant section **in the +same commit** and refresh the `_Last updated_` line: + +1. **A base class signature / new operation** → update `.synapse/catalog.json` first, then the + "Critical facts" section here if a headline fact changed; bump `catalogVersion` if the contract changed. +2. **Framework or catalog version** → update the snapshot line and `_Last updated_`. +3. **Tooling added/moved/removed** → update the "AI-agent tooling map". +4. **An open item resolved or added** (PR merged, CI fixed, proposal accepted) → update "Open items". +5. **Branch/PR status changes** → update "Current snapshot". + +Add a one-line entry to the log below for each material update. + +### Update log +- 2026-05-30 — Created. Established the AI-agent tooling layer; PR #481 open; CodeQL blocked on billing. diff --git a/archetype/archetype-catalog.xml b/archetype/archetype-catalog.xml index fdc8c87c0..cb2f90585 100644 --- a/archetype/archetype-catalog.xml +++ b/archetype/archetype-catalog.xml @@ -20,97 +20,97 @@ io.americanexpress.synapse synapse-archetype-client-graphql - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT io.americanexpress.synapse synapse-archetype-client-rest - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT io.americanexpress.synapse synapse-archetype-client-rest-delete - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT io.americanexpress.synapse synapse-archetype-client-rest-get - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT io.americanexpress.synapse synapse-archetype-client-rest-post - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT io.americanexpress.synapse synapse-archetype-client-rest-put - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT io.americanexpress.synapse synapse-archetype-client-rest-reactive - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT io.americanexpress.synapse synapse-archetype-client-rest-reactive-delete - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT io.americanexpress.synapse synapse-archetype-client-rest-reactive-get - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT io.americanexpress.synapse synapse-archetype-client-rest-reactive-post - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT io.americanexpress.synapse synapse-archetype-client-rest-reactive-put - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT io.americanexpress.synapse synapse-archetype-data-postgres - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT io.americanexpress.synapse synapse-archetype-service-rest-delete - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT io.americanexpress.synapse synapse-archetype-service-rest-post - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT io.americanexpress.synapse synapse-archetype-service-rest-get - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT io.americanexpress.synapse synapse-archetype-service-rest-put - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT io.americanexpress.synapse synapse-archetype-service-rest-reactive-get - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT io.americanexpress.synapse synapse-archetype-service-rest-reactive-post - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT io.americanexpress.synapse synapse-archetype-service-rest-reactive-put - 0.3.32-SNAPSHOT + 0.4.28-SNAPSHOT diff --git a/docs/agent/RECOMMENDATIONS.md b/docs/agent/RECOMMENDATIONS.md new file mode 100644 index 000000000..5e1b7398e --- /dev/null +++ b/docs/agent/RECOMMENDATIONS.md @@ -0,0 +1,114 @@ +# Adapting Synapse for AI agents — recommendations + +This document captures changes that make the Synapse framework reliably consumable by AI coding agents. +It is split into **(A) what was implemented on this branch** (additive, safe) and **(B) proposals that +touch the published API or build and therefore need `@americanexpress/synapse-team` review before +shipping** — because they would emit deprecation warnings or break downstream consumers on Maven Central. + +The throughline: agents fail when they must *infer* signatures and conventions from source. Every item +below either gives them machine-readable ground truth, removes ambiguity, or adds a deterministic +verify loop. + +--- + +## A. Implemented on this branch (additive) + +| # | Change | Files | +|---|---|---| +| A1 | **Machine-readable catalog** of every base class, operation, override method, generics, HTTP verb/path, config triple, ErrorCodes, archetypes. The single source of truth for the skill + MCP server. | `.synapse/catalog.json`, `.synapse/catalog.schema.json` | +| A2 | **MCP server** (Python stdlib, zero deps) exposing `list_base_classes`, `scaffold_operation`, `validate_module` to any MCP client. | `.synapse/mcp/`, `.mcp.json` | +| A3 | **Provider-agnostic agent + skill** (`synapse-engineer`, plain Markdown) with verified base-class decision tables and skeletons; any AI runtime can load them. | `.synapse/agents/`, `.synapse/skills/` | +| A4 | **Archetype catalog version fix** — `archetype/archetype-catalog.xml` was pinned to `0.3.32-SNAPSHOT` while the project is `0.4.28-SNAPSHOT`, so headless `archetype:generate` from the catalog resolved the wrong (often unavailable) version. Synced to `0.4.28-SNAPSHOT`. | `archetype/archetype-catalog.xml` | +| A5 | **`@implSpec` Javadoc** demonstrated on the create base classes — self-describing "extend me, override `executeCreate`, don't add `@PostMapping`" guidance agents read directly. | `BaseCreateController.java`, `BaseCreateService.java` | +| A6 | **ArchUnit convention tests** template — a deterministic verify loop. | `docs/agent/archunit/` | +| A7 | **Repo CLAUDE.md** orienting any agent to the monorepo. | `CLAUDE.md` | + +--- + +## B. Proposals requiring team review + +### B1 — Generate `catalog.json` from source (close the drift gap) + +`catalog.json` (A1) is currently hand-maintained from verified source. That is exactly the kind of file +that silently rots. Make it a build output so it can never disagree with the code: + +- Option 1 (lightweight): a small Maven plugin / `exec` step that scans the `service`, `data`, and + `client` modules for `Base*Controller`/`Base*Service`/`Base*Client` and emits `catalog.json` during + `package`, failing the build if the committed file differs. +- Option 2 (richer): an annotation processor driven by a new `@SynapseOperation(verb=…, path=…)` + meta-annotation on each base class, which both documents the class and feeds the generator. + +Until B1 lands, treat `catalog.json` as needing a refresh whenever a base class signature changes, and +keep the `bump-version` workflow honest (see B5). + +### B2 — Resolve the dual imperative API families (biggest ambiguity) + +There are **two** parallel imperative hierarchies and an agent cannot tell which to use: + +- `io.americanexpress.synapse.service.rest.*` — granular: `BaseCreateController` + `BaseCreateService` + with `executeCreate/executeRead/executeUpdate/executeDelete`. **All samples use this.** +- `io.americanexpress.synapse.api.rest.imperative.*` — unified: `BaseService.execute` / + `doExecute`, `PageResponse`, different paths (`/inquiry-results`, `/multiple-results`). + +Mixing them compiles but misbehaves. Recommendation: pick one as canonical and signal the other. + +```diff +// api/synapse-api-rest-imperative/.../controller/BaseCreateImperativeRestController.java ++/** ++ * @deprecated Experimental unified API family. Prefer the granular ++ * {@code io.americanexpress.synapse.service.rest.controller.BaseCreateController} ++ * used by all reference samples. The two families must not be mixed within one service. ++ */ ++@Deprecated(since = "0.4.28", forRemoval = false) + public abstract class BaseCreateImperativeRestController< ... > +``` + +If `api.rest.imperative` is in fact the intended future direction, do the inverse and migrate the +samples — but the repository should express a single recommended path either way. (`@Deprecated` is a +published-API signal, hence team review.) + +### B3 — Normalize per-DB datasource property keys + +Property keys differ by data module, which is a frequent silent misconfiguration for agents: + +- `synapse-data-postgres` / `-jdbc`: `spring.datasource.{url,username,password,driver-class-name}` +- `synapse-data-mysql`: `spring.mysql.datasource.{url,username,password,port}` ← non-standard prefix + +Recommendation: standardize all relational modules on the Spring Boot convention `spring.datasource.*` +(optionally `spring.datasource..*` for multi-datasource), with a one-release deprecation that reads +the old keys and logs a warning. This is a behavioral/config-contract change → team review + CHANGELOG. + +### B4 — Roll `@implSpec` out across all base classes + +A5 demonstrates the pattern on the create pair. Apply the same self-describing Javadoc (which extension +point to override, what not to annotate, how to signal errors) to every `Base*Controller`/`Base*Service` +/`Base*ReactiveController`/`Base*Client` and to `BaseServiceRequest` (state it's an interface → +`implements`) and `BaseServiceResponse` (abstract, carries `id` → `extends`). Pure documentation, but +broad — best done as one reviewed sweep. + +### B5 — Make `bump-version` also bump the archetype catalog + +`.github/workflows/bump-version.yml` runs `mvn versions:set` which does **not** update +`archetype/archetype-catalog.xml` (a plain XML list) — that is how the A4 drift happened. Add a step to +rewrite the catalog versions (and ideally `.synapse/catalog.json`'s `frameworkVersion`) as part of the +bump, or tokenize the catalog and filter it at build time. Prevents A4 from recurring. + +### B6 — Promote the ArchUnit rules into a shared module + +Move `docs/agent/archunit/SynapseConventionTest.java` into a new `synapse-architecture-test` module that +application teams depend on (test scope), so the conventions are versioned and enforced everywhere rather +than copy-pasted. Wire it into the reactor so CI compiles/runs it. + +### B7 — Scaffold tests by default + +`synapse-service-test` ships `BaseControllerTest` / `BaseControllerIT` / `Base*ControllerUnitTest`, but +the archetypes and skill don't always emit a test. Have both always generate a test extending the right +base, so agent-generated modules ship green, not just with `main` code. + +--- + +## Priority + +1. **B2** (resolve dual family) and **B1** (generate the catalog) — structural correctness-by-construction. +2. **B5**, **B7**, **B6** — cheap, high-yield, prevent regressions. +3. **B3**, **B4** — valuable normalization, larger surface. diff --git a/docs/agent/archunit/README.md b/docs/agent/archunit/README.md new file mode 100644 index 000000000..cd720b7a9 --- /dev/null +++ b/docs/agent/archunit/README.md @@ -0,0 +1,39 @@ +# Synapse convention tests (ArchUnit) + +`SynapseConventionTest.java` turns Synapse's implicit conventions into **executable, fail-fast checks**. +Its purpose is to give AI agents (and humans) a deterministic verify loop: generate code → `mvn test` +→ read the precise failure → fix. This catches the exact mistakes that are easy to make against +Synapse's two-families / `execute*` / interface-vs-abstract design. + +## What it enforces + +- `@RestController`s extend a Synapse `Base*Controller`. +- Controllers live in a `..controller..` package and **do not** declare their own `@PostMapping`/etc. + (the base owns the verb). +- Synapse services live in a `..service..` package. +- No hand-written `@RestControllerAdvice` (the layer config auto-registers `ControllerExceptionHandler`). +- `@Entity` classes extend Synapse `BaseEntity`. + +## Use in an application module + +1. Add the dependency (test scope): + ```xml + + com.tngtech.archunit + archunit-junit5 + 1.3.0 + test + + ``` +2. Copy `SynapseConventionTest.java` into the module's `src/test/java`. +3. Set `APP_PACKAGE` to your application's root package. +4. `mvn test`. + +## Status + +⚠️ **Build-unverified.** This is a ready-to-use template under `docs/agent`; it is intentionally not +yet part of the Maven reactor, so it has not been compiled/run in CI here. The recommended permanent +home is a shared, freezable rule set in a new `synapse-architecture-test` module that application +modules depend on — see `docs/agent/RECOMMENDATIONS.md` (#4). Class/method names referenced by the +rules were taken from the verified base classes (`BaseController`, `BaseService`, `BaseEntity`); if the +framework renames them, update the constants at the top of the test. diff --git a/docs/agent/archunit/SynapseConventionTest.java b/docs/agent/archunit/SynapseConventionTest.java new file mode 100644 index 000000000..217fc0cd5 --- /dev/null +++ b/docs/agent/archunit/SynapseConventionTest.java @@ -0,0 +1,118 @@ +/* + * Copyright 2020 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package io.americanexpress.synapse.conventions; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.Test; + +/** + * ArchUnit guardrails that make Synapse conventions machine-enforceable, so AI-generated (and + * human) modules fail fast with a precise message instead of compiling into a subtly-wrong shape. + * + *

Drop this into a Synapse application module's test sources, set {@link #APP_PACKAGE} to the + * app's root package, and run {@code mvn test}. An agent runs it as its verify loop and self-corrects + * from the failure messages. + * + *

Wiring (test scope): + *

{@code
+ * 
+ *   com.tngtech.archunit
+ *   archunit-junit5
+ *   1.3.0
+ *   test
+ * 
+ * }
+ * + * NOTE: provided as a ready-to-use template under docs/agent — it is not yet wired into the reactor + * build, so it has not been compiled in CI. See docs/agent/RECOMMENDATIONS.md (#4) for promoting it + * into a shared {@code synapse-architecture-test} module. + */ +class SynapseConventionTest { + + /** Root package of the application module under test. */ + private static final String APP_PACKAGE = "com.example.app"; + + private static final String CONTROLLER_BASE = "io.americanexpress.synapse.service.rest.controller."; + private static final String SERVICE_BASE = "io.americanexpress.synapse.service.rest.service."; + + private final JavaClasses appClasses = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages(APP_PACKAGE); + + @Test + void restControllersExtendASynapseBaseController() { + ArchRule rule = classes() + .that().areAnnotatedWith("org.springframework.web.bind.annotation.RestController") + .should().beAssignableTo(CONTROLLER_BASE + "BaseController") + .because("Synapse REST endpoints must extend a Base*Controller so the verb mapping, " + + "validation, Swagger and exception handling are inherited"); + rule.check(appClasses); + } + + @Test + void controllersLiveInAControllerPackage() { + ArchRule rule = classes() + .that().areAssignableTo(CONTROLLER_BASE + "BaseController") + .should().resideInAPackage("..controller..") + .because("Synapse modules keep controllers in a 'controller' package"); + rule.check(appClasses); + } + + @Test + void controllersDoNotDeclareTheirOwnVerbMappings() { + ArchRule rule = noClasses() + .that().areAssignableTo(CONTROLLER_BASE + "BaseController") + .should().beAnnotatedWith("org.springframework.web.bind.annotation.PostMapping") + .orShould().beAnnotatedWith("org.springframework.web.bind.annotation.GetMapping") + .orShould().beAnnotatedWith("org.springframework.web.bind.annotation.PutMapping") + .orShould().beAnnotatedWith("org.springframework.web.bind.annotation.DeleteMapping") + .because("the Base*Controller already maps the HTTP verb; concrete controllers only " + + "declare @RestController and the @RequestMapping base path"); + rule.check(appClasses); + } + + @Test + void servicesLiveInAServicePackage() { + ArchRule rule = classes() + .that().areAssignableTo(SERVICE_BASE + "BaseService") + .should().resideInAPackage("..service..") + .because("Synapse modules keep services in a 'service' package"); + rule.check(appClasses); + } + + @Test + void noHandwrittenExceptionHandlers() { + ArchRule rule = noClasses() + .that().resideInAPackage(APP_PACKAGE + "..") + .should().beAnnotatedWith("org.springframework.web.bind.annotation.RestControllerAdvice") + .because("Synapse auto-registers ControllerExceptionHandler via the layer config; a " + + "custom advice silently overrides the standard ErrorResponse contract"); + rule.check(appClasses); + } + + @Test + void entitiesExtendSynapseBaseEntity() { + ArchRule rule = classes() + .that().areAnnotatedWith("jakarta.persistence.Entity") + .should().beAssignableTo("io.americanexpress.synapse.data.jpa.entity.BaseEntity") + .because("entities inherit the id + audit columns from Synapse BaseEntity"); + rule.check(appClasses); + } +} diff --git a/service/synapse-service-rest/src/main/java/io/americanexpress/synapse/service/rest/controller/BaseCreateController.java b/service/synapse-service-rest/src/main/java/io/americanexpress/synapse/service/rest/controller/BaseCreateController.java index b43523512..b895d7756 100644 --- a/service/synapse-service-rest/src/main/java/io/americanexpress/synapse/service/rest/controller/BaseCreateController.java +++ b/service/synapse-service-rest/src/main/java/io/americanexpress/synapse/service/rest/controller/BaseCreateController.java @@ -31,6 +31,12 @@ * {@code BaseCreateController} class specifies the abstraction for listening for requests from the consumer * to Create (POST) a resource. This controller expects only one entry as request. * + * @implSpec To expose a create (HTTP POST) endpoint, extend this class and supply the type arguments; + * the {@code @PostMapping}, {@code @Valid} validation, Swagger documentation and {@code ResponseEntity} + * wrapping are inherited. Do not add verb mappings ({@code @PostMapping} etc.) on the + * subclass — declare only {@code @RestController} and {@code @RequestMapping("")}. Put the + * business logic in the paired service's {@code executeCreate} method, not here. + * * @param input request type * @param output response type * @param service type diff --git a/service/synapse-service-rest/src/main/java/io/americanexpress/synapse/service/rest/service/BaseCreateService.java b/service/synapse-service-rest/src/main/java/io/americanexpress/synapse/service/rest/service/BaseCreateService.java index 95053f3d0..f94445e03 100644 --- a/service/synapse-service-rest/src/main/java/io/americanexpress/synapse/service/rest/service/BaseCreateService.java +++ b/service/synapse-service-rest/src/main/java/io/americanexpress/synapse/service/rest/service/BaseCreateService.java @@ -46,6 +46,19 @@ public O create(HttpHeaders headers, I request) { * @param request body received from the controller * @return response body to the controller */ + /** + * Create the resource. This is the single extension point for create operations. + * + * @implSpec Implement this method (not the public {@code create}) with the create business logic. + * Return the populated response; on invalid input or business-rule failure throw + * {@code io.americanexpress.synapse.framework.exception.ApplicationClientException} with an + * {@code ErrorCode} (4XX), or {@code ApplicationServerException} (5XX) for unexpected failures — + * the framework's {@code ControllerExceptionHandler} renders the standard error response. + * + * @param headers the HTTP headers + * @param request the input request to create + * @return the created resource response + */ protected abstract O executeCreate(HttpHeaders headers, I request); }