-
Notifications
You must be signed in to change notification settings - Fork 0
#18 menu crud 및 메뉴 이미지 업로드 구현 #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a7e60d0
71155f8
68a5c2b
27a8dad
7624cb6
57d554c
cfb4cfb
95c2b71
eb1ee68
bf8a1de
2be16e4
05f9bb6
efa11ea
c2f4ffb
55a5b5d
455f6bf
b38796b
651c8d5
26cdf37
b241273
c7b4e21
6c1c4ed
edb22bc
ee04a42
95ab00f
ddd9263
04da6f2
a41dbe5
dbb5e9f
687c92f
2815b87
09f0e01
01f21f8
86a5215
38ed521
35cb4fb
13eace4
5bee7f4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package com.example.gtable.global.config; | ||
|
|
||
| import java.util.concurrent.Executor; | ||
|
|
||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; | ||
|
|
||
| @Configuration | ||
| public class AsyncConfig { | ||
| @Bean(name = "s3UploadExecutor") | ||
| public Executor s3UploadExecutor() { | ||
| ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); | ||
| executor.setCorePoolSize(5); | ||
| executor.setMaxPoolSize(10); | ||
| executor.setQueueCapacity(100); | ||
| executor.setThreadNamePrefix("S3Upload-"); | ||
| executor.initialize(); | ||
| return executor; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,33 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| package com.example.gtable.global.config; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.beans.factory.annotation.Value; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.context.annotation.Bean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.context.annotation.Configuration; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.amazonaws.auth.AWSStaticCredentialsProvider; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.amazonaws.auth.BasicAWSCredentials; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.amazonaws.services.s3.AmazonS3Client; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.amazonaws.services.s3.AmazonS3ClientBuilder; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Configuration | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| public class AwsS3Config { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Value("${cloud.aws.credentials.access-key}") | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| private String accessKey; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Value("${cloud.aws.credentials.secret-key}") | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| private String secretKey; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Value("${cloud.aws.region.static}") | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| private String region; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+15
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion AWS 크리덴셜 관리 방식을 더 안전하게 개선해보세요. 현재 프로퍼티 파일에서 직접 액세스 키와 시크릿 키를 읽어오는 방식은 보안상 취약할 수 있습니다. 프로덕션 환경에서는 다음 방법들을 고려해보시기 바랍니다:
더 안전한 크리덴셜 관리를 위한 개선 방안: + // IAM 역할 기반 인증 사용 시
+ @Bean
+ public AmazonS3Client amazonS3ClientWithIAM() {
+ return (AmazonS3Client) AmazonS3ClientBuilder.standard()
+ .withRegion(region)
+ .withCredentials(new DefaultAWSCredentialsProviderChain())
+ .build();
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Bean | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| public AmazonS3Client amazonS3Client() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return (AmazonS3Client)AmazonS3ClientBuilder.standard() | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| .withRegion(region) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| .build(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| package com.example.gtable.global.s3; | ||
|
|
||
| import java.io.InputStream; | ||
| import java.util.UUID; | ||
| import java.util.concurrent.CompletableFuture; | ||
|
|
||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.scheduling.annotation.Async; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.web.multipart.MultipartFile; | ||
|
|
||
| import com.amazonaws.services.s3.AmazonS3Client; | ||
| import com.amazonaws.services.s3.model.ObjectMetadata; | ||
|
|
||
| import io.github.resilience4j.bulkhead.annotation.Bulkhead; | ||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class S3Service { | ||
| private final AmazonS3Client amazonS3Client; | ||
|
|
||
| @Value("${cloud.aws.s3.bucket}") | ||
| private String bucket; | ||
|
|
||
| public record S3UploadResult(String key, String url) { | ||
| } | ||
|
|
||
| @Bulkhead(name = "s3UploadBulkhead", type = Bulkhead.Type.THREADPOOL) | ||
| @Async("s3UploadExecutor") | ||
| public CompletableFuture<S3UploadResult> upload(String type, Long refId, MultipartFile file) { | ||
| try (InputStream inputStream = file.getInputStream()) { | ||
| String key = createFileKey(type, refId, file.getOriginalFilename()); | ||
| ObjectMetadata metadata = new ObjectMetadata(); | ||
| metadata.setContentLength(file.getSize()); | ||
|
|
||
| amazonS3Client.putObject(bucket, key, inputStream, metadata); | ||
| String url = amazonS3Client.getUrl(bucket, key).toString(); | ||
|
|
||
| return CompletableFuture.completedFuture(new S3UploadResult(key, url)); | ||
| } catch (Exception e) { | ||
|
Comment on lines
+31
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 파일 메타데이터 보강 및 null 파일명 대비 필요
-String key = createFileKey(type, refId, file.getOriginalFilename());
+String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "unknown";
+String key = createFileKey(type, refId, originalName);
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
+metadata.setContentType(file.getContentType());🤖 Prompt for AI Agents |
||
| throw new RuntimeException("S3 업로드 실패", e); | ||
| } | ||
| } | ||
|
|
||
| public void delete(String filename) { | ||
| try { | ||
| amazonS3Client.deleteObject(bucket, filename); | ||
| } catch (Exception e) { | ||
| throw new RuntimeException("S3 파일 삭제 실패", e); | ||
| } | ||
| } | ||
|
|
||
| private String createFileKey(String type, Long refId, String filename) { | ||
| return type + "/" + refId + "/" + UUID.randomUUID() + "-" + filename; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| package com.example.gtable.menu.controller; | ||
|
|
||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.PathVariable; | ||
| import org.springframework.web.bind.annotation.PostMapping; | ||
| import org.springframework.web.bind.annotation.RequestBody; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| import com.example.gtable.global.api.ApiUtils; | ||
| import com.example.gtable.menu.dto.MenuCreateRequest; | ||
| import com.example.gtable.menu.dto.MenuCreateResponse; | ||
| import com.example.gtable.menu.service.MenuService; | ||
|
|
||
| import jakarta.validation.Valid; | ||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/menus") | ||
| @RequiredArgsConstructor | ||
| public class MenuController { | ||
|
|
||
| private final MenuService menuService; | ||
|
|
||
| @PostMapping("/create") | ||
| public ResponseEntity<?> createMenu(@Valid @RequestBody MenuCreateRequest request) { | ||
| MenuCreateResponse response = menuService.createMenu(request); | ||
|
|
||
| return ResponseEntity | ||
| .status(HttpStatus.CREATED) | ||
| .body( | ||
| ApiUtils.success( | ||
| response | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| @GetMapping("/{storeId}") | ||
| public ResponseEntity<?> getMenusByStoreId(@PathVariable Long storeId) { | ||
| return ResponseEntity | ||
| .status(HttpStatus.OK) | ||
| .body( | ||
| ApiUtils.success( | ||
| menuService.getMenusByStoreId(storeId) | ||
| ) | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| package com.example.gtable.menu.dto; | ||
|
|
||
| import com.example.gtable.menu.model.Menu; | ||
|
|
||
| import jakarta.validation.constraints.NotNull; | ||
| import lombok.AllArgsConstructor; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Getter | ||
| @NoArgsConstructor | ||
| @AllArgsConstructor | ||
| public class MenuCreateRequest { | ||
|
|
||
| @NotNull | ||
| private Long storeId; | ||
| @NotNull | ||
| private String name; | ||
| @NotNull | ||
| private String description; | ||
| @NotNull | ||
| private String price; | ||
|
|
||
| public Menu toEntity() { | ||
| return Menu.builder() | ||
| .storeId(storeId) | ||
| .name(name) | ||
| .description(description) | ||
| .price(price) | ||
| .build(); | ||
| } | ||
| } | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| package com.example.gtable.menu.dto; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| import com.example.gtable.menu.model.Menu; | ||
|
|
||
| import lombok.AllArgsConstructor; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| @AllArgsConstructor | ||
| @Builder | ||
| public class MenuCreateResponse { | ||
| private Long menuId; | ||
| private Long storeId; | ||
| private String name; | ||
| private String description; | ||
| private String price; | ||
| private LocalDateTime createdAt; | ||
|
|
||
| public static MenuCreateResponse fromEntity(Menu menu) { | ||
| return MenuCreateResponse.builder() | ||
| .createdAt(menu.getCreatedAt()) | ||
| .menuId(menu.getId()) | ||
| .storeId(menu.getStoreId()) | ||
| .name(menu.getName()) | ||
| .description(menu.getDescription()) | ||
| .price(menu.getPrice()) | ||
| .build(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| package com.example.gtable.menu.dto; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| import com.example.gtable.menu.model.Menu; | ||
| import com.example.gtable.menuImage.dto.MenuImageUploadResponse; | ||
|
|
||
| import lombok.AllArgsConstructor; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| @AllArgsConstructor | ||
| @Builder | ||
| public class MenuReadDto { | ||
| private Long menuId; | ||
| private Long storeId; | ||
| private String name; | ||
| private String description; | ||
| private String price; | ||
| private List<MenuImageUploadResponse> images; | ||
|
|
||
| public static MenuReadDto fromEntity(Menu menu, List<MenuImageUploadResponse> images) { | ||
| return MenuReadDto.builder() | ||
| .menuId(menu.getId()) | ||
| .storeId(menu.getStoreId()) | ||
| .name(menu.getName()) | ||
| .description(menu.getDescription()) | ||
| .price(menu.getPrice()) | ||
| .images(images) | ||
| .build(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package com.example.gtable.menu.dto; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| import lombok.AllArgsConstructor; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| @AllArgsConstructor | ||
| @Builder | ||
| public class MenuReadResponse { | ||
|
|
||
| private List<MenuReadDto> menuReadDto; | ||
|
|
||
| public static MenuReadResponse of(List<MenuReadDto> menuReadDto) { | ||
| return MenuReadResponse.builder() | ||
| .menuReadDto(menuReadDto) | ||
| .build(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,42 @@ | ||||||||||
| package com.example.gtable.menu.model; | ||||||||||
|
|
||||||||||
| import java.time.LocalDateTime; | ||||||||||
|
|
||||||||||
| import com.example.gtable.global.entity.BaseTimeEntity; | ||||||||||
|
|
||||||||||
| import jakarta.persistence.Entity; | ||||||||||
| import jakarta.persistence.GeneratedValue; | ||||||||||
| import jakarta.persistence.GenerationType; | ||||||||||
| import jakarta.persistence.Id; | ||||||||||
| import jakarta.persistence.Table; | ||||||||||
| import lombok.AccessLevel; | ||||||||||
| import lombok.AllArgsConstructor; | ||||||||||
| import lombok.Getter; | ||||||||||
| import lombok.NoArgsConstructor; | ||||||||||
| import lombok.experimental.SuperBuilder; | ||||||||||
|
|
||||||||||
| @Entity | ||||||||||
| @Table(name = "menus") | ||||||||||
| @Getter | ||||||||||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||||||||||
| @AllArgsConstructor(access = AccessLevel.PRIVATE) | ||||||||||
| @SuperBuilder | ||||||||||
| public class Menu extends BaseTimeEntity { | ||||||||||
|
|
||||||||||
| @Id | ||||||||||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||||||||||
| private Long Id; | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 필드명 일관성을 위해 수정이 필요합니다 Java 명명 규칙에 따라 필드명은 camelCase를 사용해야 합니다. - private Long Id;
+ private Long id;커스텀 생성자에서도 동일하게 수정이 필요합니다: - this.Id = id;
+ this.id = id;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| private Long storeId; | ||||||||||
| private String name; | ||||||||||
| private String description; | ||||||||||
| private String price; | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 가격 필드의 데이터 타입을 BigDecimal로 변경하는 것을 권장합니다 금액을 다룰 때는 정확한 계산을 위해 +import java.math.BigDecimal;
- private String price;
+ private BigDecimal price;커스텀 생성자의 매개변수 타입도 함께 변경해주세요: - public Menu(LocalDateTime createdAt, Long id, Long storeId, String name, String description, String price) {
+ public Menu(LocalDateTime createdAt, Long id, Long storeId, String name, String description, BigDecimal price) {🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| public Menu(LocalDateTime createdAt, Long id, Long storeId, String name, String description, String price) { | ||||||||||
| super(createdAt); | ||||||||||
| this.Id = id; | ||||||||||
| this.storeId = storeId; | ||||||||||
| this.name = name; | ||||||||||
| this.description = description; | ||||||||||
| this.price = price; | ||||||||||
| } | ||||||||||
| } | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.example.gtable.menu.repository; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
| import org.springframework.stereotype.Repository; | ||
|
|
||
| import com.example.gtable.menu.model.Menu; | ||
|
|
||
| @Repository | ||
| public interface MenuRepository extends JpaRepository<Menu, Long> { | ||
| List<Menu> findAllByStoreId(Long storeId); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
새로 추가된 의존성들의 버전 호환성을 검증해주세요.
새로 추가하신 의존성들이 메뉴 이미지 업로드 기능을 위한 것으로 보이는데, 몇 가지 검토가 필요한 부분이 있습니다:
spring-cloud-starter-aws:2.2.6.RELEASE는 Spring Boot 3.x와 호환성 문제가 있을 수 있습니다.resilience4j-spring-boot2:1.7.1는 Spring Boot 2 버전용입니다.다음 스크립트로 최신 호환 버전을 확인해보세요:
🌐 Web query:
💡 Result:
Spring Boot 3.5.0과 호환되는 Spring Cloud AWS의 최신 버전에 대해 알아보겠습니다.
Spring Boot 3.5.0이 최근 2025년 5월 22일에 출시되었으며, 이에 맞는 Spring Cloud AWS 버전을 확인해야 합니다[2]. Spring Cloud의 릴리스 트레인을 살펴보면, Spring Boot 3.5.x는 Spring Cloud 2025.0.x(코드명: Northfields)와 호환됩니다[3].
Spring Cloud AWS는 AWS 서비스와 Spring 애플리케이션의 통합을 쉽게 해주는 프로젝트로, 현재 커뮤니티에서 운영되고 있습니다[6]. 이 프로젝트는 AWS가 제공하는 Java SDK를 사용하지만, Spring 기반 모듈을 통합하여 AWS 서비스를 더 편리하게 사용할 수 있게 해줍니다[6].
Spring Cloud AWS 버전 호환성 정보에 따르면:
Spring Boot 3.5.0을 위한 Spring Cloud AWS의 최신 호환 버전은 아직 명시적으로 언급되어 있지 않지만, Spring Cloud 2025.0.x(Northfields) 릴리스 트레인에 맞는 Spring Cloud AWS 버전이 필요할 것입니다. 현재 기준으로는 Spring Cloud AWS 3.3.0 이상의 버전이 필요할 것으로 예상됩니다.
사용 방법:
Spring Cloud AWS를 사용하려면 다음과 같이 의존성을 추가해야 합니다:
Maven의 경우:
Spring Cloud AWS의 장점은 AWS 자격 증명을 자동으로 처리해주는 것으로, application.yml에 다음과 같이 설정만 해주면 됩니다[6]:
정확한 최신 버전 정보는 Spring Cloud AWS 공식 GitHub 저장소(https://github.com/awspring/spring-cloud-aws)에서 확인하는 것이 좋습니다[1].
Citations:
🌐 Web query:
💡 Result:
Spring Boot 3.x와 호환되는 Resilience4j 최신 버전
Spring Boot 3.x를 사용할 때는 반드시 이에 맞는 Resilience4j 아티팩트와 버전을 선택해야 합니다. Spring Boot 2.x와는 별도의 지원 라이브러리가 존재하며, 호환성 문제를 피하기 위해 다음 사항을 참고하세요.
적용 라이브러리
resilience4j-spring-boot2resilience4j-spring-boot3[1][6]최신 버전 정보
resilience4j-spring-boot3와resilience4j-spring6모듈이 추가되었습니다[8].예시 의존성 설정 (Gradle)
[6][8]
버전 호환 표
주의사항
결론
resilience4j-spring-boot3아티팩트를 사용해야 합니다[6][8].Citations:
build.gradle(54–61) 의존성 호환성 점검 및 업데이트 필요
아래 항목을 Spring Boot 3.5.0, Spring Cloud 2025.0.x, Spring Framework 6 환경에 맞춰 수정해주세요:
• 기존:
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'• 권장:
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.3.0'// 또는 최신 버전• 기존:
implementation 'io.github.resilience4j:resilience4j-spring-boot2:1.7.1'implementation 'io.github.resilience4j:resilience4j-bulkhead:1.7.1'• 권장:
implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.3.0'implementation 'io.github.resilience4j:resilience4j-bulkhead:2.3.0'변경 후 빌드·테스트가 정상 수행되는지 확인 부탁드립니다.
🤖 Prompt for AI Agents