---
name: axim-rest-framework
description: Build Spring Boot REST APIs with Axim REST Framework. Use when creating entities, repositories, services, controllers, error handling, or pagination with the axim-rest-framework (Spring Boot + MyBatis). Covers @XEntity, @XRepository, IXRepository, query derivation, save/modify/upsert, XPagination, XPage, error codes, i18n exceptions, and declarative REST client.
---
# Axim REST Framework
Spring Boot + MyBatis lightweight REST framework. Annotation-based entity mapping and repository proxy pattern that minimizes boilerplate while keeping MyBatis SQL control.
**Version:** 1.1.0
**Requirements:** Java 17+, Spring Boot 3.3+, MySQL 5.7+/8.0+, MyBatis 3.0+
**Repository:** https://github.com/Axim-one/rest-framework
## Critical Rules
- SECURITY: `axim.rest.session.secret-key` MUST be set in production. Without it, tokens have NO signature — anyone can forge a session token.
- SECURITY: Set `spring.profiles.active=prod` in production. Non-prod profiles log full request bodies including passwords.
- `@XColumn` is only needed for: primary keys, custom column names, or insert/update control. Regular fields auto-map via camelCase → snake_case — do NOT add `@XColumn` to every field.
- `@XDefaultValue(value="X")` alone does NOT work — `isDBDefaultUsed` defaults to `true`, so the value is ignored. Must set `isDBDefaultUsed=false` for literal values.
- `@XRestServiceScan` is required on the application class when using `@XRestService` declarative REST clients.
- `XWebClient` beans can be registered via `axim.web-client.services.{name}={url}` in properties, then injected with `@Qualifier`.
- Session token format is NOT JWT — it uses custom `Base64(payload).HmacSHA256(signature)`. Do not use JWT libraries.
- JSON date format is `yyyy-MM-dd HH:mm:ss`, not ISO 8601.
- `XSessionResolver` auto-detects `SessionData` subclass parameters — no annotation required on the controller parameter.
- `@XPaginationDefault` defaults: `page=1`, `size=10`, `direction=DESC`. Sort without direction defaults to ASC.
- MANDATORY: Every member variable (Entity, DTO, Request, Response, VO) and every enum item MUST have a detailed Javadoc comment including purpose, example values, format rules, constraints, and allowed values.
## Installation
### Gradle
```gradle
repositories {
mavenCentral()
maven { url 'https://jitpack.io' }
}
dependencies {
implementation 'com.github.Axim-one.rest-framework:core:1.1.0'
implementation 'com.github.Axim-one.rest-framework:rest-api:1.1.0'
implementation 'com.github.Axim-one.rest-framework:mybatis:1.1.0'
}
```
### Maven
```xml
jitpack.io
https://jitpack.io
com.github.Axim-one.rest-framework
core
1.1.0
com.github.Axim-one.rest-framework
rest-api
1.1.0
com.github.Axim-one.rest-framework
mybatis
1.1.0
```
## Application Setup
CRITICAL: All annotations below are required. Add `@XRestServiceScan` if using `@XRestService` REST clients.
```java
@ComponentScan({"one.axim.framework.rest", "one.axim.framework.mybatis", "com.myapp"})
@SpringBootApplication
@XRepositoryScan("com.myapp.repository")
@MapperScan({"one.axim.framework.mybatis.mapper", "com.myapp.mapper"})
@XRestServiceScan("com.myapp.client") // Only if using @XRestService REST clients
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
```
| Annotation | Required | Purpose |
|---|---|---|
| `@ComponentScan` | **Yes** | Must include `one.axim.framework.rest`, `one.axim.framework.mybatis`, and app packages |
| `@XRepositoryScan` | **Yes** | Scans for `@XRepository` interfaces |
| `@MapperScan` | **Yes** | Must include `one.axim.framework.mybatis.mapper` + app mapper packages |
| `@XRestServiceScan` | If using REST client | Scans for `@XRestService` interfaces, creates JDK proxy beans |
### application.properties — Complete Reference
```properties
# ── DataSource ──
spring.datasource.url=jdbc:mysql://localhost:3306/mydb
spring.datasource.username=root
spring.datasource.password=
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
mybatis.config-location=classpath:mybatis-config.xml
# ── Framework: HTTP Client ──
axim.rest.client.pool-size=200 # Max connection pool (default: 200)
axim.rest.client.connection-request-timeout=30 # seconds (default: 30)
axim.rest.client.response-timeout=30 # seconds (default: 30)
axim.rest.debug=false # REST client logging (default: false)
# ── Framework: Gateway ──
axim.rest.gateway.host=http://api-gateway:8080 # Enables gateway mode for @XRestService
# ── Framework: XWebClient Beans ──
axim.web-client.services.userClient=http://user-service:8080 # Named XWebClient bean
axim.web-client.services.orderClient=http://order-service:8080 # Named XWebClient bean
# ── Framework: Session / Token ──
axim.rest.session.secret-key=your-hmac-secret-key # HMAC-SHA256 signing (omit = unsigned)
axim.rest.session.token-expire-days=90 # Token lifetime (default: 90)
# ── Framework: i18n ──
axim.rest.message.default-language=ko-KR # Default locale (default: ko-KR)
axim.rest.message.language-header=Accept-Language # Language header (default: Accept-Language)
spring.messages.basename=messages # App message files (default: messages)
spring.messages.encoding=UTF-8
```
### mybatis-config.xml
All three elements (objectFactory, plugins, mappers) are **required**.
```xml
```
## Entity Definition
Use `@XEntity` to map a class to a database table. Fields auto-map using camelCase → snake_case conversion.
```java
@Data
@XEntity("users")
public class User {
@XColumn(isPrimaryKey = true, isAutoIncrement = true)
private Long id;
private String email;
private String name;
@XDefaultValue(value = "NOW()", isDBValue = true)
private LocalDateTime createdAt;
@XDefaultValue(updateValue = "NOW()", isDBValue = true)
private LocalDateTime updatedAt;
@XColumn(insert = false, update = false)
private String readOnlyField;
@XIgnoreColumn
private String transientField;
}
```
### Entity with Schema
```java
@XEntity(value = "orders", schema = "shop")
public class Order { ... }
```
### Entity Inheritance
Parent class fields are automatically included:
```java
public class BaseEntity {
@XColumn(isPrimaryKey = true, isAutoIncrement = true)
private Long id;
@XDefaultValue(value = "NOW()", isDBValue = true)
private LocalDateTime createdAt;
@XDefaultValue(updateValue = "NOW()", isDBValue = true)
private LocalDateTime updatedAt;
}
@Data
@XEntity("partners")
public class Partner extends BaseEntity {
private String name;
private String status;
}
```
### @XDefaultValue Patterns
```java
// Pattern 1: Use DB DEFAULT (column omitted from INSERT)
@XDefaultValue(isDBDefaultUsed = true)
private String region;
// Pattern 2: Literal string value on INSERT
@XDefaultValue(value = "ACTIVE", isDBDefaultUsed = false)
private String status;
// Pattern 3: DB expression on INSERT
@XDefaultValue(value = "NOW()", isDBValue = true, isDBDefaultUsed = false)
private LocalDateTime createdAt;
// Pattern 4: Auto-set value on UPDATE
@XDefaultValue(updateValue = "NOW()", isDBValue = true)
private LocalDateTime updatedAt;
```
## Annotations Reference
| Annotation | Target | Description |
|---|---|---|
| `@XEntity(value, schema)` | Class | Maps class to database table |
| `@XColumn(value, isPrimaryKey, isAutoIncrement, insert, update)` | Field | Column mapping with options |
| `@XDefaultValue(value, updateValue, isDBDefaultUsed, isDBValue)` | Field | Default values for INSERT/UPDATE |
| `@XIgnoreColumn` | Field | Excludes field from DB mapping |
| `@XRepository` | Interface | Marks repository for proxy generation |
| `@XRepositoryScan(basePackages)` | Class | Scans for @XRepository interfaces |
## Repository
Extend `IXRepository` and annotate with `@XRepository`:
```java
@XRepository
public interface UserRepository extends IXRepository {
User findByEmail(String email);
List findByStatus(String status);
boolean existsByEmail(String email);
long countByStatus(String status);
int deleteByStatusAndName(String status, String name);
}
```
### Repository API
| Method | Return | Description |
|---|---|---|
| `save(entity)` | `K` | PK null → INSERT, PK present → Upsert (Composite: all PKs set → upsert) |
| `insert(entity)` | `K` | Plain INSERT with auto-generated ID (Composite: returns key class) |
| `saveAll(List)` | `K` | Batch INSERT IGNORE |
| `update(entity)` | `int` | Full UPDATE (all columns including nulls) |
| `modify(entity)` | `int` | Selective UPDATE (non-null fields only) |
| `findOne(key)` | `T` | Find by primary key |
| `findAll()` | `List` | Find all rows |
| `findAll(pagination)` | `XPage` | Paginated find all |
| `findWhere(Map)` | `List` | Find by conditions |
| `findWhere(pagination, Map)` | `XPage` | Paginated find by conditions |
| `findOneWhere(Map)` | `T` | Find one by conditions |
| `exists(key)` | `boolean` | Check existence by PK |
| `count()` / `count(Map)` | `long` | Total / conditional count |
| `deleteById(key)` | `int` | Delete by primary key |
| `deleteWhere(Map)` | `int` | Delete by conditions |
### CRUD Examples
```java
// save() - Upsert
User user = new User();
user.setName("Alice");
userRepository.save(user); // INSERT, auto-increment ID set on entity
user.setId(1L);
userRepository.save(user); // INSERT ... ON DUPLICATE KEY UPDATE
// insert() - Plain INSERT
userRepository.insert(user);
// update() vs modify()
userRepository.update(user); // SET name='Alice', email=NULL, status=NULL
userRepository.modify(user); // SET name='Alice' (null fields skipped)
// saveAll() - Batch
userRepository.saveAll(List.of(user1, user2, user3));
// Find
User found = userRepository.findOne(1L);
List active = userRepository.findWhere(Map.of("status", "ACTIVE"));
boolean exists = userRepository.exists(1L);
long count = userRepository.count(Map.of("status", "ACTIVE"));
// Delete
userRepository.deleteById(1L);
userRepository.deleteWhere(Map.of("status", "INACTIVE"));
```
## Composite Primary Key
Entities with multiple primary keys use a key class for `IXRepository`.
```java
// Key class — field names must match entity PK field names
@Data
public class OrderItemKey {
private Long orderId;
private Long itemId;
}
// Entity — multiple @XColumn(isPrimaryKey = true)
@Data
@XEntity("order_items")
public class OrderItem {
@XColumn(isPrimaryKey = true)
private Long orderId;
@XColumn(isPrimaryKey = true)
private Long itemId;
private int quantity;
private BigDecimal price;
}
// Repository
@XRepository
public interface OrderItemRepository extends IXRepository {}
```
```java
// Usage
OrderItemKey key = new OrderItemKey();
key.setOrderId(1L);
key.setItemId(100L);
repository.findOne(key); // WHERE order_id = ? AND item_id = ?
repository.delete(key); // WHERE order_id = ? AND item_id = ?
repository.save(orderItem); // All PKs set → upsert, any null → insert
repository.insert(orderItem); // Returns OrderItemKey with both PK values
```
## Query Derivation
Declare methods and SQL is auto-generated from the method name.
**Supported Prefixes:** `findBy`, `findAllBy`, `countBy`, `existsBy`, `deleteBy`
**Condition Combinator:** `And`
```java
@XRepository
public interface OrderRepository extends IXRepository {
Order findByOrderNo(String orderNo); // WHERE order_no = ?
List findByUserIdAndStatus(Long userId, String status); // WHERE user_id = ? AND status = ?
long countByStatus(String status); // SELECT COUNT(*) WHERE status = ?
boolean existsByOrderNo(String orderNo); // EXISTS check
int deleteByUserIdAndStatus(Long userId, String status); // DELETE WHERE ...
}
```
## Pagination
IMPORTANT: Always use `XPagination` and `XPage` for pagination. NEVER create custom pagination classes (e.g., PageRequest, PageResponse, PaginationDTO). The framework handles COUNT, ORDER BY, and LIMIT automatically.
```java
XPagination pagination = new XPagination();
pagination.setPage(1); // 1-based
pagination.setSize(20);
pagination.addOrder(new XOrder("createdAt", XDirection.DESC));
XPage result = userRepository.findAll(pagination);
result.getTotalCount(); // total rows
result.getPage(); // current page
result.getPageRows(); // rows in this page
result.getHasNext(); // more pages?
// Controller with auto-binding
@GetMapping
public XPage searchUsers(@XPaginationDefault XPagination pagination) {
return userRepository.findAll(pagination);
}
// Accepts: ?page=1&size=10&sort=email,asc
```
## Argument Resolvers
Two resolvers are auto-registered via `XWebMvcConfiguration`:
### XPaginationResolver — @XPaginationDefault
Resolves `XPagination` from query parameters. Annotation defaults:
| Attribute | Default | Description |
|---|---|---|
| `page` | `1` | Page number (1-based) |
| `size` | `10` | Rows per page |
| `offset` | `0` | Row offset (alternative to page) |
| `column` | `""` (none) | Default sort column (camelCase) |
| `direction` | `DESC` | Default sort direction |
Sort parsing:
```
?sort=createdAt,DESC → XOrder("createdAt", DESC)
?sort=name → XOrder("name", ASC) ← omitted direction defaults to ASC
?sort=createdAt,DESC&sort=name,ASC → multi-sort
```
Priority: `?page=` present → page-based; `?offset=` only → offset-based. Query params override annotation defaults. `"undefined"` and `"null"` strings are treated as absent.
```java
@GetMapping
public XPage listUsers(
@XPaginationDefault(size = 20, column = "createdAt", direction = XDirection.DESC)
XPagination pagination) {
return userRepository.findAll(pagination);
}
```
### XSessionResolver — SessionData Subclass
Resolves any `SessionData` subclass from `Access-Token` HTTP header. **No annotation required** — auto-detected by parameter type.
```java
// UserSession extends SessionData → auto-resolved from Access-Token header
@GetMapping("/me")
public UserProfile getMyProfile(UserSession session) {
return userService.getProfile(session.getUserId());
}
```
- Requires `XAccessTokenParseHandler` bean (auto-configured or custom `@Component`)
- If `XAccessTokenParseHandler` not registered → returns `null` (no error)
- If token missing → 401 (`NOT_FOUND_ACCESS_TOKEN`)
- If token invalid → 401 (`INVALID_ACCESS_TOKEN`)
- If token expired → 401 (`EXPIRE_ACCESS_TOKEN`)
## Query Strategy: Repository vs Custom Mapper
The framework provides two query approaches. Choosing the right one is critical:
### Use @XRepository (auto-generated SQL) when:
- Exact-match WHERE conditions: `findByStatus("ACTIVE")`
- Single-table CRUD operations
- Simple AND conditions: `findByUserIdAndStatus(id, status)`
### Use @Mapper (custom SQL) when:
- **LIKE / partial match**: `WHERE name LIKE '%keyword%'`
- **BETWEEN / range**: `WHERE created_at BETWEEN ? AND ?`
- **JOIN**: Any query involving multiple tables
- **Subqueries**: `WHERE id IN (SELECT ...)`
- **Aggregation**: `GROUP BY`, `HAVING`, `SUM()`, `COUNT()` per group
- **OR conditions**: `WHERE status = ? OR role = ?`
- **Complex sorting**: Sorting by computed/joined columns
- **UNION**: Combining result sets
**CRITICAL: Query derivation only supports exact-match `=` with `And` combinator. It does NOT support LIKE, BETWEEN, OR, IN, >, <, JOIN, or any other SQL operator. When these are needed, immediately create a @Mapper interface — do not attempt to work around Repository limitations.**
### Custom Mapper with Pagination (XPagination)
Custom @Mapper methods integrate with XPagination seamlessly. The framework's `XResultInterceptor` automatically intercepts the query to handle COUNT, ORDER BY, and LIMIT — you only write the base SELECT.
**Rules for custom mapper pagination:**
1. Add `XPagination` as the **first parameter**
2. Add `Class>` as the **last parameter** (pass the entity class — used for result type mapping)
3. Return `XPage` as the return type
4. Write **only the base SELECT** — do NOT add ORDER BY or LIMIT in your SQL
```java
@Mapper
public interface UserMapper {
// LIKE search with pagination
@Select("SELECT * FROM users WHERE name LIKE CONCAT('%', #{keyword}, '%')")
XPage searchByName(XPagination pagination, @Param("keyword") String keyword, Class> cls);
// BETWEEN with pagination
@Select("SELECT * FROM users WHERE created_at BETWEEN #{from} AND #{to}")
XPage findByDateRange(XPagination pagination,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to, Class> cls);
// JOIN with pagination
@Select("SELECT u.*, d.name AS department_name FROM users u " +
"INNER JOIN departments d ON u.department_id = d.id " +
"WHERE d.status = #{status}")
XPage findUsersWithDepartment(XPagination pagination,
@Param("status") String status, Class> cls);
// Multiple conditions (OR, IN)
@Select("")
XPage findByStatuses(XPagination pagination,
@Param("statuses") List statuses, Class> cls);
// Without pagination — just return List (no XPagination, no Class>)
@Select("SELECT * FROM users WHERE email LIKE CONCAT('%', #{keyword}, '%')")
List searchByEmail(@Param("keyword") String keyword);
// Aggregation (non-paginated, returns custom projection)
@Select("SELECT department_id, COUNT(*) as user_count FROM users GROUP BY department_id")
List