Install
$ agentstack add skill-omexit-claude-skills-pack-api-first ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ● Dynamic code execution Used
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
API-First: Spring Boot Endpoint Scaffold Skill
Turns an OpenAPI 3.1 fragment (or a short resource spec) into a complete, compilable Spring Boot 4 endpoint stack: controller, service, repository, DTO, mapper, validation, exception handler, OpenAPI spec file, slice tests, and integration tests.
Before You Start — Superpowers Workflow
This skill generates a multi-file resource stack. Run it through the superpowers development workflow for reliable output.
- superpowers:brainstorming — recommended for new resources. Explore: what fields, what validation, what error cases, what pagination strategy, what status codes, what idempotency requirements. Skipped only if the OpenAPI fragment is already comprehensive.
- superpowers:writing-plans — produce a plan listing the files to generate, the validation rules per field, and the test cases. Review before generation.
- superpowers:using-git-worktrees — isolate so controller/service/repo/mapper changes don't collide with other feature work.
- superpowers:test-driven-development — write the
@WebMvcTestslice test FIRST (asserting 201 on create, 400 on invalid, 404 on missing, idempotency-key replay), then the integration test with Testcontainers. Then implement. - superpowers:dispatching-parallel-agents — controller, service, repository, mapper, and migration can be generated by independent parallel agents, each in its own worktree file. Merge back to the feature branch when all waves complete.
- superpowers:verification-before-completion — compile (
./gradlew compileJavaor./mvnw compile), run slice tests, run integration tests. Paste output. Hit the live endpoint withcurland paste the envelope response. Do not claim done without these. - superpowers:requesting-code-review — before merge. Reviewer focuses on: error shape (RFC 9457), pagination cursor correctness, mapper null handling, and OpenAPI spec parity.
0. Input Handling
Three input modes:
- OpenAPI fragment path:
/api-first docs/api/payment-links.yaml - Resource name + operations:
/api-first PaymentLink create,list,get,update,delete - Interactive: no args → prompt for resource name, fields, operations
Always confirm shape with the user before generating:
📦 API SCAFFOLD PLAN
Resource: PaymentLink
Path: /api/v1/payment-links
Operations: POST, GET /{id}, GET (list+paginate), PATCH /{id}, DELETE /{id}
Fields: id (UUID v7), amount (money), currency, description, expires_at (Instant), status (enum), created_at, updated_at
Persistence: Spring Data JDBC (detected from build.gradle)
Validation: Jakarta @NotNull, @Positive, @Size
Response: { data, meta: {requestId, timestamp, traceId} }
Errors: RFC 9457 Problem Details
Idempotency: POST accepts Idempotency-Key header
Pagination: cursor-based
Proceeding to generation...
1. Conventions (non-negotiable)
Paths & Verbs
- Plural resource nouns, kebab-case:
/api/v1/payment-links - Versioned via path prefix (
/api/v1/...) - GET for reads, POST for creates, PATCH for partial updates, PUT for full replacement (rare), DELETE for removal
Response Envelope
{
"data": { "...": "resource" },
"meta": {
"requestId": "req_01HXXX",
"timestamp": "2026-04-15T10:00:00Z",
"traceId": "a1b2c3d4..."
}
}
List responses add pagination metadata:
{
"data": [ "..." ],
"meta": {
"requestId": "req_01HXXX",
"timestamp": "2026-04-15T10:00:00Z",
"traceId": "a1b2c3d4...",
"pagination": {
"cursor": "eyJpZCI6IjAxSFhYWCJ9",
"nextCursor": "eyJpZCI6IjAxSFlZWSJ9",
"hasMore": true,
"pageSize": 20
}
}
}
Errors — RFC 9457 Problem Details
{
"type": "https://errors.kifiya.com/validation-failed",
"title": "Validation failed",
"status": 400,
"detail": "One or more fields failed validation",
"instance": "/api/v1/payment-links",
"errors": [
{ "field": "amount", "code": "NEGATIVE", "message": "amount must be positive" }
],
"traceId": "a1b2c3d4..."
}
Money
{ "amount": "150.00", "currency": "USD" }
Never a float. Always 2-decimal string (fiat) or 8-decimal string (crypto). Parsed into BigDecimal server-side.
IDs
- External: UUID v7 (time-sortable) exposed as string
- Internal (pgledger-style): Hypersistence TSID (64-bit) — still exposed as string externally
Timestamps
- Always
Instant/OffsetDateTime, serialized as ISO 8601 UTC:2026-04-15T10:00:00.000Z
2. Code Layout
For a single resource PaymentLink, generate exactly these files:
src/main/java/.../paymentlink/
├── api/
│ ├── PaymentLinkController.java @RestController
│ ├── PaymentLinkRequest.java create/update DTO (record)
│ ├── PaymentLinkResponse.java response DTO (record)
│ └── PaymentLinkListResponse.java list wrapper (record)
├── application/
│ ├── PaymentLinkService.java @Service
│ ├── PaymentLinkMapper.java @Mapper (MapStruct)
│ └── PaymentLinkNotFoundException.java sealed exception
├── domain/
│ └── PaymentLink.java aggregate root (record for JDBC)
├── infrastructure/
│ └── PaymentLinkRepository.java Spring Data JDBC interface
└── config/
└── (package-private — shared with other resources)
src/main/resources/db/changelog/changes/sql/
└── payment-link-001-schema.sql Liquibase
src/test/java/.../paymentlink/
├── PaymentLinkControllerTest.java @WebMvcTest slice
└── PaymentLinkServiceTest.java Mockito unit
src/test-integration/java/.../paymentlink/
└── PaymentLinkIntegrationTest.java @SpringBootTest + Testcontainers
src/main/resources/openapi/
└── payment-links.yaml OpenAPI 3.1 fragment
3. Generated Artifacts
3.1 Controller
@RestController
@RequestMapping("/api/v1/payment-links")
@Validated
public class PaymentLinkController {
private final PaymentLinkService service;
private final PaymentLinkMapper mapper;
public PaymentLinkController(PaymentLinkService service, PaymentLinkMapper mapper) {
this.service = service;
this.mapper = mapper;
}
@PostMapping
@Operation(summary = "Create a payment link", responses = {
@ApiResponse(responseCode = "201", description = "Created"),
@ApiResponse(responseCode = "400", description = "Validation failed", content = @Content(schema = @Schema(implementation = ProblemDetail.class))),
@ApiResponse(responseCode = "409", description = "Idempotent replay")
})
public ResponseEntity> create(
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
@RequestBody @Valid PaymentLinkRequest request
) {
var created = service.create(mapper.toDomain(request), idempotencyKey);
var response = mapper.toResponse(created);
return ResponseEntity
.created(URI.create("/api/v1/payment-links/" + created.id()))
.body(ApiResponse.ok(response));
}
@GetMapping("/{id}")
public ResponseEntity> get(@PathVariable String id) {
return ResponseEntity.ok(ApiResponse.ok(mapper.toResponse(service.getById(id))));
}
@GetMapping
public ResponseEntity>> list(
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int pageSize
) {
var page = service.list(cursor, pageSize);
var items = page.items().stream().map(mapper::toResponse).toList();
return ResponseEntity.ok(ApiResponse.ofPage(items, page.cursor(), page.nextCursor(), page.hasMore(), pageSize));
}
@PatchMapping("/{id}")
public ResponseEntity> update(
@PathVariable String id,
@RequestBody @Valid PaymentLinkRequest request
) {
var updated = service.update(id, mapper.toDomain(request));
return ResponseEntity.ok(ApiResponse.ok(mapper.toResponse(updated)));
}
@DeleteMapping("/{id}")
public ResponseEntity delete(@PathVariable String id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
}
3.2 Service
@Service
@Transactional
public class PaymentLinkService {
private final PaymentLinkRepository repository;
private final IdempotencyStore idempotency;
private final Tsid tsid;
public PaymentLinkService(PaymentLinkRepository repository, IdempotencyStore idempotency, Tsid tsid) {
this.repository = repository;
this.idempotency = idempotency;
this.tsid = tsid;
}
public PaymentLink create(PaymentLink toCreate, String idempotencyKey) {
if (idempotencyKey != null) {
return idempotency.executeOnce("payment-link:create:" + idempotencyKey,
() -> doCreate(toCreate));
}
return doCreate(toCreate);
}
private PaymentLink doCreate(PaymentLink toCreate) {
var withDefaults = toCreate.withId(tsid.next())
.withCreatedAt(Instant.now())
.withUpdatedAt(Instant.now())
.withStatus(PaymentLinkStatus.ACTIVE);
return repository.save(withDefaults);
}
@Transactional(readOnly = true)
public PaymentLink getById(String id) {
return repository.findById(id)
.orElseThrow(() -> new PaymentLinkNotFoundException(id));
}
@Transactional(readOnly = true)
public CursorPage list(String cursor, int pageSize) {
return repository.findPage(cursor, pageSize);
}
public PaymentLink update(String id, PaymentLink patch) {
var existing = getById(id);
var updated = existing
.withDescription(patch.description())
.withUpdatedAt(Instant.now());
return repository.save(updated);
}
public void delete(String id) {
if (!repository.existsById(id)) throw new PaymentLinkNotFoundException(id);
repository.deleteById(id);
}
}
3.3 DTOs (records with validation)
public record PaymentLinkRequest(
@NotNull @Positive BigDecimal amount,
@NotBlank @Size(min = 3, max = 3) String currency,
@NotBlank @Size(max = 255) String description,
@NotNull @Future Instant expiresAt
) {}
public record PaymentLinkResponse(
String id,
Money amount,
String description,
Instant expiresAt,
String status,
Instant createdAt,
Instant updatedAt
) {
public record Money(String amount, String currency) {}
}
3.4 Mapper (MapStruct)
@Mapper(componentModel = "spring", imports = {PaymentLinkResponse.Money.class})
public interface PaymentLinkMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "status", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
PaymentLink toDomain(PaymentLinkRequest request);
@Mapping(target = "amount", expression = "java(new PaymentLinkResponse.Money(link.amount().setScale(2, java.math.RoundingMode.HALF_UP).toPlainString(), link.currency()))")
@Mapping(target = "status", expression = "java(link.status().name())")
PaymentLinkResponse toResponse(PaymentLink link);
}
3.5 Repository (Spring Data JDBC)
@Repository
public interface PaymentLinkRepository extends ListCrudRepository {
@Query("""
SELECT * FROM payment_links
WHERE (:cursor IS NULL OR id findPage(@Param("cursor") String cursor, @Param("pageSize") int pageSize);
default CursorPage findPage(String cursor, int pageSize) {
var items = findPage(cursor, pageSize + 1);
var hasMore = items.size() > pageSize;
var trimmed = hasMore ? items.subList(0, pageSize) : items;
var nextCursor = hasMore ? trimmed.get(trimmed.size() - 1).id() : null;
return new CursorPage<>(trimmed, cursor, nextCursor, hasMore);
}
}
3.6 Exception Handler Contribution
If no global @ControllerAdvice exists, generate one. If one exists, add a handler for PaymentLinkNotFoundException:
@ExceptionHandler(PaymentLinkNotFoundException.class)
public ResponseEntity handleNotFound(PaymentLinkNotFoundException e) {
var problem = ProblemDetail.forStatus(404);
problem.setType(URI.create("https://errors.kifiya.com/payment-link-not-found"));
problem.setTitle("Payment link not found");
problem.setDetail(e.getMessage());
return ResponseEntity.status(404).body(problem);
}
3.7 Slice Test (@WebMvcTest)
@WebMvcTest(PaymentLinkController.class)
class PaymentLinkControllerTest {
@Autowired private MockMvc mvc;
@Autowired private ObjectMapper mapper;
@MockBean private PaymentLinkService service;
@MockBean private PaymentLinkMapper linkMapper;
@Test
void should_return_201_when_creating_valid_payment_link() throws Exception {
// Given
var request = """
{"amount":"150.00","currency":"USD","description":"Test","expiresAt":"2099-01-01T00:00:00Z"}
""";
given(service.create(any(), any())).willReturn(aPaymentLink());
given(linkMapper.toDomain(any())).willReturn(aPaymentLink());
given(linkMapper.toResponse(any())).willReturn(aResponse());
// When / Then
mvc.perform(post("/api/v1/payment-links")
.header("Idempotency-Key", "key-1")
.contentType(MediaType.APPLICATION_JSON)
.content(request))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.id").exists())
.andExpect(header().string("Location", startsWith("/api/v1/payment-links/")));
}
@Test
void should_return_400_when_amount_is_negative() throws Exception {
mvc.perform(post("/api/v1/payment-links")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"amount":"-10","currency":"USD","description":"x","expiresAt":"2099-01-01T00:00:00Z"}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[?(@.field=='amount')].code").value("NEGATIVE"));
}
}
3.8 OpenAPI 3.1 Fragment
openapi: 3.1.0
info:
title: Payment Links API
version: 1.0.0
paths:
/api/v1/payment-links:
post:
summary: Create a payment link
parameters:
- name: Idempotency-Key
in: header
required: false
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/PaymentLinkRequest' }
responses:
'201':
description: Created
content:
application/json:
schema: { $ref: '#/components/schemas/PaymentLinkResponseEnvelope' }
'400': { $ref: '#/components/responses/ValidationFailed' }
'409': { $ref: '#/components/responses/Conflict' }
components:
schemas:
PaymentLinkRequest:
type: object
required: [amount, currency, description, expiresAt]
properties:
amount: { type: string, pattern: '^[0-9]+(\.[0-9]{1,2})?$' }
currency: { type: string, minLength: 3, maxLength: 3 }
description: { type: string, maxLength: 255 }
expiresAt: { type: string, format: date-time }
4. Output Contract
produces:
- type: "code"
format: "java"
paths: []
- type: "migration"
format: "sql"
paths: ["src/main/resources/db/changelog/changes/sql/-001-schema.sql"]
- type: "openapi"
format: "yaml"
paths: ["src/main/resources/openapi/.yaml"]
- type: "test"
format: "java"
paths: []
handoff: "Write claudedocs/handoff-api-first-.yaml — suggest: verify-impl, db-migration (if schema changes)"
5. Anti-patterns (never generate)
- Manual DTO↔Entity conversion — always MapStr
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: OmexIT
- Source: OmexIT/claude-skills-pack
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.