AgentStack
SKILL verified MIT Self-run

Api First

skill-omexit-claude-skills-pack-api-first · by OmexIT

>

No reviews yet
0 installs
17 views
0.0% view→install

Install

$ agentstack add skill-omexit-claude-skills-pack-api-first

✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.

Security review

✓ Passed

No 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.

Are you the author of Api First? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

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.

  1. 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.
  2. superpowers:writing-plans — produce a plan listing the files to generate, the validation rules per field, and the test cases. Review before generation.
  3. superpowers:using-git-worktrees — isolate so controller/service/repo/mapper changes don't collide with other feature work.
  4. superpowers:test-driven-development — write the @WebMvcTest slice test FIRST (asserting 201 on create, 400 on invalid, 404 on missing, idempotency-key replay), then the integration test with Testcontainers. Then implement.
  5. 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.
  6. superpowers:verification-before-completion — compile (./gradlew compileJava or ./mvnw compile), run slice tests, run integration tests. Paste output. Hit the live endpoint with curl and paste the envelope response. Do not claim done without these.
  7. 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:

  1. OpenAPI fragment path: /api-first docs/api/payment-links.yaml
  2. Resource name + operations: /api-first PaymentLink create,list,get,update,delete
  3. 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.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.